> ## Documentation Index
> Fetch the complete documentation index at: https://runcrate.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Meeting recording to action items and an email

> A meeting recording to owners, tasks, due dates, a follow-up email and a calendar invite for the next meeting.

The useful part of a meeting is who agreed to do what by when, and it is usually lost by the afternoon. This pipeline keeps the timestamps from the transcript, has a model extract each action item with the exact words that created it and a real date resolved from "by Thursday", and writes the email from that checked JSON with a template, so nothing in the email was invented by a second model. It costs 1.2 cents for a 34-second recording, all of it the transcription; the extraction runs on the free tier.

```mermaid theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
flowchart LR
  A[meeting.mp3] --> B[Transcript + timestamps<br/>whisper-large-v3-turbo]
  B --> C[Minutes JSON<br/>DeepSeek V4.1 Flash, free tier]
  C --> D[follow-up.eml + next-meeting.ics]
  C --> E[signed POST to your mailer]
```

## Build it in your project

Paste this line into your agent (Claude Code, Codex, Cursor, OpenCode, Cline). It sets up your key, then builds the pipeline in your project:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Add Runcrate's DeepSeek V4.1 Flash to this project: run curl -fsSL "https://runcrate.ai/agents.md?model=deepseek-ai%2FDeepSeek-V4.1-Flash&intent=build" and follow it. Then build: a script that turns a meeting recording into minutes: whisper-large-v3-turbo transcript with timestamps, DeepSeek V4.1 Flash on the free tier extracting owners, tasks, ISO due dates and the quote behind each, a templated follow-up .eml with an .ics invite, and an optional signed POST to my mailer.
```

## The code

One file per language. Both read `RUNCRATE_API_KEY` from the environment; nothing secret is in the code.

```python meeting_to_action_items.py theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
"""A meeting recording to action items, a follow-up email and a calendar invite.

pip install openai requests
RUNCRATE_API_KEY=... python meeting_to_action_items.py meeting.mp3 [YYYY-MM-DD]
Optional: MAIL_TO=team@example.com MAIL_WEBHOOK_URL=https://your.app/hooks/mail WEBHOOK_SECRET=... (n8n, Make, Zapier or your own mailer)
"""
import datetime as dt, hashlib, hmac, json, os, sys, time, uuid
from email.message import EmailMessage
from pathlib import Path

import requests
from openai import OpenAI

client = OpenAI(base_url="https://api.runcrate.ai/v1", api_key=os.environ["RUNCRATE_API_KEY"], max_retries=4)
ASR = "openai/whisper-large-v3-turbo"        # timestamps per segment
LLM = "deepseek-ai/DeepSeek-V4.1-Flash"      # on the free tier
ledger = []

SCHEMA = {
    "type": "object", "additionalProperties": False,
    "required": ["summary", "decisions", "action_items", "risks", "next_meeting"],
    "properties": {
        "summary": {"type": "string", "description": "Two sentences."},
        "decisions": {"type": "array", "items": {"type": "string"}},
        "action_items": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "required": ["owner", "task", "due", "quote", "at_seconds"],
            "properties": {
                "owner": {"type": "string"}, "task": {"type": "string"},
                "due": {"type": ["string", "null"], "description": "YYYY-MM-DD, resolved from the meeting date"},
                "quote": {"type": "string", "description": "The words in the transcript that create this item"},
                "at_seconds": {"type": "number"},
            }}},
        "risks": {"type": "array", "items": {"type": "string"}},
        "next_meeting": {"type": ["string", "null"], "description": "Local start time, YYYY-MM-DDTHH:MM, or null"},
    },
}

def step(name, model, t0, cost):
    ledger.append({"step": name, "model": model, "seconds": round(time.time() - t0, 2), "cost_usd": cost})
    print(f"{name:<12} {model:<40} {time.time() - t0:6.2f}s  ${cost:.6f}")

def main(audio: str, date: str) -> None:
    out = Path("out"); out.mkdir(exist_ok=True)

    t0 = time.time()
    with open(audio, "rb") as f:
        raw = client.audio.transcriptions.with_raw_response.create(model=ASR, file=f, response_format="verbose_json")
    tr = raw.parse()
    step("transcribe", ASR, t0, float(raw.headers.get("x-llm-cost", 0)))
    lines = "\n".join(f"[{s.start:.1f}s] {s.text.strip()}" for s in tr.segments)
    (out / "transcript.txt").write_text(lines)

    t0 = time.time()
    weekday = dt.date.fromisoformat(date).strftime("%A")
    raw = client.chat.completions.with_raw_response.create(
        model=LLM,
        messages=[
            {"role": "system", "content": "You turn meeting transcripts into action items. Speakers name themselves or each other; attribute every item to a person. Never invent an item that is not in the transcript."},
            {"role": "user", "content": f"Meeting date: {date} ({weekday}).\n\nTranscript:\n{lines}"},
        ],
        response_format={"type": "json_schema", "json_schema": {"name": "minutes", "strict": True, "schema": SCHEMA}},
        temperature=0,
    )
    minutes = json.loads(raw.parse().choices[0].message.content)
    step("extract", LLM, t0, float(raw.headers.get("x-llm-cost", 0)))
    (out / "minutes.json").write_text(json.dumps(minutes, indent=2))

    # The email is a template over checked JSON: no second model call, nothing invented.
    t0 = time.time()
    body = [minutes["summary"], "", "Action items:"]
    body += [f"- {a['owner']}: {a['task']}" + (f" (due {a['due']})" if a["due"] else "") for a in minutes["action_items"]]
    if minutes["decisions"]:
        body += ["", "Decisions:"] + [f"- {d}" for d in minutes["decisions"]]
    if minutes["risks"]:
        body += ["", "Risks:"] + [f"- {r}" for r in minutes["risks"]]
    msg = EmailMessage()
    msg["Subject"] = f"Follow-up: meeting of {date}"
    msg["To"] = os.environ.get("MAIL_TO", "team@example.com")
    msg.set_content("\n".join(body))
    if minutes["next_meeting"]:
        start = dt.datetime.fromisoformat(minutes["next_meeting"])
        ics = "\r\n".join([
            "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//runcrate-recipe//EN", "BEGIN:VEVENT",
            f"UID:{uuid.uuid4()}", f"DTSTAMP:{dt.datetime.now(dt.timezone.utc):%Y%m%dT%H%M%SZ}",
            f"DTSTART:{start:%Y%m%dT%H%M%S}", f"DTEND:{start + dt.timedelta(minutes=30):%Y%m%dT%H%M%S}",
            "SUMMARY:Follow-up meeting", "END:VEVENT", "END:VCALENDAR", ""])
        msg.add_attachment(ics.encode(), maintype="text", subtype="calendar", filename="next-meeting.ics")
    (out / "follow-up.eml").write_bytes(bytes(msg))
    if os.environ.get("MAIL_WEBHOOK_URL"):
        payload = json.dumps({"to": msg["To"], "subject": msg["Subject"], "text": "\n".join(body), "minutes": minutes}).encode()
        sig = hmac.new(os.environ.get("WEBHOOK_SECRET", "").encode(), payload, hashlib.sha256).hexdigest()
        res = requests.post(os.environ["MAIL_WEBHOOK_URL"], data=payload, timeout=10, headers={"Content-Type": "application/json", "X-Signature-SHA256": sig})
        res.raise_for_status()
    step("email", "template", t0, 0.0)

    print("\n".join(body))
    Path("run.json").write_text(json.dumps({"steps": ledger, "total_usd": sum(s["cost_usd"] for s in ledger)}, indent=2))
    print(f"\ntotal ${sum(s['cost_usd'] for s in ledger):.6f}")

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else dt.date.today().isoformat())
```

```typescript meeting-to-action-items.ts theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
// A meeting recording to action items, a follow-up email and a calendar invite.
//
// npm install openai
// RUNCRATE_API_KEY=... npx tsx meeting-to-action-items.ts meeting.mp3 [YYYY-MM-DD]
// Optional: MAIL_TO=team@example.com MAIL_WEBHOOK_URL=https://your.app/hooks/mail WEBHOOK_SECRET=... (n8n, Make, Zapier or your own mailer)
import { createHmac, randomUUID } from "node:crypto";
import { createReadStream, mkdirSync, writeFileSync } from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api.runcrate.ai/v1", apiKey: process.env.RUNCRATE_API_KEY, maxRetries: 4 });
const ASR = "openai/whisper-large-v3-turbo"; // timestamps per segment
const LLM = "deepseek-ai/DeepSeek-V4.1-Flash"; // on the free tier
const ledger: { step: string; model: string; seconds: number; cost_usd: number }[] = [];

type Minutes = {
  summary: string;
  decisions: string[];
  action_items: { owner: string; task: string; due: string | null; quote: string; at_seconds: number }[];
  risks: string[];
  next_meeting: string | null;
};

const SCHEMA = {
  type: "object",
  additionalProperties: false,
  required: ["summary", "decisions", "action_items", "risks", "next_meeting"],
  properties: {
    summary: { type: "string", description: "Two sentences." },
    decisions: { type: "array", items: { type: "string" } },
    action_items: {
      type: "array",
      items: {
        type: "object",
        additionalProperties: false,
        required: ["owner", "task", "due", "quote", "at_seconds"],
        properties: {
          owner: { type: "string" },
          task: { type: "string" },
          due: { type: ["string", "null"], description: "YYYY-MM-DD, resolved from the meeting date" },
          quote: { type: "string", description: "The words in the transcript that create this item" },
          at_seconds: { type: "number" },
        },
      },
    },
    risks: { type: "array", items: { type: "string" } },
    next_meeting: { type: ["string", "null"], description: "Local start time, YYYY-MM-DDTHH:MM, or null" },
  },
};

function step(name: string, model: string, t0: number, cost: number) {
  const seconds = (Date.now() - t0) / 1000;
  ledger.push({ step: name, model, seconds, cost_usd: cost });
  console.log(`${name.padEnd(12)} ${model.padEnd(40)} ${seconds.toFixed(2).padStart(6)}s  $${cost.toFixed(6)}`);
}

const stamp = (d: Date) => d.toISOString().replace(/[-:]/g, "").slice(0, 15);
const [audio, date = new Date().toISOString().slice(0, 10)] = process.argv.slice(2);
mkdirSync("out", { recursive: true });

let t0 = Date.now();
const tr = await client.audio.transcriptions
  .create({ model: ASR, file: createReadStream(audio), response_format: "verbose_json" })
  .withResponse();
step("transcribe", ASR, t0, Number(tr.response.headers.get("x-llm-cost") ?? 0));
const lines = (tr.data.segments ?? []).map((s) => `[${s.start.toFixed(1)}s] ${s.text.trim()}`).join("\n");
writeFileSync("out/transcript.txt", lines);

t0 = Date.now();
const weekday = new Date(`${date}T12:00:00Z`).toLocaleDateString("en-GB", { weekday: "long", timeZone: "UTC" });
const ex = await client.chat.completions
  .create({
    model: LLM,
    messages: [
      {
        role: "system",
        content:
          "You turn meeting transcripts into action items. Speakers name themselves or each other; attribute every item to a person. Never invent an item that is not in the transcript.",
      },
      { role: "user", content: `Meeting date: ${date} (${weekday}).\n\nTranscript:\n${lines}` },
    ],
    response_format: { type: "json_schema", json_schema: { name: "minutes", strict: true, schema: SCHEMA } },
    temperature: 0,
  })
  .withResponse();
const minutes: Minutes = JSON.parse(ex.data.choices[0].message.content ?? "{}");
step("extract", LLM, t0, Number(ex.response.headers.get("x-llm-cost") ?? 0));
writeFileSync("out/minutes.json", JSON.stringify(minutes, null, 2));

// The email is a template over checked JSON: no second model call, nothing invented.
t0 = Date.now();
const body = [minutes.summary, "", "Action items:"];
body.push(...minutes.action_items.map((a) => `- ${a.owner}: ${a.task}${a.due ? ` (due ${a.due})` : ""}`));
if (minutes.decisions.length) body.push("", "Decisions:", ...minutes.decisions.map((d) => `- ${d}`));
if (minutes.risks.length) body.push("", "Risks:", ...minutes.risks.map((r) => `- ${r}`));
const to = process.env.MAIL_TO ?? "team@example.com";
const subject = `Follow-up: meeting of ${date}`;
const boundary = `b${randomUUID()}`;
let eml = `To: ${to}\r\nSubject: ${subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
eml += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body.join("\r\n")}\r\n`;
if (minutes.next_meeting) {
  const start = new Date(`${minutes.next_meeting}:00Z`); // floating local time, written without a zone
  const end = new Date(start.getTime() + 30 * 60_000);
  const ics = [
    "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//runcrate-recipe//EN", "BEGIN:VEVENT",
    `UID:${randomUUID()}`, `DTSTAMP:${stamp(new Date())}Z`, `DTSTART:${stamp(start)}`, `DTEND:${stamp(end)}`,
    "SUMMARY:Follow-up meeting", "END:VEVENT", "END:VCALENDAR", "",
  ].join("\r\n");
  eml += `--${boundary}\r\nContent-Type: text/calendar; charset=utf-8\r\nContent-Disposition: attachment; filename="next-meeting.ics"\r\n\r\n${ics}\r\n`;
}
eml += `--${boundary}--\r\n`;
writeFileSync("out/follow-up.eml", eml);
if (process.env.MAIL_WEBHOOK_URL) {
  const payload = JSON.stringify({ to, subject, text: body.join("\n"), minutes });
  const sig = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "").update(payload).digest("hex");
  const res = await fetch(process.env.MAIL_WEBHOOK_URL, {
    method: "POST",
    body: payload,
    headers: { "Content-Type": "application/json", "X-Signature-SHA256": sig },
  });
  if (!res.ok) throw new Error(`mail webhook answered ${res.status}`);
}
step("email", "template", t0, 0);

console.log(body.join("\n"));
const total = ledger.reduce((s, r) => s + r.cost_usd, 0);
writeFileSync("run.json", JSON.stringify({ steps: ledger, total_usd: total }, null, 2));
console.log(`\ntotal $${total.toFixed(6)}`);
```

## What the proof run cost

The Python file on a 34-second three-person launch sync dated 2026-10-19 (a Monday). Measured on 2026-10-23 against `api.runcrate.ai`; costs are what the account was billed, read from each response's `X-LLM-Cost` header or `usage.cost`.

| Step | Model or tool | Time | Cost |
| - | - | - | - |
| Transcribe 34 s | openai/whisper-large-v3-turbo | 1.0 s | \$0.0120 |
| Extract minutes | deepseek-ai/DeepSeek-V4.1-Flash (free tier) | 34.1 s | \$0.0000 |
| Email and invite | template | 0.0 s | \$0 |
| Total | | 45 s | \$0.0120 |

Both runs found the three action items with the right due dates ("by Thursday" became 2026-10-22) and the Friday 10:00 follow-up. The TypeScript run, also \$0.0120, attributed Priya's item to "Unidentified speaker".

## Scale it

* Every meeting is independent: run it from your recorder's upload webhook.
* Hours of audio: transcription is $0.021 a minute with whisper-large-v3-turbo; Voxtral Small is $0.003 a minute but gives no timestamps.
* Long meetings fit: DeepSeek V4.1 Flash takes a million tokens of context.

## Gotchas found in the proof run

* Transcripts carry no speaker labels, so attribution depends on people saying names. One of two runs missed an owner who only introduced herself at the start ("Priya here"). If it matters, diarise first or ask people to name owners.
* DeepSeek V4.1 Flash took 34 s for the extraction: it reasons before answering. gpt-oss-120b is faster if you need minutes in seconds.
* Strict JSON schemas with nullable fields (`"type": ["string", "null"]`) worked on both runs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.