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

Building AI Scheduling Assistants Without Duplicate Calendar Events

10 min read

An AI scheduling assistant can choose a sensible meeting time and still create two events, overwrite a later edit, or book a slot that stopped being free while the model was working. These failures happen when the application treats calendar writes as a final step after inference. The calendar can change during that inference, and a successful create request can lose its response on the way back.

Put a calendar mutation controller between the model and the calendar API. The model can propose attendees, duration, and candidate times. Deterministic application code should own identity, fresh availability checks, approval versions, writes, synchronization, and recovery. This guide connects those pieces so retries and edits do not turn one booking request into several calendar events.

Why scheduling fails after the model gets the answer right

A scheduling request passes through several state changes. The user asks for a meeting. The assistant reads calendars, proposes a slot, may wait for approval, and finally writes an event. Another person can accept a different invitation, move an existing meeting, or edit the proposed event during any pause.

Network outcomes add a second problem. A calendar provider may commit an event and then the client may time out before receiving the identifier. If the worker repeats the create request as though nothing happened, it can create a duplicate. Better prompting cannot distinguish a rejected write from a committed write whose response was lost.

Calendar synchronization also has its own state. Google's incremental synchronization guide uses persisted sync tokens, includes deleted entries in change results, and requires a new full sync after an expired token returns 410. Microsoft Graph's event delta query similarly returns opaque next and delta links for new, updated, and deleted events in a calendar view. A notification should therefore trigger a state refresh. It should not be treated as a complete or unique booking command.

Keep these responsibilities separate:

  1. The model produces a meeting proposal.
  2. The application identifies one logical booking request.
  3. The calendar provider owns the current event record.
  4. The synchronizer tracks later changes and deletions.

Mixing them into one job makes retries dangerous. Keeping them separate gives each failure a specific recovery path.

Give every booking request a stable identity

Create a booking intent before calling the model or calendar API. Its identity should come from trusted application context, such as a request record, approval task, or inbound command ID. Do not let the model invent it.

A useful record contains:

Field Purpose
booking_intent_id Identifies one requested meeting
requester_id Binds authority to the authenticated user
attendee_set Records the people covered by the request
duration Preserves the approved meeting length
calendar_snapshot Identifies the state used to choose the slot
provider_event_id Links the intent to the created calendar event
state Tracks proposed, approved, creating, created, unknown, or cancelled
attempt_count Supports bounded recovery without creating a new intent

Put a unique constraint on booking_intent_id. If two workers receive the same command, only one can move the record into the creating state. The other worker reads the existing disposition and stops.

Google Calendar supports a stronger provider link. Its event creation guide says a client can supply an event ID during insertion. The guide states that this keeps a local database aligned with Calendar and prevents duplicate creation when an operation succeeds in the backend but fails before the client sees success. Derive that provider event ID from the booking intent using a stable, provider-compatible encoding. Store it before the first write.

Conference creation needs separate identity. The same Google guide documents an asynchronous conference createRequest with a generated requestId. Reusing the booking identity for every suboperation is too coarse. Keep a stable event ID for the meeting and a separate request ID for conference creation so a retry does not request another meeting link.

Bind proposals and approvals to calendar state

A meeting proposal is valid only for the state it read. Save the calendar snapshot or synchronization watermark alongside the proposed slot. When a person approves the proposal, approval should reference that proposal version rather than a loose phrase such as "the 2 PM meeting."

Immediately before writing, refresh the relevant calendars and check the interval again. This check belongs after approval because approval can sit in a queue while calendars change. It also belongs inside the worker that owns the booking claim, not inside model context.

Classify changes rather than rejecting every difference:

  • A description edit on an unrelated event does not affect availability.
  • A new event that overlaps the candidate slot invalidates the proposal.
  • A changed attendee list may require a new authorization or availability check.
  • A cancellation can make the slot available, but it does not automatically authorize booking it.
  • A recurring-series edit requires checking the concrete occurrence that overlaps the slot.

If the slot is no longer valid, move the intent back to needs-proposal with a reason. Do not quietly choose the model's second choice and book it unless the user's policy explicitly allows automatic fallback. The previous approval covered a specific time and attendee set.

Do not keep a database transaction open while the model proposes times or while a person reviews them. Read the snapshot, do slow work outside a lock, then compare and claim in a short transaction before the provider write. A long lock cannot stop another calendar client from changing the external calendar anyway.

Run one controlled write sequence

The write path should be boring application code. It receives an approved proposal and returns a disposition. The model does not decide whether to retry.

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. Treat only a definite rejection as not created. A timeout becomes unknown rather than failed.

If the provider rejects an invalid attendee, permission, or time-zone value, return a structured error to application logic. Some errors can go back to the model for repair, but the model should receive a bounded field-level problem rather than permission to repeat the whole booking. Keep the same booking intent through repair so a corrected request does not become a second meeting.

Reconcile uncertain creates before retrying

An unknown outcome is not an invitation to call insert again with new identity. Look up the stable provider event ID. If the event exists and matches the approved attendee set, interval, and organizer, record the booking as created. If it exists with different material fields, stop for investigation because another path may have reused an identity incorrectly.

When the provider does not support a caller-supplied event ID, store a private correlation value in provider metadata if the API permits it. Reconcile using that value before weaker evidence such as summary text and timestamps. Titles are not identifiers. Two legitimate meetings can share a title, attendees, and start time.

If reconciliation cannot prove whether the event exists, keep the intent unknown and alert an operator. Delaying one meeting is better than creating a duplicate and sending another set of invitations. A bounded retry can be allowed only after the lookup contract proves the earlier write did not commit.

The practitioner report in GroupOffice issue 1528 describes calendar updates being imported as new events instead of merged with the original. It is one product report, not a universal provider behavior, but it illustrates the identity failure: a later version of one event is mistaken for a new event. Reconciliation must merge by provider identity, not by arrival time.

Keep local state aligned after creation

The workflow continues after event creation because attendees and organizers can change or delete it through another client. Run one synchronizer per provider account or calendar scope and persist its cursor only after all returned changes are durable locally.

For Google Calendar, process every page using the same request parameters, apply changed and deleted records, then save the new sync token after the page sequence completes. If the provider returns 410, discard the invalid cursor and rebuild the local calendar projection with a full sync as the official synchronization guide requires. Do not delete booking intents during that rebuild. Reconnect them to events by stable provider ID.

For Microsoft Graph, follow the opaque @odata.nextLink values until the round returns an @odata.deltaLink, then save that link for the next round. Do not reconstruct or edit the token URL. The Graph documentation says the state tokens encode the original query parameters.

Treat provider changes according to their meaning:

  • An event edit updates the existing local event by provider identity.
  • A deletion marks the event cancelled and invalidates pending reminders or downstream tasks.
  • A moved occurrence updates that occurrence rather than creating another series.
  • An attendee response changes participation state without creating a new booking intent.
  • An organizer change that affects time or attendees may invalidate dependent workflows.

Microsoft's event update documentation notes that attendee updates can send meeting updates to changed attendees, and some removals can notify a wider set. Keep update payloads narrow. A recovery worker should not resend the full attendee list unless the intended change requires it.

Test duplicate and stale-state boundaries

A happy-path meeting proves only that credentials and basic API calls work. Reliability tests must interrupt the state machine at the points where identity or freshness can be lost.

Start two workers with the same booking intent. Assert that one creates the provider event and the other returns the stored result. Repeat after a process restart to prove the claim is durable rather than held in memory.

Simulate a provider accepting the insert while the client times out. The intent should become unknown. Reconciliation should find the stable provider event ID and mark it created without a second insert. Run a second case where insertion was rejected before commit and verify that the controller distinguishes that definite result from a timeout.

Change an attendee's calendar after proposal approval but before the write. The final availability check should reject the stale slot and request a new proposal. Then edit an already created event through another calendar client. Incremental sync should update the same local event rather than add a second row.

Expire or invalidate a Google sync token and verify that a full rebuild preserves booking-to-event mappings. For Graph, stop between two nextLink pages and resume without advancing the saved delta link early. Add deletion and recurring-event fixtures so cleanup and instance identity are exercised, not assumed.

Track booking intents by state, duplicate claim conflicts, stale proposals, unknown creates, reconciliation outcomes, cursor age, full-sync recoveries, provider events without intents, and intents without provider events. These counts correspond to recoverable states. A rising unknown-create rate points to transport or provider latency, while a rising stale-proposal rate suggests the approval window is too long for the workflow.

Put the controller ahead of model tuning

Implement the booking-intent table, stable provider identity, and claim transaction first. Add the final availability check and unknown-outcome reconciliation next. Then build incremental synchronization for edits and deletions, including cursor recovery and recurring instances.

Run the duplicate-worker, accepted-but-timed-out, stale-slot, edited-event, deleted-event, and expired-cursor tests against test calendars. Only after they pass should you tune how the AI scheduling assistant chooses or explains times. A polished proposal does not help if the application books it twice.

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.