Most "AI email" demos hand the model the whole inbox and hope for the best. That works in a screenshot. It falls apart the first time a real conversation runs past a dozen messages, because every one of those messages β quoted replies, signatures, disclaimers, the recipient's entire previous email pasted underneath theirs β gets shoved into the prompt verbatim. A five-message sales thread can easily be 8,000 tokens of mostly-redundant text. A twenty-message support escalation is a context-window grenade.
The naive fix is "just truncate." Keep the last message, drop the rest. That's worse: the agent loses the thing it actually needs, which is what was already agreed, asked, or promised earlier in the thread. The recipient says "yes, the 14th works" and your agent has no idea what the 14th refers to.
The right move is summarization for context budget: fetch the full thread, compress the older turns into a running brief, and feed the model only brief + the latest message verbatim. The brief is a few hundred tokens. The latest message is whatever it is. You stay well inside budget and the agent keeps the plot.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm poking at a live Agent Account. I'll show every step both ways β the raw HTTP call and the CLI equivalent β because that's how I actually debug this stuff.
The data plane here is nothing exotic. An Agent Account is just a Nylas grant with a grant_id
, and it works with every grant-scoped endpoint β Messages, Threads, Drafts, the lot. There's nothing new to learn on the API side. If you've used the Threads and Messages endpoints before, you already know the whole surface area.
The flow is four steps:
thread_id
.message_ids
array β that's your list of turns.The summarization itself is your code calling your model. Nylas doesn't summarize anything for you, and it shouldn't β the brief is application state, and where you keep it is your call.
A few concrete reasons, beyond "it's smaller":
The tradeoff, stated honestly: summaries lose detail. If your workflow needs an exact quote from message three, a summary won't have it β so keep the last few messages verbatim and accept that anything older is lossy. For most conversational agents that's the correct tradeoff. For a legal-discovery bot, it isn't; keep everything and pay for the tokens.
You need an Agent Account. If you don't have one, provision it on a domain you've registered with Nylas (a custom domain or a *.nylas.email
trial subdomain).
Over HTTP, that's a POST /v3/connect/custom
with the nylas
provider:
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",
"settings": { "email": "support@yourcompany.com" },
"name": "Support Agent"
}'
From the CLI:
nylas agent account create support@yourcompany.com --name "Support Agent"
That returns a grant. The API auto-creates a default workspace and policy alongside it, so there's nothing else to wire up to start reading mail. Hold onto the grant_id
β every call below uses it. (Full details: https://developer.nylas.com/docs/v3/agent-accounts/.)
You also need a message.created
webhook subscribed at the app level so you know when new mail lands. Webhooks on Nylas are application-scoped, not grant-scoped β you subscribe once with POST /v3/webhooks
, and events for every grant arrive at that one endpoint, each payload carrying a grant_id
you filter on. There's no per-account webhook to manage.
Over HTTP:
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://yourapp.com/webhooks/nylas"
}'
Or from the CLI:
nylas webhook create \
--url https://yourapp.com/webhooks/nylas \
--triggers message.created
One thing to internalize before we go further: don't rely on the webhook payload for the body. Fetch the full message with GET /v3/grants/{grant_id}/messages/{message_id}
when you need the real content, and branch on message.created.truncated
β that's the event type you get when a message is large enough that Nylas doesn't inline it. For a summarization pipeline this matters double, because the long threads you most want to summarize are exactly the ones with bodies big enough to get truncated.
When message.created
fires, the payload includes a thread_id
. That's your entry point. Fetch the thread and you get back a message_ids
array β the ordered list of every message in the conversation. That array is how you enumerate the turns.
Over HTTP:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
--header "Authorization: Bearer $NYLAS_API_KEY"
The response includes the fields you care about for budgeting:
{
"request_id": "...",
"data": {
"id": "thread-abc123",
"subject": "Re: Onboarding question about SSO",
"message_ids": [
"msg-001",
"msg-002",
"msg-003",
"msg-004",
"msg-005"
],
"snippet": "Thanks, that worked. One more thing about...",
"participants": [
{ "email": "alice@customer.com", "name": "Alice Chen" },
{ "email": "support@yourcompany.com" }
],
"latest_message_received_date": 1718900000
}
}
From the CLI, the same fetch β pass --json
so you get the structured object and not the pretty table:
nylas email threads show "$THREAD_ID" --json
message_ids.length
is your first budget signal. Five messages? Just feed them all verbatim, summarization is overkill. Twenty-five? You're summarizing. I usually branch on a threshold β under roughly 6 messages, send everything; over it, compress.
The thread gives you ids, not bodies. To reconstruct what was actually said, fetch each message. Don't try to shortcut this with the thread snippet
β that's a 100-character preview, useless for summarizing meaning.
Over HTTP, one call per id:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/msg-003" \
--header "Authorization: Bearer $NYLAS_API_KEY"
The message object gives you from
, to
, subject
, body
, and the date
field (a Unix timestamp). That date
is what you sort on to get chronological order β don't assume message_ids
is always perfectly time-ordered across providers; sort by date
to be safe.
From the CLI:
nylas email read msg-003 --json
If you want the unprocessed body without HTML-to-text munging, nylas email read
takes a --raw
flag, and --headers
if you need to inspect threading headers directly. For summarization I usually take the default processed body β it's already closer to plain text, which is what the model wants.
In code, you fan these out in parallel. Here's the shape of it in Node:
// thread.data.message_ids came from the threads.show call above.
const messages = await Promise.all(
thread.data.messageIds.map((id) =>
nylas.messages.find({ identifier: GRANT_ID, messageId: id }),
),
);
// Sort chronologically on the `date` field, then normalize into turns.
const turns = messages
.map((m) => m.data)
.sort((a, b) => a.date - b.date)
.map((m) => ({
role: m.from[0].email === AGENT_EMAIL ? "agent" : "contact",
date: m.date,
body: stripQuotedText(m.body), // drop the quoted-reply tail
}));
That stripQuotedText
step is worth a sentence. Email clients append the previous message under the new one. Before you summarize, trim everything after the first On <date>, <person> wrote:
marker or the >
quote block. You'll cut input tokens substantially before the model even sees the text. It's not perfect across every client, but it's cheap and it helps.
This is the part Nylas has no opinion about β it's your model, your prompt, your storage. The pattern that works:
Split the turns into "old" and "recent." Keep the last 2β4 messages exactly as they are. Everything before that gets summarized into a running brief.
const KEEP_VERBATIM = 3;
const recent = turns.slice(-KEEP_VERBATIM);
const older = turns.slice(0, -KEEP_VERBATIM);
// Summarize the older turns into a compact brief.
// If you already have a stored brief from the last turn, fold the new
// "older" messages into it instead of re-summarizing from scratch.
const brief = await summarizeTurns(older, previousBrief);
The brief should be structured and short β a few hundred tokens, capped. I prompt the summarizer to produce something like:
CONVERSATION BRIEF (thread: SSO onboarding, Alice Chen @ customer.com)
- Alice asked how to enable SAML SSO for her Okta tenant.
- We sent setup steps and the metadata URL on turn 2.
- Alice confirmed SSO works but users land on the wrong default workspace.
- OPEN: how to set a default workspace per SSO group. Awaiting our answer.
Then the prompt you actually send the model is brief + recent (verbatim) + the new inbound message
. That's it. The brief carries the history at maybe 200 tokens; the recent turns carry exact recent wording; the new message is what the agent is responding to.
Some token math to make it concrete. Say each raw message averages 1,500 tokens after quote-stripping. A 20-message thread fed verbatim is ~30,000 input tokens per turn, and it grows. With this approach: a ~250-token brief, plus 3 recent messages at ~1,500 each (~4,500), plus the new inbound (~1,500). Call it ~6,250 tokens, and β this is the important part β it stays roughly flat as the thread grows to 30, 40, 50 messages, because the old turns keep collapsing into the same fixed-size brief.
The running brief is application state. You keep it. Custom metadata is not supported for Agent Accounts, so you can't stash the brief on the Nylas message or thread object. Put it in your own datastore, keyed by thread_id
:
// After generating a reply, persist the updated brief.
await db.threadBriefs.upsert({
threadId: thread.data.id,
grantId: GRANT_ID,
brief, // the compact running summary
summarizedUpToMessageId: older.at(-1)?.id ?? null,
updatedAt: new Date().toISOString(),
});
Storing summarizedUpToMessageId
lets you do incremental summarization next turn: you only feed the summarizer the messages that arrived since the last brief, fold them in, and you never re-summarize the whole thread. That keeps your summarization cost flat too, not just your agent's prompt.
A few things I'd treat as first-class, not afterthoughts:
message.created
too. If you don't check from
, the agent will summarize and react to its own replies. Compare from[0].email
against your agent's address before you process.id
message_ids
) or starts fresh. Re-fetching is the safe default β the full thread is always retrievable.X-Nylas-Signature
header: a hex HMAC-SHA256 of the raw request body, using your webhook secret. Verify it before you trust the thread_id
. If you're constant-time comparing in Node with crypto.timingSafeEqual
, check both buffers are the same length first β it throws on a length mismatch. The CLI's nylas webhook verify
does this check locally while you're testing.You've got a thread-to-brief pipeline that keeps your agent inside its context window no matter how long the conversation runs. The natural next steps:
Message-ID
, In-Reply-To
, and References
make thread_id
reliable, and why you should trust the thread over subject matching.The summarization model is yours to tune β the part Nylas gives you is clean, reliable access to the full thread, one message_ids
array and one GET
per turn. Everything else is prompt engineering and a row in your database.
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com
: