What You’re Building

A scheduling agent that handles the full meeting lifecycle: check if a time slot is open, book the event, and fire off a confirmation email. The user says “Schedule a 30-minute sync with Alice tomorrow at 2pm” and the agent handles the rest – multiple tool calls, conflict detection, everything.

This uses OpenAI’s tools parameter for function calling. The pattern works with any calendar backend (Google Calendar, Outlook, Cal.com) and any email service (SendGrid, SES, SMTP). We’ll use mock implementations so you can run the code immediately and swap in real APIs later.

Define the Tools

Three tools cover the core scheduling workflow. Each tool definition tells the model what parameters it needs and when to use it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import json
from datetime import datetime, timedelta
from openai import OpenAI

client = OpenAI()

tools = [
    {
        "type": "function",
        "function": {
            "name": "check_availability",
            "description": "Check if a person is available during a specific time window. Call this before creating any event.",
            "parameters": {
                "type": "object",
                "properties": {
                    "email": {
                        "type": "string",
                        "description": "Email address of the person to check"
                    },
                    "date": {
                        "type": "string",
                        "description": "Date in YYYY-MM-DD format"
                    },
                    "start_time": {
                        "type": "string",
                        "description": "Start time in HH:MM format (24-hour)"
                    },
                    "duration_minutes": {
                        "type": "integer",
                        "description": "Duration of the meeting in minutes"
                    }
                },
                "required": ["email", "date", "start_time", "duration_minutes"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "create_event",
            "description": "Create a calendar event after confirming availability. Returns event ID on success.",
            "parameters": {
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "Meeting title"
                    },
                    "date": {
                        "type": "string",
                        "description": "Date in YYYY-MM-DD format"
                    },
                    "start_time": {
                        "type": "string",
                        "description": "Start time in HH:MM format (24-hour)"
                    },
                    "duration_minutes": {
                        "type": "integer",
                        "description": "Duration in minutes"
                    },
                    "attendees": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "List of attendee email addresses"
                    }
                },
                "required": ["title", "date", "start_time", "duration_minutes", "attendees"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "send_email",
            "description": "Send an email notification. Use this to send meeting confirmations or updates.",
            "parameters": {
                "type": "object",
                "properties": {
                    "to": {
                        "type": "string",
                        "description": "Recipient email address"
                    },
                    "subject": {
                        "type": "string",
                        "description": "Email subject line"
                    },
                    "body": {
                        "type": "string",
                        "description": "Email body text"
                    }
                },
                "required": ["to", "subject", "body"]
            }
        }
    }
]

Implement the Tool Functions

These mock implementations simulate a real calendar and email backend. The calendar store is an in-memory dict – replace it with Google Calendar API calls or a database in production.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# In-memory calendar store
calendar_db = {
    "[email protected]": [
        {"title": "Team standup", "date": "2026-02-16", "start": "09:00", "end": "09:30"},
        {"title": "Product review", "date": "2026-02-16", "start": "14:00", "end": "15:00"},
    ],
    "[email protected]": [
        {"title": "1:1 with manager", "date": "2026-02-16", "start": "10:00", "end": "10:30"},
    ]
}

sent_emails = []


def check_availability(email: str, date: str, start_time: str, duration_minutes: int) -> str:
    events = calendar_db.get(email, [])
    req_start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M")
    req_end = req_start + timedelta(minutes=duration_minutes)

    conflicts = []
    for event in events:
        if event["date"] != date:
            continue
        ev_start = datetime.strptime(f"{event['date']} {event['start']}", "%Y-%m-%d %H:%M")
        ev_end = datetime.strptime(f"{event['date']} {event['end']}", "%Y-%m-%d %H:%M")
        if req_start < ev_end and req_end > ev_start:
            conflicts.append(event["title"])

    if conflicts:
        return json.dumps({
            "available": False,
            "conflicts": conflicts,
            "message": f"{email} has conflicting events: {', '.join(conflicts)}"
        })
    return json.dumps({
        "available": True,
        "message": f"{email} is free on {date} from {start_time} for {duration_minutes} minutes"
    })


def create_event(title: str, date: str, start_time: str, duration_minutes: int, attendees: list) -> str:
    start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M")
    end = start + timedelta(minutes=duration_minutes)
    event_id = f"evt_{datetime.now().strftime('%Y%m%d%H%M%S')}"

    event = {
        "title": title,
        "date": date,
        "start": start_time,
        "end": end.strftime("%H:%M"),
    }

    for attendee in attendees:
        if attendee not in calendar_db:
            calendar_db[attendee] = []
        calendar_db[attendee].append(event)

    return json.dumps({
        "success": True,
        "event_id": event_id,
        "title": title,
        "date": date,
        "time": f"{start_time}-{end.strftime('%H:%M')}",
        "attendees": attendees
    })


def send_email(to: str, subject: str, body: str) -> str:
    email_record = {"to": to, "subject": subject, "body": body, "sent_at": datetime.now().isoformat()}
    sent_emails.append(email_record)
    return json.dumps({
        "success": True,
        "message": f"Email sent to {to}",
        "subject": subject
    })


# Map tool names to functions
tool_functions = {
    "check_availability": check_availability,
    "create_event": create_event,
    "send_email": send_email,
}

The Agent Loop

This is where it all comes together. The loop sends the user request to the model, checks if it wants to call tools, executes them, feeds results back, and repeats until the model responds with a final message.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def run_scheduling_agent(user_message: str, max_iterations: int = 10) -> str:
    messages = [
        {
            "role": "system",
            "content": (
                "You are a scheduling assistant. When asked to schedule a meeting, "
                "always check availability for all attendees first. If there's a conflict, "
                "suggest the next available slot. After creating the event, send a "
                "confirmation email to each attendee."
            )
        },
        {"role": "user", "content": user_message}
    ]

    for i in range(max_iterations):
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
        )

        choice = response.choices[0]

        if choice.finish_reason == "tool_calls":
            # Model wants to call one or more tools
            assistant_message = choice.message
            messages.append(assistant_message)

            for tool_call in assistant_message.tool_calls:
                fn_name = tool_call.function.name
                fn_args = json.loads(tool_call.function.arguments)

                print(f"  [Tool] {fn_name}({json.dumps(fn_args, indent=2)})")

                result = tool_functions[fn_name](**fn_args)
                print(f"  [Result] {result}\n")

                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                })
        else:
            # Model is done -- return the final text
            return choice.message.content

    return "Agent hit the iteration limit without finishing."

Running the Agent

Here’s the full workflow. The agent checks availability, detects the conflict, creates the event in an open slot, and sends the confirmation email – all from a single natural language request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Schedule a meeting that conflicts with an existing event
answer = run_scheduling_agent(
    "Schedule a 30-minute sync with [email protected] on 2026-02-16 at 14:00. "
    "If she's busy, try 16:00 instead. Send her a confirmation email."
)
print(answer)

# Expected tool call sequence:
#   [Tool] check_availability({"email": "[email protected]", "date": "2026-02-16", ...})
#   [Result] {"available": false, "conflicts": ["Product review"], ...}
#
#   [Tool] check_availability({"email": "[email protected]", "date": "2026-02-16", "start_time": "16:00", ...})
#   [Result] {"available": true, ...}
#
#   [Tool] create_event({"title": "Sync", "date": "2026-02-16", "start_time": "16:00", ...})
#   [Result] {"success": true, "event_id": "evt_...", ...}
#
#   [Tool] send_email({"to": "[email protected]", "subject": "Meeting Confirmed: Sync", ...})
#   [Result] {"success": true, ...}

The agent hit a conflict at 14:00 (Alice’s product review runs from 14:00-15:00), fell back to 16:00, confirmed availability, booked the event, and emailed Alice. Four tool calls, zero manual intervention.

Adding Multi-Attendee Scheduling

When you need to coordinate across multiple people, the agent checks each person’s calendar before booking. The system prompt already tells it to do this, but you can make it more explicit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
answer = run_scheduling_agent(
    "Set up a 45-minute project kickoff with [email protected] and [email protected] "
    "on 2026-02-16 at 11:00. Email both of them when it's confirmed."
)
print(answer)
# The agent will:
# 1. check_availability for [email protected] at 11:00 -> available
# 2. check_availability for [email protected] at 11:00 -> available
# 3. create_event with both attendees
# 4. send_email to [email protected]
# 5. send_email to [email protected]

The model fires off both availability checks (often in parallel as a single response with two tool calls), then books and notifies. GPT-4o is good at parallelizing independent tool calls – you’ll see both check_availability calls in the same response.

Common Errors and Fixes

openai.BadRequestError: Invalid tool_call_id – Every tool result message must include a tool_call_id that matches the ID from the assistant’s tool call. If you’re building messages manually, make sure you’re using tool_call.id, not the function name.

Model ignores the tools parameter and just responds with text. Your tool descriptions are too vague. The description field drives tool selection – be specific about when to use it. “Check if a person is available during a specific time window” works much better than “Calendar tool.”

json.JSONDecodeError when parsing function arguments. The model occasionally produces malformed JSON in tool_call.function.arguments, especially with complex nested parameters. Wrap json.loads() in a try/except and return an error message as the tool result so the model can retry.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
for tool_call in assistant_message.tool_calls:
    fn_name = tool_call.function.name
    try:
        fn_args = json.loads(tool_call.function.arguments)
    except json.JSONDecodeError as e:
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": json.dumps({"error": f"Invalid JSON in arguments: {e}"}),
        })
        continue

    result = tool_functions[fn_name](**fn_args)
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,
        "content": result,
    })

Agent books a meeting without checking availability first. Add an explicit instruction in the system prompt: “Always check availability for all attendees before creating any event.” You can also add guardrail logic in create_event that calls check_availability internally and rejects conflicting bookings.

Tool call loop – agent keeps calling the same tool repeatedly. Set max_iterations and also check for repeated tool calls in the loop. If the same function gets called with the same arguments twice, break out and return an error message.

Swapping in Real APIs

The mock functions show the interface. To go to production, replace the function bodies:

  • Google Calendar: Use the google-api-python-client package. check_availability becomes a freebusy query, create_event becomes events().insert().
  • Microsoft Graph: Use msgraph-sdk-python. Same pattern – freebusy endpoint for availability, calendar events endpoint for creation.
  • Email: Replace send_email with SendGrid (sendgrid package), AWS SES (boto3), or direct SMTP via smtplib. The function signature stays the same.

The agent code doesn’t change at all. That’s the whole point of the tool abstraction – the model calls create_event, and it doesn’t matter whether that hits Google Calendar, Outlook, or a Postgres table.