Skip to main content

System architecture

Overview​

This page covers how EDEA Transcript v1 is built:

  • the parts and how they talk;
  • how a recording travels from a browser or phone to two pages in Confluence;
  • what happens when a step fails.

It's for anyone building or changing the system. What v1 must do is in the Spec, and why each big choice was made is in the Decision pages in the Brain. This page covers how.

The parts​

Browser (Chrome or Edge) Cloudflare, behind the staff sign-in Outside services
──────────────────────── ──────────────────────────────────── ────────────────
Recorder page ── pieces ──▶ Worker: web app + API ──▶ R2 audio, transcripts
Upload page ── pieces ──▶ │ ──▶ D1 meetings, voices, names
Review screen ◀── JSON ───── │
├─▶ Fingerprint service: our Container, in the EU ◀── the Workflow too
└─▶ Workflow, one per meeting ── asks ──▶ Soniox words
── asks ──▶ ElevenLabs Scribe v2 words Soniox can't give
── asks ──▶ Claude API English terms
── asks ──▶ DeepInfra voices

Filing, on each person's own Claude plan:
Claude (claude.ai, Claude Code) ── MCP ──▶ EDEA Transcript connector, /mcp on the same Worker
── MCP ──▶ Atlassian Rovo MCP ──▶ Confluence
PartWhat it isWhat it does
Web appPages served by the WorkerThe recorder (Meet and room modes), the phone upload page, the meeting list, the review screen and the voice profile page
WorkerOne Cloudflare Worker, in TypeScriptServes the web app, the JSON API and the connector, and checks the sign-in on every request
R2One private Cloudflare bucketUploaded pieces, joined audio, raw provider results and merged transcripts
D1Cloudflare's SQL databaseEach meeting's record: its tracks, pieces, voices, names and filed page links
WorkflowCloudflare Workflows, one instance per meetingRuns the processing steps in order, keeps progress, waits and retries
SonioxSpeech-to-text API, asynchronousWords with a time on each word. It writes most English terms in Hebrew letters
ElevenLabs Scribe v2Speech-to-text APITakes any track Soniox can't do. English terms come back in English letters
Term modelA small model on the Claude API (the TERM_MODEL setting)Lists the English terms an engine wrote in Hebrew letters, so the code can put them back
DeepInfraRuns NVIDIA's Nemotron 3 Diarization, a speaker diarization modelSplits a track into voices: who spoke when, up to 8 voices
Fingerprint serviceOur own Cloudflare Container in the EU, with ffmpeg and NVIDIA's TitaNet-large speaker model (voiceprint/)Makes a fingerprint of a team member's profile sample, and of each voice in a meeting. Cuts a short clip of each voice. Keeps nothing
Filing skillThe edea-transcript plugin in E-D-E-A/workflow-skills, run in ClaudeWrites the notes page and the transcript page, and files both after the person's yes
EDEA Transcript connectorAn MCP server at /mcp on the WorkerHow the filing skill reads a meeting and records what was filed
Atlassian Rovo MCPAtlassian's MCP server, connected in ClaudeHow the filing skill reads and writes Confluence, as the person filing

Once the backend confirms it has a recording, no copy stays on the laptop or phone.

Sign-in​

  • Access gates the whole app. The Worker runs at its workers.dev address with Cloudflare Access switched on for the Worker itself, using the "E.D.E.A staff" Google sign-in. A browser gets through only after signing in. Access then hands the Worker the person's identity on ctx.access, already checked by Cloudflare.

  • The Worker checks every request anyway. Before any route runs, it reads the identity with ctx.access.getIdentity():

    • no identity, meaning Access didn't sign the request in, gets 401;
    • an email whose domain isn't exactly first-edea.com (the STAFF_EMAIL_DOMAIN setting) gets 403. So does an Access service token, which has no email.

    The rule repeats Access's policy in code, so a loosened policy still can't let an outside account in. The lowercased email is who the person is, everywhere below. The Worker doesn't check which Access application signed the request in: the domain check holds whichever one did.

  • The web app is part of the Worker. Vite builds the pages and the build is packed into the Worker's code, rather than served as Workers static assets, which never pass ctx.access on. So every page goes through the same check as the API.

  • Changes must come from the app's own pages. A request that changes something and carries a Sec-Fetch-Site header other than same-origin gets 403 cross_site, so another website can't make a signed-in person's browser create or finish a meeting.

  • The providers never call in. ElevenLabs, the Claude API and DeepInfra answer in the same request the Workflow sends. The Workflow asks Soniox for its results until they're done. The speech services read the audio through a short-lived signed R2 link that the Workflow makes for that one file. DeepInfra takes no link, so the Workflow sends it the audio itself.

  • The connector is the one exception. Claude's servers call it without a browser. Its paths are /mcp and its OAuth endpoints. They sit outside the browser gate, but they only accept tokens the Worker issued after the person signed in with the same staff Google account through an Access for SaaS application. See The connector for Claude.

Recording and upload​

What gets recorded​

KindRecorded withTracks
Google Meet callThe recorder page in Chrome or Edge, on a laptopcall: the Meet tab's audio, shared with "Also share tab audio". mic: the microphone of the person recording.
Room meeting on a laptopThe recorder page's room modemic only
Room meeting or phone call on a phoneThe phone's own recorder (a recorder app, Voice Memos, iPhone call recording), then the upload pagefile: the uploaded file, as it is

A Meet call's two tracks come from two MediaRecorders and stay two files; they're never mixed.

  • Everything on mic is the person recording. Meet never plays a person's own voice back to them.
  • call holds everyone else.
  • In a Meet call, echo cancellation is on for mic. This keeps the call coming out of the laptop's speakers mostly off that track. Headphones keep it off entirely.
  • In room mode, echo cancellation and noise suppression are off, because both cut quiet voices across a table. Auto gain stays on.
  • mic is the microphone the person picked, remembered in the browser. Without a pick it is the system's microphone, unless that is an iPhone's or iPad's and the laptop has its own: a Mac offers a nearby iPhone's microphone as one of its own. The page opens the microphone by its own id, not as Chrome's "default", so a change of the system's microphone mid-meeting doesn't move the recording. Chrome names microphones only after the person has allowed one, so the picker appears from the second recording on. While the page is open, the picker follows the system: a headset plugged in, or another microphone chosen there.

Before recording starts, the page shows the reminder to tell everyone the meeting is being recorded.

The recorder records Opus in WebM at a speech bitrate of about 32 kbit/s, so a 5-hour track is about 70 MB.

Upload in pieces​

A dropped connection must lose nothing, so audio travels in pieces. The backend confirms each piece before the browser lets go of it.

  1. The page creates the meeting with POST /api/meetings and gets its id. A recorder asks who is signed in at the same time. If that fails although the meeting was made, the page deletes the empty meeting.
  2. The audio is split into pieces:
    • Recorder: each MediaRecorder hands over a piece every 10 seconds.
    • Upload page: the browser cuts the chosen file into 8 MiB pieces.
  3. The recorder writes each piece to the browser's IndexedDB first. The upload page doesn't need to, because the file is still on the phone.
  4. The browser sends the piece to PUT /api/meetings/:id/tracks/:track/pieces/:seq, as raw bytes, with the piece's SHA-256 in the X-Piece-SHA256 header.
  5. The Worker stores the piece in R2 and records it in D1, and only then answers 200. Only after that 200 does the browser delete its copy.
  6. If a piece isn't confirmed, it stays in IndexedDB and the upload queue retries it, in order, with backoff. That covers being offline, a timeout, a server error and an expired sign-in. Recording never waits for uploading.
  7. Sending the same piece twice is harmless: it goes to the same key with the same bytes, and the checksum is compared.
  8. When the person presses Finish, the page sends POST /api/meetings/:id/finish with the number of pieces on each track, the title and the recorded length.
    • If any are missing, the Worker answers 409 with the list, and the page sends them.
    • When all are there, the Worker answers 202 and starts the meeting's Workflow.

The person can also cancel a recording while it records or is paused, from Cancel or from Back. The page asks first, because cancelling deletes the recording. Once the person agrees:

  1. The recorders stop, and the audio they hand over as they stop isn't kept.
  2. The upload queue stops sending.
  3. The page deletes the recording's audio from IndexedDB, and notes there that the recording was cancelled. This comes before anything that waits on the network, so a tab closed from here on leaves a cancelled recording behind, never a leftover.
  4. The page waits for a piece that was already on its way, but never for a retry.
  5. The page sends DELETE /api/meetings/:id. The Worker deletes the meeting's audio from R2, then its rows from D1.
  6. The page deletes its note, then lets go of the recording's Web Lock.

If the DELETE can't reach the Worker, the note stays in IndexedDB. The page sends the DELETE again the next time the web app opens, or when another recording in the same tab ends, and deletes the note once the Worker has answered. A cancelled recording is never offered as a leftover.

A piece that reaches the Worker while its recording is being deleted isn't kept: the Worker removes its audio again and answers 404.

If the tab is closed or the laptop restarts, recording stops: a closed page can't record. Every piece recorded so far is still in IndexedDB, though, and the next time the person opens the web app it offers to finish uploading them.

  • Only to the person who recorded it. Each remembered recording keeps its owner's email, because a laptop may be shared.
  • Never one that's still recording. A tab holds a Web Lock named after the meeting while it records or finishes it, and the browser lets go of the lock when the tab closes. A recording whose lock is held is live somewhere and isn't offered.
  • With the backend's own count. Finishing a leftover asks the backend how many pieces each track has, because a closing tab may not have noted its last one.

A phone upload resumes the same way. The page asks GET /api/meetings/:id/tracks/file/pieces which pieces the backend already has and sends the rest. After a page reload the person picks the file again, because a browser can't reopen a file by itself.

Small pieces also keep every request far under Cloudflare's 100 MB limit on a request body.

Joining the pieces​

The Workflow's first step joins each track's pieces, in order, into one R2 object using an R2 multipart upload, so a 5-hour track is never held in the Worker's memory at once.

  • Parts of 8 MiB, one Workflow step each. R2 needs every part but the last to be the same size, so a part is cut across piece boundaries, and a retry redoes at most one part.

  • A track already joined is skipped, so running a meeting again doesn't join it twice.

  • A missing piece fails the meeting without retrying, with the reason in error.

  • Recorder pieces join simply by being put end to end, because only a MediaRecorder's first piece carries the file header.

  • Upload pieces were cut from one file, so they join back into that file.

The step checks the joined size against the piece sizes in D1, records the file's key in tracks.audio_key, then deletes the pieces from R2. The joined audio is kept, and so are the small pieces rows in D1.

The meeting record​

D1​

Column names here are a guide; the build owns the exact schema and its migrations.

TableOne row perMain columns
meetingsmeetingid, kind (meet, room, upload), title, created_by, created_by_name (from the sign-in), recorded_at, duration_ms, status, step, error, notes_page_url, transcript_page_url
trackstrack of a meetingmeeting_id, track (call, mic, file), pieces, bytes, audio_key, content_type
piecesuploaded piecemeeting_id, track, seq, bytes, sha256
voicesvoice that said something in a meetingmeeting_id, track, label, name, email, source, confidence, closest_email, closest_similarity, clip_start_ms, clip_end_ms, named_by
line_movesline a person moved to another speakermeeting_id, line_start_ms, voice, moved_by
voice_profilesteam member with a profileemail, name, fingerprint, model, consent_version, consented_at, saved_by
learned_voicesmeeting voice that a profile learned fromemail, meeting_id, track, label, fingerprint, added_by, added_at
voice_answersanswer to the app's question whether a voice is a team member'smeeting_id, track, label, email, answered_by, answer, answered_at
term_fixesEnglish term the term model put back in a meetingmeeting_id, heard, english

A voice's source is one of these:

  • recorder_channel;
  • voice_profile;
  • person;
  • empty, while the voice is unnamed.

A meeting's status moves through:

  • recording, or uploading for a phone upload;
  • processing, with step saying which step;
  • ready, meaning processed and waiting to be reviewed and filed;
  • filed, once the filing skill reports the pages it created.

From any of these it can go to failed, which keeps the step that failed and the reason.

R2​

meetings/ID/TRACK/pieces/00042 uploaded pieces, numbered to 5 digits, deleted once joined
meetings/ID/TRACK.webm joined audio, kept (an upload keeps its own extension)
meetings/ID/words/TRACK.soniox.job the ID of the engine's job, from when it starts until it's deleted
meetings/ID/words/TRACK.soniox.json the speech engine's answer, as returned, named after the engine
meetings/ID/words/TRACK.term-fixes.json the term model's answer, as returned
meetings/ID/words/TRACK.json the track's words with times, the same shape whatever the engine
meetings/ID/voices/TRACK.json DeepInfra's answer, as returned: who spoke when
meetings/ID/transcript.json the merged transcript
meetings/ID/voice-clips/TRACK/LABEL.m4a a short clip of one voice, cut when someone first asks to hear the meeting's voices

The merged transcript keeps voice keys, not names. A voice key is the track and the voice's label, such as call:speaker_1, because the same label on two tracks is two people. Names and moved lines live in D1, so naming a voice or moving a line never rewrites the transcript.

{
"meetingId": "01JBXAMPLE",
"lines": [
{ "start": 12.48, "end": 17.9, "voice": "call:speaker_1", "text": "..." },
{ "start": 18.1, "end": 21.02, "voice": "mic:recorder", "text": "..." }
]
}

The API​

The API is JSON over HTTPS on the Worker, at the same address as the web app. The person is whoever Access signed in.

Method and pathWhat it does
GET /api/meSays who is signed in: email and name.
GET /api/healthChecks that the database and the audio bucket answer. 200 when both do, 503 naming the one that doesn't.
POST /api/meetingsCreates a meeting from its kind, its tracks and an optional title. Returns the id.
PUT /api/meetings/:id/tracks/:track/pieces/:seqStores one piece. Answers 200 once the piece is in R2 and D1.
GET /api/meetings/:id/tracks/:track/piecesLists the pieces the backend has, so an upload can resume.
POST /api/meetings/:id/finishTakes the piece count for each track, and optionally the title and the recorded length. Answers 409 with the missing pieces, 409 meeting_failed if processing already failed, or 202 when processing starts.
DELETE /api/meetings/:idDeletes a cancelled recording: its audio in R2 and its rows in D1. Only for the person who made it, and only while it still takes audio: 409 meeting_finished after that. Answers 204.
GET /api/meetingsLists the processed meetings, the one that took place last first, at most 200. Each has three counts about one person's voice: the person asking, or the team member in ?person=. They are described under The voice profile page. Only a profile admin can ask about a colleague: 403 not_admin otherwise, because the counts say which meetings a profile learned from.
GET /api/meetings/:idReturns one meeting: status, step, tracks, voices and names.
GET /api/meetings/:id/transcriptReturns the merged transcript, with names.
GET /api/meetings/:id/voicesLists a processed meeting's voices: the name, the email, where the name came from, whose profile an unnamed voice sounds like, whether it has a clip, and whether it said enough for a profile to learn from. The similarity is given to two places, which is enough to order the voices by and says less about the profile's fingerprint.
POST /api/meetings/:id/clipsMakes sure a clip of each voice is kept, as Clips of a meeting's voices describes. Answers 204, or 503 voiceprint_unavailable.
GET /api/meetings/:id/voices/:track/:label/clipPlays a voice's clip, with range requests. 404 until the clips are cut.
PUT /api/meetings/:id/voices/:track/:labelA person names a voice, or corrects a name for the whole meeting. A team member's name can teach their voice profile, as A profile learns from meetings describes.
GET /api/voice-questionsThe voices the app asks the signed-in person about, as Questions about voices describes: up to 20, their own voice first.
PUT /api/meetings/:id/voices/:track/:label/answerA person's answer, yes, no or not_sure, to whether the voice is the team member in email. 400 invalid_answer; 409 already_named for a yes about a voice that has another name, 409 already_answered for a yes after someone else's no, and 409 no_profile.
DELETE /api/meetings/:id/voices/:track/:label/learnedThe profile that learned from the voice forgets it, and the voice keeps its name. Only for the profile's owner and the profile admins: 403 not_yours_to_teach otherwise. Answers 204, also when no profile had learned from it.
PUT /api/meetings/:id/lines/:line/voiceA person moves one line to a different speaker.
POST /api/meetings/:id/retryRuns a failed meeting again.
GET, PUT and DELETE /api/voice-profileThe signed-in person's own voice profile. PUT takes a recorded sample and their agreement, described under Voice profiles. GET never returns the fingerprint. DELETE answers 204 whether or not there was a profile.
PUT /api/team/:email/voice-profileLets a profile admin save a team member's voice profile from a recording that person gave them, described under Saving a profile for a colleague. Nobody else can call it.
GET /api/team/:email/voice-profileShows a team member's profile as GET /api/voice-profile shows a person their own. Only for that person and the profile admins: 403 not_admin otherwise, 404 no_profile when there is none.
GET /api/voice-profilesLists the email and the name of each profile the person may add meeting voices to: their own, and for a profile admin everyone's.
/mcpThe connector for Claude, described below.

Adding pieces, listing them and finishing are limited to the person who created the meeting. To anyone else the meeting answers 404, as if it didn't exist.

Every team member sees every processed meeting's title, its voices and their clips (Eden, 2026-10-10). A person names voices, and adds their own voice to their profile, in meetings that a colleague recorded.

intent needed

Can every staff member read and file every meeting, or only the meetings they recorded? The Spec only says that everything is limited to staff. Titles, voices and clips are decided, above. For the transcript and for filing, this page assumes the same.

Processing​

Each meeting gets one Workflow instance, started by finish. Its id is the meeting's id, so a second finish can't start a second run.

Each step is a Workflows step with its own retries. A step returns R2 keys and ids, never the transcript itself, because a step's result is capped at 1 MiB.

Every step first checks for work it has already done and skips it: the join looks in D1, the words step looks in R2 for the track's words, the engine's kept answer or its job, the terms step looks for the term model's kept answer, and the voices step looks for DeepInfra's kept answer. A retried meeting therefore never pays a provider twice for the same track.

1. Join​

The step joins the pieces into one audio file per track, as described above.

2. Words​

Each track gets its words on its own, and a Meet call's two tracks go side by side. The engines are tried in the order the SPEECH_ENGINES setting lists them, ["soniox", "elevenlabs"]: a track goes to the next engine when one can't give its words.

Soniox works as a job. The Workflow starts it, then asks about it until it's done:

  1. POST /v1/transcriptions with:
    • model: "stt-async-v5" and audio_url, a signed R2 link to the track's file, valid for 60 minutes, because Soniox can queue a job before it reads the audio;
    • language_hints: ["he", "en"];
    • enable_language_identification: true, because each word's language is how the transcript's language is found;
    • enable_speaker_diarization: false, because voices come from DeepInfra;
    • context.terms, the entries of SPEECH_KEY_TERMS.
  2. The job's ID goes to R2 (TRACK.soniox.job) as soon as Soniox gives it, so a step run again asks about the same job rather than paying for another.
  3. The Workflow sleeps between checks: 15 seconds, then 30, then a minute each time, for up to an hour. Each check is GET /v1/transcriptions/ID. A sleeping Workflow doesn't count towards the instances that run at once.
  4. When the job is completed, the step reads GET /v1/transcriptions/ID/transcript, keeps it in R2 as it came, deletes the job (DELETE /v1/transcriptions/ID), then keeps the words.

Soniox keeps every job until it's deleted, up to 2,000 at once, so the job is deleted whenever the track is done with it. A job Soniox is still working on can't be deleted. Its ID then stays in R2, so running the meeting again picks it up, and Soniox deletes it itself after 30 days.

Soniox sends pieces of words. A piece that starts with a space starts a new word, and a word runs from its first piece's start to its last piece's end.

When Soniox can't do a track, ElevenLabs takes it.

What happens at SonioxWhat the step does
It turns the request down (for example, a used-up balance), or the job ends with an error (for example, a recording over its fixed 300-minute limit)Deletes the job and asks ElevenLabs at once
Starting a job meets a rate limit or a server errorTries once more ten minutes on, then asks ElevenLabs
A check on a running job meets a rate limit, a server error or no answer within a minuteChecks again, for about half an hour, then asks ElevenLabs
The job hasn't finished after an hourAsks ElevenLabs

ElevenLabs answers in the same request. The request is POST /v1/speech-to-text with:

  • model_id=scribe_v2, and source_url set to a signed R2 link to the track's file, valid for 15 minutes;
  • timestamps_granularity=word, so every word has a start and an end;
  • diarize=false and tag_audio_events=false, because voices come from DeepInfra;
  • language_code only when SPEECH_LANGUAGE is set. It's empty, so ElevenLabs detects the language, as it advises for speech that mixes languages;
  • one keyterms field for each entry of SPEECH_KEY_TERMS.

Its asynchronous mode delivers results only to a webhook, and refuses requests when none is set up (checked against the live API on 2026-10-06). A webhook would mean ElevenLabs calling in. So each attempt waits for the answer, for up to an hour. Separate requests matter for a Meet call: ElevenLabs' 10-hour limit counts a multichannel file's channels together, so a 5-hour call sent as one two-channel file would be at that limit.

Once an answer reads as a transcript, the step keeps it in R2 as it came, then the track's words:

{ "engine": "soniox", "language": "he", "words": [{ "text": "צריך", "start": 0.6, "end": 1.0 }] }
  • Language. language is the one the engine heard most in the file.
  • Silence. A track with no speech, such as a muted microphone, keeps no words and an empty language.
  • Paying once. The answer is kept before the words, so an attempt that fails between the two reads the answer back next time instead of paying for the track again.

The engines sit behind one interface, chosen by name in src/speech/engines.ts, so adding an engine needs no change beyond the engine itself:

interface SpeechEngine {
linkValidForSeconds: number; // how long the engine may take to read the audio
start(audioUrl: string, signal: AbortSignal): Promise<Progress>; // the answer, or a job to ask about
check(job: string, signal: AbortSignal): Promise<Progress>;
clearAway(job: string): Promise<boolean>; // false while the engine is still working on the job
transcriptOf(answer: ArrayBuffer): Transcript; // the words read from the engine's answer
}

type Progress = { answer: ArrayBuffer } | { job: string };
type Transcript = { language: string; words: Word[] };
type Word = { text: string; start: number; end: number }; // seconds from the start of the track

3. English terms​

Soniox writes most English terms in Hebrew letters: "אדמין קונסול" for "admin console". This step puts them back, track by track, once every track has its words. It never changes a word into other Hebrew, and a word keeps its times.

  1. Known names, by code. Each entry of the KNOWN_NAMES setting, such as "אידאה": "EDEA", is put into English letters wherever it's heard.

  2. The term model lists the terms. The step sends the track's words to the Claude API (POST /v1/messages) with the model named by TERM_MODEL, output_config.effort: "low" and max_tokens: 64000. The model answers with a list of fixes:

    {"fixes": [{"heard": "אדמין קונסול", "english": "admin console"}]}

    The answer is kept in R2 (TRACK.term-fixes.json) as soon as it reads as fixes, so a step run again doesn't pay for it twice.

  3. The code applies the fixes.

    • A fix applies wherever its spelling appears, also after Hebrew prefix letters in the order Hebrew puts them (ו, then ש or כש, then ב, כ, ל or מ, then ה). The prefix stays in front with a hyphen: "באדמין קונסול" becomes "ב-admin console".
    • Where two fixes start at the same word, the one with more words wins, then the one that needs fewer prefix letters.
    • Words that a fix joins become one word, from the first word's start to the last word's end.
    • A fix is used only when what was heard is in Hebrew letters and at most six words, and the English is at most 60 characters of English letters, digits and simple punctuation, with a Hebrew prefix and hyphen at most.
  4. The track's words are saved again, with what the engine wrote on each changed word:

    { "engine": "soniox", "language": "he", "englishTerms": "fixed",
    "words": [{ "text": "ה-admin console.", "start": 0.9, "end": 1.9, "was": "האדמין קונסול." }] }

The glossary keeps itself. Each fix that changed something is saved in term_fixes, once per meeting. The 350 fixes that the most meetings used go into the model's instructions as hints, marked as data, and the model still judges each meeting. Nothing in the glossary is applied by code, and nobody needs to review it.

If the Claude API stays down after four retries, turns the request down, or the answer can't be read or is cut short, the meeting is still ready. Its words then carry "englishTerms": "as heard" and a note saying why, with the known names put back.

4. Voices​

After the English terms, this step sends each track that can hold more than one voice to DeepInfra, one track at a time:

  • a Meet call's call track;
  • a room recording's mic track;
  • an upload's file track.

A Meet call's mic track isn't sent, because it holds only the person recording.

The request is POST https://api.deepinfra.com/v1/inference/nvidia/Nemotron-3-Diarization, with the track's audio as a file upload in the audio field.

  • The audio streams from R2. It goes through a FixedLengthStream, so the Worker never holds the track in memory, and the request still carries its length.

  • The upload is named after the meeting and the track's file, such as 01JBXAMPLE-call.webm, so a request can be traced to its meeting. Its type is the track's file type.

  • DeepInfra answers in the same request, with each stretch of speech and its voice:

    { "speaker_segments": [{ "start": 0.0, "end": 4.2, "speaker": "speaker_0" }], "num_speakers": 1 }
  • The answer is kept in R2 (voices/TRACK.json) as soon as it reads as voices, so a step run again doesn't pay for it twice.

  • Each request waits up to 15 minutes. A 5-hour track took 73 seconds in a test. A track with no answer in that time is asked about once more. A second wait that long fails the meeting rather than paying again.

5. Lines​

This step turns words and voices into lines, and writes them to transcript.json. It runs in the same Workflow step as the names below.

  1. Each word gets a voice.
    • On a Meet call's mic track, every word gets the voice recorder.
    • On every other track, a word gets the DeepInfra segment that covers most of the word, among the segments speaking at its middle. On a tie, the longer segment wins. This keeps slivers of a voice, some a fiftieth of a second long, from taking words.
    • A word in a gap between segments gets the nearest segment.
    • On a track where DeepInfra found no voice at all, every word gets the voice unknown.
  2. Words become lines. The words of every track go into one time order, and consecutive words of one voice form a line. A line ends when another voice speaks, on either track, so a one-to-one call reads as a conversation. It also ends when the voice stops for more than 2 seconds, so a long speech reads in paragraphs.

6. Names​

Each voice that said at least one word gets a row in voices. A voice that DeepInfra found but that said nothing, such as noise, gets no row. Each voice gets its name from the first source that can give one:

  1. The recorder's channel. On a Meet call, the mic voice is the person who recorded. Their name comes from their sign-in, kept on the meeting as created_by_name when the meeting is created, or is their email when the sign-in gave no name. The track decides this, never a label from DeepInfra.
  2. A voice profile. A step after this one compares each unnamed voice with the team's voice profiles, as Voice profiles describes. A match names the voice with the source voice_profile.
  3. A person. Any other voice stays unnamed until someone names it on the review screen or in the filing skill.

Each voice gets a clip, up to 10 seconds: its longest DeepInfra segment that no other voice speaks over and that is 2 seconds or longer, which is also the first stretch a profile learns from. A voice with no such segment gets its longest segment. A voice with no segments, such as unknown, gets its longest line.

A voice already in voices keeps what it has, so running a meeting again never undoes a name a person gave.

The system never guesses a name. That rules out a below-threshold match, a name taken from what was said, and a "most likely" pick. The closest profile that a voice keeps is not a name: a person must confirm it. Each name records its source, and the review screen shows it.

7. Ready​

The meeting becomes ready and appears on the review screen.

Voice profiles​

A voice profile lets processing name a team member by their voice. Voiceprints count as biometric data, so a profile is kept only with the person's agreement, never for a guest, and deleted on request or when the person leaves.

Saving a profile​

PUT /api/voice-profile takes a form with three fields: name, consent and sample. The email is always the signed-in person's, never one from the form. The checks run in this order, and the sample reaches the fingerprint service only after all of them:

  1. A body over 5 MiB and 64 KiB is refused before it's read: 413 sample_too_large.
  2. A missing field, or a name that's blank or over 100 characters: 400 invalid_profile.
  3. consent isn't the current agreement version, CONSENT_VERSION in shared/voice-profiles.ts: 400 consent_needed. The profile page (ENG-297) shows the text, and a new text gets a new version.
  4. A sample over 5 MiB: 413 sample_too_large. Thirty seconds of Opus is about 150 KB.

The fingerprint service then answers with the fingerprint and the sample's length:

  • It can't read the sample as audio: 422 sample_unreadable.
  • The sample is under 10 seconds: 422 sample_too_short.
  • It can't be reached: 503 voiceprint_unavailable.

Otherwise the profile is one row in voice_profiles: the email, the name, the fingerprint as JSON, the model that made it, the agreement version and the time. Saving again replaces the row, and the profile forgets what it learned from meetings, because the person is starting again from the new sample. The sample itself is kept nowhere.

Saving a profile for a colleague​

A team member may hand over a recording of their voice instead of recording in the app. A profile admin then saves their profile with PUT /api/team/<email>/voice-profile and the same form (Eden, 2026-10-10, ENG-303). The profile admins are the setting VOICE_PROFILE_ADMINS, which starts with Eden only.

Here the consent field is the admin's word that the person agreed to the current agreement text. So the row keeps who saved it, in saved_by, and consented_at is when the admin said so. saved_by is empty when the person saved their own.

Three checks come before the checks of a person's own save:

  1. The caller isn't a profile admin: 403 not_admin. A setting that isn't a list makes nobody an admin.
  2. The email in the address isn't a team member's: 400 not_a_team_member. It must look like a work address and end in the staff domain. It's kept in lower case, as the sign-in gives it, so the person finds the profile under their own email.
  3. The person saved their own profile: 409 has_own_profile. A person's own agreement outweighs an admin's word that they agreed.

The person stays in charge of the profile. GET /api/voice-profile shows them savedBy, they can delete it, and their own save replaces it. An admin can see a colleague's profile, and add meeting voices to it or take them out, but can't delete it in the app. Seeing it never includes the fingerprint. An admin's save for their own email counts as their own.

Nothing checks three things:

  • Whether the address is a real account. A mistyped address makes a profile that nobody can see in the app, and it can still name voices. The statement under When someone leaves removes it.
  • Whether the person deleted their profile before. A deletion leaves no record, so an admin could save the profile again.
  • The email in the request's address. Cloudflare's request logs keep it for some days, for people who can already read the database.

Naming voices in a meeting​

This step runs after the voices step. It does nothing when nobody has a profile, so no container starts.

  1. Clean speech for each unnamed voice. A stretch is one of DeepInfra's segments for the voice, at least 2 seconds long, that no other voice overlaps. The longest come first, up to 60 seconds. A voice with under 6 seconds of clean speech isn't compared, so unknown never is.
  2. One request per track. The fingerprint service gets a signed link to the track's file, valid for 30 minutes, and each voice's stretches. It downloads the track once, decodes it once, and answers with one fingerprint per voice: the average of its stretches' fingerprints.
  3. Only fingerprints from the same model are compared. The comparison is the cosine similarity: 1 for the same direction, 0 for unrelated voices. A profile is compared by the average of its sample and the voices it learned from.
  4. A voice gets a name when its best similarity is at least VOICE_MATCH_THRESHOLD (0.45) and at least 0.1 above the next profile's. When only one profile can be compared, there's no next profile to lead, so the voice must reach the bar plus 0.1 instead. The closest pairs are settled first, and a profile names one voice per meeting. An email already on one of the meeting's voices isn't given again, and on a Meet call the person recording is never given, because Meet never plays their own voice back.
  5. The row is updated only while it's unnamed (WHERE name IS NULL), with the source voice_profile and the similarity in confidence.
  6. A voice the profiles didn't name keeps the closest profile: its email in closest_email and the similarity in closest_similarity, however low. A voice that was never compared has neither, so "scored 0.12" and "never scored" stay apart.
    • The closest profile is never the person recording a Meet call, nor someone a voice was named after before this run. It can be someone this run names a closer voice after, which is what a person split into two voices looks like.
    • Only the email is kept, not the name.
    • The values go when a later run names the voice, and when the profile's person deletes the profile. A run that can't compare the voice, such as one while the service is down, leaves the old values.

The similarity a name was given on stays in confidence. Both numbers are there to tune the threshold on real meetings.

The fingerprints of a meeting's voices, guests' included, are compared and dropped. One is kept only when a person names the voice as a team member's, as A profile learns from meetings describes. If the service can't be reached after three tries, the meeting is still ready, with its voices unnamed for a person.

The closest profile has two uses. A person who names the voice is shown it as a suggestion: the voice profile page does that from a similarity of 0.25. And the threshold is tuned on it: a voice a person names "Eden" that scored 0.42 against Eden's profile says the bar was too high for that recording.

The threshold comes from one meeting on 2026-10-08: the right people scored 0.60 to 0.78, and the highest wrong score was 0.43. It was 0.5 at first. Eden lowered it to 0.45 on 2026-10-10, for poorer recordings than that meeting, which leaves almost no room above the highest wrong score. So a guest who sounds like a team member is the risk to watch, and more meetings, including a room recording, will retune it.

A profile learns from meetings​

One sample describes a voice on one microphone in one room, so a profile from it alone misses the same person on other recordings. A profile gets better when it also holds that person's voice from real meetings (Eden, 2026-10-10).

PUT /api/meetings/:id/voices/:track/:label takes JSON with a name and, for a team member, an email. Any team member can name any voice, and can correct a name. The voice gets the name with the source person, and named_by keeps who gave it. A name without an email is a guest's, and the voice then stops keeping the profile that was closest to it.

When the name is a team member's, their profile learns from the voice. The fingerprint service fingerprints the voice's clean stretches again, and the fingerprint goes into learned_voices as part of that person's profile. The answer says learned: true, or learned: false with why:

whyWhat it means
a_guestThe name has no email.
not_yours_to_teachOnly the profile's owner and the profile admins add to a profile. A colleague's name still names the voice.
no_profileThe person hasn't saved a profile. A name never makes one.
not_enough_speechThe voice has under 6 seconds of clean speech.
another_modelThe profile was made by a different model.
voiceprint_unavailableThe fingerprint service couldn't be reached. The name is kept, and naming the voice again later teaches the profile.
profile_fullThe profile has learned from 20 other meetings already. The name is kept.
changed_meanwhileWhile the voice was being fingerprinted, it got another name, or the person saved a new sample or deleted their profile. Nothing is kept.

How a profile keeps and loses what it learned:

  • It learns from a voice once, however often the voice is named. A colleague who names an already-learned voice the same way gets learned: true.
  • It forgets the voice when the name is corrected to someone else or to a guest. Any team member's correction does this, because a wrong fingerprint in a profile is worse than a missing one. If the new name is another team member's, their profile learns it instead.
  • It forgets a voice that its owner or a profile admin takes out, with DELETE /api/meetings/:id/voices/:track/:label/learned. The voice keeps its name, because the name can be right while the stretch is a poor one to learn from. Naming the voice again teaches the profile again.
  • It forgets everything it learned when the person saves a new sample, and loses it all with the profile.
  • It learns from 20 meetings at most (Eden, 2026-10-10), MAX_MEETINGS_LEARNED_FROM in shared/voice-profiles.ts. A second voice from a meeting it already learned from still fits. The limit is checked before the voice is fingerprinted, and again when the fingerprint is written, so two namings at once can't pass it.
  • GET /api/voice-profile shows the person each voice it learned from, in learned: the meeting, its title and day, the voice, who added it and when. Never the fingerprint.

A profile is compared by the average of its sample and its meetings, each counted once. The voices learned from one meeting are averaged first, so a person whom DeepInfra split into two voices doesn't count twice.

Two limits:

  • Learning takes as long as the naming step, because the service downloads and decodes the whole track, and it runs inside the request. If the request is cut off, naming the voice again teaches the profile.
  • One wrong name is enough to mislead a profile. If an admin names a guest's voice as a team member, that profile matches the guest about as well as its owner, and it holds the guest's fingerprint until the name is corrected. Nothing checks that the voice is like the sample: a profile is meant to learn voices that sound different from it.

The person who records a Meet call is the only voice on the mic track, and it is the cleanest recording of them. That track is never sent to DeepInfra, so it has no segments to cut it by. Their profile learns from the lines of the transcript that they said: a line is a stretch of their speech, and Chrome's echo cancellation keeps the call's sound off the track. The same rules apply as for any voice: lines of 2 seconds or more, the longest first, up to 60 seconds, and at least 6 seconds in all. The app names that person from their sign-in, but their profile learns only when a person presses Add.

A learned fingerprint is kept because it belongs to a team member who has a profile. The fingerprints of the meeting's other voices are still dropped.

Clips of a meeting's voices​

A person must hear a voice to say whose it is. Each voice has one clip that plays the voice at up to three places in the meeting, with a short silence between them, 15 seconds in all at most. One place can sound right while the voice DeepInfra found is two people; the start, the middle and the end together show it.

The parts are chosen when the clip is cut (src/voices/clip-parts.ts), so a meeting that was processed under an older rule gets the same clips as a new one:

  1. Take the voice's stretches of 2 seconds or more that nobody speaks over. They are the stretches a profile learns from, so the clip is part of that.
  2. Cut the time from the first of them to the last into three equal places, and take the longest stretch of each place.
  3. Share the 15 seconds between the parts: 5 seconds each for three, 7.5 for two, 15 for one.

The person who records a Meet call is cut by the lines they said, since their track has no DeepInfra segments. A voice with no such stretch is played from the one stretch that processing kept for it in clip_start_ms and clip_end_ms: its longest segment, or its longest line.

The clip is cut into a small file of its own, and the track itself is never served to a browser. There are three reasons:

  • A browser can't play every upload. A phone can upload amr or 3gp, which Chrome doesn't play.
  • A recorder's WebM has no seek index. A browser would download the track from its start to reach a clip in the middle.
  • A person who names voices needs a few seconds of each, not the whole meeting's audio.

How a clip is made and played:

  1. The first time someone asks to hear a meeting's voices, on the profile page or in a question, the page sends POST /api/meetings/:id/clips. The Worker lists the clips that R2 already keeps for the meeting, chooses the parts of the missing ones, and asks the fingerprint service for them, one request per track.
  2. The service cuts from the decoded samples, not by seeking in the file, because a seek that lands a few seconds off would play another person's voice. It reads the track through a signed link, decodes it once, as far as the last part's end, joins each clip's parts with 0.4 seconds of silence, and answers each clip as an M4A file (AAC, one channel, 16 kHz). It refuses a clip of more than 5 parts, or of more than 30 seconds.
  3. The Worker keeps each clip at meetings/ID/voice-clips/TRACK/LABEL.m4a. Clips of one stretch each were kept under clips/ (PR #13); nothing reads them now. If the service leaves one out, none of that track's clips is kept and the answer is 503, so the next try cuts the track again.
  4. GET /api/meetings/:id/voices/:track/:label/clip plays it from R2, and answers the part a player asks for, which Safari needs. A browser may keep it for an hour.

Cutting takes as long as reading the track up to its last part, which is near the track's end: 2 seconds for a 35-minute meeting on a laptop, longer for a long one. It runs inside the request, once per meeting. Cutting the clips when the meeting is processed would remove the wait; that isn't built.

Questions about voices​

The app asks team members about the voices it couldn't name but that sound like a team member: "Is this Dvir?", or "Is this you?" for the person's own voice (Eden, 2026-10-12). A colleague knows a colleague's voice, so the work of naming is shared, and nobody has to open a page for it.

Which voices. GET /api/voice-questions lists the voices of processed meetings that have no name, and whose closest profile is at a similarity of 0.25 or more. Such a voice was fingerprinted, so it said enough for a profile to learn from. The person's own voice comes first.

The card. components/VoiceQuestions.vue shows the questions above the page, as one line that the person can open or put off. It isn't shown while a recording runs, nor on the profile page. A question takes an answer only after its clip played to the end, and the three answers look alike, so that the brighter one doesn't answer for the person. The next question shows at once; the answers are sent one after the other behind it, because a yes makes the service listen to the meeting.

What an answer does (PUT …/answer, kept in voice_answers):

AnswerWhat it does
yesNames the voice as that team member, by the name their profile shows, the same way as PUT …/voices/:track/:label. So their profile learns from it by the rules of A profile learns from meetings: today when the person themselves or a profile admin answers. A voice that got another name meanwhile keeps it: 409 already_named; the check is part of the update, so a name given a moment before isn't lost. A yes after someone else said no, as from a card that was opened earlier, is refused: 409 already_answered.
noEnds the question for everyone: one colleague who knows it isn't that person is enough.
not_sureEnds the question for the person who said so. The others are still asked.

A yes also ends the question, since the voice then has a name. A person who answers again changes their answer. A person can go past a voice whose clip can't be cut or played; nothing is sent, so the app asks about it again on a later visit.

A person's own no outweighs a colleague's yes. When the team member themselves answers no, a voice that has their name loses it, their profile forgets the voice, and the voice no longer counts as sounding like them. On the profile page this is the "Not my voice" button, on a voice that someone else added. Remove, beside it, keeps the name.

The voice profile page​

The page is at /profile, reached from the "Voice profile" link in the header, beside the person's initials. Its heading says whose profile it is: the name the profile shows, the email, and whether it is the person's own or a colleague's. It is where a profile learns from meetings (Eden, 2026-10-10). It doesn't record a sample yet; that is ENG-297, and it goes on this page.

  • In this profile. The sample, with the day it was saved and who saved it. Then each meeting voice the profile learned from, with its clip and a Remove button, and how many of the 20 meetings are used. The sample can't be played, because only its fingerprint is kept.
  • Add from meetings. The processed meetings that hold the person's voice, as far as the app knows: a voice named as theirs, an unnamed voice that sounds like their profile, or a voice the profile learned from. GET /api/meetings counts the three. The other meetings are one click away, because a profile can miss its owner.
  • A meeting, opened. Its clips are cut, and each voice with a clip is listed, the likeliest first. A voice that said too little for a profile to learn from isn't listed: the list uses the rule that Add checks (6 seconds that nobody speaks over, in stretches of 2 seconds or more), read from the kept voices when the list is asked for, so it holds for meetings processed earlier too. One line says how many voices were left out. A voice that is in the profile stays listed, so that it can be removed. Add names the voice as the profile's owner, which is what teaches the profile. A voice named as someone else has no Add: a correction belongs to the review screen.
  • One Add at a time, because each one makes the service read a whole track.
  • A profile admin picks whose profile the page shows, from GET /api/voice-profiles. Everyone else has only their own.

A voice counts as sounding like a profile from a similarity of 0.25 (LEAST_SIMILARITY_TO_SUGGEST in src/processed-meetings.ts). The floor is needed because every compared voice has a closest profile, however unlike it: with one profile kept, that profile is the closest to every guest. The number is a first guess, not measured. Under it, the list and the page say nothing about the voice.

The fingerprint service​

  • What it is. voiceprint/server.mjs, a small Node server in a Cloudflare Container, with ffmpeg and NVIDIA's TitaNet-large speaker model run through sherpa-onnx. The model is CC-BY-4.0, credited in voiceprint/NOTICE, and the Dockerfile pins it by its checksum, because a profile's fingerprint can only be compared with fingerprints from the very same model.
  • What it answers. POST /sample takes the sample's bytes and decodes at most 120 seconds of it. POST /voices takes a link and each voice's stretches. POST /clips takes a link and a stretch for each voice, and answers the clips, since the service is where ffmpeg runs. It reads tracks only from hosts ending in .r2.cloudflarestorage.com, and it deletes everything it wrote when it answers.
  • Where it runs. One named instance, voiceprint, of VoiceprintContainer (src/voiceprints/container.ts). It runs on standard-1 (half a vCPU, 4 GiB), at most 2 instances, limited to the EU, and sleeps after 2 idle minutes. The Worker asks for its Durable Object in the EU too, with jurisdiction("eu"). The local runtime can't run that, so the first profile saved on the deployed app confirmed it (2026-10-10).
  • How it's checked. The Worker's tests stand in for it, since the Workers test runtime has no containers. .github/workflows/voiceprint.yml builds the image on pull requests that change voiceprint/ and runs voiceprint/smoke-test.sh on it.

When someone leaves​

Eden deletes their profile, the notes that it was closest to a voice, and the answers to whether a voice is theirs:

pnpm exec wrangler d1 execute DB --remote --command "DELETE FROM voice_profiles WHERE email = 'name@first-edea.com'; UPDATE voices SET closest_email = NULL, closest_similarity = NULL WHERE closest_email = 'name@first-edea.com'; DELETE FROM voice_answers WHERE email = 'name@first-edea.com'"

A person's own delete in the app does all three too. What the profile learned from meetings goes with it either way, because learned_voices rows are deleted with their profile. Names the profile already gave in past meetings stay, like a name a person typed. D1's Time Travel can restore the whole database to an earlier moment for a limited time, so a deleted profile isn't gone from those restore points at once.

The review screen​

The review screen is where a person reads a meeting and gets its speakers right before filing. It doesn't file.

  • The meeting list shows meetings newest first, with their status. Once a meeting is filed, its entry links to its Confluence pages.
  • The transcript shows each line with its speaker and time, right to left for Hebrew. Clicking a line plays the audio from there.
  • Each unnamed voice shows a few of its lines and its clip, and the person picks a team member or types a guest's name. A team member is sent with their email, which is what lets their voice profile learn from the voice.
  • Corrections: a wrong name can be corrected for the whole meeting, and a single line can be moved to a different speaker.
  • Sources: each name shows where it came from: the recorder's channel, a voice profile or a person.

The words of a line can't be edited here. Wording is corrected on the Confluence page after filing.

Filing​

In v1, meetings are filed from Claude only, on each person's own Claude plan. Filing never calls the Claude API, so it adds no per-meeting cost. Processing calls it only to list English terms, for under a cent per hour of audio.

The filing skill is in the edea-transcript plugin in E-D-E-A/workflow-skills:

  • It's adapted from today's Fireflies filing skill and builds on the Brain's writing rules in the edea-confluence plugin.
  • It reads the meeting through the connector and writes Confluence through Atlassian's MCP server, both connected in Claude.
  • Its checks, the eight-section notes template and the Hebrew right-to-left recipe live in the skill, not in this backend.

How a meeting is filed​

  1. Speakers are named first. On the review screen, the person names any unnamed voices and fixes wrong names.
  2. The person asks Claude. In claude.ai or Claude Code, they say something like "file my last meeting". The skill finds the meeting with list_meetings and reads it with get_meeting and get_transcript.
  3. The skill checks the names. If a voice is still unnamed, it points the person to the review screen to hear the clip, or takes a name from them and saves it with name_voice.
  4. The skill asks the usual questions: the space, Hebrew or English, and the task owners.
  5. The skill drafts. It writes the notes, runs the names-only pass, checks for an existing page and links related pages. It shows both drafts and waits for a yes.
  6. On the yes, it creates the pages. It creates the notes page and then the transcript as its child page, as the person, through their own Atlassian connection. The notes page links back to the meeting in the web app, which is how the audio is found again.
  7. The skill reports back. It calls record_filing with the two page links, and the meeting becomes filed.

Team setup, once​

An admin adds the EDEA Transcript connector, Atlassian's connector and the filing skill for the whole team in Claude's organization settings. Each person then signs in to each connector once. Developers can also install the plugin in Claude Code from the "edea" marketplace.

workflow-skills is a public repository. The plugin carries no secrets and no backend address; the connector is added by its address during setup.

Left out of v1​

Filing straight from the review screen would mean the backend running the same skill on Anthropic's Managed Agents, which is billed Claude API usage, an estimated $0.50 to $1 per meeting. It's left out of v1. It can be added later if filing from Claude proves too hard for the non-developers, without changing the skill.

The connector for Claude​

The connector is an MCP server at /mcp on the Worker, over Streamable HTTP, built with Cloudflare's workers-oauth-provider.

  1. The connector is added to Claude as "EDEA Transcript".
  2. On first use, the Worker sends the person to a Cloudflare Access for SaaS application, where they sign in with their staff Google account.
  3. The Worker then issues Claude its own token for that person.
ToolWhat it does
list_meetingsLists recent meetings with status and date
get_meetingReturns one meeting: its tracks, voices, names and their sources, and a link to the review screen for each unnamed voice
get_transcriptReturns the merged transcript with names and times, including moved lines
name_voiceRecords a name a person gave a voice while filing
record_filingStores the links of the pages just created and marks the meeting filed

When something fails​

What failsWhat happensWhat the person sees
The connection drops while recordingPieces wait in IndexedDB, recording carries on, and uploading resumes when the connection is backA banner saying how much audio is waiting to upload
The sign-in expires while recordingAs above. The page asks the person to sign in again in a new tab, then resumes"Sign in again to keep uploading"
The tab is closed or the laptop restarts mid-meetingRecording stops. Recorded pieces stay in IndexedDBOn the next visit, an offer to finish uploading that recording
A phone loses signal mid-uploadUpload resumes from the backend's piece listProgress carries on
A piece arrives damagedThe checksum doesn't match and the Worker answers 400. A recording stops uploading, keeps every piece in IndexedDB and carries on recording; a phone upload stops"Uploading has stopped", and on the next visit an offer to finish; on a phone, "Try again", which resumes
The browser can't store a piece, for example a full diskRecording stops and uploads the pieces it keptWhy it stopped, on the done screen
A provider call fails with a rate limit (429), a server error (5xx) or a request timeout (408)The step asks again, waiting 15 seconds and twice as long each time: 7 more times, about half an hour in total"Processing"
Soniox can't do a trackElevenLabs takes it, as the words step describes"Processing"
ElevenLabs doesn't answer within an hourThe step asks once more. A second hour without an answer fails the meeting rather than paying againThe reason, and a Retry button
A provider rejects the audio, for example an unsupported fileThe meeting fails without retrying, keeping the step and the provider's reason. A reason names the track, never carries a link and is cut to 300 charactersThe reason, and a Retry button
The speech engine answers with something that isn't a transcript, or DeepInfra with something that isn't voicesThe meeting fails without retrying, and the answer isn't keptThe reason, and a Retry button
One track of a Meet call failsThe other track still finishes and its words are kept, so running the meeting again only pays for the failed trackThe reason, and a Retry button
The Claude API stays down, or its answer can't be readThe meeting is ready with English terms as the engine wrote them, and a note in the words saying whyThe meeting, ready
DeepInfra doesn't answer within 15 minutesThe step asks once more. A second 15 minutes without an answer fails the meeting rather than paying againThe reason, and a Retry button
The fingerprint service can't be reached while a meeting is processed, or isn't deployed yetThe naming step tries three times, then the meeting is readyThe meeting, ready, with its voices unnamed
The fingerprint service can't be reached while a profile is saved503 voiceprint_unavailable, and nothing is storedTry again later
Filing in Claude stops after the notes page but before the transcript pageFiling again creates only what's missing, because the skill checks for existing pages firstClaude says what was created
The person's connector sign-in in Claude has expiredClaude asks them to sign in to that connector againClaude's own sign-in prompt

Limits​

  • Meetings up to 5 hours. This is the target v1 is built and tested for.
  • Room above that. Soniox takes up to 300 minutes per file, a limit it never raises, so a longer track goes to ElevenLabs, which takes up to 10 hours. DeepInfra answered a 5-hour track in 73 seconds.
  • Audio size. A 5-hour recorder track is about 70 MB. A 5-hour phone recording in AAC at 64 kbit/s is about 145 MB.
  • Pieces. An upload piece is at most 8 MiB and a recorder piece at most 1 MiB, against Cloudflare's 100 MB request limit.
  • Workflow step results. These are capped at 1 MiB, so steps pass R2 keys, never transcripts.
  • Voices. DeepInfra finds at most 8 voices in a track. A track with more people than that has some of them merged into one voice.
  • Voice profiles. A profile learns from 20 meetings at most. A voice needs 6 seconds of clean speech to be compared. The fingerprint service read a 5-hour track in 18 seconds on a laptop; on half a vCPU it takes minutes, and the naming step allows 20.

Configuration​

KindNames
BindingsDB (D1), AUDIO (R2), MEETING_WORKFLOW (the Workflow), VOICEPRINT (the Durable Object that runs the fingerprint service's container), OAUTH_KV (KV, which workers-oauth-provider keeps its tokens in)
SecretsSONIOX_API_KEY, ELEVENLABS_API_KEY, ANTHROPIC_API_KEY (a key scoped to one Anthropic workspace), DEEPINFRA_API_KEY, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY (an R2 API token that can only read the audio bucket, for signed links), plus the OAuth client details for the Access for SaaS application
SettingsSTAFF_EMAIL_DOMAIN, SPEECH_ENGINES, SONIOX_API_URL, SPEECH_LANGUAGE, SPEECH_KEY_TERMS, TERM_MODEL, KNOWN_NAMES, R2_ACCOUNT_ID, VOICE_MATCH_THRESHOLD, VOICE_PROFILE_ADMINS
  • Where the data sits. The D1 database and the R2 bucket are both in Cloudflare's EU jurisdiction. A location is set when each is created and can never be moved. Wrangler creates a missing bucket in the EU on its own, but would create a missing database without the jurisdiction. So before wrangler runs, the deploy creates the database in the EU itself, and it refuses to deploy if the database exists anywhere else.
  • Deploying. GitHub Actions deploys the Worker on every merge to main. The Cloudflare API token lives in the repo's production GitHub Environment, which only main can use.
    • The secrets in use are listed under secrets.required in wrangler.jsonc, so a deploy fails while one isn't set on the Worker.
    • The deploy fills in R2_ACCOUNT_ID from the org's CLOUDFLARE_ACCOUNT_ID variable.
    • wrangler deploy builds the fingerprint service's image with Docker, which GitHub's runner has, and pushes it to Cloudflare. Containers need the Workers Paid plan. The Worker goes live before the container is ready, so the first meetings after a first deploy may stay unnamed.
    • dev.enable_containers is off, so pnpm dev needs no Docker. Locally, saving a profile answers 503 and voices stay unnamed.
  • Changing a setting. Settings are typed loosely (wrangler types --strict-vars=false), so adding a key term or a known name is an edit to wrangler.jsonc and nothing else. test/speech-settings.test.ts holds the key terms to ElevenLabs' limits (at most 1,000, each under 50 characters and 5 words), checks that SPEECH_ENGINES names only engines the Worker has, and that each known name becomes English letters only. test/team-voice-profiles.test.ts runs with the real VOICE_PROFILE_ADMINS and checks that it's a list of staff emails in lower case.
  • Soniox's region. SONIOX_API_URL is Soniox's address for the project its key belongs to. Its EU region is https://api.eu.soniox.com/v1, for a project set up there, so moving is a new key and this setting.

To check while building​

  • How long Soniox takes on a 5-hour track, and ElevenLabs on a longer one. Soniox gets an hour of checks, and each ElevenLabs attempt an hour. QA on the deployed app runs a 5-hour file to measure it.
  • Echo on the mic track without headphones. If the call still reaches mic, the merge step must drop words that are on both tracks at the same time.
  • Seeking in recorder audio. MediaRecorder's WebM files have no seek index. A voice's clip no longer needs one, because it is cut into its own file. Playing the audio from a line of the transcript, on the review screen, still does: the join step may need to add an index, or the recorder may need to use a format that has one.
  • How long a meeting's clips take to cut. QA on the deployed app times the first opening of a 35-minute meeting and of a 5-hour one.
  • Picking the same file again on an iPhone. An upload resumes when the same file is picked again, recognised by its name, size and last-modified time. If iOS gives a re-picked file a new last-modified time, the upload starts over instead of resuming.
  • The fingerprint service on half a vCPU. QA on the deployed app times it on a 5-hour track.
  • The connector's paths and the gate. The Worker checks the sign-in before any route, and Access covers the whole Worker. The connector's /mcp and OAuth paths must be routed before that check, and left out of the browser gate in Access.