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 s. An agent doesn't ; 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.
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:
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.
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 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)));
}
}
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
: