DEV Community

Qasim
Qasim

Posted on

Let an agent own the invite: RSVP reconciliation with Agent Accounts

Most "AI scheduling" demos point a model at a human's inbox, parse a few invite emails, and call it a day. That's fine until you want the agent to actually run the meeting — to be the organizer whose name is at the top of the invite, whose calendar is the source of truth, and who has to know, in real time, who's coming.

That last part is the interesting one. When your agent is the organizer, sending the invite is the easy 20%. The other 80% is reconciliation: someone accepts, someone declines, someone flips from "yes" to "maybe" the morning of, and the agent's internal picture of "who is attending this meeting" has to stay correct without you babysitting it. RSVP replies arrive over the next few hours and days as ICS REPLY messages bouncing back from Google Calendar, Outlook, and Apple Calendar. If you're treating those as emails to parse, you've signed up for the worst job in calendaring.

This post is about doing it the boring, correct way: let the agent send the invite, let Nylas fold the incoming RSVP replies into the event's participant list, and react to a single webhook when status changes. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm poking at this by hand before wiring it into app code.

What you actually get

An Agent Account is a Nylas grant with its own real mailbox and its own real calendar. From a participant's side there's nothing special about it — it shows up as a normal organizer on a normal invite. Under the hood it speaks standard iCalendar, so it interoperates with Google Calendar, Microsoft 365, and Apple Calendar as a first-class participant.

The reconciliation loop has three moving parts, and Nylas owns two of them for you:

  • Send the invite. Create an event with participants and notify_participants=true. Nylas sends an ICS REQUEST from the Agent Account's address to each attendee.
  • Absorb the replies. When an attendee clicks Yes / No / Maybe in their calendar client, their provider mails an ICS REPLY back to the Agent Account's mailbox. Nylas reads it and updates that participant's status on the event object to yes, no, maybe, or noreply. You don't parse anything.
  • React. Each time a participant's status changes, an event.updated webhook fires for the Agent Account's calendar. That's your cue to recompute attendance and do whatever your app does with it.

The conceptual pivot worth internalizing: the event object is your participant database. You don't reconstruct attendance from a pile of reply emails — you read it off participants[].status, which Nylas keeps current. The Agent Account calendars doc confirms this reconciliation behavior specifically for Agent Account grants: each response lands in the mailbox, Nylas reads it, and the event's participants[].status is updated automatically.

Why this beats parsing reply emails

If you went the naive route — agent reads its inbox, finds the "Accepted: Product demo" email, regexes out who and what — you'd be reimplementing an iCalendar parser badly. A few reasons not to:

  • ICS REPLY messages are not consistent across providers. Google, Microsoft, and Apple format the human-readable part differently, localize it, and sometimes thread it oddly. The machine-readable METHOD:REPLY block is consistent, but now you're parsing MIME and text/calendar parts by hand.
  • State, not events, is what you care about. A participant can flip their answer three times. You want their current status, and the event object already collapses the history into one value per participant.
  • You'd duplicate work Nylas already did. Nylas processes the inbound reply, matches it to the event, and updates status. Re-deriving that from the raw email is strictly more work for a worse result.

The honest tradeoff: you're trusting Nylas's reconciliation instead of owning the parse. For organizer-side RSVP tracking, that's the trade you want. If you needed full round-trip time negotiation — propose slots, collect picks, book the winner — that's a different problem, and one the Events API doesn't try to solve on its own.

Before you begin

You need an Agent Account and an API key. If you don't have an account yet, the Agent Accounts quickstart walks through provisioning; the short version from the CLI is:

nylas agent account create scheduler@yourcompany.nylas.email --name "Scheduling Agent"
Enter fullscreen mode Exit fullscreen mode

The same thing over raw HTTP is a POST /v3/connect/custom with provider: "nylas" and a settings.email on a domain you've registered — no refresh token, no OAuth dance:

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.nylas.email" }
  }'
Enter fullscreen mode Exit fullscreen mode

Either way you get a grant with a primary calendar already provisioned. Grab the grant_id — every endpoint below is grant-scoped at /v3/grants/{grant_id}/..., which is the whole point of the grant abstraction: there's nothing new to learn on the data plane. An Agent Account hits the same Events endpoints as any connected Google or Microsoft grant.

For the raw HTTP examples I'll use https://api.us.nylas.com and a bearer token:

export NYLAS_API_KEY="<your-api-key>"
export GRANT_ID="<agent-account-grant-id>"
Enter fullscreen mode Exit fullscreen mode

Step 1 — Send the invite as the organizer

Creating an event with participants and notify_participants=true is what makes the Agent Account the organizer of record. Nylas sends the ICS REQUEST and the attendees see an invite from scheduler@yourcompany.nylas.email.

Here's the API call. Note notify_participants=true is a query parameter, not a body field — this is the flag that turns "save a private event" into "send invitations":

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": "Product demo",
    "when": { "start_time": 1744387200, "end_time": 1744390800 },
    "participants": [
      { "email": "alice@example.com" },
      { "email": "bob@example.com" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

The response includes the new event's id and a participants array where each entry currently reads "status": "noreply". Hold onto that event id — it's the key you'll read status back from and the key the webhook will reference.

The CLI equivalent uses nylas calendar events create with one --participant flag per attendee:

nylas calendar events create "$GRANT_ID" \
  --calendar primary \
  --title "Product demo" \
  --start "2026-07-15 10:00" \
  --end "2026-07-15 11:00" \
  --participant "alice@example.com" \
  --participant "bob@example.com"
Enter fullscreen mode Exit fullscreen mode

One thing to call out honestly: as of CLI v3.1.27, nylas calendar events create doesn't expose a --notify-participants flag — there's no such flag, so don't invent one. When you need to be explicit about the notify_participants=true query parameter (for example to guarantee invites go out, or to suppress them with false for a silent backfill), reach for the API call. The CLI is the fast path for creating the event; the curl form is where you control the notification semantics precisely. I use the CLI to set things up interactively and the API in the actual service.

A note on invite quota: every create, update, and delete sent with notify_participants=true counts against the Agent Account's daily send limit, because each invitation is an outbound email. If the account is over quota the event still saves but the invitation is skipped silently. Worth a guard in your app.

Step 2 — Read participant status off the event

Once invites are out, the event object becomes your live attendance record. As RSVP replies arrive, Nylas updates participants[].status in place. You read it back with a plain GET on the event:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/events/<EVENT_ID>?calendar_id=primary" \
  --header "Authorization: Bearer $NYLAS_API_KEY"
Enter fullscreen mode Exit fullscreen mode

The participants array now reflects reality — something like:

"participants": [
  { "email": "alice@example.com", "status": "yes" },
  { "email": "bob@example.com",   "status": "noreply" }
]
Enter fullscreen mode Exit fullscreen mode

Alice accepted; Bob hasn't answered. No email parsing happened on your side — Nylas folded Alice's ICS REPLY into the event for you. The four values you'll see are yes, no, maybe, and noreply.

From the CLI, nylas calendar events show (aliased as read and get) fetches the same event:

nylas calendar events show <EVENT_ID> "$GRANT_ID" --calendar primary --json
Enter fullscreen mode Exit fullscreen mode

Pipe that through jq '.data.participants' and you've got the same status list in the terminal. This is genuinely the move I make first when something looks off — read the event, look at the statuses, trust the object before I trust anything I think I remember sending.

Polling this endpoint on a cadence is a legitimate pattern for batch jobs. But if you want to react the moment someone responds, polling is the wrong tool. That's what the webhook is for.

Step 3 — React to status changes with the event.updated webhook

When a participant's status changes, Nylas fires an event.updated webhook for the Agent Account's calendar. The supported-endpoints reference lists event.updated explicitly as a trigger for Agent Account grants, so this is a supported path, not a hopeful one. This is what turns the loop from "the agent checks occasionally" into "the agent knows within seconds."

One important detail about scope: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, and notifications for every grant in the app land at the same endpoint, each payload carrying the grant_id you filter on. So you create the subscription against /v3/webhooks, not against the grant:

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.updated"],
    "webhook_url": "https://yourapp.com/webhooks/nylas",
    "description": "RSVP reconciliation for the scheduling agent"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI mirrors this with nylas webhook create:

nylas webhook create \
  --url "https://yourapp.com/webhooks/nylas" \
  --triggers event.updated \
  --description "RSVP reconciliation for the scheduling agent"
Enter fullscreen mode Exit fullscreen mode

--triggers takes a comma-separated list or repeated flags, so in practice I subscribe to event.created,event.updated,event.deleted together and branch in the handler. The part I like as an SRE: one subscription covers every Agent Account you ever provision in that app. You don't re-subscribe per agent.

When event.updated arrives, the payload carries the event id and grant_id. The handler shape is the same regardless of why the event changed:

  1. Verify the signature. Nylas signs each delivery with X-Nylas-Signature, a hex HMAC-SHA256 of the raw request body using your webhook secret. Compare it constant-time, and guard that both buffers are the same length first or the comparison throws on mismatch. nylas webhook verify does this locally while you're testing.
  2. Dedupe. Nylas guarantees at-least-once delivery — the same event can arrive up to three times. Dedupe on the top-level notification id, which stays constant across retries of one delivery.
  3. Refetch the event with the GET from Step 2. Don't trust a status snapshot embedded in the payload — read the current participant list from the source of truth.
  4. Reconcile. Diff the new statuses against what you stored last time and act on the delta.

Reacting to the delta is your job, not Nylas's

Here's the boundary worth being precise about. Nylas keeps participants[].status correct. Everything you do with a status change — that's your application logic, and it lives in your code and your database.

Concretely, the things teams usually want — "nudge everyone still on noreply 24 hours out," "alert the host if the key stakeholder declines," "auto-cancel if fewer than three people accept" — none of those are Nylas features. They're a function over the participant list that you run when the webhook fires. The pattern is:

  • Keep your own table keyed by event id, storing each participant's last-known status. Agent Accounts don't support custom metadata on events, so you can't stash this on the Nylas side — own the state yourself.
  • On each event.updated, refetch, diff against your stored row, and emit whatever actions the delta implies (a reminder email via nylas email send, a Slack ping, a row update).
  • Persist the new statuses so the next webhook diffs against fresh state.

And to be clear about what's not on the table here: Scheduler isn't available for Agent Account grants, so this isn't a "let people pick a slot" flow. The agent already knows the time. Its job is to send the invite and keep an accurate, real-time picture of who's in — which the Events API plus event.updated handles cleanly.

Updates and cancellations close the loop

Reconciliation isn't only inbound. When the agent changes the meeting — say it pushes the start time, or swaps the participant list to nudge non-responders — those changes have to propagate too. A PUT on the event sends an ICS update to every participant:

curl --request PUT \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/events/<EVENT_ID>?calendar_id=primary&notify_participants=true" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "when": { "start_time": 1744390800, "end_time": 1744394400 } }'
Enter fullscreen mode Exit fullscreen mode

The CLI equivalent is nylas calendar events update, which takes the event id and the fields you're changing:

nylas calendar events update <EVENT_ID> "$GRANT_ID" \
  --calendar primary \
  --start "2026-07-15 11:00" \
  --end "2026-07-15 12:00"
Enter fullscreen mode Exit fullscreen mode

Same --notify-participants caveat as create: the CLI flag doesn't exist in v3.1.27, so when you need the explicit notify_participants=true query parameter on the update, use the API call.

Cancelling is a DELETE. With the Agent Account as organizer, it sends an ICS CANCEL to every participant when notify_participants=true:

curl --request DELETE \
  --url "https://api.us.nylas.com/v3/grants/$GRANT_ID/events/<EVENT_ID>?calendar_id=primary&notify_participants=true" \
  --header "Authorization: Bearer $NYLAS_API_KEY"
Enter fullscreen mode Exit fullscreen mode

From the CLI that's nylas calendar events delete, with --force to skip the confirmation prompt in a script:

nylas calendar events delete <EVENT_ID> "$GRANT_ID" --calendar primary --force
Enter fullscreen mode Exit fullscreen mode

The gotcha: deleting without notification leaves the meeting sitting on everyone's calendar. Cancel with notification unless you have a specific reason not to.

What's next

The short version: when the agent owns the invite, don't parse reply emails — let Nylas reconcile RSVPs onto the event, read status off the object, and let event.updated tell you the moment it changes. The reaction logic is yours; the reconciliation is handled.

Top comments (2)

Collapse
 
mateo_ruiz_6992b1fce47843 profile image
Mateo Ruiz

This is a great example of where production agent engineering is mostly systems engineering. The model isn't the hard part it's designing reliable event flows, idempotent state updates, and webhook-driven reconciliation so the agent always acts on the current truth. The more deterministic the integration layer is, the less reasoning the agent has to do, which usually leads to more reliable automation.

Collapse
 
francofuji profile image
Francisco Perez

The distinction you're drawing between parsing invite emails from a human's inbox versus the agent being the organizer with its own grant is the part most scheduling demos skip entirely, and it's where they break in production. Reconciling ICS REPLY messages from three different calendar clients without normalizing the participant state first is where I've seen "AI scheduling" demos silently lose RSVPs — Google and Outlook serialize accepted/declined status differently in the iCalendar spec, and if you're counting on raw email body text rather than the VEVENT participant list, you get stale state.

For testing this kind of agent flow end-to-end before connecting it to a real inbox, disposable programmatic mailboxes are worth knowing about — uncorreotemporal.com provides a SMTP-receivable inbox per API call, which makes it straightforward to simulate RSVP reply flows in CI without a real calendar account involved.