Guide
Summarize Email Threads with AI
A 30-message email thread about a product launch has 4 decisions buried in it, 2 deadlines, and 1 action item nobody remembers assigning. This guide builds a CLI pipeline that fetches a thread, feeds it to an LLM, and returns a structured summary with decisions, owners, and due dates — all without opening a mail client.
Written by Hazik Director of Product Management
Command references used in this guide: nylas email threads list and nylas email read.
Why summarize email threads with AI?
Email threads grow fast. A 2024 Radicati Group report found the average business user receives 121 emails per day. Reading a 30-message thread takes 12-15 minutes. An LLM extracts the key decisions, action items, and deadlines in under 5 seconds.
Manual summaries also drift. People miss nested replies, skip attachments mentioned in message #14, and forget who committed to what by message #28. An LLM reads every message in order and produces a structured output with owners and dates. The trade-off is accuracy: models hallucinate names and dates in about 10-15% of summaries, so you should treat the output as a draft and verify deadlines against the source thread.
How do you fetch email threads from the CLI?
The CLI groups related messages into threads using the In-Reply-To and References headers from the email protocol. The nylas email threads list command returns threads sorted by the most recent message date. Each thread object includes a message_ids array and a snippet from the latest message.
Start by listing recent threads to find the one you want to summarize. The --json flag returns structured data you can pipe into jq to filter by subject, participant, or message count. Threads with fewer than 3 messages usually don't need summarization, so filtering by length saves LLM tokens.
# List recent threads with message counts
nylas email threads list --json | \
jq '.[] | {id, subject, message_count: (.message_ids | length), snippet}' | head -40
# Find threads with 5+ messages (worth summarizing)
nylas email threads list --json | \
jq '[.[] | select((.message_ids | length) >= 5)] | sort_by(-(.message_ids | length))'
# Show details for a specific thread
nylas email threads list --json | \
jq '.[] | select(.subject | test("product launch"; "i"))'Each thread object contains the subject line, participant list, and an array of message IDs. The next step fetches the full body of each message in that array and assembles them into a single document the LLM can process.
How do you extract and format thread content?
Extracting thread content means fetching each message body in chronological order and assembling them into a single text block. The CLI returns both HTML and plain text for each message. Plain text works better for LLM input — it's smaller (40-60% fewer tokens than HTML) and avoids confusing the model with markup. The script below fetches up to 50 messages per thread, which covers 99% of business threads based on the Radicati data.
The formatting step adds a header to each message with the sender, date, and position in the thread. This context helps the LLM attribute statements to the right person. Without it, the model often confuses who said what in threads with 3 or more participants.
import subprocess
import json
def fetch_thread_content(thread_id):
"""Fetch all messages in a thread, ordered chronologically."""
# Get the thread directly by ID
result = subprocess.run(
["nylas", "email", "threads", "show", thread_id, "--json"],
capture_output=True, text=True
)
if result.returncode != 0:
return "", []
thread = json.loads(result.stdout)
messages = []
for msg_id in thread.get("message_ids", []):
result = subprocess.run(
["nylas", "email", "read", msg_id, "--json"],
capture_output=True, text=True
)
if result.returncode == 0:
messages.append(json.loads(result.stdout))
# Sort by date ascending (oldest first)
messages.sort(key=lambda m: m.get("date", 0))
return format_for_llm(messages, thread.get("subject", "")), messages
def format_for_llm(messages, subject):
"""Format thread messages into a single text document."""
parts = [f"Thread: {subject}", f"Messages: {len(messages)}", "---"]
for i, msg in enumerate(messages, 1):
sender = msg["from"][0]["email"] if msg.get("from") else "unknown"
name = msg["from"][0].get("name", sender) if msg.get("from") else sender
date = msg.get("date", "unknown date")
body = msg.get("body", msg.get("snippet", ""))[:2000]
parts.append(f"\n[Message {i}/{len(messages)}] {name} ({sender}) — {date}")
parts.append(body)
return "\n".join(parts)The [:2000] per-message truncation keeps the total prompt under 128K tokens for threads up to 50 messages. Most LLMs handle this comfortably — gpt-4o supports 128K context and claude-3.5-sonnet supports 200K. For threads with long email signatures or legal disclaimers, consider stripping repeated footer blocks before sending to the model.
How do you generate summaries with an LLM?
The summary prompt asks the model for 4 structured sections: a 2-3 sentence overview, a list of decisions made, action items with owners and due dates, and open questions that haven't been resolved. Structuring the output as JSON makes downstream processing easier — you can feed it into a task tracker, a Slack message, or a daily digest email. The call uses the chat completions endpoint from OpenAI's text generation API and costs about $0.02-0.05 per thread with gpt-4o at current API pricing.
The prompt uses response_format to enforce JSON output. This eliminates the need to parse markdown or free-text summaries. Temperature 0.3 balances factual accuracy with readable phrasing — lower values produce dry but accurate summaries, higher values risk inventing details.
from openai import OpenAI
client = OpenAI()
SUMMARY_PROMPT = """Summarize this email thread. Return valid JSON only.
{thread_content}
Return this exact JSON structure:
{{
"overview": "2-3 sentence summary of the thread",
"decisions": ["each decision made, with who decided"],
"action_items": [
{{"task": "what needs to be done", "owner": "person's name", "due": "date or 'unspecified'"}}
],
"open_questions": ["unresolved questions still pending"],
"participants": ["list of unique participants"],
"message_count": <number>
}}"""
def summarize_thread(thread_id):
"""Generate a structured summary of an email thread."""
content, messages = fetch_thread_content(thread_id)
if not content:
return None
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": SUMMARY_PROMPT.format(
thread_content=content
)}],
response_format={"type": "json_object"},
max_tokens=1000,
temperature=0.3,
)
return json.loads(resp.choices[0].message.content)The returned JSON makes it easy to post a Slack summary, create Jira tickets from action items, or build a daily digest. Each action item includes an owner name extracted from the thread, so you know who committed to what without re-reading 30 messages.
How do you extract structured action items?
Action items are the highest-value output from a thread summary. The script below wraps the full pipeline into a single command: pick a thread by subject keyword, summarize it, and print the action items as a table. Running this against 5 threads takes about 15 seconds and costs roughly $0.15 total. You can run it as a daily cron to surface outstanding action items from the previous day's email.
The jq one-liner at the end formats the action items for terminal display. For integration with project management tools, pipe the JSON directly to a webhook or API call instead.
#!/usr/bin/env python3
"""Summarize a thread and extract action items."""
import sys
def main():
keyword = sys.argv[1] if len(sys.argv) > 1 else None
# Find matching threads
result = subprocess.run(
["nylas", "email", "threads", "list", "--json"],
capture_output=True, text=True
)
threads = json.loads(result.stdout)
if keyword:
threads = [t for t in threads if keyword.lower() in t.get("subject", "").lower()]
if not threads:
print(f"No threads matching '{keyword}'")
return
# Summarize the first match
thread = threads[0]
msg_count = len(thread.get("message_ids", []))
print(f"Summarizing: {thread['subject']} ({msg_count} messages)")
summary = summarize_thread(thread["id"])
if not summary:
print("Failed to generate summary.")
return
print(f"\nOverview: {summary['overview']}")
print(f"\nDecisions ({len(summary.get('decisions', []))}):")
for d in summary.get("decisions", []):
print(f" - {d}")
print(f"\nAction Items ({len(summary.get('action_items', []))}):")
for item in summary.get("action_items", []):
print(f" - [{item.get('due', '?')}] {item['task']} (owner: {item['owner']})")
print(f"\nOpen Questions ({len(summary.get('open_questions', []))}):")
for q in summary.get("open_questions", []):
print(f" - {q}")
if __name__ == "__main__":
main()For batch processing, loop through all threads with 5+ messages and write the summaries to a JSON file. A morning digest script can then email you the combined action items across all active threads. See Automate Email Reports from Terminal for cron-based email delivery patterns.
Next steps
- Build an AI Email Triage Agent — classify and route messages automatically across your inbox
- Manus AI Email Research Agent — use email as a data source for AI research workflows
- Email as Memory for AI Agents — treat your inbox as long-term context for agent pipelines
- Full command reference — every flag and subcommand documented