teachyou.ai academy
← All posts
AI Agents

Building a Scheduling Agent That Coordinates Calendars

Pramod Dutta · May 4, 2026 · 13 min read

Why scheduling is a deceptively hard agent problem

Ask any engineer to build a "simple" scheduling bot and they'll usually reach for a calendar API, a date picker, and a form. Ask them to build one that actually works the way a competent human assistant works, and the scope explodes. A real scheduling agent has to read multiple calendars, understand that "free" on one person's calendar might mean "focus block, do not touch," reconcile time zones without silently getting them wrong, propose alternatives when the first slot fails, and do all of this while emailing or messaging humans who are not going to respond instantly.

This is why scheduling is one of the best teaching examples for agentic AI. It is not a toy problem. It has real state (calendars change while you're reasoning about them), real ambiguity (natural language like "sometime next week, afternoons are better"), real external tools (calendar APIs, email, Slack), and a real failure mode if you get it wrong (double-booked executives, missed meetings, angry Slack messages). If you can build a scheduling agent that behaves reliably, you've built something that generalizes to almost every other tool-using agent you'll ever design.

In this article we'll walk through the architecture of a scheduling agent end to end: how it represents calendars, how it reasons about availability, how it handles the negotiation loop when the first proposal doesn't work, and how it avoids the classic bugs — timezone drift, stale availability data, and infinite retry loops. We'll write actual code, not pseudocode, using a tool-calling pattern that works with any modern LLM API. If you want the fuller, project-based version of this build with a real calendar backend, that's exactly what we cover in 30 Days of Hermes Agent.

The core architecture: perception, reasoning, action

Every scheduling agent, no matter how sophisticated, breaks down into three loops repeated until a meeting is confirmed:

  • Perception — pull current calendar state for every participant (busy/free blocks, working hours, existing meetings)
  • Reasoning — the LLM decides candidate time slots given constraints (duration, participant priority, timezone, deadline)
  • Action — the agent calls a tool: propose a slot, send an invite, or ask a human for clarification

The trap most people fall into is letting the LLM "just figure out" availability by reasoning over raw calendar data dumped into the prompt. That works for two people and three events. It falls apart at five participants across three time zones with recurring meetings. The fix is to do the heavy lifting in code — computing actual free/busy intersections — and use the LLM only for the parts that genuinely require judgment: interpreting fuzzy language, weighing tradeoffs, and drafting the human-facing message.

Here's the skeleton of that separation:

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import List, Tuple

@dataclass
class BusyBlock:
    start: datetime
    end: datetime

@dataclass
class Participant:
    email: str
    timezone: str
    working_hours: Tuple[int, int]  # e.g. (9, 18) local hours
    busy_blocks: List[BusyBlock]

def free_slots_for(participant: Participant, day_start: datetime,
                    day_end: datetime, min_duration_minutes: int) -> List[BusyBlock]:
    """Compute free slots for a single participant within a window."""
    sorted_busy = sorted(participant.busy_blocks, key=lambda b: b.start)
    free = []
    cursor = day_start

    for block in sorted_busy:
        if block.start > cursor:
            gap = (block.start - cursor).total_seconds() / 60
            if gap >= min_duration_minutes:
                free.append(BusyBlock(start=cursor, end=block.start))
        cursor = max(cursor, block.end)

    if (day_end - cursor).total_seconds() / 60 >= min_duration_minutes:
        free.append(BusyBlock(start=cursor, end=day_end))

    return free

Notice this function has nothing to do with the LLM. It is deterministic, testable, and fast. That's deliberate. Agent reliability comes from minimizing the surface area where the model has to be "correct" about arithmetic or date logic, and maximizing the surface area where it interprets intent.

Representing calendars as tools, not context

A common early mistake is stuffing every participant's full calendar into the system prompt as text. This burns tokens, invites hallucination ("I see you're free at 3pm" when the model actually misread an overlapping event), and doesn't scale past a couple of people. Instead, expose calendar access as tools the agent calls on demand, and keep only the resolved intersection in context.

A minimal tool contract looks like this:

TOOLS = [
    {
        "name": "get_free_busy",
        "description": "Fetch free/busy blocks for one or more participants over a date range.",
        "input_schema": {
            "type": "object",
            "properties": {
                "emails": {"type": "array", "items": {"type": "string"}},
                "start_date": {"type": "string", "description": "ISO 8601 date"},
                "end_date": {"type": "string", "description": "ISO 8601 date"}
            },
            "required": ["emails", "start_date", "end_date"]
        }
    },
    {
        "name": "propose_slot",
        "description": "Draft a meeting proposal for a specific time slot and duration.",
        "input_schema": {
            "type": "object",
            "properties": {
                "start_time": {"type": "string"},
                "duration_minutes": {"type": "integer"},
                "attendees": {"type": "array", "items": {"type": "string"}},
                "title": {"type": "string"}
            },
            "required": ["start_time", "duration_minutes", "attendees", "title"]
        }
    },
    {
        "name": "send_invite",
        "description": "Create and send a calendar invite once a slot is confirmed.",
        "input_schema": {
            "type": "object",
            "properties": {
                "start_time": {"type": "string"},
                "duration_minutes": {"type": "integer"},
                "attendees": {"type": "array", "items": {"type": "string"}},
                "title": {"type": "string"},
                "location": {"type": "string"}
            },
            "required": ["start_time", "duration_minutes", "attendees", "title"]
        }
    }
]

The agent's job is to chain these: call get_free_busy, compute an intersection (either in code you run after the tool call, or via a follow-up compute_intersection tool), pick a candidate with propose_slot, and only call send_invite after getting explicit confirmation. That last gate — never auto-sending an invite without confirmation — is not a nice-to-have. It's the single most important safety rail in a scheduling agent, because the cost of a wrong action (a real invite landing on a real person's calendar) is much higher than the cost of a wrong suggestion.

Intersecting multiple calendars without getting timezones wrong

Timezone bugs are the single most common source of scheduling agent failures, and they're insidious because they often look correct in testing (when everyone happens to share a timezone) and only break in production with a distributed team. The fix is boring but non-negotiable: normalize everything to UTC internally, and only convert to local time at the very edges — when displaying to a human or when reading a human's stated preference.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def to_utc(local_dt: datetime, tz_name: str) -> datetime:
    """Convert a naive local datetime to a timezone-aware UTC datetime."""
    local_tz = ZoneInfo(tz_name)
    aware_local = local_dt.replace(tzinfo=local_tz)
    return aware_local.astimezone(timezone.utc)

def intersect_free_windows(all_free: List[List[BusyBlock]]) -> List[BusyBlock]:
    """Given free slots for N participants (already in UTC), find overlapping windows."""
    if not all_free:
        return []

    # Start with the first participant's free windows, narrow down from there
    result = all_free[0]

    for participant_free in all_free[1:]:
        next_result = []
        for a in result:
            for b in participant_free:
                start = max(a.start, b.start)
                end = min(a.end, b.end)
                if start < end:
                    next_result.append(BusyBlock(start=start, end=end))
        result = next_result

    return result

def filter_by_min_duration(windows: List[BusyBlock], minutes: int) -> List[BusyBlock]:
    return [w for w in windows if (w.end - w.start).total_seconds() / 60 >= minutes]

Run every participant's busy blocks through free_slots_for, convert to UTC with to_utc, then pass the list of free-window lists to intersect_free_windows. What comes out the other end is a clean list of genuinely mutual free slots — no LLM guesswork involved. The LLM's role kicks back in only when you need to rank these slots by soft preferences ("prefer mornings," "avoid Fridays," "the VP should get first pick of timezone-friendly slots").

The negotiation loop: what happens when nobody agrees

Real scheduling rarely resolves on the first proposal. Someone declines, someone has a conflict that wasn't in their calendar (an external meeting, a flight), or a stakeholder wants a different day entirely. This is where a scheduling agent needs an actual state machine rather than a single prompt-response cycle.

A practical model treats each meeting request as a small workflow with explicit states:

  • collecting_constraints — gathering duration, required attendees, deadline, and preferences
  • proposing — the agent has offered one or more candidate slots and is waiting on responses
  • confirming — enough people have accepted a slot; final confirmation pending
  • booked — invite sent
  • stalled — no mutual slot found within constraints; needs human escalation
class SchedulingState:
    COLLECTING = "collecting_constraints"
    PROPOSING = "proposing"
    CONFIRMING = "confirming"
    BOOKED = "booked"
    STALLED = "stalled"

class MeetingRequest:
    def __init__(self, attendees, duration_minutes, deadline):
        self.attendees = attendees
        self.duration_minutes = duration_minutes
        self.deadline = deadline
        self.state = SchedulingState.COLLECTING
        self.proposed_slots = []
        self.responses = {}  # email -> "accepted" | "declined" | None
        self.retry_count = 0
        self.max_retries = 3

    def record_response(self, email: str, decision: str):
        self.responses[email] = decision
        if all(v == "accepted" for v in self.responses.values()):
            self.state = SchedulingState.CONFIRMING
        elif any(v == "declined" for v in self.responses.values()):
            self.retry_count += 1
            if self.retry_count >= self.max_retries:
                self.state = SchedulingState.STALLED
            else:
                self.state = SchedulingState.PROPOSING
                self.responses = {}

The max_retries guard matters more than it looks. Without it, an agent stuck in a loop with a genuinely unavailable participant will keep proposing slots forever, burning API calls and annoying everyone on the thread. Three strikes and escalate to a human is a good default — it mirrors how a competent human assistant behaves: try a reasonable number of times, then say "I'm stuck, can you weigh in?"

Handling natural language constraints

The part of this system that actually benefits from an LLM is turning fuzzy human input into structured constraints. "Let's grab 30 minutes sometime next week, mornings work best for me but I'm flexible" needs to become a structured object before any of the deterministic code above can run.

import json

CONSTRAINT_EXTRACTION_PROMPT = """
Extract scheduling constraints from the user's message. Return JSON only.

User message: "{message}"
Current date: {current_date}
User's timezone: {user_timezone}

Return this exact structure:
{{
  "duration_minutes": <int>,
  "earliest_date": "<ISO date>",
  "latest_date": "<ISO date>",
  "time_preference": "<morning|afternoon|evening|no_preference>",
  "flexibility": "<strict|flexible>"
}}
"""

def extract_constraints(message: str, current_date: str, user_timezone: str, llm_call):
    prompt = CONSTRAINT_EXTRACTION_PROMPT.format(
        message=message,
        current_date=current_date,
        user_timezone=user_timezone
    )
    raw_response = llm_call(prompt)
    return json.loads(raw_response)

Keep this extraction step narrow and single-purpose. Don't ask the same LLM call to also pick the final slot or write the email — separate concerns into separate calls (or separate tool invocations within one agentic loop) so that each step is easy to test and debug independently. When something goes wrong in production, you want to know whether the bug was in constraint extraction, slot computation, or message drafting, not have to guess which part of one giant prompt misfired.

Drafting the human-facing message

Once a slot is chosen, the agent needs to write something a real person will read. This is a place where LLM generation genuinely adds value over templating, because tone matters and context varies — a scheduling message to a candidate is different from one to an internal team, which is different from a reschedule apology.

def draft_proposal_message(meeting: dict, recipient_name: str, tone: str, llm_call) -> str:
    prompt = f"""
    Write a short, professional scheduling message.

    Meeting: {meeting['title']}
    Proposed time: {meeting['start_time']} ({meeting['timezone']})
    Duration: {meeting['duration_minutes']} minutes
    Recipient: {recipient_name}
    Tone: {tone}

    Keep it under 60 words. Include the proposed time clearly.
    Do not invent details not provided above.
    """
    return llm_call(prompt)

That last line — "do not invent details not provided above" — is doing real work. Scheduling messages are a common place for models to hallucinate plausible-sounding but false specifics: a meeting link that doesn't exist, a location that was never specified, an attendee who isn't actually invited. Constrain the prompt tightly and pass only verified data from your deterministic layer.

Testing a scheduling agent without waiting on real calendars

You cannot iterate on this system by testing against your own live Google Calendar every time — it's slow, non-reproducible, and you'll pollute your actual schedule. Build a fake calendar provider that implements the same interface as your real one, and drive your test suite off scripted scenarios: overlapping meetings, back-to-back edge cases, a participant with zero free slots, an all-day event that should block scheduling, a recurring meeting that only sometimes conflicts.

class FakeCalendarProvider:
    def __init__(self):
        self.calendars = {}

    def add_participant(self, email: str, timezone: str, busy_blocks: List[BusyBlock]):
        self.calendars[email] = Participant(
            email=email,
            timezone=timezone,
            working_hours=(9, 18),
            busy_blocks=busy_blocks
        )

    def get_free_busy(self, emails: List[str], start: datetime, end: datetime):
        return {email: self.calendars[email].busy_blocks
                for email in emails if email in self.calendars}


def test_no_mutual_slot_triggers_stalled_state():
    provider = FakeCalendarProvider()
    provider.add_participant("a@co.com", "UTC",
        [BusyBlock(datetime(2026, 7, 6, 9), datetime(2026, 7, 6, 17))])
    provider.add_participant("b@co.com", "UTC",
        [BusyBlock(datetime(2026, 7, 6, 9), datetime(2026, 7, 6, 17))])

    request = MeetingRequest(["a@co.com", "b@co.com"], 30, deadline="2026-07-06")
    for _ in range(request.max_retries):
        request.record_response("a@co.com", "declined")

    assert request.state == SchedulingState.STALLED

This kind of test suite is what separates a demo from something you'd trust to actually run unattended. Every edge case you hit in real usage should become a regression test in the fake-calendar suite, so the agent gets more reliable over time instead of accumulating the same class of bug repeatedly.

Escalation: knowing when to stop and ask a human

The most mature scheduling agents are defined as much by what they refuse to do autonomously as by what they automate. Good escalation triggers include: a participant marked "required" has no free slots within the deadline window, a proposed time conflicts with a meeting tagged high-priority even though it shows as "free" (some calendars mark focus time as available), or the retry budget is exhausted. In every one of these cases, the right agent behavior is a clear, specific message to a human — not a guess, and not silence.

def check_escalation(request: MeetingRequest, free_slots: List[BusyBlock]) -> str | None:
    if request.state == SchedulingState.STALLED:
        return (f"Could not find a mutual slot for {', '.join(request.attendees)} "
                f"within the deadline of {request.deadline}. Manual scheduling needed.")

    if not free_slots:
        return (f"No overlapping free time found for all {len(request.attendees)} "
                f"attendees. Consider shortening the meeting or splitting attendees.")

    return None

This function is small on purpose. Escalation logic should be simple enough to audit at a glance, because it's the safety net for everything else in the system. If it's buried in a complex prompt instead of explicit code, you lose the ability to guarantee it always fires when it should.

Bringing it together

A working scheduling agent is really four smaller systems wired together: a deterministic availability engine (free/busy computation and intersection), a thin LLM layer for interpreting fuzzy language and drafting messages, an explicit state machine for the negotiation loop, and a hard-coded escalation path for when automation should hand off to a person. None of these pieces is individually exotic — the skill is in the wiring, in deciding precisely where the LLM adds value and where it should be kept out of the loop entirely.

This pattern — deterministic core, thin LLM edges, explicit state machine, hard safety rails — isn't unique to scheduling. It's the blueprint for building agents that manage inventory, triage support tickets, or coordinate multi-step approvals. Scheduling just happens to be one of the clearest, most relatable ways to learn it, because everyone has lived through the pain of a meeting that took eleven emails to book.

If you want to build this system properly from scratch — real calendar integrations, a production-grade negotiation loop, proper testing harnesses, and deployment — that's exactly the project we build step by step in 30 Days of Hermes Agent. It's designed for engineers who want to stop reading about agents and start shipping one that people actually rely on.

Building a Scheduling Agent That Coordinates Calendars · TeachYou Academy