cd /news/ai-agents/run-an-event-waitlist-with-an-email-… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-64569] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Run an event waitlist with an email agent that promotes the first yes

Nylas demonstrated how to build an event waitlist using an email agent that processes replies in arrival order and atomically allocates seats. The solution uses an Agent Account with a grant_id to own the mailbox, listens for message.created webhooks, and relies on an external database for seat allocation since custom metadata is not supported on Agent Accounts. The approach avoids double-booking by using atomic operations like Redis SET NX or Postgres row locks to ensure only the first reply wins.

read11 min views1 publishedJul 18, 2026

Most "AI email" demos point a model at a human's inbox and let it draft replies. That's fine for a copilot. It falls apart the moment you want the agent to be a participant β€” to own an address, send the offer, and decide who gets the spot based on who replied first. A waitlist is exactly that kind of problem, and it exposes a detail every reply-handling tutorial glosses over.

A waitlist is first-come-by-reply. When a seat opens at your sold-out event, you email the people next in line, and whoever says yes first gets it. "First" is not who you emailed first, and it is not who your webhook handler happened to schedule first β€” it is the order their acceptances actually arrived in your mailbox. So before any of the clever agent stuff, the core requirement is plain: you must read inbound mail in order, and you must allocate the open seat atomically so two yeses don't both win.

This post builds that. An Agent Account owns waitlist@events.yourcompany.com

, emails a batch of waitlisted people when a spot opens, watches replies land in message.created

order, confirms the first acceptance in-thread, and releases the rest. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and every operation is shown twice β€” the raw HTTP call and the CLI equivalent β€” because the two-angle view is the fastest way to understand what's actually moving.

The whole thing rides on one abstraction: an Agent Account is just a grant. It has a grant_id

, and that grant_id

works with every grant-scoped endpoint you already know β€” Messages, Threads, Drafts, Folders, Webhooks. There's nothing new to learn on the data plane. The only new thing is who the mailbox belongs to: a piece of software, not a person.

That buys you three things for a waitlist:

message.created

, which is your trigger to check arrival order and try to claim the seat.The naive version of this looks done in an afternoon: email everyone, wait for replies, confirm anyone who says yes. Then two people say yes to the same single seat and you've double-booked. Or your webhook handler runs on three Lambda instances and two of them race to confirm different people for the same opening.

The honest framing: Nylas tells you the order replies arrived, but it does not allocate your seats for you. That allocation is your application logic. You need two things working together:

date

timestamp, and the message.created

webhook fires in arrival order. That's your tiebreaker for "who was first."SET key NX

in Redis or an INSERT ... ON CONFLICT DO NOTHING

(or a SELECT ... FOR UPDATE

row lock) in Postgres. Whoever wins the claim sends the confirmation; everyone else sends a release.One more constraint worth saying out loud: custom metadata isn't supported on Agent Accounts. You can't stash "seat 3, offer batch 7, claimed=false" on the Nylas message and read it back. Waitlist state lives in your database β€” the offer batch, who you emailed, the per-seat claim flag, and the inbound message IDs you've already processed. Nylas is the transport and the source of arrival order; it is not your state store.

You need a Nylas API key, a registered sending domain (a custom domain, or a *.nylas.email

trial subdomain for testing), and a small datastore for waitlist state. New domains warm over roughly four weeks, so don't run your first real batch from a domain you provisioned yesterday.

A note on volume: free-plan Agent Accounts cap at 200 messages per account per day. A waitlist offer batch plus confirmations and releases burns through that faster than you'd think β€” budget accordingly if you're promoting in bulk.

If you're brand new to Agent Accounts, start with the Agent Accounts overview and come back.

Create the account with POST /v3/connect/custom

. Provider is nylas

, and settings.email

must be on a domain you've registered. The optional top-level name

sets the display name recipients see.

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": "Events Waitlist",
    "settings": { "email": "waitlist@events.yourcompany.com" }
  }'

The response includes the grant_id

. Hold onto it β€” it's the identifier in every call from here on. No refresh token, no OAuth dance; the account is ready to send and receive immediately.

The CLI collapses the connector-creation step (it auto-creates the nylas

connector if it's missing) into a single command:

nylas agent account create waitlist@events.yourcompany.com --name "Events Waitlist"

The API auto-creates a default workspace and policy for the account, so there's no extra setup before the waitlist flow can run. (If you later want to lock down which domains the agent can email, that's a separate policy concern, out of scope here.)

Reply order comes from the message.created

webhook, so subscribe to it. Point it at your handler URL.

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"],
    "webhook_url": "https://yourapp.com/webhooks/nylas",
    "description": "Waitlist inbound replies"
  }'
nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Waitlist inbound replies"

Agent Accounts also emit message.delivered

, message.bounced

, and message.complaint

, which are worth subscribing to so you know if an offer never landed. For the waitlist mechanics, message.created

is the one that matters.

A seat opens. You pull the next N people off the waitlist (your DB knows the order) and email each one an offer that says, in effect, "a spot opened, reply YES to claim it." Send one message per person so each lives in its own thread β€” that keeps acceptances cleanly separated by recipient.

Here's a single offer over HTTP. Store the returned id

and thread_id

against the waitlist entry so you can match the reply later.

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": "A spot opened for DevConf β€” reply YES to claim it",
    "body": "Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat."
  }'

The same send from the CLI:

nylas email send <GRANT_ID> \
  --to alice@example.com \
  --subject "A spot opened for DevConf β€” reply YES to claim it" \
  --body "Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat."

Loop that over your batch. In your DB, record the offer: the seat ID, each recipient's message_id

and thread_id

, the send time, and a single claimed

flag (or claim row) for the seat β€” initialized to unclaimed. That flag is the thing the first yes will atomically flip.

Now you wait for replies. Each inbound reply fires message.created

. The payload carries summary fields only β€” id

, thread_id

, from

, subject

, snippet

, and the message's date

timestamp (e.g. "date": 1723821981

) β€” not the full body. So the webhook tells you that someone replied and when, but to read whether they actually said "yes," you fetch the full message from the API. (The arrival time you sort on is the date

field on the message object β€” not the webhook envelope's own time

.)

A sketch of the handler, with the two non-negotiable guards up front:

app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature, then ack fast.
  res.status(200).end();

  const event = req.body;
  if (event.type !== "message.created") return;

  const msg = event.data.object;
  if (msg.grant_id !== WAITLIST_GRANT_ID) return;

  // 1. Skip the agent's own outbound β€” sends fire message.created too.
  if (msg.from?.[0]?.email === WAITLIST_EMAIL) return;

  // 2. Dedup on inbound message id β€” webhooks are at-least-once.
  const isNew = await db.processed.setIfAbsent(msg.id, { at: Date.now() });
  if (!isNew) return;

  await handleAcceptance(msg);
});

The date

field on msg

is your arrival-order key. If you process strictly in webhook-delivery order, that usually matches arrival order β€” but under retries and concurrency it won't always, which is exactly why the atomic claim below is the real guarantee, not the ordering of your handler.

To actually read the reply, fetch the full body. Over HTTP that's a single message GET:

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

To list what's landed in the inbox (useful for a reconciliation sweep, or if you missed a webhook), use the messages collection:

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

The CLI mirrors both. List recent inbound, then read the full body of a specific reply:

nylas email list <GRANT_ID> --limit 20
nylas email read <MESSAGE_ID> <GRANT_ID>

If you want to see the whole conversation β€” the offer plus the reply β€” read the thread directly. Over HTTP that's a thread GET:

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

And the CLI equivalent:

nylas email threads show <THREAD_ID> <GRANT_ID>

Whether the body contains a "yes" is a classification call. A regex on /\byes\b/i

covers the obvious cases; an LLM handles "sure, I'm in" and "yeah sign me up." Either way, the decision is: this reply is an acceptance for the seat tied to this thread_id

.

This is the whole point. An acceptance arrived. Before you confirm anyone, try to claim the seat β€” and only the worker that wins the claim sends the confirmation.

async function handleAcceptance(msg) {
  const offer = await db.offerByThread(msg.thread_id);
  if (!offer) return; // Not part of an active waitlist batch.

  const body = await fetchFullBody(msg.id); // message GET
  if (!isYes(body)) return;

  // Atomic claim: SET NX in Redis, or INSERT ON CONFLICT in Postgres.
  // Exactly one acceptance can win the seat.
  const won = await db.claimSeat(offer.seatId, { winner: msg.id });

  if (won) {
    await confirmSeat(offer, msg);     // first yes
    await releaseOthers(offer, msg.id); // everyone else in the batch
  } else {
    await sendTooLate(offer, msg);      // a later yes for an already-claimed seat
  }
}

db.claimSeat

is where correctness lives. In Redis it's SET seat:{seatId} {messageId} NX

; in Postgres it's an INSERT INTO seat_claims (seat_id, winner_message_id) VALUES (...) ON CONFLICT (seat_id) DO NOTHING

and you check whether a row was inserted. If two acceptances arrive in the same millisecond, the database β€” not your handler's scheduling luck β€” decides which one wins. That's the only place this is safe.

The winner gets a confirmation, sent in-thread so it lands under their original offer. The CLI's reply

command is built for exactly this β€” it fetches the original to populate recipient and subject, and preserves threading automatically:

nylas email reply <MESSAGE_ID> <GRANT_ID> \
  --body "You're confirmed for DevConf on March 12. A calendar invite is on its way."

Over HTTP, threading is preserved by passing reply_to_message_id

β€” Nylas reads the original's Message-ID

and sets In-Reply-To

and References

on the outbound message for you:

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": [{ "email": "alice@example.com" }],
    "subject": "Re: A spot opened for DevConf β€” reply YES to claim it",
    "body": "You'\''re confirmed for DevConf on March 12. A calendar invite is on its way."
  }'

Every other person in the batch needs to hear the seat is gone β€” both the ones who haven't replied and any later yes that lost the claim. Loop over the batch's remaining offers and reply in-thread to each, excluding the winner's message:

nylas email reply <OTHER_MESSAGE_ID> <GRANT_ID> \
  --body "Thanks for the quick reply β€” the spot was just claimed by someone earlier in line. You're still on the waitlist for the next opening."

And the HTTP form, identical shape to the confirmation:

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": "<OTHER_MESSAGE_ID>",
    "to": [{ "email": "bob@example.com" }],
    "subject": "Re: A spot opened for DevConf β€” reply YES to claim it",
    "body": "Thanks for the quick reply β€” the spot was just claimed by someone earlier in line. You'\''re still on the waitlist for the next opening."
  }'

The release replies in-thread, so each person sees a clean follow-up under their own offer rather than a cold "sorry" with no context.

A few things I'd want a teammate to know before they ship this:

setIfAbsent

guard, a redelivered message.created

makes you process the same acceptance twice β€” and double-fire your confirmation. This is separate from the seat claim; you need both.SET NX

/ ON CONFLICT

claim is the only thing that actually guarantees one seat, one winner.message.created

too. If you don't skip from === WAITLIST_EMAIL

at the top, your confirmation can re-enter the handler as if it were an acceptance.message.created

is summary-only.message.created.truncated

and the body is omitted entirely, so the API fetch is the reliable path regardless.thread_id

/ message_id

.The reply-ordered claim is the spine of this; everything else is reuse of patterns the Agent Accounts cookbook already documents in depth.

Message-ID

, In-Reply-To

, and References

keep each offer and its replies in one thread.nylas email

, nylas agent

, and nylas webhook

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

:

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/run-an-event-waitlis…] indexed:0 read:11min 2026-07-18 Β· β€”