Guide
Automate Meeting Notes to Email
You finished a 45-minute product review. Three people took partial notes. Nobody wrote down the two decisions that matter. This guide builds a pipeline that records the meeting, fetches the transcript, summarizes it with an LLM, and emails the notes to every attendee — all from the command line.
Written by Caleb Geene Director, Site Reliability Engineering
Command references used in this guide: nylas email send, nylas email list, and nylas calendar events list.
Why automate meeting notes?
Meeting notes that arrive 3 days late are useless. A 2022 Harvard Business Review study found that 71% of meetings are unproductive, and one of the top reasons is poor follow-through on action items. Automating the notes pipeline solves the delay problem: the summary arrives in attendees' inboxes within 5 minutes of the call ending, while context is still fresh and action items are still actionable.
Manual note-taking has another problem: coverage. One person can't capture everything in a 45-minute meeting with 6 participants. A transcript-based approach records every word, and the LLM extracts the structure — decisions, action items, owners, deadlines. The raw transcript costs nothing to store; the LLM summary costs about $0.03-0.08 per meeting depending on length.
How do you record a meeting from the CLI?
The CLI's notetaker feature sends a bot to any Zoom, Google Meet, or Microsoft Teams call. The Nylas Notetaker API handles the recording and transcription pipeline. The bot joins with a name you specify (e.g., “Notes Bot”), records audio and video, and generates a transcript after the call ends. One command starts the recording. No browser extensions, no desktop apps, no manual upload to a transcription service.
You need the meeting link — a Zoom URL, a Google Meet code, or a Teams join link. If the meeting is on your calendar, you can pull the link from nylas calendar events list instead of copying it manually. The notetaker supports calls up to 4 hours long and produces transcripts within 2-5 minutes after the call ends.
# Send a notetaker bot to a Zoom call
nylas notetaker create --meeting-link "https://zoom.us/j/123456789"
# Send to a Google Meet
nylas notetaker create --meeting-link "https://meet.google.com/abc-defg-hij"
# Send to a Teams meeting
nylas notetaker create --meeting-link "https://teams.microsoft.com/l/meetup-join/..."
# Pull the meeting link from your calendar instead
nylas calendar events list --json | \
jq '.[] | select(.title | test("product review"; "i")) | .conferencing'The notetaker create command returns a notetaker ID you'll use in subsequent steps to check status and fetch the transcript. Save this ID — the pipeline needs it to poll for completion and retrieve the recording. To schedule a bot for a future meeting, add --join-time with a timestamp:
nylas notetaker create --meeting-link "https://zoom.us/j/123" --join-time "tomorrow 2pm"The bot will wait and join at the specified time instead of connecting immediately.
How do you fetch the transcript?
After the meeting ends, the notetaker processes the recording and generates a transcript. Processing takes 2-5 minutes for a 30-minute call. The nylas notetaker show command returns the current status: joining, recording, processing, or completed. Once the status is completed, the media endpoint returns the transcript with speaker labels and timestamps.
The script below polls for completion every 30 seconds. In practice, a 30-minute meeting finishes processing in about 3 minutes. The transcript includes speaker diarization — each segment is tagged with who said it — which the LLM uses to attribute action items to specific people.
import subprocess
import json
import time
def wait_for_transcript(notetaker_id, timeout=600, interval=30):
"""Poll until the notetaker finishes processing."""
elapsed = 0
while elapsed < timeout:
result = subprocess.run(
["nylas", "notetaker", "show", notetaker_id, "--json"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"Status check failed: {result.stderr}")
time.sleep(interval)
elapsed += interval
continue
status = json.loads(result.stdout)
# Field name may be "status" or "state" depending on CLI version
# — check `nylas notetaker show <id> --json` output
state = status.get("state", status.get("status", "unknown"))
print(f"Notetaker status: {state} ({elapsed}s elapsed)")
if state == "completed":
return fetch_media(notetaker_id)
elif state in ("failed", "cancelled"):
print(f"Notetaker {state}. Aborting.")
return None
time.sleep(interval)
elapsed += interval
print(f"Timeout after {timeout}s. Check manually with: nylas notetaker show {notetaker_id}")
return None
def fetch_media(notetaker_id):
"""Fetch the transcript from the notetaker media endpoint."""
result = subprocess.run(
["nylas", "notetaker", "media", notetaker_id, "--json"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"Media fetch failed: {result.stderr}")
return None
media = json.loads(result.stdout)
# The notetaker media response format depends on the recording service.
# Some versions return inline transcript text; others return a URL.
if not media.get("transcript") and media.get("transcript_url"):
import requests
resp = requests.get(media["transcript_url"])
if resp.ok:
media["transcript"] = resp.text
return mediaThe media response includes the transcript text, speaker labels, timestamps, and a link to the recording file. The transcript is plain text with speaker tags like [Speaker 1] — the LLM handles mapping these to real names when attendee names are available from the calendar event.
How do you generate a summary with an LLM?
The summary prompt asks the model for 5 sections: a 2-3 sentence overview, key decisions, action items with owners, open questions, and a list of participants. A 30-minute meeting produces roughly 5,000-8,000 words of transcript, which fits comfortably in gpt-4o's 128K context window. The script uses the chat completions endpoint from OpenAI's text generation API. The summary call costs about $0.03 for a 30-minute meeting and $0.08 for a 60-minute one.
Setting temperature=0.2 keeps the summary factual. Higher temperatures produce more readable prose but risk inventing details — a bad trade-off for meeting notes where accuracy matters more than style. The JSON output format makes it easy to format the email body in the next step.
from openai import OpenAI
client = OpenAI()
SUMMARY_PROMPT = """Summarize this meeting transcript. Return valid JSON only.
Transcript:
{transcript}
Return this exact JSON structure:
{{
"title": "meeting topic in 5-8 words",
"overview": "2-3 sentence summary of what was discussed",
"decisions": ["each decision made, attributed to who decided"],
"action_items": [
{{"task": "what", "owner": "who", "due": "when or 'not specified'"}}
],
"open_questions": ["unresolved items for follow-up"],
"duration_minutes": <estimated meeting length>,
"participant_count": <number of distinct speakers>
}}"""
def summarize_transcript(media):
"""Generate a structured summary from the transcript."""
transcript = media.get("transcript", "")
if not transcript:
print("No transcript found in media response.")
return None
# Truncate to ~100K chars to stay within context limits
transcript = transcript[:100000]
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": SUMMARY_PROMPT.format(
transcript=transcript
)}],
response_format={"type": "json_object"},
max_tokens=1000,
temperature=0.2,
)
return json.loads(resp.choices[0].message.content)The structured JSON output separates the summary into sections that map directly to what attendees care about: what was decided, who owns what, and what's still open. The next step formats this into an email and sends it.
How do you email the notes to attendees?
The final step formats the summary as a readable email body and sends it to each attendee individually. Attendee email addresses come from the calendar event if available, or from the notetaker's participant list. The CLI's email send command accepts one recipient per call, so the script loops over the attendee list. The --yes flag skips the interactive confirmation prompt, which would hang in automated pipelines. The full pipeline — from transcript to sent emails — runs in under 10 seconds once the transcript is ready.
The email body uses plain text formatting that reads well in any mail client. HTML emails with styled tables look better but add complexity. For a v1 pipeline, plain text with clear section headers and bullet points is enough. Plain text emails render consistently across all email clients, avoiding HTML formatting issues.
def format_email_body(summary):
"""Format the summary as a readable email."""
lines = [
f"Meeting Notes: {summary['title']}",
f"Duration: ~{summary.get('duration_minutes', '?')} minutes",
f"Participants: {summary.get('participant_count', '?')} people",
"",
"OVERVIEW",
summary.get("overview", "No overview available."),
"",
"DECISIONS",
]
for d in summary.get("decisions", []):
lines.append(f" * {d}")
if not summary.get("decisions"):
lines.append(" (none recorded)")
lines.append("")
lines.append("ACTION ITEMS")
for item in summary.get("action_items", []):
due = item.get("due", "not specified")
lines.append(f" * [{due}] {item['task']} — owner: {item['owner']}")
if not summary.get("action_items"):
lines.append(" (none recorded)")
lines.append("")
lines.append("OPEN QUESTIONS")
for q in summary.get("open_questions", []):
lines.append(f" * {q}")
if not summary.get("open_questions"):
lines.append(" (none)")
return "\n".join(lines)
def send_notes(summary, attendees):
"""Send the meeting notes to each attendee individually."""
body = format_email_body(summary)
subject = f"Meeting Notes: {summary['title']}"
failed = []
for recipient in attendees:
result = subprocess.run(
["nylas", "email", "send",
"--to", recipient,
"--subject", subject,
"--body", body,
"--yes"],
capture_output=True, text=True
)
if result.returncode != 0:
failed.append(recipient)
print(f"Send failed for {recipient}: {result.stderr}")
sent = len(attendees) - len(failed)
print(f"Notes sent to {sent}/{len(attendees)} attendees.")
if failed:
print(f"Failed: {', '.join(failed)}")
# Get attendees from calendar event
def get_attendees(event_title):
"""Pull attendee emails from a calendar event."""
result = subprocess.run(
["nylas", "calendar", "events", "list", "--json"],
capture_output=True, text=True
)
events = json.loads(result.stdout)
event = next((e for e in events if event_title.lower() in e.get("title", "").lower()), None)
if not event:
return []
return [a["email"] for a in event.get("participants", []) if a.get("email")]For recurring meetings, wrap the entire pipeline in a script triggered by a calendar webhook or a cron job that checks for recently ended events. The Record Meetings from Terminal guide covers the notetaker setup in more detail, including how to auto-join scheduled meetings.
Next steps
- Record Meetings from Terminal — deeper coverage of notetaker setup and auto-join workflows
- Summarize Email Threads with AI — apply the same LLM summarization to long email threads
- Manage Calendar from Terminal — list, create, and manage calendar events from the CLI
- Full command reference — every flag and subcommand documented