{"slug": "summarize-long-email-threads-for-agent-context", "title": "Summarize long email threads for agent context", "summary": "Nylas engineer demonstrates a technique for summarizing long email threads to fit within AI agent context windows, using the Nylas API to fetch threads, compress older messages into a brief, and feed only the brief plus the latest message to the model. The approach avoids token overflow while preserving key context like agreements and promises, with tradeoffs noted for detail loss.", "body_md": "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.\n\nThe 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.\n\nThe 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.\n\nI 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.\n\nThe data plane here is nothing exotic. An **Agent Account** is just a Nylas grant with a `grant_id`\n\n, 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.\n\nThe flow is four steps:\n\n`thread_id`\n\n.`message_ids`\n\narray — 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.\n\nA few concrete reasons, beyond \"it's smaller\":\n\nThe 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.\n\nYou 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`\n\ntrial subdomain).\n\nOver HTTP, that's a `POST /v3/connect/custom`\n\nwith the `nylas`\n\nprovider:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer $NYLAS_API_KEY\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"settings\": { \"email\": \"support@yourcompany.com\" },\n    \"name\": \"Support Agent\"\n  }'\n```\n\nFrom the CLI:\n\n```\nnylas agent account create support@yourcompany.com --name \"Support Agent\"\n```\n\nThat 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`\n\n— every call below uses it. (Full details: [https://developer.nylas.com/docs/v3/agent-accounts/](https://developer.nylas.com/docs/v3/agent-accounts/).)\n\nYou also need a `message.created`\n\nwebhook 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`\n\n, and events for every grant arrive at that one endpoint, each payload carrying a `grant_id`\n\nyou filter on. There's no per-account webhook to manage.\n\nOver HTTP:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer $NYLAS_API_KEY\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://yourapp.com/webhooks/nylas\"\n  }'\n```\n\nOr from the CLI:\n\n```\nnylas webhook create \\\n  --url https://yourapp.com/webhooks/nylas \\\n  --triggers message.created\n```\n\nOne 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}`\n\nwhen you need the real content, and branch on `message.created.truncated`\n\n— 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.\n\nWhen `message.created`\n\nfires, the payload includes a `thread_id`\n\n. That's your entry point. Fetch the thread and you get back a `message_ids`\n\narray — the ordered list of every message in the conversation. That array is how you enumerate the turns.\n\nOver HTTP:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID\" \\\n  --header \"Authorization: Bearer $NYLAS_API_KEY\"\n```\n\nThe response includes the fields you care about for budgeting:\n\n```\n{\n  \"request_id\": \"...\",\n  \"data\": {\n    \"id\": \"thread-abc123\",\n    \"subject\": \"Re: Onboarding question about SSO\",\n    \"message_ids\": [\n      \"msg-001\",\n      \"msg-002\",\n      \"msg-003\",\n      \"msg-004\",\n      \"msg-005\"\n    ],\n    \"snippet\": \"Thanks, that worked. One more thing about...\",\n    \"participants\": [\n      { \"email\": \"alice@customer.com\", \"name\": \"Alice Chen\" },\n      { \"email\": \"support@yourcompany.com\" }\n    ],\n    \"latest_message_received_date\": 1718900000\n  }\n}\n```\n\nFrom the CLI, the same fetch — pass `--json`\n\nso you get the structured object and not the pretty table:\n\n```\nnylas email threads show \"$THREAD_ID\" --json\n```\n\n`message_ids.length`\n\nis 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.\n\nThe 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`\n\n— that's a 100-character preview, useless for summarizing meaning.\n\nOver HTTP, one call per id:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/msg-003\" \\\n  --header \"Authorization: Bearer $NYLAS_API_KEY\"\n```\n\nThe message object gives you `from`\n\n, `to`\n\n, `subject`\n\n, `body`\n\n, and the `date`\n\nfield (a Unix timestamp). That `date`\n\nis what you sort on to get chronological order — don't assume `message_ids`\n\nis always perfectly time-ordered across providers; sort by `date`\n\nto be safe.\n\nFrom the CLI:\n\n```\nnylas email read msg-003 --json\n```\n\nIf you want the unprocessed body without HTML-to-text munging, `nylas email read`\n\ntakes a `--raw`\n\nflag, and `--headers`\n\nif 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.\n\nIn code, you fan these out in parallel. Here's the shape of it in Node:\n\n``` js\n// thread.data.message_ids came from the threads.show call above.\nconst messages = await Promise.all(\n  thread.data.messageIds.map((id) =>\n    nylas.messages.find({ identifier: GRANT_ID, messageId: id }),\n  ),\n);\n\n// Sort chronologically on the `date` field, then normalize into turns.\nconst turns = messages\n  .map((m) => m.data)\n  .sort((a, b) => a.date - b.date)\n  .map((m) => ({\n    role: m.from[0].email === AGENT_EMAIL ? \"agent\" : \"contact\",\n    date: m.date,\n    body: stripQuotedText(m.body), // drop the quoted-reply tail\n  }));\n```\n\nThat `stripQuotedText`\n\nstep 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:`\n\nmarker or the `>`\n\nquote 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.\n\nThis is the part Nylas has no opinion about — it's your model, your prompt, your storage. The pattern that works:\n\n**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.\n\n``` js\nconst KEEP_VERBATIM = 3;\n\nconst recent = turns.slice(-KEEP_VERBATIM);\nconst older = turns.slice(0, -KEEP_VERBATIM);\n\n// Summarize the older turns into a compact brief.\n// If you already have a stored brief from the last turn, fold the new\n// \"older\" messages into it instead of re-summarizing from scratch.\nconst brief = await summarizeTurns(older, previousBrief);\n```\n\nThe brief should be structured and short — a few hundred tokens, capped. I prompt the summarizer to produce something like:\n\n```\nCONVERSATION BRIEF (thread: SSO onboarding, Alice Chen @ customer.com)\n- Alice asked how to enable SAML SSO for her Okta tenant.\n- We sent setup steps and the metadata URL on turn 2.\n- Alice confirmed SSO works but users land on the wrong default workspace.\n- OPEN: how to set a default workspace per SSO group. Awaiting our answer.\n```\n\nThen the prompt you actually send the model is `brief + recent (verbatim) + the new inbound message`\n\n. 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.\n\nSome 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.\n\nThe 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`\n\n:\n\n```\n// After generating a reply, persist the updated brief.\nawait db.threadBriefs.upsert({\n  threadId: thread.data.id,\n  grantId: GRANT_ID,\n  brief,                       // the compact running summary\n  summarizedUpToMessageId: older.at(-1)?.id ?? null,\n  updatedAt: new Date().toISOString(),\n});\n```\n\nStoring `summarizedUpToMessageId`\n\nlets 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.\n\nA few things I'd treat as first-class, not afterthoughts:\n\n`message.created`\n\ntoo. If you don't check `from`\n\n, the agent will summarize and react to its own replies. Compare `from[0].email`\n\nagainst your agent's address before you process.`id`\n\n`message_ids`\n\n) or starts fresh. Re-fetching is the safe default — the full thread is always retrievable.`X-Nylas-Signature`\n\nheader: a hex HMAC-SHA256 of the raw request body, using your webhook secret. Verify it before you trust the `thread_id`\n\n. If you're constant-time comparing in Node with `crypto.timingSafeEqual`\n\n, check both buffers are the same length first — it throws on a length mismatch. The CLI's `nylas webhook verify`\n\ndoes 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:\n\n`Message-ID`\n\n, `In-Reply-To`\n\n, and `References`\n\nmake `thread_id`\n\nreliable, 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`\n\narray and one `GET`\n\nper turn. Everything else is prompt engineering and a row in your database.\n\nWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/summarize-long-email-threads-for-agent-context", "canonical_source": "https://dev.to/mqasimca/summarize-long-email-threads-for-agent-context-249p", "published_at": "2026-07-17 21:17:50+00:00", "updated_at": "2026-07-17 21:28:59.849836+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "natural-language-processing", "large-language-models"], "entities": ["Nylas", "Nylas CLI", "Nylas API"], "alternates": {"html": "https://wpnews.pro/news/summarize-long-email-threads-for-agent-context", "markdown": "https://wpnews.pro/news/summarize-long-email-threads-for-agent-context.md", "text": "https://wpnews.pro/news/summarize-long-email-threads-for-agent-context.txt", "jsonld": "https://wpnews.pro/news/summarize-long-email-threads-for-agent-context.jsonld"}}