Let your agent negotiate meeting times over email

작성자

카테고리:

← 피드로
DEV Community · Qasim · 2026-06-25 개발(SW)

Most “AI scheduling assistant” demos cheat. They show a model that already knows the time, calls “create event” once, and takes a bow. That’s not scheduling — that’s data entry with a chat wrapper. Real scheduling is a negotiation: someone asks “are you free Tuesday?”, you check, you counter with “Tuesday’s full, how about Wednesday at 2?”, they push back, and eventually two humans (or an agent and a human) converge on a slot nobody hates. It’s a multi-turn conversation that happens to end in a calendar event.

I already wrote about giving your agent a calendar — provisioning a real mailbox, hosting events, RSVPing to invites. That post stops at the single create call. This one is about everything before that call: the back-and-forth where the time gets decided. The create event is the last line of the story, not the whole plot.

The vehicle is a Nylas Agent Account. It’s a grant with its own email address and its own calendar, so it can be an actual participant in the negotiation instead of a bot reading over a human’s shoulder. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I’m prototyping one of these loops.

One thing I’ll call out up front, because it’s the most common wrong turn: Scheduler is not available for Agent Accounts. No availability-configuration API, no booking pages, no /v3/scheduling/*. That’s documented in Supported endpoints. So if your instinct is “just point it at Scheduler,” stop — that door is locked for this provider. What you do have is the grant’s own free/busy plus the Events API, and that’s enough to build the whole negotiation yourself. This post shows how.

What the negotiation loop actually looks like

Strip away the AI and a meeting negotiation is a state machine with four moves:

  1. An inbound message proposes times (or asks the agent to propose some). It arrives as email, on a thread.
  2. The agent checks its own free/busy over the requested window and finds open slots.
  3. The agent replies in-thread with a proposal — accepting one of the requested times, or countering with its own.
  4. The other side replies again. Loop back to step 2 with the new constraints until somebody says yes, then create the event.

Three of those four moves map directly onto Nylas primitives — inbound mail, free/busy, and in-thread reply — and the fourth is the event create. The part that isn’t a Nylas call is the decision in the middle: parsing “Wednesday afternoon works but not before 2” out of an email and turning it into a concrete slot. That’s your application logic — an LLM call or a parser you own. I’ll be honest about that boundary throughout, because pretending the API does the reasoning for you is exactly the kind of demo magic I’m complaining about.

The nice thing is the data plane never changes. An Agent Account is just a grant with a grant_id, so every call below is a standard /v3/grants/{grant_id}/... endpoint. Nothing new to learn — if you’ve used the Nylas email and calendar APIs, you already know the verbs.

Before you begin

You need:

  • An Agent Account (a grant). Create one with nylas agent account create [email protected] or POST /v3/connect/custom. The quickstart walks through it.
  • Its grant_id, and a Nylas API key exported as NYLAS_API_KEY.
  • A way to receive inbound mail. Subscribe to the message.created webhook so the agent reacts within seconds of a request arriving, or poll GET /messages on a cadence for batch flows. Both are supported.

New to Agent Accounts? Read What are Agent Accounts first — the rest of this assumes you’ve got a working account.

All API examples use the US base host https://api.us.nylas.com and a bearer token. Swap in api.eu.nylas.com if your app lives in the EU region.

Step 1: Read the inbound request

When someone emails the agent — “Can we grab 30 minutes next week?” — that’s a message.created webhook with a thread_id. The whole negotiation lives on that one thread, so the thread_id is the key you’ll carry through every later step. (See Email threading for agents for why the thread, not the subject line, is the stable identifier.)

Fetch the message body so your code can extract the requested window:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?thread_id=<THREAD_ID>&limit=5" \
  --header "Authorization: Bearer $NYLAS_API_KEY"

Enter fullscreen mode Exit fullscreen mode

From the CLI:

nylas email threads show <THREAD_ID>

Enter fullscreen mode Exit fullscreen mode

…and to read the specific message in full:

nylas email read <MESSAGE_ID>

Enter fullscreen mode Exit fullscreen mode

What you do with the body is your code. “Next week, ideally Tuesday or Wednesday afternoon” is natural language — feed it to an LLM and have it return a structured window (start, end, plus any preferences). Don’t pretend the API parses intent; it hands you text and you decide what it means. Keep the message_id you’re replying to and the thread_id — you need both in a moment.

Step 2: Check the agent’s own free/busy

Now the agent looks at its own calendar over the requested window. This is the move that makes the agent a real participant: it has commitments, and it should refuse to propose slots it’s already booked into.

The endpoint is POST /v3/grants/{grant_id}/calendars/free-busy. Give it a window and the email addresses to check — the agent’s own address checks the agent’s own calendar:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/calendars/free-busy" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "start_time": 1750766400,
    "end_time": 1751025600,
    "emails": ["[email protected]"]
  }'

Enter fullscreen mode Exit fullscreen mode

The response is a list of busy blocks (epoch start_time/end_time pairs) for that address over the window. The open slots are the gaps between them — computing those gaps is, again, your code. Free/busy tells you what’s taken; you decide what counts as a proposable slot (business hours only? 30-minute granularity? buffer between meetings?).

The CLI wraps the same call as nylas calendar availability check:

# Check the agent's own busy blocks over a window
nylas calendar availability check \
  --emails [email protected] \
  --start "next monday 9am" \
  --end "next friday 6pm"

Enter fullscreen mode Exit fullscreen mode

There’s also nylas calendar availability find, which goes one step further and surfaces open meeting slots directly — handy when you’re prototyping and don’t want to write the gap-finding logic yet. But for a production negotiation loop you’ll usually want the raw busy blocks from check (or the API) so your own code controls how slots get picked.

A subtle but important point: free/busy here checks the agent’s calendar. If you also want to check the human’s availability, and that human is on a connected Google or Microsoft grant your app can see, you can include their address in the same emails array — every address in one request must share a provider. For a pure email negotiation, though, you usually don’t have programmatic access to the other side’s calendar. That’s the whole reason you’re negotiating over email instead of querying their free/busy directly. Lean into it: propose, let them counter, converge.

Step 3: Propose times by replying in-thread

The agent has open slots. Now it proposes them — and the proposal is an email reply on the existing thread, not a calendar invite. This is the heart of the difference from the “just create the event” approach. You don’t book anything yet. You float two or three options and wait.

Replying in-thread matters because it keeps the whole negotiation in one conversation, in the other person’s inbox, where they’ll naturally reply again. Nylas preserves the threading headers (In-Reply-To, References) for you when you pass reply_to_message_id:

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 '{
    "reply_to_message_id": "<INBOUND_MESSAGE_ID>",
    "to": [{ "email": "[email protected]" }],
    "subject": "Re: 30 minutes next week",
    "body": "Tuesday is full on my end, but I have Wednesday open at 2:00 PM or 3:30 PM ET, or Thursday at 11:00 AM ET. Any of those work?"
  }'

Enter fullscreen mode Exit fullscreen mode

The CLI version is shorter because reply fetches the original message and fills in recipient, subject, and threading automatically:

nylas email reply <INBOUND_MESSAGE_ID> \
  --body "Tuesday is full on my end, but I have Wednesday open at 2:00 PM or 3:30 PM ET, or Thursday at 11:00 AM ET. Any of those work?"

Enter fullscreen mode Exit fullscreen mode

The body text — phrasing the options, choosing how many to offer, matching the requester’s preferences — is generated by your LLM from the slots you computed in step 2. The send and the threading are Nylas; the wording and the choice of which slots to surface are yours.

Step 4: Read the counter-proposal and converge

This is the turn most demos skip entirely, and it’s the one that makes the whole thing a negotiation. The other side replies: “Wednesday at 2 doesn’t work, can you do Wednesday morning instead?” That reply fires another message.created on the same thread. Your handler looks up the thread_id in whatever state store you’re keeping, sees that this conversation is mid-negotiation, and loops back to step 2 with the new constraint (“Wednesday morning”).

Two things make this loop survivable in production:

  • Carry state on the thread. Map thread_id to your negotiation state — which window you last proposed, how many rounds you’ve done, whether you’re waiting on a reply. Keep it in a durable store, not memory; these conversations span hours or days. The threading guide has a worked pattern for this.
  • Cap the rounds. Decide what happens after, say, three counter-proposals with no convergence: hand off to a human, or send a Scheduler-style “here are my next five openings, pick one” message and stop negotiating. Open-ended back-and-forth with an LLM on both ends is how you burn your daily send quota for nothing.

Each round is just steps 2 and 3 again — re-check free/busy against the narrowed window, reply in-thread with a tighter proposal. You converge when one side’s reply is an acceptance, which your parser detects the same way it detected the original request: by reading the email.

Step 5: Book the agreed slot

Once both sides have said yes to a specific time, now you create the event — the single call the other demos lead with. Because the agent is a real calendar participant, creating with notify_participants=true sends a normal ICS invitation from the agent’s address. The requester gets it in Gmail or Outlook and clicks Accept like they would for any colleague.

calendar_id is a required query parameter on create; use primary for the agent’s default calendar:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/events?calendar_id=primary&notify_participants=true" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "title": "Acme <> Support sync",
    "when": { "start_time": 1750944600, "end_time": 1750946400 },
    "participants": [
      { "email": "[email protected]" }
    ]
  }'

Enter fullscreen mode Exit fullscreen mode

The CLI mirrors it:

nylas calendar events create \
  --calendar primary \
  --title "Acme <> Support sync" \
  --start "2026-06-26 14:00" \
  --end "2026-06-26 14:30" \
  --participant [email protected]

Enter fullscreen mode Exit fullscreen mode

After this fires, an event.created webhook lands on the agent’s calendar, and when the requester clicks Accept, their response flows back to the agent’s mailbox and event.updated fires with their RSVP status. At that point the negotiation is genuinely done: a real event exists on real calendars, agreed over email, picked from times the agent confirmed it was actually free for.

A nice touch: send one final reply on the thread confirming the booking (“Sent you an invite for Wednesday at 2 — see you then”). That closes the conversation in the requester’s inbox where it started, instead of leaving the last word as a silent calendar invite.

Guardrails and honest tradeoffs

A few things I’d build in before pointing this at real users:

  • Every reply and invite counts against the send quota. Agent Accounts have a daily send limit. A negotiation that runs five rounds plus a confirmation plus an invite is seven sends per booking. Cap your rounds partly for sanity and partly for quota.
  • The reasoning is yours, and it will be wrong sometimes. Parsing “Wednesday afternoon, but not the 30th” out of free-text email is an LLM task, and LLMs misread dates. Validate the slot against free/busy one more time right before you create the event — never trust the parse alone.
  • Free/busy only knows the agent’s calendar. You’re negotiating over email precisely because you can’t see the other side’s calendar. Don’t propose a slot as “confirmed for both of us” — propose it as “open on my end,” and let their reply (or their RSVP) confirm theirs.
  • Handle the double-reply. Someone might reply twice, or two people on a thread might both answer. Your handler should be idempotent on thread_id so the agent doesn’t fire off two competing proposals.
  • There’s no counter-propose-time endpoint. When the agent is an invitee (someone invited it), it can RSVP yes/no/maybe via send-rsvp, but “propose a different time” isn’t a first-class calendar action — you do it exactly as above, by replying to the invite email with an alternative. Same loop, different starting point.

What’s next

The shape here is deliberately simple: inbound message → check own free/busy → reply with a proposal → read the counter → loop → create the event. Four Nylas primitives and one piece of reasoning you own. No Scheduler, no booking pages, no availability config — none of which exist for Agent Accounts anyway.

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다