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
| Part | What it is | What it does |
|---|---|---|
| Web app | Pages served by the Worker | The recorder (Meet and room modes), the phone upload page, the meeting list, the review screen and the voice profile page |
| Worker | One Cloudflare Worker, in TypeScript | Serves the web app, the JSON API and the connector, and checks the sign-in on every request |
| R2 | One private Cloudflare bucket | Uploaded pieces, joined audio, raw provider results and merged transcripts |
| D1 | Cloudflare's SQL database | Each meeting's record: its tracks, pieces, voices, names and filed page links |
| Workflow | Cloudflare Workflows, one instance per meeting | Runs the processing steps in order, keeps progress, waits and retries |
| Soniox | Speech-to-text API, asynchronous | Words with a time on each word. It writes most English terms in Hebrew letters |
| ElevenLabs Scribe v2 | Speech-to-text API | Takes any track Soniox can't do. English terms come back in English letters |
| Term model | A 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 |
| DeepInfra | Runs NVIDIA's Nemotron 3 Diarization, a speaker diarization model | Splits a track into voices: who spoke when, up to 8 voices |
| Fingerprint service | Our 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 skill | The edea-transcript plugin in E-D-E-A/workflow-skills, run in Claude | Writes the notes page and the transcript page, and files both after the person's yes |
| EDEA Transcript connector | An MCP server at /mcp on the Worker | How the filing skill reads a meeting and records what was filed |
| Atlassian Rovo MCP | Atlassian's MCP server, connected in Claude | How 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.devaddress 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 onctx.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(theSTAFF_EMAIL_DOMAINsetting) 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.accesson. 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-Siteheader other thansame-origingets 403cross_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
/mcpand 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
| Kind | Recorded with | Tracks |
|---|---|---|
| Google Meet call | The recorder page in Chrome or Edge, on a laptop | call: the Meet tab's audio, shared with "Also share tab audio". mic: the microphone of the person recording. |
| Room meeting on a laptop | The recorder page's room mode | mic only |
| Room meeting or phone call on a phone | The phone's own recorder (a recorder app, Voice Memos, iPhone call recording), then the upload page | file: 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
micis the person recording. Meet never plays a person's own voice back to them. callholds 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.
micis 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.
- The page creates the meeting with
POST /api/meetingsand 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. - 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.
- 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.
- The browser sends the piece to
PUT /api/meetings/:id/tracks/:track/pieces/:seq, as raw bytes, with the piece's SHA-256 in theX-Piece-SHA256header. - 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.
- 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.
- Sending the same piece twice is harmless: it goes to the same key with the same bytes, and the checksum is compared.
- When the person presses Finish, the page sends
POST /api/meetings/:id/finishwith 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:
- The recorders stop, and the audio they hand over as they stop isn't kept.
- The upload queue stops sending.
- 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.
- The page waits for a piece that was already on its way, but never for a retry.
- The page sends
DELETE /api/meetings/:id. The Worker deletes the meeting's audio from R2, then its rows from D1. - 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.
| Table | One row per | Main columns |
|---|---|---|
meetings | meeting | id, 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 |
tracks | track of a meeting | meeting_id, track (call, mic, file), pieces, bytes, audio_key, content_type |
pieces | uploaded piece | meeting_id, track, seq, bytes, sha256 |
voices | voice that said something in a meeting | meeting_id, track, label, name, email, source, confidence, closest_email, closest_similarity, clip_start_ms, clip_end_ms, named_by |
line_moves | line a person moved to another speaker | meeting_id, line_start_ms, voice, moved_by |
voice_profiles | team member with a profile | email, name, fingerprint, model, consent_version, consented_at, saved_by |
learned_voices | meeting voice that a profile learned from | email, meeting_id, track, label, fingerprint, added_by, added_at |
voice_answers | answer to the app's question whether a voice is a team member's | meeting_id, track, label, email, answered_by, answer, answered_at |
term_fixes | English term the term model put back in a meeting | meeting_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, oruploadingfor a phone upload;processing, withstepsaying 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 path | What it does |
|---|---|
GET /api/me | Says who is signed in: email and name. |
GET /api/health | Checks that the database and the audio bucket answer. 200 when both do, 503 naming the one that doesn't. |
POST /api/meetings | Creates a meeting from its kind, its tracks and an optional title. Returns the id. |
PUT /api/meetings/:id/tracks/:track/pieces/:seq | Stores one piece. Answers 200 once the piece is in R2 and D1. |
GET /api/meetings/:id/tracks/:track/pieces | Lists the pieces the backend has, so an upload can resume. |
POST /api/meetings/:id/finish | Takes 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/:id | Deletes 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/meetings | Lists 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/:id | Returns one meeting: status, step, tracks, voices and names. |
GET /api/meetings/:id/transcript | Returns the merged transcript, with names. |
GET /api/meetings/:id/voices | Lists 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/clips | Makes 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/clip | Plays a voice's clip, with range requests. 404 until the clips are cut. |
PUT /api/meetings/:id/voices/:track/:label | A 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-questions | The 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/answer | A 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/learned | The 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/voice | A person moves one line to a different speaker. |
POST /api/meetings/:id/retry | Runs a failed meeting again. |
GET, PUT and DELETE /api/voice-profile | The 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-profile | Lets 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-profile | Shows 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-profiles | Lists the email and the name of each profile the person may add meeting voices to: their own, and for a profile admin everyone's. |
/mcp | The 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.
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:
POST /v1/transcriptionswith:model: "stt-async-v5"andaudio_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 ofSPEECH_KEY_TERMS.
- 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. - 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. - When the job is
completed, the step readsGET /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 Soniox | What 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 error | Tries 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 minute | Checks again, for about half an hour, then asks ElevenLabs |
| The job hasn't finished after an hour | Asks ElevenLabs |
ElevenLabs answers in the same request. The request is POST /v1/speech-to-text with:
model_id=scribe_v2, andsource_urlset 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=falseandtag_audio_events=false, because voices come from DeepInfra;language_codeonly whenSPEECH_LANGUAGEis set. It's empty, so ElevenLabs detects the language, as it advises for speech that mixes languages;- one
keytermsfield for each entry ofSPEECH_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.
languageis 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.
-
Known names, by code. Each entry of the
KNOWN_NAMESsetting, such as"אידאה": "EDEA", is put into English letters wherever it's heard. -
The term model lists the terms. The step sends the track's words to the Claude API (
POST /v1/messages) with the model named byTERM_MODEL,output_config.effort: "low"andmax_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. -
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.
-
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
calltrack; - a room recording's
mictrack; - an upload's
filetrack.
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.
- Each word gets a voice.
- On a Meet call's
mictrack, every word gets the voicerecorder. - 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.
- On a Meet call's
- 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:
- The recorder's channel. On a Meet call, the
micvoice is the person who recorded. Their name comes from their sign-in, kept on the meeting ascreated_by_namewhen the meeting is created, or is their email when the sign-in gave no name. The track decides this, never a label from DeepInfra. - 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. - 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:
- A body over 5 MiB and 64 KiB is refused before it's read: 413
sample_too_large. - A missing field, or a name that's blank or over 100 characters: 400
invalid_profile. consentisn't the current agreement version,CONSENT_VERSIONinshared/voice-profiles.ts: 400consent_needed. The profile page (ENG-297) shows the text, and a new text gets a new version.- 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:
- The caller isn't a profile admin: 403
not_admin. A setting that isn't a list makes nobody an admin. - 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. - 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.
- 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
unknownnever is. - 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.
- 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.
- 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. - The row is updated only while it's unnamed (
WHERE name IS NULL), with the sourcevoice_profileand the similarity inconfidence. - A voice the profiles didn't name keeps the closest profile: its email in
closest_emailand the similarity inclosest_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:
why | What it means |
|---|---|
a_guest | The name has no email. |
not_yours_to_teach | Only the profile's owner and the profile admins add to a profile. A colleague's name still names the voice. |
no_profile | The person hasn't saved a profile. A name never makes one. |
not_enough_speech | The voice has under 6 seconds of clean speech. |
another_model | The profile was made by a different model. |
voiceprint_unavailable | The fingerprint service couldn't be reached. The name is kept, and naming the voice again later teaches the profile. |
profile_full | The profile has learned from 20 other meetings already. The name is kept. |
changed_meanwhile | While 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_FROMinshared/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-profileshows the person each voice it learned from, inlearned: 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:
- 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.
- Cut the time from the first of them to the last into three equal places, and take the longest stretch of each place.
- 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
amror3gp, 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:
- 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. - 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.
- The Worker keeps each clip at
meetings/ID/voice-clips/TRACK/LABEL.m4a. Clips of one stretch each were kept underclips/(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. GET /api/meetings/:id/voices/:track/:label/clipplays 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):
| Answer | What it does |
|---|---|
yes | Names 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. |
no | Ends the question for everyone: one colleague who knows it isn't that person is enough. |
not_sure | Ends 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/meetingscounts 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 invoiceprint/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 /sampletakes the sample's bytes and decodes at most 120 seconds of it.POST /voicestakes a link and each voice's stretches.POST /clipstakes 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, ofVoiceprintContainer(src/voiceprints/container.ts). It runs onstandard-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, withjurisdiction("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.ymlbuilds the image on pull requests that changevoiceprint/and runsvoiceprint/smoke-test.shon 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-confluenceplugin. - 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
- Speakers are named first. On the review screen, the person names any unnamed voices and fixes wrong names.
- 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_meetingsand reads it withget_meetingandget_transcript. - 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. - The skill asks the usual questions: the space, Hebrew or English, and the task owners.
- 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.
- 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.
- The skill reports back. It calls
record_filingwith the two page links, and the meeting becomesfiled.
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.
- The connector is added to Claude as "EDEA Transcript".
- On first use, the Worker sends the person to a Cloudflare Access for SaaS application, where they sign in with their staff Google account.
- The Worker then issues Claude its own token for that person.
| Tool | What it does |
|---|---|
list_meetings | Lists recent meetings with status and date |
get_meeting | Returns one meeting: its tracks, voices, names and their sources, and a link to the review screen for each unnamed voice |
get_transcript | Returns the merged transcript with names and times, including moved lines |
name_voice | Records a name a person gave a voice while filing |
record_filing | Stores the links of the pages just created and marks the meeting filed |
When something fails
| What fails | What happens | What the person sees |
|---|---|---|
| The connection drops while recording | Pieces wait in IndexedDB, recording carries on, and uploading resumes when the connection is back | A banner saying how much audio is waiting to upload |
| The sign-in expires while recording | As 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-meeting | Recording stops. Recorded pieces stay in IndexedDB | On the next visit, an offer to finish uploading that recording |
| A phone loses signal mid-upload | Upload resumes from the backend's piece list | Progress carries on |
| A piece arrives damaged | The 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 disk | Recording stops and uploads the pieces it kept | Why 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 track | ElevenLabs takes it, as the words step describes | "Processing" |
| ElevenLabs doesn't answer within an hour | The step asks once more. A second hour without an answer fails the meeting rather than paying again | The reason, and a Retry button |
| A provider rejects the audio, for example an unsupported file | The 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 characters | The reason, and a Retry button |
| The speech engine answers with something that isn't a transcript, or DeepInfra with something that isn't voices | The meeting fails without retrying, and the answer isn't kept | The reason, and a Retry button |
| One track of a Meet call fails | The other track still finishes and its words are kept, so running the meeting again only pays for the failed track | The reason, and a Retry button |
| The Claude API stays down, or its answer can't be read | The meeting is ready with English terms as the engine wrote them, and a note in the words saying why | The meeting, ready |
| DeepInfra doesn't answer within 15 minutes | The step asks once more. A second 15 minutes without an answer fails the meeting rather than paying again | The reason, and a Retry button |
| The fingerprint service can't be reached while a meeting is processed, or isn't deployed yet | The naming step tries three times, then the meeting is ready | The meeting, ready, with its voices unnamed |
| The fingerprint service can't be reached while a profile is saved | 503 voiceprint_unavailable, and nothing is stored | Try again later |
| Filing in Claude stops after the notes page but before the transcript page | Filing again creates only what's missing, because the skill checks for existing pages first | Claude says what was created |
| The person's connector sign-in in Claude has expired | Claude asks them to sign in to that connector again | Claude'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
| Kind | Names |
|---|---|
| Bindings | DB (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) |
| Secrets | SONIOX_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 |
| Settings | STAFF_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'sproductionGitHub Environment, which onlymaincan use.- The secrets in use are listed under
secrets.requiredinwrangler.jsonc, so a deploy fails while one isn't set on the Worker. - The deploy fills in
R2_ACCOUNT_IDfrom the org'sCLOUDFLARE_ACCOUNT_IDvariable. wrangler deploybuilds 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_containersis off, sopnpm devneeds no Docker. Locally, saving a profile answers 503 and voices stay unnamed.
- The secrets in use are listed under
- Changing a setting. Settings are typed loosely (
wrangler types --strict-vars=false), so adding a key term or a known name is an edit towrangler.jsoncand nothing else.test/speech-settings.test.tsholds the key terms to ElevenLabs' limits (at most 1,000, each under 50 characters and 5 words), checks thatSPEECH_ENGINESnames only engines the Worker has, and that each known name becomes English letters only.test/team-voice-profiles.test.tsruns with the realVOICE_PROFILE_ADMINSand checks that it's a list of staff emails in lower case. - Soniox's region.
SONIOX_API_URLis Soniox's address for the project its key belongs to. Its EU region ishttps://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
mictrack without headphones. If the call still reachesmic, 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
/mcpand OAuth paths must be routed before that check, and left out of the browser gate in Access.