Back to Blog
A monthly paper calendar with handwritten notes beside a cup of coffee

Building AI Scheduling Assistants Without Duplicate Calendar Events

8 min read

An AI scheduling assistant can pick a sensible time and still create two events, overwrite a later edit, or book a slot that stopped being free while the model was thinking. Those failures come from treating the calendar write as the last step of a prompt. Put a mutation controller between the model and the calendar API. The model proposes attendees, duration and candidate times. Deterministic code owns identity, fresh availability checks, approval versions, writes, synchronization and recovery.

Why scheduling fails after the model is right

A request changes state several times. The assistant reads calendars, proposes a slot, may wait for approval, then writes. In between, another person can accept a different invitation or move a meeting. Network outcomes add a second problem: the provider commits the event, the client times out before seeing the ID, and a naive retry creates a duplicate. Calendar sync has its own state too. Google's incremental sync guide uses persisted sync tokens, includes deleted entries in change results, and requires a full sync after an expired token returns 410, and Microsoft Graph's event delta query follows the same idea with opaque links. Keep four responsibilities apart: the model proposes, the application identifies one logical booking, the provider owns the current event, and the synchronizer tracks later changes and deletions.

Give each booking request a stable identity

Create a booking intent before calling the model or the calendar, with an ID from trusted application context such as a request record or inbound command ID. The model never invents it. The record holds the requester, attendee set, approved duration, the calendar snapshot used to pick the slot, the provider event ID once known, a state (proposed, approved, creating, created, unknown, cancelled) and an attempt count. Put a unique constraint on the intent ID so that of two workers holding the same command, only one can move it into creating.

Google lets the client choose the event ID at insert. The create-events guide says this prevents duplicate creation if the operation fails after succeeding in the Calendar backend. The ID rules are stricter than most teams expect, and the events reference spells them out: only lowercase letters a to v and digits 0 to 9 (base32hex), 5 to 1024 characters, unique per calendar, and Google says it cannot guarantee ID collisions will be detected at creation time. A UUID with dashes or an uppercase intent ID falls outside that alphabet, so derive the event ID by hashing the intent ID and writing it in lowercase hex, which stays inside the alphabet. Because collisions may not be detected at insert, still run the lookup in the reconciliation step below instead of trusting a clean response. Conference creation uses a separate requestId, so keep that identity separate from the event ID.

Bind proposals and approvals to calendar state

A proposal is valid only for the state it read. Save the snapshot or sync watermark with the proposed slot, and have approval reference that proposal version instead of a loose "the 2 pm one". Check availability again immediately before writing, inside the worker that holds the booking claim, since approval can sit in a queue for hours.

Classify changes rather than rejecting any difference. An edit to an unrelated event's description changes nothing. A new overlapping event invalidates the proposal. A changed attendee list may need a new authorization check. A cancellation frees the slot without authorizing anyone to book it. For a recurring series, check the concrete occurrence that overlaps.

Define what "busy" means before you write the overlap check. Google's events reference gives each event a transparency value: opaque is the default and blocks time, transparent does not block time and shows as Available in the UI. A check that treats every overlapping event as a conflict rejects slots the owner considers open, such as a reminder-style block marked Available. The error looks like a model mistake in the logs.

If the slot is gone, move the intent back to needs-proposal with a reason. Do not quietly book the model's second choice unless the user's policy allows it, because the approval covered a specific time and attendee set. Hold no database transaction while the model works or a person reviews. Read the snapshot, do the slow work outside any lock, then compare and claim in a short transaction.

One controlled write path

create_booking(intent_id):
    intent = load_intent(intent_id)
    if intent.state == "created":
        return intent.provider_event_id
    claim = claim_once(intent_id, expected_state="approved")
    if not claim.acquired:
        return current_disposition(intent_id)
    current = refresh_relevant_calendars(intent)
    if not slot_is_still_free(intent.slot, current):
        mark_needs_proposal(intent_id, reason="calendar_changed")
        return
    event_id = stable_provider_id(intent_id)
    mark_creating(intent_id, event_id)
    try:
        result = calendar.insert(event_id, approved_event_body(intent))
        mark_created(intent_id, result.event_id, result.provider_version)
    except definite_rejection as error:
        mark_failed(intent_id, classify(error))
    except timeout_or_disconnect:
        mark_unknown(intent_id)
        enqueue_reconciliation(intent_id)

Persist the provider event ID before the external call. Only a definite rejection means "not created", and a timeout is unknown. A structured error such as an invalid attendee or time zone can go back to the model as a bounded field-level problem, without letting it repeat the write.

Reconcile before you retry

An unknown outcome never justifies a second insert under a new identity. Look up the stable event ID. If it exists and matches the approved attendees, interval and organizer, record the booking as created. If it exists with different material fields, alert a person. When the provider cannot take a caller-supplied ID, store a private correlation value in extended properties and reconcile on that before falling back to summary and time, because titles are not identifiers and two legitimate meetings can share one. If you still cannot prove whether the event exists, keep the intent unknown and tell an operator. A delayed meeting is cheaper than a second set of invitations. A GroupOffice issue describes edited invitations being imported as new events instead of merged, which is one product's report of the same identity failure.

Keep local state aligned

People edit and delete events in other clients after you create them. Run one synchronizer per account or calendar and save its cursor only after every returned change is durable. For Google, process every page with the same parameters, apply changed and deleted records, then store the sync token, and on a 410 discard it and rebuild while preserving the booking-to-event mappings. For Graph, follow the opaque @odata.nextLink values until a round returns an @odata.deltaLink, and do not edit the URL. Map each change to its meaning: an edit updates the local event by provider identity, a deletion cancels it and invalidates dependent reminders, a moved occurrence updates that occurrence, an attendee response updates participation without a new intent, and an organizer change that affects time or attendees may invalidate dependent workflows. Keep update payloads narrow, since Microsoft's event update behavior can notify changed attendees and some removals can notify a wider set.

Graph has its own idempotency field

Most write-ups on this problem stop at Google's caller-supplied event ID. Microsoft Graph has a different mechanism that few of them mention: the event resource carries a transactionId, described as a custom identifier "for the server to avoid redundant POST operations in case of client retries to create the same event", aimed at clients that time out before the response arrives. Two details matter for an assistant. Once transactionId is set at creation it cannot be changed by a later update, so derive it from the booking intent and never from the draft text. And it is returned in a response only if the app set it, so a reconciliation read after a timeout can tell your events apart from ones created by other clients. Google's ID rules differ (a fixed character set and a collision caveat), so keep one intent key and map it to each provider's form instead of reusing a string.

Test the duplicate and stale boundaries

Start two workers on one intent: one creates the event and the other returns the stored result, including after a process restart. Make the provider accept an insert while the client times out, and check that reconciliation finds the stable ID without a second insert, then run the case where the insert was rejected before commit and a retry is correct. Change an attendee's calendar between approval and write, and expect a new proposal. Edit a created event from another client and check that sync updates the same record. Invalidate a Google sync token and verify the rebuild keeps the mappings. Stop a Graph round between two pages and resume without advancing the delta link. Add recurring and deleted-event fixtures, and one event marked Available that overlaps the slot. Track intents by state, duplicate claim conflicts, stale proposals, unknown creates and how they resolved, cursor age, provider events with no intent and intents with no event.

Build the intent table, stable ID and claim transaction first, then the final availability check and reconciliation, then incremental sync. Tune how the assistant chooses and explains times only after those tests pass.

References


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation with the booking identities, freshness checks, and uncertain-write reconciliation described above, at published fixed prices. Schedule a call to discuss your next project.