appscript.dev
Automation Intermediate Gmail

Summarize long email threads into three bullets

Collapse Northwind threads to three bullets — for fast handovers and weekly digests.

Published Jul 10, 2025

A twenty-message email thread holds three useful facts: where things stand, what happens next, and who owes what. Everything else is greeting lines, quoted history, and “thanks, will do”. When a Northwind account changes hands or a manager wants a Friday digest, somebody still has to scroll the whole thread to extract those three facts.

This function does the extracting. Give it a thread ID and it stitches every message into a transcript, asks Claude to boil it down to exactly three bullets, and hands the summary back. It is the building block for a handover note or a weekly digest — wrap it in a loop and you have both.

What you’ll need

  • A Gmail account with the threads you want summarised — you’ll pass each thread’s ID to the function.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.
  • Nothing else — this is a single function you call with a thread ID.

The script

// The three things every summary must answer. Edit this list to
// retune what the bullets cover.
const SUMMARY_POINTS = ['current status', "what's next", 'who owes what'];

// Cap the transcript so a very long thread cannot blow the token budget.
const MAX_TRANSCRIPT_CHARS = 12000;

/**
 * Summarises a Gmail thread into exactly three bullet points.
 *
 * @param {string} threadId - The Gmail thread ID to summarise.
 * @return {string} A three-bullet summary, or '' if the thread is empty.
 */
function summariseThread(threadId) {
  // 1. Load the thread; bail out if the ID does not resolve.
  const thread = GmailApp.getThreadById(threadId);
  if (!thread) {
    Logger.log('No thread found for ID ' + threadId);
    return '';
  }

  // 2. Stitch every message into a labelled transcript.
  const transcript = thread.getMessages()
    .map((m) =>
      `From ${m.getFrom()} on ${m.getDate().toISOString()}:\n${m.getPlainBody()}`)
    .join('\n\n---\n\n')
    .slice(0, MAX_TRANSCRIPT_CHARS);

  if (!transcript.trim()) {
    Logger.log('Thread has no readable content — nothing to summarise.');
    return '';
  }

  // 3. Ask Claude for exactly three bullets, one per summary point.
  const prompt =
    'Summarise this Northwind email thread into exactly three bullet points: ' +
    SUMMARY_POINTS.join(', ') + '. ' +
    'One bullet per point, in that order. No preamble.\n\n' +
    transcript;

  // 4. Haiku is enough for summarisation — fast and cheap.
  return callClaude(prompt);
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
function callClaude(prompt, model = 'claude-haiku-4-5-20251001', maxTokens = 300) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('ANTHROPIC_API_KEY');
  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify({
      model,
      max_tokens: maxTokens,
      messages: [{ role: 'user', content: prompt }],
    }),
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. summariseThread loads the thread by ID. If the ID does not resolve — a deleted or mistyped thread — it logs the miss and returns an empty string.
  2. It maps every message to a From … on …: header followed by its plain-text body, then joins them with a divider into one transcript.
  3. It trims the transcript to MAX_TRANSCRIPT_CHARS so a very long thread cannot push past the model’s input budget. If the trimmed transcript is empty, it stops early.
  4. It builds a prompt that asks for exactly three bullets, one for each entry in SUMMARY_POINTS, in a fixed order.
  5. It calls Claude Haiku — summarisation does not need a reasoning model, and Haiku is fast and cheap enough to run across a whole inbox.
  6. It returns the bullet text so the caller can log it, email it, or collect it into a digest.

Example run

Call it from the editor or another function with a thread ID:

const summary = summariseThread('18c2a1f9e4b0d3a7');
Logger.log(summary);

For a long back-and-forth about a delayed Northwind invoice, the returned string looks like this:

- Status: The April invoice was disputed over a duplicate line item;
  finance has confirmed the duplicate and agreed a credit note.
- What's next: A corrected invoice goes out by Friday; the customer pays
  within their usual 14-day terms once received.
- Who owes what: Northwind finance owes the corrected invoice; the
  customer owes payment after it arrives.

Three bullets that answer the only questions a handover needs — instead of scrolling twenty messages to find them.

Run it

This is an on-demand building block. Wrap it in a loop to build a weekly digest of every thread under a label:

function weeklyDigest() {
  const threads = GmailApp.search('label:accounts newer_than:7d');
  if (!threads.length) return;

  const digest = threads
    .map((t) => `### ${t.getFirstMessageSubject()}\n${summariseThread(t.getId())}`)
    .join('\n\n');

  GmailApp.sendEmail(
    Session.getActiveUser().getEmail(),
    'Weekly account digest',
    digest);
}

Add a weekly time-driven trigger for weeklyDigest and the summary lands in your inbox every Friday.

Watch out for

  • The summary is only as good as the thread. If the messages never say who owes what, Claude will guess or hedge — that is a sign the thread itself was unclear, not a bug.
  • Long threads are trimmed to MAX_TRANSCRIPT_CHARS, which drops the oldest messages first. If the early history matters, raise the cap and max_tokens together, or summarise in batches.
  • Quoted reply chains inflate the transcript — the same text repeats in every message. The character cap absorbs most of this, but a cleaner summary comes from stripping quoted lines before sending.
  • Running this across a large label hits one API call per thread. For a big digest, watch the six-minute execution limit and split the work across runs if needed.
  • getPlainBody drops formatting and attachments. A thread whose decision lives in a spreadsheet attachment will summarise as “see attached” — the script cannot read what it cannot see.

Related