# Transcripts (/docs/api/gateway/transcripts)

Record meetings and calls on any device, get a diarized Romanian/English transcript with word timings, then a summary, key points, actions and ready-to-run agent prompts.

Transcripts turns a recording into searchable text and something you can act on. The desktop app, the phone and the watch record locally and upload in the background; the gateway transcribes with speaker labels and word timings (Romanian and English, mixed in one recording too), then writes the insights: a title, a summary, cited key points, actions, decisions, open questions and agent prompts you can launch.

<Callout>
  Transcripts is enabled per account while it is in preview. When it is off for your key, every
  endpoint below answers `404`.
</Callout>

## How a recording flows [#how-a-recording-flows]

1. **Create.** `POST /v1/transcripts` with a client-generated `client_id` (a retry after a crash returns the same transcript) and `started_at`. Status `recording`.
2. **Upload.** Each track (`mic`, `system` loopback, or one `mixed` track) goes up with **tus 1.0** at `/v1/uploads`: resumable, at most 8 MB per `PATCH`, so a dropped connection or a restart continues from the last byte the server holds (`HEAD /v1/uploads/{id}`). Upload ids belong to your account; another key cannot append to them.
3. **Finalize.** `POST /v1/transcripts/{id}/finalize` with the upload ids, the duration, bookmarks and (optionally) the live preview text. Status `queued`.
4. **Transcribe.** The worker cuts the audio into windows of up to 15 minutes (diarization limit) and sends them to Gemini 3.5 Transcribe. Speakers are matched across windows. Status `transcribing`, then `analyzing`.
5. **Insights.** A model writes the insights from the transcript (prompt caching on). Status `ready`. A failure after 5 attempts ends in `failed` with `error`; `POST …/retranscribe` and `…/reanalyze` re-run a step.

While a step retries, `status` does not change; `GET /v1/transcripts/{id}` returns `processing` with `attempts`, `max_attempts`, `last_error` and `next_attempt_at`, so a client can say "retrying" instead of looking stuck. `GET /v1/transcripts/stream` (server-sent events) sends a `transcript` event on every status change.

**Live view.** While recording, the device can `POST /v1/transcripts/{id}/live` with its rolling caption (`finals`, `partial`, `level`, `device`, `elapsed_ms`, `paused`) about once a second. Every viewer of the stream (the hub detail page, desktop, phone, watch) receives it as `op: live`; nothing is stored, and frames closer than 250 ms are dropped.

```ts
import { readFile } from 'node:fs/promises';
import { Codai } from 'codai-sdk';

const codai = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const t = await codai.transcripts.create({
  client_id: crypto.randomUUID(),
  source: 'desktop',
  started_at: new Date().toISOString(),
});
const uploadId = await codai.transcripts.uploadAudio(await readFile('meeting.ogg'), {
  transcriptId: t.id,
  onProgress: (sent, total) => console.log(`${sent}/${total}`),
});
await codai.transcripts.finalize(t.id, {
  duration_ms: 1_800_000,
  uploads: [{ track: 'mic', upload_id: uploadId }],
});
for await (const e of codai.transcripts.stream()) if (e.id === t.id && e.status === 'ready') break;
const done = await codai.transcripts.get(t.id);
console.log(done.insights?.summary);
```

```python
from codai import Codai

codai = Codai(api_key="codai_...")
t = codai.transcripts.create({"client_id": "rec-2026-10-06-1", "source": "desktop", "started_at": "2026-10-06T09:00:00Z"})
upload_id = codai.transcripts.upload_audio(open("meeting.ogg", "rb").read(), transcript_id=t["id"])
codai.transcripts.finalize(t["id"], {"duration_ms": 1_800_000, "uploads": [{"track": "mic", "upload_id": upload_id}]})
srt = codai.transcripts.export(t["id"], "srt")["text"]
```

## Working with a transcript [#working-with-a-transcript]

- **Read.** `GET /v1/transcripts/{id}`: speakers (rename one, or mark yourself, with `PATCH …/speakers/{label}`), utterances with word timings (correct one with `PATCH …/utterances/{seq}`), bookmarks and the latest insights.
- **Search and ask.** `GET /v1/transcripts/search` is full-text plus semantic (`mode=hybrid`, the default). `POST /v1/transcripts/ask` answers a question across your recordings with `[n]` citations to the transcript, speaker and time.
- **Act.** Insights carry suggested agent `prompts`; `POST …/prompts/{index}/accept` turns one into a [pending prompt](/docs/api/gateway/prompts) with `source: transcript`, ready to launch on a device.
- **Export and play.** `GET …/export?format=md|txt|srt|vtt|json|pdf|docx` (see below); `GET …/audio` returns short-lived signed playback URLs per track.
- **Organise.** Folders (`/v1/transcript-folders`), tags and pins; a personal vocabulary (`PUT /v1/transcript-vocabulary`) for names and jargon the transcriber should spell right.

## Export as PDF or Word [#export-as-pdf-or-word]

`GET /v1/transcripts/{id}/export?format=pdf` and `format=docx` return a binary attachment named after the title (an ASCII `filename` plus the exact title in `filename*`). The document holds the title, date, duration and speakers, then the summary, key points and actions when the insights exist, then the speaker-labelled transcript with `[hh:mm:ss]` timestamps. The PDF embeds a Unicode font, so Romanian diacritics (ș, ț, ă, î, â) print correctly. Labels follow the transcript language (Romanian or English).

```ts
import { writeFile } from 'node:fs/promises';

const pdf = await codai.transcripts.export(t.id, 'pdf'); // { bytes, filename, contentType }
await writeFile(pdf.filename ?? 'transcript.pdf', pdf.bytes);
```

```python
docx = codai.transcripts.export(t["id"], "docx")  # {"bytes", "filename", "content_type"}
open(docx["filename"] or "transcript.docx", "wb").write(docx["bytes"])
```

## Share a read-only link [#share-a-read-only-link]

`POST /v1/transcripts/{id}/shares` with an optional `expiresInDays` (1–365; omit it for a link that never expires) returns `{ id, token, url, expiresAt }`, where `url` is `https://hub.codai.ro/t/<token>`. Anyone with the link can read the title, speakers, transcript, summary, key points and actions, **never the audio**, and nothing that identifies your account. Decisions, open questions and suggested agent prompts stay private.

- The token is 32 random bytes and is shown **once**: only its SHA-256 is stored, so `GET …/shares` lists the active links with `url: null`.
- `DELETE …/shares/{shareId}` revokes a link immediately; deleting the transcript removes all its links.
- The page behind the link reads `GET /v1/public/transcripts/{token}`, which needs no API key, is rate-limited per visitor and answers `404` for an unknown, expired or revoked link.

```ts
const link = await codai.transcripts.shares.create(t.id, { expiresInDays: 30 });
console.log(link.url); // copy it now; it cannot be shown again
const active = await codai.transcripts.shares.list(t.id);
await codai.transcripts.shares.revoke(t.id, active[0]!.id);
```

## Privacy, retention and limits [#privacy-retention-and-limits]

- Audio lives in an EU bucket encrypted with a dedicated key and is **deleted after 30 days**; the transcript text and insights stay until you delete them. After the audio is gone, `audio_purged_at` is set and playback and re-transcription are no longer offered.
- `DELETE /v1/transcripts/{id}` removes the transcript and its audio. Account deletion removes all recordings; the account export includes every transcript table (not the audio).
- Up to **3 hours** per recording and **20 hours a month** per account (`GET /v1/transcripts/usage`).

## MCP tools [#mcp-tools]

The [MCP server](/docs/gateway/clients#mcp-server) exposes `list_transcripts`, `get_transcript`, `search_transcripts`, `ask_transcripts`, `export_transcript` (text formats only; PDF and Word are binary files), `accept_transcript_prompt` and `transcript_share_create`, called with your API key (OAuth tokens are refused). Deleting, revoking links, re-running and editing stay in the apps.

<OpenAPIPage
  document="./openapi/en/gateway.yaml"
  operations={[
    { path: '/v1/transcripts', method: 'get' },
    { path: '/v1/transcripts', method: 'post' },
    { path: '/v1/transcripts/usage', method: 'get' },
    { path: '/v1/transcripts/stream', method: 'get' },
    { path: '/v1/transcripts/search', method: 'get' },
    { path: '/v1/transcripts/ask', method: 'post' },
    { path: '/v1/transcripts/{id}', method: 'get' },
    { path: '/v1/transcripts/{id}', method: 'patch' },
    { path: '/v1/transcripts/{id}', method: 'delete' },
    { path: '/v1/transcripts/{id}/finalize', method: 'post' },
    { path: '/v1/transcripts/{id}/retranscribe', method: 'post' },
    { path: '/v1/transcripts/{id}/reanalyze', method: 'post' },
    { path: '/v1/transcripts/{id}/live', method: 'post' },
    { path: '/v1/transcripts/{id}/bookmarks', method: 'post' },
    { path: '/v1/transcripts/{id}/speakers/{label}', method: 'patch' },
    { path: '/v1/transcripts/{id}/utterances/{seq}', method: 'patch' },
    { path: '/v1/transcripts/{id}/audio', method: 'get' },
    { path: '/v1/transcripts/{id}/export', method: 'get' },
    { path: '/v1/transcripts/{id}/prompts/{index}/accept', method: 'post' },
    { path: '/v1/transcripts/{id}/shares', method: 'post' },
    { path: '/v1/transcripts/{id}/shares', method: 'get' },
    { path: '/v1/transcripts/{id}/shares/{shareId}', method: 'delete' },
    { path: '/v1/public/transcripts/{token}', method: 'get' },
    { path: '/v1/transcript-folders', method: 'get' },
    { path: '/v1/transcript-folders', method: 'post' },
    { path: '/v1/transcript-folders/{id}', method: 'patch' },
    { path: '/v1/transcript-folders/{id}', method: 'delete' },
    { path: '/v1/transcript-vocabulary', method: 'get' },
    { path: '/v1/transcript-vocabulary', method: 'put' },
    { path: '/v1/uploads', method: 'post' },
    { path: '/v1/uploads', method: 'options' },
    { path: '/v1/uploads/{id}', method: 'get' },
    { path: '/v1/uploads/{id}', method: 'patch' },
  ]}
/>
