Pull a Retell account's entire call history, and transcribe every recording on your own machine.
Call recordings are the most sensitive thing a voice agent produces. VoiceFlow pages through the Retell API, downloads every recording, and runs Whisper large-v3 on the machine doing the export, so the audio never reaches a third-party transcription service. Built against a production account of 2,100+ outbound calls.
Full case study: hammadahmad.co.uk/projects/voiceflow
The export configuration screen
Pick an output directory, optionally bound it by date, choose your columns, and start. Progress and a live log stream while it runs, and the job can be cancelled part-way without losing the work already on disk.
Sending call audio to a hosted speech API means a recording of your customer crosses a trust boundary you do not control, and usually one you have to declare. Whisper large-v3 is good enough to remove that requirement entirely. The cost is wall-clock time: roughly 5 to 10 minutes per hour of audio on CPU, considerably less on a GPU.
Nothing leaves the machine except the Retell API calls themselves.
One dated directory per run, under the export root you chose:
2026年08月13日_retell_export/
├── csv/retell_calls_2026年08月13日.csv one row per call
├── recordings/<to_number>_<call_id>.wav
├── transcripts/<to_number>_<call_id>.txt
└── run.log
Plus, at the export root itself, processed_ids.json, which is what makes a
second run skip everything already fetched.
The CSV and the transcripts are separate. The CSV carries Retell's own
server-side transcript in its Transcript column. Whisper's output goes to the
transcripts/ directory as plain text, keyed by the recording filename. Nothing
joins the two, so if you want Whisper's text in the spreadsheet, that is a step
you still have to write.
The 24 columns
Selectable individually in the UI. Defaults to all of them.
Time, Duration_ms, Channel Type, Cost_USD, Session ID, End Reason,
Session Status, User Sentiment, From, To, Session Outcome,
E2E Latency_p50, detailed_call_summary, callback_time,
user_intent_summary, recording_path, Agent ID, Agent Version,
Direction, Call Type, End Timestamp, Transcript, Recording URL,
Public Log URL
The three custom_analysis_data fields come from whatever post-call analysis
your Retell agent is configured to emit, so they may be empty on your account.
flowchart LR
UI[Next.js UI<br/>key held in sessionStorage] -->|POST /api/run| API[FastAPI]
API --> JOB[job thread<br/>uuid, cancel flag]
JOB --> P[page through Retell<br/>pagination_key, 250 per request]
P --> F[dedup vs processed_ids<br/>then filter by date]
F --> CSV[write CSV<br/>downloading each WAV inline]
CSV --> DL[download pass<br/>emits progress]
DL --> TR[Whisper large-v3<br/>one file at a time]
TR --> OUT[(csv + wav + txt + run.log)]
API -.->|GET /api/logs SSE| UI
API -.->|GET /api/progress, polled| UI
A job is an OS thread, not an asyncio task. That keeps the blocking work, HTTP
downloads and torch inference, off the event loop, at the cost of there being no
queue: every POST /api/run starts another thread immediately, and each one
loads its own copy of the model.
Job lifecycle and progress
POST /api/run -> {"job_id": "<uuid>"}
GET /api/progress/:id -> {"current","total","percent","stage"}
GET /api/logs/:id -> text/event-stream, one log line per frame
GET /api/summary/:id -> 400 until the job is done, then the result
POST /api/cancel/:id -> sets a cooperative cancel flag
Stage is one of queue, download_recordings, transcribe. Worth knowing:
the fetch and CSV phase both report as queue, so on a large account progress
sits at 0% for a long time before it starts moving. That is the Retell paging and
the inline audio downloads, not a hang. The log stream is the thing to watch
during that window.
Cancellation is checked between calls, between rows, and on every download chunk, but not inside a single Whisper call, so a cancel lands when the current file finishes rather than immediately.
The SSE contract
Deliberately minimal. GET /api/logs/{job_id} returns text/event-stream and
emits unnamed frames, one log line each:
data: [TR] 41/2100 447xxxxxxxxx_call_abc123.wav
No event names, no JSON, no ids, no heartbeat. The server drains its log buffer
once a second. Progress is not on this stream; the UI polls /api/progress
every 1.5 seconds for that.
Log prefixes you will see: [CSV] while rows are being built, [DL] during the
download pass, [MODEL] during the one-off model download, [GPU] for device
selection, [TR] per transcription.
Whisper configuration
openai/whisper-large-v3 through Transformers, cached in
backend/models/whisper-large-v3. About 2.9 GB on first run, resumable, and
re-downloaded automatically if the weight files are missing or truncated.
Device selection is a single attempt: CUDA device 0 with fp16 if available, otherwise CPU with fp32, logged either way. There is no multi-GPU path.
The pipeline runs with chunk_length_s=None and return_timestamps=True, which
uses the sequential long-form path rather than chunked batching. batch_size is
8 but never fills, because files are transcribed one at a time. no_repeat_ngram_size
is 3, which is the only guard against the repetition loops Whisper falls into on
silence. Language is not pinned, so it is detected per file.
Only two environment variables are actually read by the backend:
| Variable | Default | Purpose |
|---|---|---|
RETELL_API_KEY |
none | Fallback if the request does not carry a key |
EXPORT_ROOT |
./exports |
Where run directories are created |
Plus one on the frontend:
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_BACKEND_URL |
http://localhost:8000 |
Where the UI sends requests |
.env.example lists several more, including WHISPER_MODEL, LOG_LEVEL and
WORKERS. Those are not wired up. The model id is hardcoded, and the app does
not call load_dotenv, so a .env file only takes effect if something else
loads it into the environment first.
In normal use the key is not an environment variable at all. It is typed into the
UI, held in sessionStorage, and cleared on tab close.
Requires Python 3.11, Node 18+, and ffmpeg on PATH.
git clone https://github.com/1oNN/VoiceFlow.git cd VoiceFlow # backend cd backend python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt python -m uvicorn app.main:app --port 8000 # frontend, in a second terminal cd frontend npm install echo "NEXT_PUBLIC_BACKEND_URL=http://localhost:8000" > .env.local npm run dev # http://localhost:3000
The first export downloads the model before it transcribes anything. Expect that to dominate the first run.
Worth reading before you point this at a real account.
- The Docker setup does not build as committed. The Dockerfile uses a shell
redirect inside a
COPY, the frontend stage expects a static export thatnext buildis not configured to produce, anddocker-compose.ymlreferences afrontend/Dockerfilethat does not exist. Run the two processes directly. There is also no GPU wiring in any container, so a containerised run would be CPU-only regardless. GET /api/fileserves any path it is given, with no authentication and no allowlist. It exists so the UI can offer download links. Do not expose this backend beyond localhost as it stands.- The whole call list is held in memory before filtering. The date range is applied client-side after fetching, not pushed into the API query, so a narrow date range on a large account still pages through everything.
/api/columnsnever receives the browser's key. The frontend sends it as a header, the backend reads a query parameter, so column discovery falls back to the server-side key or to the default list.- A run with nothing left to transcribe never shows its summary. The UI waits
for the
transcribestage to reach 100%, and that stage emits no progress if every recording already has a transcript. - Job state is in memory and unbounded. Restarting the backend loses every job, and completed jobs are never evicted from the dict.
- No tests.
CONTRIBUTING.mdrefers to a suite that does not exist.
ISC. See LICENSE.