DEV Community

Qasim
Qasim

Posted on

Give your agent mailbox a vacation auto-responder

Most "AI email" demos point a model at a human's inbox and call it a day. That's fine right up until you want the agent to be a participant — to own an address, receive its own mail, and answer for itself. And the moment an agent owns an address, it inherits a very human expectation: people who write to it expect a reply, even when nobody's home.

That's the gap I keep seeing. Teams stand up an agent inbox, wire it to a model, and then the model is paused, or rate-limited, or it's 2 a.m. and the off-hours queue is empty. A message lands and... nothing comes back. To the sender, a silent mailbox is indistinguishable from a broken one. They don't know your agent is off-shift. They just know they got ignored.

The fix is the oldest pattern in email: an out-of-office auto-responder. But there's a twist that makes it interesting for agents, and it's the whole point of this post — the auto-acknowledge pattern. You reply once per thread, you reply in-thread so it threads cleanly in the sender's client, and you never reply again to that conversation no matter how many messages arrive on it. It's distinct from a multi-turn agent that actually converses. This is a lightweight, deterministic acknowledgment that buys you goodwill and a paper trail while the real agent is unavailable.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm building one of these. I'll show both forms for every operation — the raw HTTP call and the CLI equivalent — because you'll provision and debug from the terminal and run the actual handler against the API.

What you get

The spine of all of this is the grant. An Agent Account is just a grant with a grant_id, and a grant works with every grant-scoped endpoint you already know — Messages, Threads, Drafts, Folders, Webhooks. There's nothing new to learn on the data plane. If you've sent a message through Nylas, you've already used 90% of the API surface this needs.

Here's the loop:

  1. Inbound mail to the agent's address fires a standard message.created webhook.
  2. Your handler checks a loop guard (is this a bounce, an auto-reply, a mailer-daemon?) and an idempotency guard (have I already acknowledged this thread?).
  3. If both checks pass, you send a canned reply with reply_to_message_id set, so it lands in the same thread.
  4. You record the thread_id as "acknowledged" in your own database so the next message on that thread is a no-op.

That's it. No model in the hot path, no per-message reasoning. Deterministic code answers, which is exactly what you want for an acknowledgment.

Why this beats a forwarding rule or a provider OOO

You could set an out-of-office on the underlying mailbox, or forward everything to a human. Both have problems an agent inbox makes worse:

  • Provider OOO replies are dumb about threads. Many fire once per sender per window, not once per conversation, and you don't control the dedup key. With your own handler, the dedup key is the thread_id, which is what actually matches a human's sense of "this conversation."
  • Forwarding hides the agent. If you forward to a human, the sender never gets an acknowledgment — they just wait while a human triages. The auto-acknowledge replies immediately and sets expectations.
  • You want a loop guard you can reason about. Provider auto-responders are notorious for ping-ponging with other auto-responders. Owning the logic means you can refuse to reply to anything that smells automated.

The honest tradeoff: you're now running code, a webhook endpoint, and a small bit of state. If your needs are genuinely "one canned reply per sender, I don't care about threads," a provider OOO is less work. But for an agent that owns an address and needs to behave well in long conversations, the few lines below are worth it.

Before you begin

You need an Agent Account on a registered domain (a custom domain you've verified, or a Nylas *.nylas.email trial subdomain). New domains warm over roughly four weeks, so don't provision one the morning of a launch. You also need your API key handy — every call authenticates with Authorization: Bearer <NYLAS_API_KEY> against https://api.us.nylas.com.

Read the Agent Accounts docs first if this is your first one. The email threading guide covers the header mechanics I'll lean on below, and the CLI reference lives at cli.nylas.com/docs/commands.

Provision the Agent Account

The API call creates the grant. provider is always nylas, settings.email is the address on your registered domain, and the optional top-level name sets the display name senders 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": "Support Bot",
    "settings": { "email": "support@yourcompany.com" }
  }'
Enter fullscreen mode Exit fullscreen mode

From the terminal it's one line. The CLI always uses provider=nylas and creates the connector for you if it doesn't exist yet:

nylas agent account create support@yourcompany.com --name "Support Bot"
Enter fullscreen mode Exit fullscreen mode

The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up. (If you later need a custom policy, you attach it with nylas workspace update <workspace-id> --policy-id <policy-id> — there's no --workspace flag on create.) Note the grant_id that comes back. That's the identifier every endpoint below uses.

Subscribe to inbound mail

Inbound mail fires the standard message.created webhook. Point it at your handler:

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": "Agent inbox auto-acknowledge"
  }'
Enter fullscreen mode Exit fullscreen mode

Or, the way I actually do it:

nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Agent inbox auto-acknowledge"
Enter fullscreen mode Exit fullscreen mode

While you're developing, nylas webhook server spins up a local receiver so you can watch payloads land without deploying anything, and nylas webhook triggers prints the full list of trigger types. Both are CLI-only local/dev helpers with no API analog — they're conveniences for the terminal, not endpoints you'd call in production. Agent Accounts also emit message.delivered, message.bounced, and message.complaint, which are handy for the loop guard later.

Build the auto-acknowledge handler

The handler is small on purpose. Acknowledge 200 fast (so Nylas doesn't retry while you work), filter to the right grant and event, run your two guards, then send the canned reply.

// Node.js / Express
app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature here first -- see /docs/v3/notifications/.
  res.status(200).end();

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

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

  // Loop guard: never auto-reply to automated mail.
  if (isAutomated(msg)) return;

  // Idempotency guard: once per thread, tracked in OUR database.
  const already = await db.hasAcknowledged(msg.thread_id);
  if (already) return;

  await db.markAcknowledged(msg.thread_id, msg.id);
  await sendAutoReply(msg);
});
Enter fullscreen mode Exit fullscreen mode

A few things worth slowing down on.

The webhook payload is a summary, not the full message

message.created carries summary fields — subject, from, a snippet, the thread_id, the message id. It does not carry the full body. For an auto-acknowledge you usually don't need the body at all; subject and from are enough to greet the sender. But if you want to inspect the body (say, to skip messages that already look like an auto-reply), fetch the full 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
nylas email read <message-id>
Enter fullscreen mode Exit fullscreen mode

One sharp edge: if the message body exceeds roughly 1 MB, the webhook type comes through as message.created.truncated and the body is omitted entirely. Don't write code that assumes a body is always present in the payload — there isn't one to begin with, and the truncated variant is a separate trigger. Fetch from the API when you need real content.

"Once per thread" lives in your database, not Nylas

This is the part I want to be loud about. The idempotency key is the thread_id, and the record of "already acknowledged" belongs in your own store — a row keyed by thread_id, or by message_id if you want per-message granularity.

You might reach for custom message metadata to stash an auto_acked: true flag on the Nylas side. Custom metadata is not supported for Agent Accounts, so that door is closed. Even if it were open, your own database is the right home for this: webhook redelivery and concurrent workers will both re-trigger the handler, and you want the dedup decision to be a fast, transactional read against state you control. Make the write atomic — a unique constraint on thread_id or an upsert — so two simultaneous deliveries can't both slip past the guard and double-send.

When a new message lands on a thread you've already acknowledged, the guard short-circuits and you do nothing. That's the whole "once per thread" promise: the sender gets exactly one out-of-office reply per conversation, not one per email they fire off.

The loop guard: don't acknowledge robots

An auto-responder that replies to other auto-responders is how you generate a mail storm and get your warming domain flagged. Refuse to reply when the inbound message looks automated:

function isAutomated(msg) {
  const from = (msg.from?.[0]?.email || "").toLowerCase();
  const subject = (msg.subject || "").toLowerCase();

  // Mailer-daemon / postmaster bounces.
  if (/(mailer-daemon|postmaster|no-?reply|do-?not-?reply)@/.test(from)) {
    return true;
  }
  // Common auto-reply subjects.
  if (/(out of office|automatic reply|auto[- ]?reply|undeliverable)/.test(subject)) {
    return true;
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

If you fetch the full message, you can do this properly by inspecting headers — Auto-Submitted: auto-replied, Precedence: bulk, or the presence of List-Id are all strong signals that you're looking at a machine, not a person. The subject/sender heuristics above catch the common cases without a second API call, which is usually enough for an acknowledgment. Pair this with the message.bounced webhook so you're never acknowledging a delivery failure.

Send the in-thread reply

Now the actual acknowledgment. The key is reply_to_message_id: pass the inbound message's id, and Nylas fetches its Message-ID and stamps In-Reply-To and References on your outbound message. The reply threads correctly in Gmail, Outlook, Apple Mail — everywhere — and it shows up in the same thread in the Agent Account's own mailbox.

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: Following up on your demo request",
    "body": "Thanks for reaching out. Our team is away until Monday and will reply when we are back. This is an automated acknowledgment so you know your message arrived."
  }'
Enter fullscreen mode Exit fullscreen mode

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

nylas email reply <message-id> --body "Thanks for reaching out. Our team is away until Monday and will reply when we are back. This is an automated acknowledgment so you know your message arrived."
Enter fullscreen mode Exit fullscreen mode

By default the reply goes only to the original sender; add --all if you genuinely want to include the other To/Cc recipients, though for an acknowledgment I'd keep it to the sender. Threading is preserved either way via reply_to_message_id — that's the same mechanism the curl call uses, just handled for you.

If you want to confirm the reply landed in the right conversation while testing, pull the thread back up:

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
nylas email threads show <thread-id>
Enter fullscreen mode Exit fullscreen mode

That shows every message Nylas grouped into the conversation, so you can eyeball that your acknowledgment sits alongside the inbound message rather than spawning a new thread. (There's no --thread flag on email list; reading a conversation is what threads show is for.) To browse what's hit the inbox more generally:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?limit=10" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode
nylas email list
Enter fullscreen mode Exit fullscreen mode

Guardrails and gotchas

A handful of things that have bitten me or that I'd flag in review:

  • The webhook fires for your own sends, too. When the agent sends its acknowledgment, that outbound message also triggers message.created. Your grant_id filter won't save you here — it's the same grant. Filter on msg.from (skip anything from the agent's own address) at the top of the handler, or the idempotency guard will catch it on the second pass anyway. Belt and suspenders: do both.
  • Acknowledge the webhook before you do work. Return 200 immediately, then process. If you send the reply before responding to the webhook and your send is slow, Nylas may time out and redeliver, and now you're racing your own idempotency guard. Fast ack, then work.
  • Make the dedup write atomic. Two webhook deliveries for the same thread can arrive within milliseconds. A read-then-write without a unique constraint will let both through. Use an upsert or a INSERT ... ON CONFLICT DO NOTHING and only send if you were the one that inserted.
  • Respect the plan limits. On the free plan you get 200 messages per account per day, 3 GB of org storage, and a 30-day inbox / 7-day spam retention window. A chatty acknowledgment loop can burn through 200 sends faster than you'd think if your loop guard is weak — another reason the guard matters.
  • Verify the signature. Every webhook carries an X-Nylas-Signature header. Verify it before you trust the payload. An unauthenticated /webhooks/nylas endpoint is an open invitation to make your agent send mail on command.
  • Decide what happens when the agent comes back. The acknowledgment is the floor, not the ceiling. When the real agent returns, it should pick up the thread and reply for real — and because you tracked the thread_id, you already know which conversations got only an auto-ack and are still waiting on a human or model.

What's next

The auto-acknowledge is the simplest useful thing an agent inbox can do, and it's a clean on-ramp to the rest. Once you're comfortable with the webhook-plus-idempotency shape, the natural next steps are an agent that actually reads the thread and responds:

The pattern scales down to "one canned reply" and up to "full multi-turn agent" with the same primitives. Start with the acknowledgment. It's the thing your senders will notice the absence of.

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)