# Add a rate-limit backstop to your agent's email sending

> Source: <https://dev.to/mqasimca/add-a-rate-limit-backstop-to-your-agents-email-sending-2kle>
> Published: 2026-07-14 14:41:59+00:00

Most "AI email agent" demos send one email and call it a feature. The agent drafts a reply, fires it off, the screenshot looks great. What the demo never shows is the agent in a `for`

loop — working a backlog, fanning out a digest, chasing a list of stragglers — quietly racking up sends until it trips a daily quota and gets a wall of `429`

s *mid-task*. Now half your batch went out, half didn't, and the agent has no idea which half.

That failure mode is the whole problem with letting a model decide how fast to send. A human glances at "Send" and pauses. An agent doesn't pause; it has a task list and a tool, and it will happily call that tool two hundred times in a minute. So the fix isn't smarter prompting. It's a *backstop* in your own code: a throttle that paces sends under the documented ceiling, a queue that holds the overflow, and a backoff loop for the rare `429`

that slips through anyway.

Let me be precise about who enforces what, because it's easy to oversell this. **Nylas enforces the cap.** Your backstop's only job is to keep you from *reaching* it — to turn a hard `429`

wall into a smooth, self-paced drip. I work on the Nylas CLI, so every terminal command below is one I've run against `nylas`

v3.1.27, and every endpoint is checked against the [Agent Account usage limits](https://developer.nylas.com/docs/v3/agent-accounts/send-limits/) doc.

An **Agent Account** is just a grant. It has a `grant_id`

and works with every grant-scoped endpoint — Messages, Drafts, Threads, Folders, Contacts — exactly like a connected Gmail or Microsoft account would. The difference is that it's an inbox the agent *owns*: `support@yourcompany.com`

is the agent, not a human mailbox the agent borrows. Outbound mail goes through Nylas-managed sending infrastructure, which is exactly why there's a documented, enforced send quota to design around.

Nothing new to learn on the data plane. You send with `POST /v3/grants/{grant_id}/messages/send`

, the same call you'd use on any grant. The quota is the new constraint, and the backstop is plain client-side engineering: a token bucket, a queue, and a retry loop. That work transfers to any grant you wire up later — it's just most visible here, because an Agent Account's sending is metered per account by Nylas rather than by an upstream provider.

Get the numbers right before you write a single line of throttle code, because the whole backstop is calibrated to them. From the [send-limits doc](https://developer.nylas.com/docs/v3/agent-accounts/send-limits/):

`429`

`too_many_requests`

until the counter resets.`notify_participants`

.That last point is the one that bites. If your agent both emails *and* schedules, calendar invitations draw from the same 200. Worse, **over the quota, calendar invitations are skipped silently** — the event still gets created, but no invite goes out and participants aren't notified. Your send path errors loudly with a `429`

; your scheduling path fails quietly. Plan for both.

There's also a separate **per-second rate limit** (1 request/second pooled across Sandbox apps, 5 across non-Sandbox apps, org-wide) that returns `429`

with `"per-second rate limit exceeded"`

. The daily quota is a *budget*; the per-second limit is a *speed limit*. A good backstop respects both — and conveniently, the same token bucket handles the speed limit while the queue handles the budget.

The data plane is a grant, so creating one is a single call. Two angles, as always — the HTTP call and the CLI.

Create via `POST /v3/connect/custom`

with `"provider": "nylas"`

:

```
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" }
  }'
```

The CLI wraps that, auto-creating the `nylas`

connector plus a default workspace and policy if they don't exist yet:

```
nylas agent account create support@yourcompany.com --name "Support Bot"
```

No refresh token, no OAuth dance — the email lives on a domain you've registered. Grab the `grant_id`

from the response; it's the identifier on every send below.

Before throttling anything, here's the operation you're wrapping. The HTTP call:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "subject": "Your invoice is ready",
    "to": [{ "email": "customer@example.com" }],
    "body": "Hi — your invoice for March is attached below."
  }'
```

And the CLI equivalent, which takes the grant id as a positional argument:

```
nylas email send <NYLAS_GRANT_ID> \
  --to customer@example.com \
  --subject "Your invoice is ready" \
  --body "Hi — your invoice for March is attached below."
```

Every one of those counts as one send against the daily quota. Your agent, left alone, calls this as fast as its task loop generates work. The backstop sits between the agent's intent and this call.

Three layers, each solving a different problem. Frame them honestly:

`429`

. `429`

that slips through anyway — a quota you share with another worker, a calendar invite you forgot was counted, a burst the bucket didn't predict. The first two keep you off the wall. The third is the net for when you hit it anyway. You want all three, because each covers a gap the others don't.

The bucket refills a fixed number of tokens each interval; every send spends one; when the bucket is empty, sends wait. Size it to the per-second limit (5/sec on a non-Sandbox app) with headroom for retries — say 4/sec. This is your own code; Nylas never sees it.

```
// Per-account token bucket: refills `ratePerSecond` tokens each second.
function createLimiter(ratePerSecond = 4) {
  let tokens = ratePerSecond;
  setInterval(() => (tokens = ratePerSecond), 1000);

  return async function acquire() {
    while (tokens <= 0) {
      await new Promise((resolve) => setTimeout(resolve, 50));
    }
    tokens -= 1;
  };
}
```

Call `acquire()`

before every send. That alone keeps you under the per-second ceiling. But the per-second limit isn't the one that ends your batch — the *daily* budget is. For that, you need to count.

The token bucket controls *rate*; it knows nothing about the 200-per-day *budget*. Track that separately: a counter that resets at 00:00 UTC, and a queue that refuses to release a send once you've spent the day's allotment. Leave a margin below the documented cap so concurrent work — including those silent calendar invites — doesn't push you over.

``` js
const DAILY_CAP = 200;        // Free-plan per-account quota
const SAFETY_MARGIN = 10;     // stop short; invites & other paths also count
const acquire = createLimiter(4);

let sentToday = 0;
let resetsAt = nextUtcMidnight();

function nextUtcMidnight() {
  const d = new Date();
  return Date.UTC(
    d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0,
  );
}

async function enqueueSend(grantId, apiKey, message) {
  if (Date.now() >= resetsAt) {
    sentToday = 0;
    resetsAt = nextUtcMidnight();
  }
  if (sentToday >= DAILY_CAP - SAFETY_MARGIN) {
    // Don't burn the request — defer until the quota resets.
    throw new QuotaExhausted(resetsAt);
  }

  await acquire();                       // per-second pacing
  const res = await sendWithBackoff(grantId, apiKey, message);
  sentToday += 1;                        // only count a real send
  return res;
}
```

The point of the counter isn't to replace Nylas's enforcement — it's to let your agent *know* it's out of budget without spending a request to find out. When `enqueueSend`

throws `QuotaExhausted`

, the agent can park the remaining work, tell a human, or wait for `resetsAt`

instead of hammering a wall. That's the difference between a batch that fails cleanly and one that fails halfway.

A `429`

can still happen — you're sharing the org-wide per-second pool, or a calendar invite you didn't count slid through. When it does, the [rate-limit handling recipe](https://developer.nylas.com/docs/cookbook/use-cases/build/handle-rate-limit-errors/) is unambiguous: **read the Retry-After header and honor it exactly**; only fall back to exponential backoff with jitter when the header is absent.

``` js
const BASE_MS = 1000;
const MAX_MS = 32000;
const MAX_RETRIES = 5;

function backoffWithJitter(attempt) {
  const exp = Math.min(MAX_MS, BASE_MS * 2 ** attempt);
  return exp + Math.floor(Math.random() * 1000); // up to 1s jitter
}

function getDelayMs(res, attempt) {
  const header = res.headers.get("retry-after");
  if (header) return Number.parseInt(header, 10) * 1000;
  return backoffWithJitter(attempt);
}

async function sendWithBackoff(grantId, apiKey, message) {
  const url = `https://api.us.nylas.com/v3/grants/${grantId}/messages/send`;
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(message),
    });
    if (res.status !== 429) return res;
    if (attempt === MAX_RETRIES) {
      throw new Error("Send rate-limited after 5 retries");
    }
    await new Promise((r) => setTimeout(r, getDelayMs(res, attempt)));
  }
}
```

One honest caveat: backoff helps for the *per-second* `429`

(`"per-second rate limit exceeded"`

), which clears in a rolling one-second window — a short wait recovers. It does **not** help for the *daily* `429`

(`too_many_requests`

), which doesn't clear until 00:00 UTC. Retrying that one for five attempts just wastes five attempts. That's exactly why the queue's budget counter matters: it stops you reaching the daily wall, so the backoff loop only ever fights the per-second one. Branch on it if you want to be strict — read the error `type`

, and treat `too_many_requests`

as "park until reset" rather than "retry."

The client-side backstop is your first line, but you can also lower the *server-side* ceiling so a runaway agent can't possibly send more than you intend — belt and suspenders. The send-limits doc notes you can set a stricter per-account quota through a **policy** field, `limit_count_daily_email_sent`

. Below your plan max, Nylas itself enforces it.

Create a policy with the cap via `POST /v3/policies`

. The limit lives in a nested `limits`

object:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/policies" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Capped send policy",
    "limits": { "limit_count_daily_email_sent": 50 }
  }'
```

The CLI takes the same JSON through `--data`

:

```
nylas agent policy create \
  --data '{"name":"Capped send policy","limits":{"limit_count_daily_email_sent":50}}'
```

A policy on its own does nothing until a workspace points at it. The API auto-created a default workspace when you made the account; attach the policy to it. Over the API, `PATCH /v3/workspaces/{id}`

accepts `policy_id`

(and `rule_ids`

):

```
curl --request PATCH \
  --url "https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "policy_id": "<POLICY_ID>" }'
```

The CLI wraps that with `--policy-id`

:

```
nylas workspace update <WORKSPACE_ID> --policy-id <POLICY_ID>
```

Now your 50-per-day cap is enforced by Nylas *and* shadowed by your client-side counter. If a bug doubles your agent's send rate, the policy is the floor that catches it — the backstop just means you almost never feel it.

Detaching restores the plan maximum. Over the API, `PATCH /v3/workspaces/{id}`

with `policy_id`

set to `null`

clears it:

```
curl --request PATCH \
  --url "https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "policy_id": null }'
```

The CLI detaches with an empty `--policy-id ""`

:

```
nylas workspace update <WORKSPACE_ID> --policy-id ""
```

The agent doesn't call `fetch`

or `nylas email send`

directly anymore. It calls `enqueueSend`

, and the backstop decides whether that send goes now, waits in the queue, retries on a `429`

, or gets parked because the day's budget is spent. A clean shape:

```
async function agentSend(grantId, apiKey, message) {
  try {
    return await enqueueSend(grantId, apiKey, message);
  } catch (err) {
    if (err instanceof QuotaExhausted) {
      // Budget spent for the UTC day — defer, don't hammer.
      await parkUntil(err.resetsAt, grantId, message);
      return { deferred: true };
    }
    throw err; // a real failure: surface it
  }
}
```

The agent's task loop stays simple. It generates intent — "reply to this thread," "send this digest" — and hands each off to `agentSend`

. Pacing, budgeting, and recovery live in one place, not scattered across every tool call the model can make. That separation is the whole point: the model decides *what* to send; your code decides *how fast*.

A few things I'd verify before trusting this in production:

`QuotaExhausted`

early. Increment after `sendWithBackoff`

returns a non-`429`

, never before.`notify_participants`

, route those through the same budget counter, or your "200 sends" silently becomes "200 sends plus N invites" and the invites are the ones that vanish without an error.The backstop above is deliberately small — a bucket, a counter, a retry loop — because the constraint it defends is small and well-documented. Scale it the obvious ways: persist `sentToday`

in Redis so it survives restarts, expose the queue depth as a metric, and alert when `QuotaExhausted`

fires so a human knows the agent ran out of road.

`429`

and `403`

means.`Retry-After`

.`limit_count_daily_email_sent`

.`cli.nylas.com/docs/commands`

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

:
