Agent Quickstarts
This page is for building on MeetStream with a coding agent. It has three parts:
- Point your agent here first. The machine-readable surfaces and tools, and when to use each.
- The MeetStream Agent Guide. One canonical block you paste once. It holds the setup split, hard rules, retry helper, webhook model, and transcription flows.
- Quickstarts. Short task prompts that build on the guide, each with acceptance criteria your agent must meet before it says “done”.
Point your agent here first
| Resource | Where | Use it for |
|---|---|---|
| llms.txt | docs.meetstream.ai/llms.txt | Page index. The first thing an agent should fetch |
| Markdown twin | Append .md to any docs URL | Clean page content with no HTML chrome, e.g. docs.meetstream.ai/guides/webhooks/webhooks-and-events.md |
| OpenAPI spec | docs.meetstream.ai/openapi.json | Exact paths, fields, and types for codegen and validation |
| Docs-search MCP | https://docs.meetstream.ai/_mcp/server | Read-only search over these docs. No key needed |
| Action MCP | MeetStream MCP Server | Acts on your account: create bots, read status and details, fetch transcripts, debug a real session |
| Agent skills | Agent Skills | Instructions loaded on demand that encode the API surface and its gotchas |
| SDKs | Official SDKs | Typed TypeScript and Python clients that already handle 202, 507, and retries |
| CLI | MeetStream CLI | Shell access with --json output, plus meetstream listen to print webhooks during development |
Which to use when. Always give a coding agent the guide below, plus the docs-search MCP or the .md twins so it reads the docs instead of guessing. Add the action MCP when you want the agent to create test bots and inspect real sessions while it builds or debugs. That is the fastest way to answer “why did this bot not join?”. Install the skills in Claude Code or Cursor so the same rules load automatically in every session. For generated application code, prefer the SDKs over raw fetch: the guide’s helper is for projects that can’t take a dependency. Use the CLI for one-off checks and for watching webhooks locally.
Claude Code
Cursor
Codex and others
# Skills (inside Claude Code)/plugin marketplace add meetstream-ai/claude-plugin/plugin install meetstream@meetstream-ai# Action MCP (REST uses Token, the MCP server uses Bearer)claude mcp add --transport http meetstream https://mcp.meetstream.ai/mcp \--header "Authorization: Bearer YOUR_API_KEY"# Docs-search MCP (no key)claude mcp add --transport http meetstream-docs https://docs.meetstream.ai/_mcp/server
Save the Agent Guide below as MEETSTREAM.md in your repo root and add the line @MEETSTREAM.md to your CLAUDE.md. Claude Code then loads it in every session.
The MeetStream Agent Guide
Copy this whole block into your agent. Every quickstart below assumes the agent has it. The guide keeps the human’s steps separate from the agent’s, and it ends with a verification checklist the agent must run.
Never paste a real API key into the guide, a prompt, or a committed file. The guide tells the agent to read MEETSTREAM_API_KEY from the environment.
# MeetStream Agent GuideYou are building on MeetStream, an API that sends bots into Zoom, Google Meet, andMicrosoft Teams meetings to record, transcribe, stream, and speak. Follow this guideexactly. When it conflicts with your prior knowledge of meeting-bot APIs, the guide wins.When you need a detail that is not here, fetch the docs as Markdown (section 9) or usethe docs-search MCP. Do not guess endpoints or field names.## 1. Glossary and environment- human: the person you are working with. Only they can do the steps in section 2.- agent: you.- bot: one MeetStream participant in one meeting, identified by `bot_id`.- MEETSTREAM_API_KEY: REST API key. Read it from the environment. Never log, print,commit, or hardcode it.- PUBLIC_BASE_URL: public HTTPS origin that reaches this app, with no trailing slash.In development it is the tunnel URL (ngrok or cloudflared). MeetStream cannotreach localhost.- WEBHOOK_PATH_TOKEN: a long random string you generate and put in the webhook path.Per-bot `callback_url` deliveries are not signed, so the unguessable path is whatkeeps strangers from posting fake events.- MEETSTREAM_WEBHOOK_SECRET (optional): signing secret of a workspace webhook endpointcreated in the dashboard. Only workspace endpoints are signed.- callback_url: per-bot URL for lifecycle and post-call webhooks.- live_transcription_required.webhook_url: per-bot URL for live transcript chunks.- transcript_id: the key for post-call transcripts. It is not the bot_id, and it isnever in a webhook.At startup, fail fast with a clear message if MEETSTREAM_API_KEY or PUBLIC_BASE_URL ismissing, or if PUBLIC_BASE_URL does not start with https://.## 2. Human-required setup (stop and ask; do not work around these)The human does these. You check the result.1. API key: create one at https://app.meetstream.ai/api-key and export it asMEETSTREAM_API_KEY. You check: `GET /bots` returns 200 (401 = header missing,403 = key rejected).2. Transcription provider keys: for deepgram, assemblyai, sarvam, or jigsawstack(including their streaming variants), add that provider's key in the dashboard athttps://app.meetstream.ai/integrations. `meetstream_streaming` and `meeting_captions`need no external key. You check: a create_bot 400 that names the provider meansits key is missing. Report it to the human; do not switch providers silently.3. Public HTTPS URL: webhooks require HTTPS. The human runs `ngrok http <port>` or`cloudflared tunnel --url http://localhost:<port>` and gives you the URL asPUBLIC_BASE_URL. A reserved or static domain avoids re-creating bots after everytunnel restart. You check: a health route you add (for example`curl -X POST $PUBLIC_BASE_URL/healthz`) answers from outside the machine.4. Zoom only: a Zoom Marketplace General App with Meeting SDK enabled, with its ClientID and Secret saved in the dashboard Integrations page. Guide:https://docs.meetstream.ai/guides/app-integrations/zoom-marketplace-app-setup.mdGoogle Meet and Teams need no setup.5. A live test meeting: the human opens a real meeting, gives you the link, and admitsthe bot from the lobby when it knocks.The agent does everything else: code, payloads, webhook server, storage, UI, tests.## 3. Hard rules- Base URL: `https://api.meetstream.ai/api/v1`.- REST auth header: `Authorization: Token <key>`. The word is Token, NOT Bearer.The MCP server at mcp.meetstream.ai is the exception: it uses `Bearer`.- Create: `POST /bots/create_bot`. Required: `meeting_link` (NOT `meeting_url`) and`bot_name`. Success is 201 with `bot_id`.- Send an `Idempotency-Key` header on every create. Generate it ONCE, outside the retryloop, and reuse it on every attempt. A replay returns 507 with the original bot:507 is success, never retry it and never treat it as an error.- 202 means "accepted, not ready yet". It is in the 2xx range but it is not data.Poll with a fixed cap (interval and max attempts). Never loop forever.- Retry 429 (honor Retry-After), 500, and 503 with exponential backoff. Never retry400, 401, 403, or 404: fix the request. 400 is never transient; log its `message`.- `video_required` defaults to true. Set `"video_required": false` for transcript-onlybots.- `automatic_leave.in_call_recording_timeout` has a 600 second floor. Below 600 is a400. Only set automatic_leave fields you need, since out-of-range values are 400s.- One transcription provider per request under `recording_config.transcript.provider`.- MIA voice agents: pass `agent_config_id` and nothing else agent-related. Do NOT send`socket_connection_url` or `live_audio_required` with it. Those are for bring-your-ownbridges, and adding them is the usual cause of an agent that joins but never speaks.- `GET /bots/{bot_id}/remove_bot` makes a bot leave (it is a GET). Data is kept.`DELETE /bots/{bot_id}/delete` destroys data and is irreversible: never call itwithout the human's explicit confirmation.- Media and transcript download URLs are presigned and expire (about 1 hour). Store`bot_id` and `transcript_id`, never the URLs. Re-fetch a URL when you need it.- Webhooks are delivered once and NOT retried. Return 2xx immediately, then processasynchronously. A slow or failing handler loses events.- Echo your own IDs through `custom_attributes` on create. They are echoed back onlifecycle webhooks and live transcript chunks (not on `data_deletion`), which ishow you route events to your own records.## 4. API client with retries (TypeScript)Use the official SDK (`@meetstream/sdk`, `meetstream-sdk` for Python) when the projectallows dependencies. Otherwise use this helper for every call.```tsconst BASE_URL = 'https://api.meetstream.ai/api/v1';const RETRYABLE = new Set([429, 500, 503]);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const backoff = (n: number) => Math.min(30_000, 1_000 * 2 ** n) + Math.random() * 250;export class MeetStreamError extends Error {constructor(public status: number, public body: string) {super(`MeetStream ${status}: ${body}`);}}// Returns { status, data }. 201/200 = done, 202 = not ready (caller polls with a cap),// 507 = idempotent replay of an earlier create (success, same bot).export async function meetstream<T = any>(path: string,init: RequestInit & { headers?: Record<string, string> } = {},maxRetries = 4,): Promise<{ status: number; data: T }> {for (let attempt = 0; ; attempt++) {let res: Response;try {res = await fetch(`${BASE_URL}${path}`, {...init,headers: {Authorization: `Token ${process.env.MEETSTREAM_API_KEY}`,'Content-Type': 'application/json',...init.headers,},});} catch (err) {// Network failure. Safe to retry a create only because it carries an Idempotency-Key.if (attempt >= maxRetries) throw err;await sleep(backoff(attempt));continue;}const text = await res.text();let data: any = null;try { data = text ? JSON.parse(text) : null; } catch { data = text; }if (res.ok || res.status === 507) return { status: res.status, data };if (!RETRYABLE.has(res.status) || attempt >= maxRetries) {throw new MeetStreamError(res.status, text); // 400/401/403/404: fix, don't retry}const retryAfter = Number(res.headers.get('retry-after'));await sleep(res.status === 429 && retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));}}```Creating a bot:```tsimport { randomUUID } from 'node:crypto';const idempotencyKey = randomUUID(); // once, outside any retry loopconst { status, data } = await meetstream('/bots/create_bot', {method: 'POST',headers: { 'Idempotency-Key': idempotencyKey },body: JSON.stringify(payload),});// status is 201 (created) or 507 (replayed the same bot). Both are success.const botId: string = data.bot_id;```## 5. Create-bot payloadsPost-call transcript (notetaker):```json{"meeting_link": "https://meet.google.com/abc-defg-hij","bot_name": "Notetaker","video_required": false,"callback_url": "https://PUBLIC_BASE_URL/webhooks/meetstream/WEBHOOK_PATH_TOKEN","custom_attributes": { "meeting_id": "your-internal-id" },"recording_config": {"transcript": { "provider": { "deepgram": { "model": "nova-3", "language": "en" } } }}}```Other post-call providers: `assemblyai`, `sarvam`, `jigsawstack`, `meetstream`. Read theprovider page before changing its config block.Real-time transcript (streaming):```json{"meeting_link": "https://meet.google.com/abc-defg-hij","bot_name": "Live Captions","video_required": false,"callback_url": "https://PUBLIC_BASE_URL/webhooks/meetstream/WEBHOOK_PATH_TOKEN","live_transcription_required": {"webhook_url": "https://PUBLIC_BASE_URL/webhooks/meetstream-live/WEBHOOK_PATH_TOKEN"},"recording_config": {"transcript": { "provider": { "meetstream_streaming": {} } }}}```Other streaming providers: `deepgram_streaming`, `assemblyai_streaming`,`jigsawstack_streaming`, `meeting_captions`. Setting `live_transcription_required`without a streaming provider is a 400.Optional on any bot: `join_at` (ISO 8601) to schedule ahead,`recording_config.retention` as `{ "type": "timed", "hours": N }` (default 720 hours).## 6. WebhooksThe verified model (checked against 4,139 production deliveries). Trust it overolder examples.Envelope- Every delivery has `event`. Most also have `bot_event`, which equals `event` on everyevent EXCEPT terminals. Some deliveries have no `bot_event` (manifest.completed,manifest.skipped, bot.transcriptionready, bot.uploading, participant_events.*), soread `bot_event ?? event` for the specific name.- Every delivery has an ISO 8601 `timestamp`.- Dedupe on `{bot_id, bot_event ?? event, timestamp}`.- `participant_events.*` are nested: the bot id is at `data.bot.id`, and there is notop-level bot_id, bot_status, or bot_event.- `transcript_id` is never in a webhook. `data_deletion` has no custom_attributes.Terminals (two layers)- Every ending arrives exactly once with `event: "bot.stopped"`. `bot_event` is the reason:| bot_event | status_code | bot_status | meaning ||----------------|-------------|-------------------------|--------------------------------------|| bot.stopped | 200 | Stopped | clean exit (meeting ended, removed, timeout) || bot.kicked | 200 | Stopped | a participant removed the bot || bot.notallowed | 500 | NotAllowed | never admitted (lobby timeout) || bot.denied | 500 | Denied | host refused entry or recording || bot.failed | usually 500 | FAILED / ERROR / Failed | crashed |- No delivery ever has `event: "bot.kicked"` (or notallowed, denied, failed). Thosenames only appear in `bot_event`.- `status_code` is not always 200 on bot.stopped. Branch on `bot_event`, not`bot_status` (a kick and a clean exit both say "Stopped", and failure casing varies).If `bot_event` is missing, map `bot_status` case-insensitively: notallowed ->bot.notallowed, denied -> bot.denied, error or failed -> bot.failed, else bot.stopped.- `bot.error` is NOT terminal (a streaming provider hiccup); the bot keeps running.Typical orderbot.scheduled (if join_at) -> bot.joining -> bot.in_waiting_room -> bot.inmeeting ->bot.recording -> participant_events.* -> bot.leaving -> bot.stopped ->audio.processed / manifest.completed (either order) -> [post-call providers only:transcription.processed or transcription.failed, then bot.transcriptionready] ->video.processed (if video) -> bot.done -> data_deletion (after delete or retention expiry).Never admitted: bot.joining -> bot.in_waiting_room -> bot.leaving -> bot.stopped(bot_event bot.notallowed) -> bot.done.Also possible: audio.skipped, manifest.skipped, transcription.skipped, bot.uploading.bot.done is the final event on EVERY path, including streaming-only bots and botsthat never got in. Use it as the single "session finished" signal. Never treataudio.processed as final.Handler (Express):```tsimport express from 'express';const app = express();type StopReason = 'bot.stopped' | 'bot.kicked' | 'bot.notallowed' | 'bot.denied' | 'bot.failed';export function stopReason(p: any): StopReason {if (p.bot_event) return p.bot_event;const s = String(p.bot_status ?? '').toLowerCase();if (s === 'notallowed') return 'bot.notallowed';if (s === 'denied') return 'bot.denied';if (s === 'error' || s === 'failed') return 'bot.failed';return 'bot.stopped';}const seen = new Set<string>(); // use your database or Redis in productionapp.post('/webhooks/meetstream/:token', express.raw({ type: '*/*' }), (req, res) => {if (req.params.token !== process.env.WEBHOOK_PATH_TOKEN) return res.sendStatus(404);// Workspace endpoints only: verify X-MeetStream-Signature over the raw bytes here.res.sendStatus(200); // ack first: deliveries are not retriedlet p: any;try { p = JSON.parse(req.body.toString('utf8')); } catch { return; }const botId: string | undefined = p.bot_id ?? p.data?.bot?.id;const key = `${botId}|${p.bot_event ?? p.event}|${p.timestamp}`;if (!botId || seen.has(key)) return;seen.add(key);setImmediate(() => handle(p, botId).catch((e) => console.error('webhook', e)));});async function handle(p: any, botId: string) {switch (p.event) {case 'bot.stopped': return onEnded(botId, stopReason(p), p.message);case 'transcription.processed': return onTranscriptReady(botId);case 'transcription.failed': return onTranscriptFailed(botId, p.message);case 'bot.done': return onSessionFinished(botId); // always lastdefault: return recordEvent(botId, p.bot_event ?? p.event, p.timestamp);}}```## 7. TranscriptionPost-call flow (deepgram, assemblyai, sarvam, jigsawstack, meetstream):1. Create the bot with `recording_config.transcript.provider` set to a post-call provider.2. Wait for the `transcription.processed` webhook. (`transcription.failed` means therun failed: surface `message`, then offer a re-run with `POST /bots/{bot_id}/transcribe`.)3. `GET /bots/{bot_id}/detail` and read `bot_details.transcript_id`. Fallback:`GET /bots/{bot_id}/transcriptions` lists every run with its `transcript_id` and status.4. `GET /transcript/{transcript_id}/get_transcript` returns an array of segments:`{ speaker, transcript, start_time, end_time, words[] }`. The text field is`transcript`, NOT `text`. Add `?raw=true` for the provider's raw output.```tsexport async function fetchTranscript(botId: string, attempts = 20, intervalMs = 15_000) {const { data: detail } = await meetstream(`/bots/${botId}/detail`);let transcriptId: string | undefined = detail?.bot_details?.transcript_id;if (!transcriptId) {const { data } = await meetstream(`/bots/${botId}/transcriptions`);transcriptId = data?.transcriptions?.find((t: any) => t.status === 'Success')?.transcript_id;}if (!transcriptId) throw new Error(`No post-call transcript for ${botId}`);for (let i = 0; i < attempts; i++) {const { status, data } = await meetstream(`/transcript/${transcriptId}/get_transcript`);if (status === 200) return data as { speaker: string; transcript: string; start_time: number; end_time: number }[];await sleep(intervalMs); // 202: still processing}throw new Error(`Transcript ${transcriptId} still not ready after ${attempts} polls`);}```Four ways to get a finished transcript, pick by where you are:| From | Call | Use when ||------|------|----------|| The MCP server | `get_transcript` with `bot_id` (optional `wait`) | Prototyping or debugging in chat. It resolves transcript_id for you || A bot id (REST) | `/bots/{bot_id}/detail` -> `bot_details.transcript_id` -> `/transcript/{id}/get_transcript` | The default in app code (fetchTranscript above) || All runs of a bot | `/bots/{bot_id}/transcriptions` | After a re-transcribe, to pick or compare runs || Native captions | `bot_details.caption_file` on `/detail` | The bot used `meeting_captions` |Render segments for people (and for LLM prompts) with one helper, not ad hoc joins:```tstype Segment = { speaker?: string; transcript?: string; start_time?: number };const mmss = (s = 0) =>`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;// Merges consecutive segments from the same speaker into one turn.export function toReadable(segments: Segment[]): string {const turns: { speaker: string; start: number; text: string[] }[] = [];for (const s of segments) {const speaker = s.speaker || 'Unknown speaker';const text = (s.transcript ?? '').trim();if (!text) continue;const last = turns[turns.length - 1];if (last && last.speaker === speaker) last.text.push(text);else turns.push({ speaker, start: s.start_time ?? 0, text: [text] });}return turns.map((t) => `[${mmss(t.start)}] ${t.speaker}: ${t.text.join(' ')}`).join('\n');}```Real-time flow (meetstream_streaming, deepgram_streaming, assemblyai_streaming,jigsawstack_streaming, meeting_captions):1. Set a streaming provider AND `live_transcription_required: { webhook_url }`.2. Chunks POST to that URL during the meeting. Fields: `bot_id`, `speakerName`,`timestamp`, `transcript` (current buffer, may be partial), `words[]` (word, start,end, confidence, speaker, punctuated_word), `is_final` (false = interim, will bereplaced; true = committed), `end_of_turn`, `transcription_mode` (observed "raw" formeetstream_streaming), `custom_attributes`. Some providers also send `word_is_final`per word; treat it as optional and decide on the top-level `is_final`.3. Ack 2xx immediately, same as lifecycle webhooks.```tstype LiveChunk = {bot_id: string;speakerName?: string;transcript: string; // the speaker's current buffer; interim chunks get replacedis_final: boolean; // true = committed textend_of_turn?: boolean;timestamp: string;words?: { word: string; start: number; end: number; speaker?: string; punctuated_word?: string }[];custom_attributes?: Record<string, string>;};// One interim line per speaker, replaced until it is committed.const interim = new Map<string, string>(); // key: bot_id|speakerexport function onLiveChunk(c: LiveChunk, commit: (botId: string, speaker: string, text: string) => void) {const speaker = c.speakerName ?? 'Unknown speaker';const key = `${c.bot_id}|${speaker}`;if (c.is_final) {interim.delete(key);if (c.transcript.trim()) commit(c.bot_id, speaker, c.transcript.trim());} else {interim.set(key, c.transcript); // render as a grey, replaceable line}}```4. Streaming-only bots produce NO post-call transcript: no transcription.processed, andget_transcript returns 202 forever. They still get bot.done. If you need a post-calltranscript too, call `POST /bots/{bot_id}/transcribe` after bot.done with a post-callprovider in the body; the response carries the new `transcript_id`.## 8. Verify before you say doneRun every check that applies, and report each result with evidence (status codes,event names, IDs). If a check needs the human (admitting the bot, speaking), ask them.- [ ] No API key in source, logs, or git history. Search the repo for the key's value;only placeholders such as YOUR_API_KEY may appear.- [ ] Every REST call sends `Authorization: Token`, and `GET /bots` returns 200.- [ ] Every create sends `meeting_link` and `bot_name` and an Idempotency-Key madeonce. Replaying the same key returns 507 and your code treats it as success.- [ ] A 400 is surfaced with its `message` and not retried.- [ ] PUBLIC_BASE_URL is HTTPS and reachable from outside, and the webhook pathwithout the right token returns 404.- [ ] The webhook route returns 2xx before any slow work, and the same payload senttwice is processed once.- [ ] A real bot ran end to end, and you logged: bot.joining -> bot.inmeeting ->bot.stopped (with its bot_event) -> bot.done.- [ ] A kick (remove the bot from the meeting UI) is recorded as bot.kicked, and a lobbytimeout as bot.notallowed. Neither is recorded as a clean stop.- [ ] Post-call: the transcript was fetched via transcript_id, segments render from`transcript`, and polling stops after its cap.- [ ] Real-time: interim chunks are replaced, not appended, and `is_final` chunks commit.- [ ] Nothing stores a presigned URL.- [ ] No test bot was left running (`GET /bots/{bot_id}/status`), and no data was deletedwithout the human's confirmation.## 9. Docs to fetch as Markdown- Index: https://docs.meetstream.ai/llms.txt- OpenAPI: https://docs.meetstream.ai/openapi.json- Webhooks: https://docs.meetstream.ai/guides/webhooks/webhooks-and-events.md- Local webhooks: https://docs.meetstream.ai/guides/webhooks/local-webhook-server.md- Signatures: https://docs.meetstream.ai/guides/webhooks/webhook-signature-verification.md- Post-call: https://docs.meetstream.ai/guides/transcription-recordings/post-call-transcription.md- Live: https://docs.meetstream.ai/guides/transcription-recordings/live-transcription.md- Providers: https://docs.meetstream.ai/guides/transcription-recordings/providers/transcription-providers.md- Errors: https://docs.meetstream.ai/errors.md- Debugging: https://docs.meetstream.ai/guides/help/debugging-bots.md
Quickstarts
Each quickstart is a short prompt that relies on the Agent Guide above. Paste the guide first (or load it through CLAUDE.md, a Cursor rule, or AGENTS.md), then paste one of these. The acceptance criteria are part of the prompt, so the agent knows what “done” means before it starts.
| # | Build | Transcription | Human setup beyond the guide |
|---|---|---|---|
| 1 | Post-call notetaker with summary | Post-call | None |
| 2 | Real-time transcript dashboard | Streaming | None |
| 3 | Google Calendar auto-join | Post-call | Google OAuth client and refresh token |
| 4 | MIA voice agent in a meeting | Handled by the agent | Model and voice provider keys |
| 5 | Migrate from Recall.ai | Mapped from your code | Your Recall.ai codebase |
| 6 | Debug a bot with the MCP | None | Action MCP connected |
1. Post-call notetaker with summary
Goal: paste a meeting link into a small web app. A bot joins and records audio only. After the meeting, the app shows a speaker-labeled transcript and MeetStream’s AI summary.
Follow the MeetStream Agent Guide above.Build a post-call notetaker (Node + TypeScript, Express, SQLite).Spec:- A page with a form: meeting link. On submit, create a bot withvideo_required false, a post-call provider (deepgram nova-3 unless I sayotherwise), callback_url on PUBLIC_BASE_URL with WEBHOOK_PATH_TOKEN, andcustom_attributes.meeting_id set to our own row id.- Store bot_id and every webhook event (name, bot_event, timestamp) per meeting.- On transcription.processed: fetch the transcript via detail -> transcript_id ->get_transcript and store the segments.- On transcription.failed: show the message and a "Re-run transcription" button thatcalls POST /bots/{bot_id}/transcribe.- On bot.done: mark the meeting finished, then call GET /bots/{bot_id}/summary.Log the raw response the first time and render what it returns. Do not inventfield names.- A meeting page showing live status, the end reason (from bot_event), thetranscript grouped by speaker, and the summary.Acceptance criteria:- I paste a real Meet link, admit the bot, talk for 2 minutes, and end the call.Without a refresh loop on my side, the page shows the transcript and summary.- If I refuse the bot at the lobby, the page says "denied" (or "not admitted" ontimeout), not "completed".- Re-submitting the form after a network error does not create a second bot.- Every item in the guide's "Verify before you say done" list passes, and youreport the evidence.
Docs: Post-call transcription · Transcription providers · Webhooks and events · Deduplication and idempotency
2. Real-time transcript dashboard
Goal: a browser view of what people are saying, updating as they speak, with no external transcription key.
Follow the MeetStream Agent Guide above.Build a real-time transcript dashboard (Node + TypeScript, Express, Server-SentEvents to the browser, no frontend framework).Spec:- Form: meeting link. Create a bot with video_required false, providermeetstream_streaming, live_transcription_required.webhook_url on/webhooks/meetstream-live/WEBHOOK_PATH_TOKEN, and callback_url on/webhooks/meetstream/WEBHOOK_PATH_TOKEN for lifecycle events.- Live route: ack 2xx immediately, then push each chunk to the browser over SSE,keyed by bot_id.- Browser: one line per speaker turn, labelled with speakerName. While is_final isfalse, replace the current interim line. When is_final is true, commit it andstart a new line.- Show a status pill driven by lifecycle webhooks (joining, in waiting room, inmeeting, ended with reason). On bot.done show "Session finished".- Persist committed lines so a page reload restores the transcript.- Do not poll get_transcript for this bot: streaming-only bots return 202 forever.Acceptance criteria:- Words appear in the browser within a few seconds of being spoken, and interimtext never duplicates once committed.- Two browser tabs on the same meeting show the same transcript.- Ending the meeting shows the end reason, then "Session finished" on bot.done.- Optional button "Generate post-call transcript" calls POST /bots/{bot_id}/transcribeafter bot.done and renders the result via the post-call flow.- Every applicable item in "Verify before you say done" passes, with evidence.
Docs: Live transcription · Transcription providers · Set up a local webhook server
3. Google Calendar auto-join
Goal: connect a Google Calendar once, and a bot joins every upcoming meeting that has a video link.
Human first: create a Google Cloud OAuth client and run the refresh-token helper from the Google Calendar OAuth setup guide. Put GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN in the environment. For signed deliveries from every calendar bot, also create a workspace webhook endpoint and store its secret as MEETSTREAM_WEBHOOK_SECRET.
Follow the MeetStream Agent Guide above.Build Google Calendar auto-join (Node + TypeScript).Before coding, fetchhttps://docs.meetstream.ai/guides/calendar-integrations/google-calendar-oauth-setup.mdand use its request bodies exactly.Spec:- A setup script that connects the calendar with POST /calendar/create_calendar(google_client_id, google_client_secret, google_refresh_token from env; never logthem), then confirms with GET /calendar.- GET /calendar/events: print upcoming events with their ids and meeting links.- Enable auto-join with POST /calendar/auto-schedule/enable and a default_bot_config(bot_name, video_required false). Confirm with GET /calendar/auto-schedule/settings.Only add default_bot_config fields the guide documents. If the settings don'techo a transcription provider, transcribe after the fact: on bot.done, callPOST /bots/{bot_id}/transcribe with a post-call provider.- A command to schedule one event manually (POST /calendar/schedule/{event_id}) andto list scheduled bots (GET /calendar/scheduled_bots). Treat 409 on schedule as"already scheduled", not an error. Change a scheduled bot withPATCH /calendar/scheduled_bots/{bot_id} instead of re-scheduling.- Receive events on a workspace endpoint: verify X-MeetStream-Signature(sha256 HMAC of the raw body with MEETSTREAM_WEBHOOK_SECRET) and rejectmismatches with 401.Acceptance criteria:- Creating a Google Calendar event with a Meet link 10 minutes out produces ascheduled bot, visible in GET /calendar/scheduled_bots.- At start time the bot joins, and after the meeting the transcript is fetched viatranscript_id.- Running the setup script twice does not create duplicate bots for the same event.- A webhook with a wrong signature is rejected. A correct one is processed once.- Every applicable item in "Verify before you say done" passes, with evidence.
Docs: Google Calendar OAuth setup · Scheduling bots · Workspace webhooks · Verifying webhook signatures
4. MIA voice agent in a meeting
Goal: a MeetStream Infrastructure Agent (MIA) joins a meeting, greets people, and answers questions by voice.
Human first: MIA is bring-your-own-key. Add the model, voice, and speech-to-text provider keys you plan to use in the dashboard’s Integrations page.
Follow the MeetStream Agent Guide above.Put a MIA voice agent into a meeting (TypeScript script, no web server neededbeyond the lifecycle webhook).Before coding, fetch https://docs.meetstream.ai/guides/mia/create-an-agent.md andhttps://docs.meetstream.ai/guides/mia/what-is-mia.md, and only use provider idsand models they list.Spec:- Create an agent config with POST /mia: agent_name, mode "pipeline", model{ provider, model, system_prompt, first_message }, voice { provider, voice_id },transcriber { provider }. Use providers I have keys for; ask me which if unsure.- Pipeline agents gate on a wake word by default. Setwake_word { enabled: false } for an always-on agent, or tell me the wake words.- Save the returned agent_config_id and reuse it. Don't create a new config per run.- Send the agent: POST /bots/create_bot with meeting_link, bot_name,agent_config_id, and callback_url ONLY. No socket_connection_url, nolive_audio_required.- Log lifecycle events and the end reason. Provide a "leave" command that callsGET /bots/{bot_id}/remove_bot.Acceptance criteria:- After I admit the bot, it speaks the first_message within a few seconds.- It answers a spoken question in character with the system prompt.- The create_bot payload contains no bridge fields (show it).- "leave" makes the bot exit, and the log shows bot.stopped then bot.done.
Docs: What is MIA? · Create an agent · MIA API guide
5. Migrate from Recall.ai
Goal: move an existing Recall.ai integration to MeetStream with a reviewed diff and a working webhook handler.
Follow the MeetStream Agent Guide above.Migrate this repository from Recall.ai to MeetStream.Before editing, fetch https://docs.meetstream.ai/migration/migrate-from-recall.md.Steps:1. Run `npx @meetstream/migrate scan .` and show me the report.2. Run `npx @meetstream/migrate --dry-run .` and summarize the diff by file.Wait for my go-ahead before writing anything.3. Apply the migration, then review every file it flagged by hand.4. Check the result against the guide's hard rules. In particular: Token auth,meeting_link, bot_name on every create, Idempotency-Key made once, and 507 assuccess. Webhooks move from Recall's global endpoint to a per-bot callback_url,and the handler branches terminals on bot_event (section 6). Transcripts arefetched via transcript_id, and segment text is in `transcript`.5. Replace RECALL_API_KEY with MEETSTREAM_API_KEY in env files and examples, neverwith a real value.6. Run `npx @meetstream/migrate test --api-key "$MEETSTREAM_API_KEY"`, then theproject's own tests.Acceptance criteria:- No Recall base URL, header, env var, or SDK import remains (show the grep).- The project's test suite passes.- One real bot runs end to end through the migrated code path, and you reportthe bot_id and the events received.- A list of anything that needs a product decision rather than a code change.
Docs: Migrate from Recall.ai · Webhooks and events · Errors
6. Debug a bot with the MCP
Goal: answer “why did bot X not join?” (or “why is there no transcript?”) from real data in about a minute, without writing code.
Needs: the action MCP connected in your agent.
Using the meetstream MCP tools, find out why bot <BOT_ID> did not join (or didnot produce a transcript). Do not create, remove, or delete anything.1. Call webhook_events_guide and use its model for everything below.2. get_bot_status for the current status.3. get_bot_detail: read StatusTimeline, Platform, the original RequestPayload,and transcript_id.4. Diagnose. Common causes:- InWaitingRoom then NotAllowed: nobody admitted it before waiting_room_timeout.On Google Meet, check whether host management hides the admit prompt.- Denied: a host refused it. Nothing to fix in code.- Failed/Error: a join failure. On Zoom, check the Marketplace app and themeeting link or passcode.- Stopped early: a timeout in automatic_leave fired. Name which one.- No transcript: no provider in RequestPayload, a streaming-only provider(no post-call transcript by design), or a provider key missing fromIntegrations.- Joined but silent MIA agent: RequestPayload includes socket_connection_urlor live_audio_required alongside agent_config_id.5. Answer with: the root cause in one sentence, the evidence (status, timelineentries, payload fields), and the exact change to make next time.
What good looks like: “Bot … was never admitted. The timeline shows InWaitingRoom for 600 seconds, then NotAllowed. Admit bots faster, or use a signed-in bot so it isn’t held as an anonymous guest.”
Docs: Debugging bots · Google Meet lobby and admission · Automatic leave configuration
Why this is structured the way it is
The rules live in one block. Quickstarts reference it instead of repeating it, so fixing a rule once fixes it everywhere and your agent’s context stays small.
Keys, tunnels, Zoom apps, and lobby admission are things only a person can do. The guide tells the agent to stop and ask for them, then verify the result.
Every quickstart ends in acceptance criteria, and the guide ends in a checklist that needs evidence: status codes, event names, IDs.
The webhook model reflects thousands of captured production deliveries, including the two-layer terminal and bot.done as the final event on every path.
Next steps
- Docs for Agents & LLMs: the full machine-readable surface
- Create Bot payload reference: every create_bot field
- Errors: every status code and what to do about it
