{"slug": "run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes", "title": "Run an event waitlist with an email agent that promotes the first yes", "summary": "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.", "body_md": "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.\n\nA 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.**\n\nThis post builds that. An **Agent Account** owns `waitlist@events.yourcompany.com`\n\n, emails a batch of waitlisted people when a spot opens, watches replies land in `message.created`\n\norder, 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.\n\nThe whole thing rides on one abstraction: an Agent Account is just a **grant**. It has a `grant_id`\n\n, and that `grant_id`\n\nworks 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.\n\nThat buys you three things for a waitlist:\n\n`message.created`\n\n, 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.\n\nThe 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:\n\n`date`\n\ntimestamp, and the `message.created`\n\nwebhook fires in arrival order. That's your tiebreaker for \"who was first.\"`SET key NX`\n\nin Redis or an `INSERT ... ON CONFLICT DO NOTHING`\n\n(or a `SELECT ... FOR UPDATE`\n\nrow 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.\n\nYou need a Nylas API key, a registered sending domain (a custom domain, or a `*.nylas.email`\n\ntrial 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.\n\nA 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.\n\nIf you're brand new to Agent Accounts, start with the [Agent Accounts overview](https://developer.nylas.com/docs/v3/agent-accounts/) and come back.\n\nCreate the account with `POST /v3/connect/custom`\n\n. Provider is `nylas`\n\n, and `settings.email`\n\nmust be on a domain you've registered. The optional top-level `name`\n\nsets the display name recipients see.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Events Waitlist\",\n    \"settings\": { \"email\": \"waitlist@events.yourcompany.com\" }\n  }'\n```\n\nThe response includes the `grant_id`\n\n. 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.\n\nThe CLI collapses the connector-creation step (it auto-creates the `nylas`\n\nconnector if it's missing) into a single command:\n\n```\nnylas agent account create waitlist@events.yourcompany.com --name \"Events Waitlist\"\n```\n\nThe 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.)\n\nReply order comes from the `message.created`\n\nwebhook, so subscribe to it. Point it at your handler URL.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://yourapp.com/webhooks/nylas\",\n    \"description\": \"Waitlist inbound replies\"\n  }'\nnylas webhook create \\\n  --url https://yourapp.com/webhooks/nylas \\\n  --triggers message.created \\\n  --description \"Waitlist inbound replies\"\n```\n\nAgent Accounts also emit `message.delivered`\n\n, `message.bounced`\n\n, and `message.complaint`\n\n, which are worth subscribing to so you know if an offer never landed. For the waitlist mechanics, `message.created`\n\nis the one that matters.\n\nA 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.\n\nHere's a single offer over HTTP. Store the returned `id`\n\nand `thread_id`\n\nagainst the waitlist entry so you can match the reply later.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"to\": [{ \"email\": \"alice@example.com\" }],\n    \"subject\": \"A spot opened for DevConf — reply YES to claim it\",\n    \"body\": \"Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat.\"\n  }'\n```\n\nThe same send from the CLI:\n\n```\nnylas email send <GRANT_ID> \\\n  --to alice@example.com \\\n  --subject \"A spot opened for DevConf — reply YES to claim it\" \\\n  --body \"Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat.\"\n```\n\nLoop that over your batch. In your DB, record the offer: the seat ID, each recipient's `message_id`\n\nand `thread_id`\n\n, the send time, and a single `claimed`\n\nflag (or claim row) for the seat — initialized to unclaimed. That flag is the thing the first yes will atomically flip.\n\nNow you wait for replies. Each inbound reply fires `message.created`\n\n. The payload carries **summary fields only** — `id`\n\n, `thread_id`\n\n, `from`\n\n, `subject`\n\n, `snippet`\n\n, and the message's `date`\n\ntimestamp (e.g. `\"date\": 1723821981`\n\n) — 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`\n\nfield on the message object — not the webhook envelope's own `time`\n\n.)\n\nA sketch of the handler, with the two non-negotiable guards up front:\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  // Verify X-Nylas-Signature, then ack fast.\n  res.status(200).end();\n\n  const event = req.body;\n  if (event.type !== \"message.created\") return;\n\n  const msg = event.data.object;\n  if (msg.grant_id !== WAITLIST_GRANT_ID) return;\n\n  // 1. Skip the agent's own outbound — sends fire message.created too.\n  if (msg.from?.[0]?.email === WAITLIST_EMAIL) return;\n\n  // 2. Dedup on inbound message id — webhooks are at-least-once.\n  const isNew = await db.processed.setIfAbsent(msg.id, { at: Date.now() });\n  if (!isNew) return;\n\n  await handleAcceptance(msg);\n});\n```\n\nThe `date`\n\nfield on `msg`\n\nis 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.\n\nTo actually read the reply, fetch the full body. Over HTTP that's a single message GET:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nTo list what's landed in the inbox (useful for a reconciliation sweep, or if you missed a webhook), use the messages collection:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?limit=20\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nThe CLI mirrors both. List recent inbound, then read the full body of a specific reply:\n\n```\nnylas email list <GRANT_ID> --limit 20\nnylas email read <MESSAGE_ID> <GRANT_ID>\n```\n\nIf you want to see the whole conversation — the offer plus the reply — read the thread directly. Over HTTP that's a thread GET:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nAnd the CLI equivalent:\n\n```\nnylas email threads show <THREAD_ID> <GRANT_ID>\n```\n\nWhether the body contains a \"yes\" is a classification call. A regex on `/\\byes\\b/i`\n\ncovers 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`\n\n.\n\nThis 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.\n\n``` js\nasync function handleAcceptance(msg) {\n  const offer = await db.offerByThread(msg.thread_id);\n  if (!offer) return; // Not part of an active waitlist batch.\n\n  const body = await fetchFullBody(msg.id); // message GET\n  if (!isYes(body)) return;\n\n  // Atomic claim: SET NX in Redis, or INSERT ON CONFLICT in Postgres.\n  // Exactly one acceptance can win the seat.\n  const won = await db.claimSeat(offer.seatId, { winner: msg.id });\n\n  if (won) {\n    await confirmSeat(offer, msg);     // first yes\n    await releaseOthers(offer, msg.id); // everyone else in the batch\n  } else {\n    await sendTooLate(offer, msg);      // a later yes for an already-claimed seat\n  }\n}\n```\n\n`db.claimSeat`\n\nis where correctness lives. In Redis it's `SET seat:{seatId} {messageId} NX`\n\n; in Postgres it's an `INSERT INTO seat_claims (seat_id, winner_message_id) VALUES (...) ON CONFLICT (seat_id) DO NOTHING`\n\nand 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.\n\nThe winner gets a confirmation, sent **in-thread** so it lands under their original offer. The CLI's `reply`\n\ncommand is built for exactly this — it fetches the original to populate recipient and subject, and preserves threading automatically:\n\n```\nnylas email reply <MESSAGE_ID> <GRANT_ID> \\\n  --body \"You're confirmed for DevConf on March 12. A calendar invite is on its way.\"\n```\n\nOver HTTP, threading is preserved by passing `reply_to_message_id`\n\n— Nylas reads the original's `Message-ID`\n\nand sets `In-Reply-To`\n\nand `References`\n\non the outbound message for you:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"alice@example.com\" }],\n    \"subject\": \"Re: A spot opened for DevConf — reply YES to claim it\",\n    \"body\": \"You'\\''re confirmed for DevConf on March 12. A calendar invite is on its way.\"\n  }'\n```\n\nEvery 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:\n\n```\nnylas email reply <OTHER_MESSAGE_ID> <GRANT_ID> \\\n  --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.\"\n```\n\nAnd the HTTP form, identical shape to the confirmation:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<OTHER_MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"bob@example.com\" }],\n    \"subject\": \"Re: A spot opened for DevConf — reply YES to claim it\",\n    \"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.\"\n  }'\n```\n\nThe release replies in-thread, so each person sees a clean follow-up under their own offer rather than a cold \"sorry\" with no context.\n\nA few things I'd want a teammate to know before they ship this:\n\n`setIfAbsent`\n\nguard, a redelivered `message.created`\n\nmakes you process the same acceptance twice — and double-fire your confirmation. This is separate from the seat claim; you need both.`SET NX`\n\n/ `ON CONFLICT`\n\nclaim is the only thing that actually guarantees one seat, one winner.`message.created`\n\ntoo. If you don't skip `from === WAITLIST_EMAIL`\n\nat the top, your confirmation can re-enter the handler as if it were an acceptance.`message.created`\n\nis summary-only.`message.created.truncated`\n\nand the body is omitted entirely, so the API fetch is the reliable path regardless.`thread_id`\n\n/ `message_id`\n\n.The reply-ordered claim is the spine of this; everything else is reuse of patterns the Agent Accounts cookbook already documents in depth.\n\n`Message-ID`\n\n, `In-Reply-To`\n\n, and `References`\n\nkeep each offer and its replies in one thread.`nylas email`\n\n, `nylas agent`\n\n, and `nylas webhook`\n\nflag used above.When this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes", "canonical_source": "https://dev.to/mqasimca/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes-19ne", "published_at": "2026-07-18 11:54:41+00:00", "updated_at": "2026-07-18 11:58:32.220109+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools"], "entities": ["Nylas", "Agent Account", "Redis", "Postgres"], "alternates": {"html": "https://wpnews.pro/news/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes", "markdown": "https://wpnews.pro/news/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes.md", "text": "https://wpnews.pro/news/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes.txt", "jsonld": "https://wpnews.pro/news/run-an-event-waitlist-with-an-email-agent-that-promotes-the-first-yes.jsonld"}}