Add a rate-limit backstop to your agent's email sending
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"
}
}'
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.
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."
}'
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.
The Backstop: Throttle, Queue, Back Off
Three layers, each solving a different problem. Frame them honestly:
- A token bucket paces sends so you stay under the per-second speed limit and drip steadily toward - not past - the daily budget. Proactive.
- 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.
- 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;
};
}
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;
}
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)));
}
}
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
}
}'
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 ""
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
}
}
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.
Comments
No comments yet. Start the discussion.