DEV Community

Qasim
Qasim

Posted on

A clinic reminder agent that can actually move the appointment

Most appointment-reminder systems are a one-way street. A cron job reads a calendar, fires an email, and that's the end of the conversation. If a patient hits reply and says "can we move it to Thursday?", that reply lands in a noreply inbox that nobody reads, or worse, in a shared mailbox that a human has to triage by hand. The reminder did its job; the reschedule fell on the floor.

I want the other thing. I want a reminder that can act on the reply. When the patient writes back asking to move their appointment, the agent should reschedule the event on its own calendar, send the patient an updated invite, and confirm in the same thread — without a person in the loop for the routine cases.

That's exactly what an Agent Account is for. An Agent Account is a real mailbox and a real calendar that belongs to your application, not to a human user. It has its own email address (reminders@clinic.yourapp.nylas.email), it owns the events it hosts, and it works with every grant-scoped Nylas endpoint you already know. The reminder-to-reschedule loop becomes: send from the agent, listen for the reply, update the agent's event, re-notify the patient. No human inbox to babysit.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring one of these up. Every operation also has its curl equivalent, because in production you're calling the API, not shelling out.

Why this beats a generic reminder cron

The standard event-reminder recipe reads a human's calendar and emails the attendees from that human's address. It's good. But it has two structural problems the moment you want two-way behavior:

  • The sender is a person. Replies go to a real human's inbox. There's no clean place for an automated reschedule handler to live.
  • The calendar is a person's. Rescheduling means writing to someone's personal calendar, which is exactly the access nobody wants to hand an automated agent.

With an Agent Account, the agent is the participant. It owns the calendar the appointment lives on. A reply to a reminder is a reply to the agent, and a reschedule is the agent editing its own event. Nothing about the data plane is new — it's still messages/send, still the Events API, still webhooks. The grant abstraction is the spine here: an Agent Account is just a grant with a grant_id, so everything you'd do against a connected Gmail grant works identically.

One honest caveat up front: this is a docs/demo walkthrough, not a compliance blueprint. If you're moving real patient data, the PHI handling, retention, and BAA questions are yours to answer with your security team. Keep appointment bodies lean, don't log message contents, and treat every inbound reply as untrusted text.

What the agent does, end to end

cron (hourly) ─▶ list agent's events in next 48h ─▶ send reminder from the agent
                                                          │
patient replies "move it to Thursday?" ──▶ message.created webhook
                                                          │
                                          fetch full body ─▶ parse intent
                                                          │
                            free/busy check ─▶ events update (notify_participants=true)
                                                          │
                                          reply in-thread: "Done — moved to Thursday 2pm"
Enter fullscreen mode Exit fullscreen mode

Two flows around one shared state store. The cron sends reminders proactively. The webhook reacts to replies and drives the reschedule. The state — which appointment maps to which patient, which reminder you already sent, which inbound messages you've already processed — lives in your database. Agent Accounts don't support custom metadata, so don't try to stash reschedule state on the message or event object. Keep it in your own table keyed by event ID and patient.

Before you begin

You need a Nylas application, an API key, and a registered domain (a custom domain or a free *.nylas.email trial subdomain). New domains warm up over roughly four weeks, so provision early if you care about deliverability.

Create the Agent Account. Via the CLI:

nylas agent account create reminders@clinic.yourapp.nylas.email \
  --name "Clinic Reminders"
Enter fullscreen mode Exit fullscreen mode

The API auto-creates a default workspace and policy for the account, so there's nothing else to configure to start sending and receiving. The equivalent raw call is POST /v3/connect/custom:

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": "Clinic Reminders",
    "settings": { "email": "reminders@clinic.yourapp.nylas.email" }
  }'
Enter fullscreen mode Exit fullscreen mode

The response includes a grant_id. That ID is the only thing the rest of this post needs — it's the <GRANT_ID> in every call below. No OAuth dance, no refresh token. The agent's mailbox and calendar are live immediately.

One thing worth internalizing: this account is a real mailbox and a real calendar at the same time. When a patient is invited to an appointment, the invitation flows through the agent's inbox and shows up on its calendar automatically. You'll see both a message.created and an event.created webhook for the same inbound invite. Pick which one drives your logic and ignore the other.

Send the reminder from the agent

Reminders go out from the agent's own address. Pull the agent's events for the next 48 hours, then send each patient a reminder. The send is a plain messages/send against the agent's grant.

CLI:

nylas email send <GRANT_ID> \
  --to patient@example.com \
  --subject "Reminder: your appointment Thursday at 2:00 PM" \
  --body "<html><body><p>Hi Jordan,</p><p>This is a reminder of your appointment with Dr. Lee on <strong>Thursday, June 26 at 2:00 PM</strong>.</p><p>Need to reschedule? Just reply to this email with a day and time that works.</p></body></html>"
Enter fullscreen mode Exit fullscreen mode

The explicit "reply to this email to reschedule" line matters — it's what turns the reminder into the front door of the reschedule flow. The patient replies to the agent, not to a noreply void.

The equivalent API call:

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 '{
    "subject": "Reminder: your appointment Thursday at 2:00 PM",
    "body": "<html><body><p>Hi Jordan,</p><p>This is a reminder of your appointment with Dr. Lee on <strong>Thursday, June 26 at 2:00 PM</strong>.</p><p>Need to reschedule? Just reply to this email with a day and time that works.</p></body></html>",
    "to": [{ "name": "Jordan Lee", "email": "patient@example.com" }]
  }'
Enter fullscreen mode Exit fullscreen mode

Record the send in your store: appointment event ID, patient email, the time you reminded them. You'll need that mapping when the reply comes back, because the inbound webhook won't carry your appointment context — only Nylas IDs.

Listen for the reply

Inbound mail to the agent fires the standard message.created webhook. Subscribe to it. CLI:

nylas webhook create \
  --triggers message.created \
  --url https://your-server.com/webhooks/nylas \
  --description "Clinic reminder reply handler" \
  --notify ops@clinic.example.com
Enter fullscreen mode Exit fullscreen mode

Same thing over the API:

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": ["message.created"],
    "description": "Clinic reminder reply handler",
    "webhook_url": "https://your-server.com/webhooks/nylas",
    "notification_email_addresses": ["ops@clinic.example.com"]
  }'
Enter fullscreen mode Exit fullscreen mode

Now the important detail that trips people up: the message.created payload carries summary fields only. You get the message ID, sender, subject, thread ID, snippet, and timestamps — not the full body. To read what the patient actually wrote, fetch the message by ID.

CLI:

nylas email read <MESSAGE_ID> <GRANT_ID>
Enter fullscreen mode Exit fullscreen mode

Or pull the whole thread if you want the conversation context around the reply:

nylas email threads show <THREAD_ID> <GRANT_ID>
Enter fullscreen mode Exit fullscreen mode

The same thread read over the API is GET /v3/grants/{grant_id}/threads/{thread_id}:

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode

The API equivalent for the single message:

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode

Before you do anything with the body, dedup on the inbound message ID. Webhooks can be redelivered, and you do not want to reschedule an appointment twice because the same message.created arrived twice. A unique constraint on processed_message_id in your store is the whole fix. Check it, insert it, then act.

Then parse intent. This is the one place a model earns its keep — turn "any chance we can do Thursday afternoon instead?" into a structured { action: "reschedule", proposed: "2026-06-26T14:00" }. Everything around it is deterministic plumbing, so keep the LLM scoped to extraction and let code handle the rest. If the model isn't confident, fall back to a human. A misparsed appointment time is the kind of mistake that gets noticed.

Check the agent's own availability

Before you commit to a new slot, make sure the agent isn't already booked then. Agent Accounts do not support Scheduler or availability configurations, so don't reach for those — use the agent's own calendar free/busy instead. That's the supported path, and it's all you need for a single-calendar reschedule.

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": 1782489600,
    "end_time": 1782496800,
    "emails": ["reminders@clinic.yourapp.nylas.email"]
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI wraps the same check behind availability check:

nylas calendar availability check <GRANT_ID> \
  --emails reminders@clinic.yourapp.nylas.email \
  --start "2026-06-26 14:00" \
  --end "2026-06-26 15:00"
Enter fullscreen mode Exit fullscreen mode

If the response shows a busy block over the patient's proposed time, the agent picks the nearest open slot and proposes it back instead of blindly double-booking Dr. Lee. If it's clear, proceed to the reschedule.

Reschedule the event and re-invite the patient

This is the move that generic reminders can't make. The appointment is an event on the agent's calendar, so rescheduling is just an update to that event — and because the agent owns it, it can push the change straight to the patient's calendar with notify_participants=true.

The API call is PUT /v3/grants/{grant_id}/events/{event_id}, with notify_participants as a query parameter. Setting it to true is what sends the updated invite (the fresh ICS) 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": 1782489600, "end_time": 1782493200 }
  }'
Enter fullscreen mode Exit fullscreen mode

The patient gets an updated invitation in their inbox from the agent's address. In Gmail, Outlook, or Apple Calendar, accepting it moves the appointment on their calendar — same as if a colleague had rescheduled a meeting. No "delete and recreate" dance, no orphaned original event.

The CLI updates the event the same way:

nylas calendar events update <EVENT_ID> <GRANT_ID> \
  --calendar primary \
  --start "2026-06-26 14:00" \
  --end "2026-06-26 15:00" \
  --timezone America/New_York
Enter fullscreen mode Exit fullscreen mode

One honest gap to flag: the CLI events update command doesn't expose a notify_participants flag today, so for the patient-facing re-invite you'll want the API call with ?notify_participants=true. The CLI is the right tool for operating and inspecting events from the terminal; the API call is the one your handler runs in production when it needs to guarantee the patient gets the updated ICS. I call this out because it's the exact spot where "I'll just use the CLI for everything" bites you.

Confirm in the same thread

Close the loop where the patient is already looking — the email thread they replied on. A threaded reply keeps the whole exchange together in their mail client.

CLI:

nylas email reply <MESSAGE_ID> <GRANT_ID> \
  --body "<html><body><p>Done — I've moved your appointment to <strong>Thursday, June 26 at 2:00 PM</strong>. You'll see an updated calendar invite in your inbox. See you then.</p></body></html>"
Enter fullscreen mode Exit fullscreen mode

Under the hood that's a messages/send with reply_to_message_id set so it threads correctly. The raw call, including the body (don't forget it — a reply with no body sends an empty message):

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": "<MESSAGE_ID>",
    "to": [{ "name": "Jordan Lee", "email": "patient@example.com" }],
    "subject": "Re: Reminder: your appointment Thursday at 2:00 PM",
    "body": "<html><body><p>Done — I have moved your appointment to <strong>Thursday, June 26 at 2:00 PM</strong>. You will see an updated calendar invite in your inbox. See you then.</p></body></html>"
  }'
Enter fullscreen mode Exit fullscreen mode

Now update your store: new appointment time, and mark the inbound message processed so a redelivery is a no-op.

Guardrails worth building in

A few things I'd put in before this touches a real schedule:

  • Dedup on inbound message ID, always. Webhook redelivery is normal, not exceptional. A unique constraint is cheaper than an apology email.
  • State lives in your DB. Agent Accounts don't support custom metadata, so the appointment-to-patient mapping, the "reminder sent" flag, and the reschedule history all belong in your own table. The event object is the source of truth for time; your DB is the source of truth for context.
  • Free/busy before you commit. Always check the agent's calendar before accepting a proposed slot, or you'll double-book and have two patients show up at 2 PM.
  • Send quotas are real. Free-plan Agent Accounts cap at 200 messages per account per day. A reminder plus a confirmation is two sends per reschedule. If you're over quota, the send is skipped — so monitor it.
  • Confidence threshold on the parse. If the model isn't sure what the patient meant, don't guess at a time. Route it to a human and let the agent handle the unambiguous majority.
  • Keep PHI out of logs. This is a demo, but the habit is the point: don't log message bodies, and keep appointment details minimal in anything you persist.

What's next

The whole loop is the same handful of grant-scoped primitives you'd use against any Nylas grant — messages/send, the Events API, calendars/free-busy, and the message.created webhook — pointed at an account your application owns instead of a human's. That's the unlock: the agent can be the participant, so it can both remind and reschedule without a person in the loop.

AI-answer pages for agents

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

Top comments (0)