Inboxes · September 26, 2026
How Many Tokens an Email Thread Really Costs
How many tokens does an email thread use? We built a 10-message thread and counted: 4% was new text. Here is the retrieval pattern that fixes it.
An agent that answers email usually gets handed “the thread” and told to reply. Every reply carries the whole conversation below it, every quoted copy carries its own signature and legal footer, and the HTML alternative repeats all of it in markup. So how many tokens does an email thread use once an agent reads it? That needs a measurement, not an estimate.
We built a ten-message invoice dispute and counted it with a real tokenizer: the ten bodies total 10,267 tokens, and only 411 of them (4.0%) are text anyone newly wrote. The script is published below so you can rerun it, followed by the retrieval pattern that keeps a long thread small without losing the reply chain. If what you need is the header mechanics that decide which messages belong to a thread at all, read keeping an agent’s replies in the right thread first.
How many tokens does an email thread use in an agent’s context?
In the constructed sample, the ten messages together contain 411 tokens of text
anyone actually wrote. Read as full text/plain bodies, the same ten messages cost
10,267 tokens. Only 4.0% of what the model would read is new. The rest is quoted
history, signatures, a confidentiality footer and quote headers.
Three other numbers from the same run, all with the o200k_base encoding:
- The newest message’s body alone is 1,970 tokens, of which 44 are new.
- The same message as the body endpoint’s JSON, carrying both the text and the sanitized HTML, is about 3,870 tokens, nearly double the text alone.
- An agent that rebuilds its context from every body each time a message lands spends 39,779 input tokens across the ten messages. Previews of earlier messages plus the newest sender’s own words cost 3,172 for the same ten context builds.
The quoted copies are the dominant term, and they grow with every reply.
None of these numbers is a product limit. Email for Agents does not count tokens or cap a thread by tokens. Tokens are a property of the model you feed, so the budget is yours to set.
How the sample thread was built
The thread is constructed, not captured. Real customer mail cannot be published, and a
synthetic thread has the advantage that every byte is visible and reproducible. It
models an accounts-payable agent at invoices@agents.emailforagents.ai disputing an
invoice with a supplier’s receivables clerk. Ten messages, alternating vendor and
agent, each written to be a plausible business email of two or three sentences.
What each message carries:
| Part | How it was built |
|---|---|
| Authored text | 33 to 55 tokens per message, written by hand |
| Vendor signature | name, title, company, phone and fax, website |
| Legal footer | a 91-token confidentiality notice on every vendor message |
| Agent signature | three lines, 25 tokens |
| Quoted history, vendor | Outlook style: a rule, a From:/Sent:/To:/Subject: block, then the previous body unindented |
| Quoted history, agent | Gmail style: an On ... wrote: line, then every line of the previous body prefixed with > |
| HTML alternative | the same content as paragraphs, the history inside a styled <blockquote>, with a small <style> |
| Headers | Received, DKIM-Signature, Authentication-Results, Message-ID, In-Reply-To, References |
| Attachments | 18,000 pseudo-random bytes, base64 at 76 columns, on messages 1, 3 and 7 |
Each reply quotes the whole of the previous message, including that message’s own
quoted history, which is what real clients do by default. The two quoting styles
alternate, so the vendor’s footer ends up indented under several levels of > by the
end. The pseudo-random attachment stands in for a compressed PDF, which is close to
random bytes after deflate. The MIME parts are left unencoded rather than
quoted-printable, which makes the raw figures slightly low.
Counting uses js-tiktoken 1.0.21 with o200k_base, the encoding tiktoken assigns to
the GPT-4o family. The run was repeated with cl100k_base: the text totals moved by
less than 1%, 10,222 tokens for all bodies with 416 authored, while the raw MIME
total, dominated by base64, rose by about 3%. Other model families ship their own tokenizers
and will not match these counts exactly. The ratios, which are what matter here, come
from the structure of the mail, not from the encoding.
What the measurement shows, message by message
o200k_base, tokens per message:
| Msg | From | Authored | text/plain | text/html | Raw MIME | New share of text |
|---|---|---|---|---|---|---|
| 1 | vendor | 45 | 180 | 258 | 17,806 | 25.0% |
| 2 | agent | 40 | 294 | 413 | 1,309 | 13.6% |
| 3 | vendor | 33 | 530 | 677 | 18,627 | 6.2% |
| 4 | agent | 55 | 685 | 847 | 2,154 | 8.0% |
| 5 | vendor | 35 | 923 | 1,113 | 2,669 | 3.8% |
| 6 | agent | 36 | 1,082 | 1,264 | 2,981 | 3.3% |
| 7 | vendor | 43 | 1,328 | 1,538 | 20,338 | 3.2% |
| 8 | agent | 44 | 1,518 | 1,697 | 3,881 | 2.9% |
| 9 | vendor | 36 | 1,757 | 1,964 | 4,388 | 2.0% |
| 10 | agent | 44 | 1,970 | 2,123 | 4,781 | 2.2% |
Read the first row carefully. Message 1 has no history at all, and it is still only 25% new. The vendor’s signature and confidentiality footer are 135 tokens, three times what Dana actually wrote. Boilerplate is a fixed tax on every vendor message before quoting adds anything.
From message 2 onward the quoted copy takes over. By message 10, the body is 1,970 tokens and 44 of them are the agent’s reply. The other 1,926 tokens are nine earlier messages the model has already seen, re-wrapped in quote markers, plus five copies of a legal notice.
The raw MIME column is what you get if an integration hands the model the RFC 5322
source. The three messages with an attachment each cost around 17,000 to 20,000 tokens,
almost all of it base64 that carries no meaning a language model can use. The DKIM
signatures and the References header, which lists every earlier Message-ID and so
also grows with the thread, add more.
Why quoted history makes cost grow quadratically
Each body contains every earlier body, so body size grows linearly with position in the thread: here, by roughly 200 tokens per reply, from 180 at message 1 to 1,970 at message 10. An agent that reads every body in the thread on each turn therefore reads a sum of linearly growing terms, which grows with the square of thread length.
The measured context size per turn, if the agent re-reads every body each time a message lands:
| After message | Re-read every body | Previews + newest authored text |
|---|---|---|
| 1 | 180 | 45 |
| 2 | 474 | 102 |
| 3 | 1,004 | 156 |
| 5 | 2,612 | 286 |
| 8 | 6,540 | 480 |
| 10 | 10,267 | 588 |
| Sum, 10 turns | 39,779 | 3,172 |
Doubling the thread from five messages to ten multiplied the re-read cost by 3.9. That is the quadratic showing through. The right-hand column grows linearly, because each earlier message contributes one fixed-size preview no matter how much it quoted.
There is a tempting shortcut: read only the newest body, since it already quotes everything. In this sample that costs exactly what one full read does, 1,970 tokens at message 10, so it is linear rather than quadratic. It is still the wrong default, for three reasons. It pays for the boilerplate every time. It depends on every participant quoting in full, and people trim, forward, and reply from phones that drop the history. And the quoted block is not a record of what was said. It is text the latest sender chose to include, and they can edit it. An agent that trusts a quoted “you agreed to pay by Friday” is trusting the sender’s paraphrase of your own mail. The stored messages are the record; treat quoted text as untrusted claims, the same way prompt injection in email treats any inbound content.
Where the HTML part and attachments go
Neither needs to reach the model, and in this API neither has to. The body
endpoint returns two fields, text and html, where html has been run through an
allowlist sanitizer (packages/domain/src/services.ts:3520-3524,
packages/domain/src/content.ts:3). If you serialise that whole response into the
prompt, you pay for the conversation twice. Running message 10’s HTML through the same
sanitizer settings and serialising the response gave 3,871 tokens, against 1,970 for the
text alone (the sanitizer drops the <style> block, which is why this is below the raw
text/html column plus the text). Pass text. Reach for html only when the text part is empty or the
meaning lives in a table.
The inbound text is stored as the provider delivered it
(packages/mail/src/resend-inbound.ts:309). No step in this build strips quoted
history or signatures from it, so the cleanup is yours. Stripping quoted reply text
before the model sees it covers the markers
across clients in more depth than the sketch below.
Attachments never appear in a body. Thread message listings carry attachment metadata
only: filename, size in bytes, content type, scan status and whether it is downloadable
(packages/domain/src/services.ts:3090). That metadata is a few tokens per file. The
18,000-byte stub cost about 16,800 tokens as base64, so an agent that decides whether to open
an attachment from its filename and size spends roughly a thousandth of what one that
opens everything does.
The retrieval pattern: page the thread, fetch bodies lazily
The pattern that produced the right-hand column above has four steps, each one mapped to an endpoint on the developer API.
1. Page the thread’s message list. GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages returns
messages oldest first, ordered by their sequence in the thread
(packages/domain/src/mail-pages.ts:151-159). Each row has id, direction, from,
created_at, sequence, preview, attachments and body_available, but no body
(packages/domain/src/services.ts:3468-3485). The default page is 50 and limit accepts
1 to 100 (mail-pages.ts:55-68, :148). Follow next_cursor while has_more is true.
Cursors are signed, bound to the workspace, project, inbox and listing they came from,
and expire after 24 hours (mail-pages.ts:69, :127); a stale or foreign one returns
invalid_cursor. This endpoint takes no q; to find the thread in the first place,
list threads with q, which matches subject, sender and preview, not full bodies. The
search and pagination docs spell out what the index covers.
2. Build the skeleton from metadata. One line per message: sequence, direction,
sender, time, attachment names, and the preview. A preview is the body with whitespace
collapsed, cut at 240 characters (packages/domain/src/content.ts:54-56). For a
top-posted reply that is mostly the new text, which is why previews make a cheap
index. It is a pointer, not a substitute: a long message is truncated mid-sentence.
3. Fetch full bodies only where the decision lives. GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body for the newest
inbound message, and for the agent’s own last reply so it knows what it committed to.
Strip the quoted part. Cache the result by message ID, so each body is fetched and
stripped once however many turns the thread runs.
4. Escalate on demand. If the model needs an older message, it asks for it by sequence number and you fetch that body. The reply chain survives because every message is still in the skeleton with its position and sender; only the redundant copies are gone.
Both listing and body calls need messages:read; listing threads needs threads:read
(packages/domain/src/services.ts:3447, :3489, :3429).
// Node 18+. EFA_API_KEY needs messages:read.
const BASE = "https://api.emailforagents.ai/v1";
const { EFA_API_KEY, EFA_PROJECT_ID: P, EFA_INBOX_ID: I } = process.env;
async function get(path) {
const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${EFA_API_KEY}` } });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
async function threadMessages(threadId) {
const all = [];
let cursor;
do {
const qs = new URLSearchParams({ limit: "100", ...(cursor && { cursor }) });
const page = await get(`/projects/${P}/inboxes/${I}/threads/${threadId}/messages?${qs}`);
all.push(...page.data);
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return all;
}
// Cut at the first quote marker. A sketch: real mail has more markers than these.
const MARKERS = [/^On .+wrote:\s*$/m, /^_{10,}\s*$/m, /^-+ ?Original Message ?-+/im, /^>/m];
function ownWords(text) {
const cut = Math.min(...MARKERS.map((re) => re.exec(text)?.index ?? text.length));
return text.slice(0, cut).trim();
}
const bodies = new Map(); // message id -> stripped text, fetched once
async function body(id) {
if (!bodies.has(id)) {
const { text } = await get(`/projects/${P}/inboxes/${I}/messages/${id}/body`);
bodies.set(id, ownWords(text));
}
return bodies.get(id);
}
export async function threadContext(threadId) {
const msgs = await threadMessages(threadId);
const full = new Set([
msgs.findLast((m) => m.direction === "inbound")?.id,
msgs.findLast((m) => m.direction === "outbound")?.id,
]);
const parts = [];
for (const m of msgs) {
const files = m.attachments?.map((a) => `${a.filename} (${a.bytes} B)`).join(", ");
const head =
`#${m.sequence} ${m.direction} ${m.from.email} ${m.created_at}` +
(files ? ` [attachments: ${files}]` : "");
const content = full.has(m.id) && m.body_available ? await body(m.id) : m.preview;
parts.push(`${head}\n${content}`);
}
return parts.join("\n\n");
}
Run against the sample, ownWords cuts every message at the right place. It leaves 69
tokens for message 10, the agent’s 44 plus its signature, and about 170 for each vendor
message, because the signature and confidentiality footer sit above the quote marker and
survive. The lean column in the table counts authored text alone, so a real run lands
above it by the signatures and footers of the bodies fetched in full: at most about 160
tokens per turn here, still far below re-reading. Dropping a footer you have already
seen from the same sender closes most of that gap.
If your agent reaches the inbox over MCP rather than REST, two differences matter.
list_messages pages the whole inbox and takes no thread filter, so group by
thread_id yourself (packages/mcp/src/index.ts:308-313). And a tool result whose JSON
exceeds 131,072 characters is returned as an error telling you to use the scoped API for
the full body (packages/mcp/src/index.ts:338-350). That is a character ceiling on one
tool response, not a token budget, and a long enough thread’s newest message can reach
it. How an agent reads its inbox walks through the
read path on both surfaces.
Measure your own threads with this script
The whole measurement is one file. Install one dependency and run it; set
ENCODING=cl100k_base to compare encodings. To measure real mail instead, replace the
thread array with bodies from the body endpoint and keep the counting loop.
// measure-thread.mjs: build a constructed 10-message email thread, count tokens.
// npm i js-tiktoken && node measure-thread.mjs
import { getEncoding } from "js-tiktoken";
const enc = getEncoding(process.env.ENCODING ?? "o200k_base");
const tok = (s) => enc.encode(s).length;
// Deterministic filler for DKIM signatures and the attachment stub.
let seed = 42;
const bytes = (n) =>
Uint8Array.from({ length: n }, () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return seed >>> 24;
});
const b64 = (n) => Buffer.from(bytes(n)).toString("base64").replace(/.{76}/g, "$&\r\n");
const vendor = { name: "Dana Whitfield", email: "dana.whitfield@northfield-supply.example" };
const agent = { name: "Accounts Payable", email: "invoices@agents.emailforagents.ai" };
const vendorSig =
"Dana Whitfield\nAccounts Receivable Specialist\nNorthfield Supply Co.\n" +
"T +1 (312) 555-0148 | F +1 (312) 555-0199\nnorthfield-supply.example";
const agentSig =
"Accounts Payable\nHarbor Labs, Inc.\nThis mailbox is operated by an automated " +
"agent. A person reviews payment changes.";
const disclaimer =
"CONFIDENTIALITY NOTICE: This e-mail message, including any attachments, " +
"is for the sole use of the intended recipient(s) and may contain confidential and " +
"privileged information. Any unauthorized review, use, disclosure or distribution is " +
"prohibited. If you are not the intended recipient, please contact the sender by reply " +
"e-mail and destroy all copies of the original message. Northfield Supply Co. accepts no " +
"liability for any damage caused by any virus transmitted by this email.";
// What each sender actually wrote. Everything else in the thread is packaging.
const turns = [
[
vendor,
"Hello,\n\nPlease find attached invoice INV-20931 for the September order (PO " +
"7714), total $18,420.00, due net 30. Let me know if you need anything else to process it.",
],
[
agent,
"Thanks, Dana. We received INV-20931. The PO shows 1,200 units of SKU NF-220 but " +
"the invoice bills 1,250. Can you confirm the shipped quantity?",
],
[
vendor,
"Good catch. Our warehouse shipped 1,250 because the extra 50 were backordered " +
"from the August PO. I have attached the packing slip for both shipments.",
],
[
agent,
"Understood. The August PO (7690) was closed short at 1,150 of 1,200, so the 50 " +
"units belong to it. Please split the invoice: 1,200 units on 7714 and 50 on 7690.",
],
[
vendor,
"Our system can't split an issued invoice. I can issue a credit memo for the 50 " +
"units and a new invoice against 7690. Would that work on your side?",
],
[
agent,
"Yes, a credit memo plus a new invoice works. Please reference INV-20931 on the " +
"credit memo so they reconcile. We will hold INV-20931 until both arrive.",
],
[
vendor,
"Credit memo CM-4410 ($736.80) and invoice INV-20958 ($736.80, PO 7690) are " +
"attached. Our payment terms stay net 30 from the original invoice date.",
],
[
agent,
"Both received and matched. One issue: INV-20958 lists remit-to bank details that " +
"differ from the account on file. We can't pay a changed account from email. Can you " +
"confirm the change through the vendor portal?",
],
[
vendor,
"Apologies, that template was out of date. The account on file is correct and " +
"nothing has changed. I have reissued INV-20958 with the right remit-to block.",
],
[
agent,
"Thank you. INV-20931 (net of CM-4410) and the reissued INV-20958 are approved and " +
"scheduled for the October 14 payment run. We'll send remittance advice when it clears.",
],
];
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const paras = (s) =>
s
.split("\n\n")
.map((p) => `<p>${esc(p).replace(/\n/g, "<br>")}</p>`)
.join("");
const date = (i) => new Date(Date.UTC(2026, 8, 1 + i, 14, 7 + i * 3)).toUTCString();
const thread = [];
for (const [i, [from, authored]] of turns.entries()) {
const to = from === vendor ? agent : vendor;
const prev = thread.at(-1);
const subject = i === 0 ? "Invoice INV-20931 - PO 7714" : "RE: Invoice INV-20931 - PO 7714";
const sig = from === vendor ? `${vendorSig}\n\n${disclaimer}` : agentSig;
let text = `${authored}\n\n${sig}`;
let html = `<div>${paras(authored)}</div><div class="sig">${paras(sig)}</div>`;
if (prev) {
if (from === vendor) {
// Outlook style: header block, history unindented
text +=
`\n\n________________________________\nFrom: ${prev.from.name} <${prev.from.email}>\n` +
`Sent: ${prev.date}\nTo: ${from.name} <${from.email}>\nSubject: ${prev.subject}\n\n${prev.text}`;
} else {
// Gmail style: attribution line, every quoted line prefixed "> "
text +=
`\n\nOn ${prev.date}, ${prev.from.name} <${prev.from.email}> wrote:\n` +
prev.text
.split("\n")
.map((l) => `> ${l}`)
.join("\n");
}
html +=
`<div class="quote"><b>From:</b> ${esc(prev.from.name)}<br><b>Sent:</b> ${prev.date}` +
`<blockquote style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">` +
`${prev.htmlBody}</blockquote></div>`;
}
const htmlDoc =
`<html><head><meta charset="utf-8"><style>p{margin:0 0 12px;font-family:` +
`Calibri,Arial,sans-serif;font-size:11pt}</style></head><body>${html}</body></html>`;
const msgId = `<${i + 1}.${from === vendor ? "NF" : "EFA"}@${from.email.split("@")[1]}>`;
const refs = thread.map((m) => m.msgId);
const attach = [0, 2, 6].includes(i); // invoice, packing slip, credit memo + invoice
const bnd = `b${i}`;
const raw = [
`Received: from mail-${i}.${from.email.split("@")[1]} (mail-${i}.${from.email.split("@")[1]} ` +
`[203.0.113.${10 + i}]) by mx.example with ESMTPS id x${i}; ${date(i)}`,
`DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=${from.email.split("@")[1]}; ` +
`s=s1; h=from:to:subject:date:message-id; bh=${b64(32)}; b=${b64(256)}`,
`Authentication-Results: mx.example; dkim=pass; spf=pass; dmarc=pass`,
`Message-ID: ${msgId}`,
...(prev ? [`In-Reply-To: ${prev.msgId}`, `References: ${refs.join(" ")}`] : []),
`Date: ${date(i)}`,
`From: ${from.name} <${from.email}>`,
`To: ${to.name} <${to.email}>`,
`Subject: ${subject}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${bnd}"`,
``,
`--${bnd}`,
`Content-Type: multipart/alternative; boundary="${bnd}a"`,
``,
`--${bnd}a`,
`Content-Type: text/plain; charset="utf-8"`,
``,
text,
`--${bnd}a`,
`Content-Type: text/html; charset="utf-8"`,
``,
htmlDoc,
`--${bnd}a--`,
...(attach
? [
`--${bnd}`,
`Content-Type: application/pdf; name="doc-${i + 1}.pdf"`,
`Content-Transfer-Encoding: base64`,
``,
b64(18_000),
]
: []),
`--${bnd}--`,
].join("\r\n");
thread.push({
from,
subject,
date: date(i),
msgId,
text,
htmlBody: html,
htmlDoc,
raw,
authored,
});
}
// The product's preview rule: collapse whitespace, keep the first 240 characters.
const preview = (s) => s.replace(/\s+/g, " ").trim().slice(0, 240);
console.log(`encoding ${process.env.ENCODING ?? "o200k_base"}`);
console.log("msg\tfrom\tauthored\ttext\thtml\traw\tnew%\tturn_all\tturn_lean");
let sumText = 0,
sumAuth = 0,
naive = 0,
lean = 0;
for (const [i, m] of thread.entries()) {
const [a, t, h, r] = [tok(m.authored), tok(m.text), tok(m.htmlDoc), tok(m.raw)];
const leanTurn = thread.slice(0, i).reduce((n, p) => n + tok(preview(p.text)), 0) + a;
sumText += t;
sumAuth += a;
naive += sumText; // this turn re-reads every full body in the thread
lean += leanTurn; // this turn reads earlier previews + what the newest sender wrote
const who = m.from === vendor ? "vendor" : "agent";
const share = ((100 * a) / t).toFixed(1);
console.log([i + 1, who, a, t, h, r, share, sumText, leanTurn].join("\t"));
}
console.log(
`all bodies: ${sumText} tokens, authored: ${sumAuth} ` +
`(${((100 * sumAuth) / sumText).toFixed(1)}%)`,
);
console.log(`10 turns, re-reading every body each turn: ${naive}`);
console.log(`10 turns, earlier previews + newest authored text: ${lean}`);
Output columns are tokens for the authored text, the text/plain part, the text/html
part and the raw MIME, then the authored share, then the two per-turn context sizes from
the table above.
What this sample does not tell you
The ratios move with the mail. Authored messages here are short, two or three sentences, which is typical of back-and-forth business email and unkind to the new-text share. A thread of long, carefully written messages would show a higher share per message and the same quadratic shape, because the quoting behaviour is identical. A thread where people trim their quotes, or reply from a client that drops history, grows more slowly and makes the newest-body shortcut even less reliable, since the history is then missing rather than duplicated.
Model choice changes absolute counts, not shape. o200k_base and cl100k_base agreed
within 1% on the text here; other vendors’ tokenizers may differ by more, and none of the counts
above should be copied into a budget for a model they were not measured on. Rerun the
script with that model’s tokenizer.
What holds regardless is structural. The fix is a retrieval choice, not a bigger context window: list the thread, index it by preview, and fetch and strip only the bodies the decision depends on. The agent keeps its own address and the full thread history in its inbox, so nothing is lost by not reading it all on every turn.
Keep reading
Keep an Agent's Replies in the Right Email Thread
How Message-ID, In-Reply-To and References decide threading, why subject matching breaks, and how to keep an agent reply in the right conversation.
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Prompt Injection by Email: What Actually Helps
Any stranger can email your agent. What an email-borne injection looks like, why message text must never grant permission, and the controls that limit damage.