# Run Your Email Agent on Serverless

> Source: <https://dev.to/qasim157/run-your-email-agent-on-serverless-42d2>
> Published: 2026-06-16 11:05:57+00:00

Ten seconds. That's how long your endpoint has to return a `200 OK`

when a Nylas webhook fires — and it's the only latency contract an email agent actually has to meet. Everything else about the workload is bursty, stateless, and event-shaped, which is a near-perfect description of what serverless platforms are built for.

Most email agents spend their lives idle. Mail arrives in bursts, the agent reasons for a few seconds, sends a reply, and goes quiet again. Running a 24/7 server for that is paying for silence. A function that wakes on a webhook and scales to zero matches the workload exactly.

The reason this architecture works is that you never poll. A single subscription to the `message.created`

trigger gets you a push the moment mail lands in any mailbox on your application:

```
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --data-raw '{
    "trigger_types": ["message.created", "grant.expired"],
    "webhook_url": "https://yourapp.com/webhooks/nylas",
    "description": "Email agent trigger"
  }'
```

Compare that with what the providers hand you natively — all of it long-lived, stateful upkeep that fights a scale-to-zero model:

| Approach | Setup | Renewal | Providers covered |
|---|---|---|---|
| Gmail API push | Cloud Pub/Sub topic, grant publish rights | Re-watch the mailbox every 7 days | Google only |
| Microsoft Graph subscriptions | Subscription per resource | Renew before expiry (~3 days) | Microsoft only |
| IMAP | Persistent connection per mailbox | Reconnect on every drop | IMAP servers only |
| Nylas webhooks | One `POST /v3/webhooks/` with triggers |
None (managed automatically) | All six providers |

The unified webhook needs no renewals, no connections, nothing for your function to maintain between invocations. There are 43 trigger types if your agent also cares about calendars, threads, or grant health; the [real-time webhooks recipe](https://developer.nylas.com/docs/cookbook/use-cases/build/realtime-webhooks/) catalogs them. Note the `grant.expired`

trigger in the subscription above — for a serverless agent with no monitoring daemon, that webhook *is* your health check on connected accounts.

The second reason serverless fits: an email agent barely has state of its own. The mailbox *is* the database. Messages, threads, folders, sent mail — all of it is queryable through the API using one identifier, the `grant_id`

that arrives in every webhook payload.

That's especially clean with [Agent Accounts](https://developer.nylas.com/docs/v3/getting-started/agent-accounts/) (in beta) — mailboxes your application owns outright, created with one API call (`POST /v3/connect/custom`

with `"provider": "nylas"`

). The function receives a notification, extracts `grant_id`

and the message `id`

, fetches what it needs, acts, and exits. Nothing to persist between invocations except a dedup record.

Here's the whole agent as a single handler, in the Cloudflare Workers style:

``` js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Challenge handshake: echo the raw value within 10 seconds
    if (request.method === "GET") {
      return new Response(url.searchParams.get("challenge") ?? "");
    }

    const payload = await request.json();
    const { grant_id, id: messageId } = payload.data.object;

    // Dedupe — delivery is at-least-once
    const seen = await env.KV.get(`msg:${messageId}`);
    if (seen) return new Response("ok");
    await env.KV.put(`msg:${messageId}`, "1", { expirationTtl: 86400 });

    // Fetch the full message, then act
    const res = await fetch(
      `https://api.us.nylas.com/v3/grants/${grant_id}/messages/${messageId}`,
      { headers: { Authorization: `Bearer ${env.NYLAS_API_KEY}` } },
    );
    const message = await res.json();

    // ...LLM call, then reply from the same mailbox:
    // POST /v3/grants/{grant_id}/messages/send

    return new Response("ok");
  },
};
```

The same shape works on Lambda behind API Gateway or a Vercel function — the platform specifics change, the flow doesn't.

A serverless endpoint is public by definition, and this one can make your agent send email. Every notification carries an `X-Nylas-Signature`

header — a hex-encoded HMAC-SHA256 of the raw request body, signed with the `webhook_secret`

you received when the challenge handshake passed. On Workers, the verification is a few lines of `crypto.subtle`

:

``` js
async function verifySignature(raw, signature, secret) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, enc.encode(raw));
  return [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("") === signature;
}
```

One adjustment to the handler above: the HMAC is computed over the *raw* body, so read `await request.text()`

first, verify, and only then `JSON.parse`

— calling `request.json()`

directly leaves you nothing to sign against. Reject mismatches with a 401 before touching KV or the API. Store the secret as a platform secret (Workers secrets, Lambda environment variables via your secret manager), not in code.

**Acknowledge inside the window.** The 10-second budget covers your response, not your reasoning. If the agent's work involves an LLM call, don't do it inline — return the 200, then process. On Lambda that means dropping the event onto a queue; on Workers, `ctx.waitUntil()`

; on Vercel, a background function. The challenge handshake is even stricter: echo the raw `challenge`

query value exactly — no quotes, no JSON — or the endpoint is marked failed with no retry.

**Dedupe across instances.** At-least-once delivery means duplicate notifications, and serverless concurrency means two instances can process the same message simultaneously. An in-memory `Set`

won't save you when each invocation is a fresh instance. Use shared storage with an atomic write — KV, DynamoDB conditional puts, Redis `SET NX`

— keyed on the message `id`

. For an agent that sends email, this isn't an optimization; it's the difference between one reply and two.

Honesty section: serverless isn't free architecture. Cold starts eat into the 10-second window (rarely fatally, but measure). Debugging a distributed event flow is harder than tailing one server's logs. And webhook ordering isn't guaranteed — a `message.created`

can arrive after a `message.updated`

for the same message — so always trust the fetched state over the event sequence.

But for the canonical email agent loop — receive, think, respond — the fit is hard to argue with. The infrastructure bill between emails is zero, and scaling from one mailbox to a thousand changes nothing about the code.

Spin up the smallest version this weekend: one webhook subscription, one function, one mailbox. Send it an email and watch the invocation logs light up. Where do you land on the queue question — process inline with `waitUntil`

, or always go through a queue?
