DEV Community

Qasim
Qasim

Posted on

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

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 429s 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 doc.

What an Agent Account changes (and what it doesn't)

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.

The exact send limit you're designing around

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:

  • Every Agent Account has a per-account daily send quota that resets at 00:00 UTC. When an account goes over it, send requests return HTTP 429 with the type too_many_requests until the counter resets.
  • On the Free plan, that cap is 200 sends per account per day. On Full Platform, there's no daily per-account cap by default.
  • Nylas reserves the quota before sending and releases it if the send fails, so failed attempts don't count against you.
  • The quota counts every message the account sends: API sends, drafts you send, SMTP submissions, and calendar invitations/updates sent with 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.

Provision the Agent Account

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" }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

Send one email, two ways

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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.

The backstop: throttle, queue, back off

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

  1. A token bucket paces sends so you stay under the per-second speed limit and drip steadily toward — not past — the daily budget. Proactive.
  2. A queue holds work the agent generates faster than the bucket releases it, and tracks the daily count so you stop before the 429. Proactive.
  3. A backoff loop catches the 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. Reactive.

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.

A per-account token bucket

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

A queue that respects the daily budget

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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Back off on 429, honoring Retry-After

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 is unambiguous: read the Retry-After header and honor it exactly; only fall back to exponential backoff with jitter when the header is absent.

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)));
  }
}
Enter fullscreen mode Exit fullscreen mode

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."

Make Nylas enforce a stricter cap, too

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 }
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI takes the same JSON through --data:

nylas agent policy create \
  --data '{"name":"Capped send policy","limits":{"limit_count_daily_email_sent":50}}'
Enter fullscreen mode Exit fullscreen mode

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>" }'
Enter fullscreen mode Exit fullscreen mode

The CLI wraps that with --policy-id:

nylas workspace update <WORKSPACE_ID> --policy-id <POLICY_ID>
Enter fullscreen mode Exit fullscreen mode

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 }'
Enter fullscreen mode Exit fullscreen mode

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

nylas workspace update <WORKSPACE_ID> --policy-id ""
Enter fullscreen mode Exit fullscreen mode

Wiring it into the agent loop

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Guardrails worth keeping

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

  • Count only real sends. The token bucket spends a token per attempt; the daily counter must increment only on a successful send, or a retry storm will inflate your count and trip QuotaExhausted early. Increment after sendWithBackoff returns a non-429, never before.
  • The daily reset is UTC, not local. Tie your counter reset to 00:00 UTC to match Nylas. A counter that resets at local midnight will be wrong by your timezone offset every single day.
  • Calendar invites count. If the same agent schedules meetings with 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.
  • One bucket per account. The daily quota is per Agent Account. If you run several, give each its own limiter and counter — a shared one will starve some accounts while others sit idle.

What's next

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.

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)