Build a calendar conflict-resolution agent with Nylas
Build a calendar conflict-resolution agent with Nylas
Booking races create conflicts nobody notices until two people show up for the same slot. Two requests land within a few seconds of each other, both pass whatever availability check you had, and now there are two events sitting on the same calendar at 2pm Thursday. The calendar UI happily renders both. Nobody finds out until the meeting starts and a second invite link shows up in the chat.
Most "AI scheduling" demos sidestep this entirely. They assume an open slot, negotiate a time, and book it. That's the easy half. The hard half is what happens after the booking, when the calendar already has a clash and somebody has to decide which event survives and tell the loser to move. That's a detection-and-resolution problem, not a negotiation problem, and it's the one I want to walk through here.
The approach: give the agent its own calendar - an Agent Account - let it watch its own events for overlaps, pick a winner by a rule you control, move the losing event, and email that event's participants to renegotiate. No human inbox involved. The agent is the participant.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring this up. Every operation also gets the raw curl, because in production your agent is talking to the API directly, not shelling out to a CLI.
What you actually get
An Agent Account is just a grant - the same grant_id abstraction every Nylas integration already uses. There's nothing new to learn on the data plane. It hits the same /v3/grants/{grant_id}/... endpoints as any connected Google or Microsoft account: Events, Messages, Threads, Webhooks. The difference is that the mailbox and calendar belong to the agent, not to a human you're impersonating.
For conflict resolution specifically, that buys you three things:
- A real calendar the agent owns. Events created or RSVP'd land here, and the agent can list them, compare them, and move them.
- Event webhooks -
event.created,event.updated,event.deleted- that fire whenever something changes on that calendar, including when an external participant accepts an invite the agent doesn't control. - A real mailbox on the same grant, so the renegotiation email comes from the agent's own address and threads correctly when someone replies.
One honest scoping note up front: Scheduler and availability-config endpoints aren't supported for Agent Accounts. You can't lean on a hosted booking page to enforce no-double-booking for you. You work with the grant's own Events and free/busy data, and the conflict logic lives in your app. That's actually fine for this use case - you want full control over the which-to-keep rule anyway, and Scheduler's "find an open slot" model doesn't help once the slot is already double-booked.
Provision the agent
You need a registered domain (a custom domain or a Nylas *.nylas.email trial subdomain) and an API key. Provisioning is a single call - no OAuth, no refresh token, because there's no human account to delegate from.
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"name": "Scheduling Agent",
"settings": {
"email": "scheduler@yourcompany.com"
}
}'
The response carries a grant_id. That's the agent. Everything else in this post is scoped to it.
From the CLI it's one line:
nylas agent account create scheduler@yourcompany.com --name "Scheduling Agent"
The API auto-creates a default workspace and policy for the account. If you later want a custom policy - to tighten attachment limits or spam handling on the inbox - attach it with nylas workspace update <workspace-id> --policy-id <policy-id>. There's no --workspace flag on create; the workspace comes for free.
Detect the overlap
This is the heart of it, and it's the part that's your code, not an endpoint. Nylas doesn't have a "find me conflicts" call. What it gives you is the event data; you compare start/end times and decide.
There are two ways to know an event changed: react to a webhook, or poll. Both are legitimate, and I'll show both because the right answer depends on how fast you need to react.
React to event webhooks
Webhooks in Nylas are application-scoped, not grant-scoped. You subscribe once at the app level, and events for every grant arrive at one endpoint, each payload carrying the grant_id you filter on.
Subscribe to the event triggers:
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["event.created", "event.updated"],
"webhook_url": "https://your-app.example.com/nylas/webhooks",
"description": "Calendar conflict detection"
}'
The CLI does the same subscription in one line - and nylas webhook triggers lists the valid trigger names so you don't guess:
nylas webhook create \
--url https://your-app.example.com/nylas/webhooks \
--triggers event.created,event.updated \
--description "Calendar conflict detection"
event.created and event.updated are both supported for Agent Accounts - they fire for every change to the agent's events, whether the agent made the change or an external participant did by accepting an invite.
When one arrives, dedupe on the top-level notification id (constant across the up-to-3 delivery attempts Nylas makes), verify the X-Nylas-Signature HMAC, then pull the surrounding window to look for overlaps. The webhook tells you something moved. It doesn't tell you what it now clashes with. For that you list the events around the changed event's time range and compare.
List events and compare
Whether you got here from a webhook or a polling loop, the detection step is the same: list the events in a window and check every pair for overlap.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/events?calendar_id=primary&start=1744387200&end=1744473600" \
--header "Authorization: Bearer $NYLAS_API_KEY"
The start and end query params bound the window in epoch seconds.
From the CLI:
nylas calendar events list scheduler@yourcompany.com \
--calendar primary \
--days 1 \
--json
events list defaults to the primary calendar and the next 7 days; --days 1 narrows it to the day you care about, and --json gives you something to feed your overlap check. Pass the agent's email (or grant_id) as the positional argument so you're listing the agent's calendar, not your own.
Now the logic that's entirely yours. Two events A and B overlap when A.start < B.end AND B.start < A.end. Sort the window by start time, walk it, and any pair that satisfies that condition is a conflict.
Pseudocode:
def overlaps(a, b):
return a["when"]["start_time"] < b["when"]["end_time"] \
and b["when"]["start_time"] < a["when"]["end_time"]
events = sorted(fetch_events(grant_id), key=lambda e: e["when"]["start_time"])
conflicts = [(a, b) for i, a in enumerate(events) for b in events[i+1:] if overlaps(a, b)]
That's the detection-nobody-noticed-the-clash problem solved: you're now finding the double-booking the moment it lands, instead of at 2pm Thursday.
Decide which event to keep
Once you have a conflicting pair, you need a rule for which one survives. This is a business decision, so it lives in your code - and a quick warning: Agent Accounts don't support custom metadata on events, so you can't stash "priority: high" on the event itself and filter by it. Keep your conflict state and your tie-break inputs in your own database, keyed by event id.
Common rules, roughly in order of how often I see them:
- First-booked wins. Whichever event was created first keeps the slot; the later one moves. Compare your DB's recorded creation timestamps, not the event's
whentime. - Priority wins. A VIP customer or an internal all-hands outranks a routine sync. Look the priority up in your DB by attendee or event type.
- Shorter event yields. If a 30-minute call collides with a 2-hour workshop, move the call - it's easier to re-slot.
Pick one, encode it, and you have a winner and a loser. The rest is mechanical: move the loser and tell its participants.
Move the losing event
Here's a real wrinkle worth being honest about. The CLI's events update changes the event, but it has no flag to notify participants - that's an API-only query parameter. So when you need the move to push an updated invite to everyone's calendar, use curl with notify_participants=true:
curl --request PUT \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/events/$LOSER_EVENT_ID?calendar_id=primary¬ify_participants=true" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"when": {
"start_time": 1744398000,
"end_time": 1744401600
}
}'
With notify_participants=true, Nylas sends an ICS update and the new time lands on every participant's Google, Outlook, or Apple calendar automatically. The agent is the organizer here, so it's allowed to move its own event.
If you only want to reschedule on the agent's side - say you're staging a tentative new time before you've confirmed it with anyone - the CLI is the cleaner tool, since its silent-by-default behavior is exactly what you want:
nylas calendar events update $LOSER_EVENT_ID scheduler@yourcompany.com \
--calendar primary \
--start "2025-04-11 15:00" \
--end "2025-04-11 16:00"
That updates the stored event without firing invites. In practice I reach for the CLI version while developing and testing the rule, then switch to the notify_participants=true API call in production once I trust the logic enough to actually email people.
Email the participants to renegotiate
Moving the event tells calendars the new time. It doesn't explain anything, and for a contested slot you usually want a human-readable note: "your meeting clashed with a higher-priority booking, here's the new time, reply if it doesn't work." That's a plain email from the agent's own address.
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"to": [{"email": "alice@example.com"}],
"subject": "Your 2pm Thursday call moved",
"body": "Hi Alice - your call collided with another booking on my calendar, so I moved it to 3pm Thursday. The updated invite is already on your calendar. If 3pm does not work, reply here and I will find another slot."
}'
Omit the from field and Nylas defaults it to the Agent Account's own address and name - so the email genuinely comes from scheduler@yourcompany.com, not some no-reply relay.
The CLI equivalent:
nylas email send scheduler@yourcompany.com \
--to alice@example.com \
--subject "Your 2pm Thursday call moved" \
--body "Hi Alice - your call collided with another booking, so I moved it to 3pm Thursday. Reply here if that does not work."
When Alice replies, that reply lands in the agent's mailbox and threads against the original message. Read the thread to keep the negotiation context before the agent decides on a counter-slot:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
--header "Authorization: Bearer $NYLAS_API_KEY"
The CLI fetches the same thread, with its message summaries, in one command:
nylas email threads show $THREAD_ID scheduler@yourcompany.com --json
Now you have a closed loop: detect the clash, move the loser, notify its people, and read their responses to renegotiate if the new time doesn't stick - all on one grant the agent owns end to end.
Guardrails and gotchas
A few things that bit me, or that I'd want to know before shipping this.
- The invite email and the event webhook both fire. When an external invite hits the agent, you get
message.createdfor the email andevent.createdfor the calendar entry. Decide which one drives your logic and ignore the other; driving off the event is cleaner because the object already has structured times and participants. - Watch the send quota. Every
notify_participants=trueupdate and every renegotiation email counts toward the Agent Account's daily send limit (200 messages/account/day on the free plan). If a storm of conflicts triggers a storm of emails, you can blow through it. Batch where you can. - Conflict state is yours to keep. No event metadata means your DB is the source of truth for "which conflicts have I already resolved." Without it, a re-delivered webhook or a re-run of your polling loop will happily "resolve" the same conflict twice. Dedupe on the event
idand your own resolution records. - Recurring events are sneaky. A single recurring series can generate dozens of conflicting instances. Pass
expand_recurring=truewhen you list, and decide whether you're moving one instance or the whole series before you callPUT. - The CLI won't notify; the API will. This is the one asymmetry to keep straight. If a step has to push an updated invite to other people's calendars, it's a
curlcall withnotify_participants=true. If it's an internal/staging change, the CLI is fine and quieter.
What's next
The skeleton above is deliberately small: list, compare, move, email. The interesting work is the rule - first-booked, priority, duration - and the renegotiation flow once someone pushes back. Both of those are your domain logic sitting on top of a grant that behaves exactly like every other Nylas grant you've used.
- Agent Account calendars - how the agent hosts, receives, and RSVPs to events
- Supported endpoints for Agent Accounts - the full Events, Messages, and webhook surface
- Agent Accounts overview - what they are and how grants map to them
- Nylas CLI command reference - every
nylascommand used above
If you want the negotiation-from-an-open-slot side of this - proposing times, collecting picks, booking the winner - that's a different post. This one is strictly about the clash that already happened, and how to resolve it.
Comments
No comments yet. Start the discussion.