docs(cookbook): add the voice-agent instrument-and-verify recipe and its checker - #844
Conversation
…its checker A voice call is selected by six conditions at once, and a span that misses any one of them is absent from the Voice tab while remaining a perfectly healthy trace. The text recipe cannot cover it: its gate set opens on "at least one LLM span", which a pulled provider call never has. New page, same contract as instrument-and-verify.mdx section for section, plus public/fi_verify_voice.py: twelve gates, a conversation-shaped preflight, and a capture that wraps the exporter rather than a span processor, because traceai-livekit rewrites attributes inside export().
Both listings taught fi_span_kind="agent". The Voice tab lists a conversation-typed span with no parent, so the page's own example produced a call that appears in Traces and in no voice surface at all.
…e up the sidebar The page listed Vapi and Retell. The product also pulls ElevenLabs, Bland.ai and Twilio, each with its own parser, and the page itself was unreachable from the sidebar. Adds both sidebar entries: this page under Observability > Guides, and the new voice recipe beside Instrument and Verify.
All three are 3024x1890 full-window dark, taken off the same run as the printed gate output (call ID 1965f5ba, session call_355937e9c818), annotated to the same house spec as the text recipe's three. The list shot carries the point the page is really about: the row is the conversation span itself, so opening it after session.start() costs you the row. The detail shot says out loud why Duration, Latency, User / AI and Silence are blank on a text-mode run. The attributes shot is the Attributes tab filtered to transcript, which is the only place the three transcript keys are visible side by side. The old call-detail.png came off an earlier run and showed a different call ID and a different answer than the transcript printed above it. Replaced. The last line of the printed read-back is now the assistant's answer verbatim, punctuation included, so the block and the screenshot say the same thing.
Run end to end against api.futureagi.com by an agent with no context but the page URL and a key file. It reached GREEN on the first pass, and everything below is something it had to work out for itself on the way. - The Install listing never created `observability/__init__.py` or `observability/futureagi/__init__.py`, which the tree diagram lists but no command produced, so both track listings fail to import until you guess it. - No virtualenv anywhere, while every command says `python`. System python on macOS is 3.9 and a bare `pip install` is refused outright, so the first listing on the page cannot run as written. One venv line fixes both. - Step 6 ran `agent.py` with one ask and the result block below it reports the two-ask default: 4 turns, 16 spans, unreachable from the page's own command. - `LLM_API_KEY` existed only inside `agent.py`. The export block that sets up the run never named it, and the prose only said "one model key". - The example calls Groq by default through a variable named `OPENAI_BASE_URL`, which the page never said out loud. - Two step cross-references were wrong: the conversation span is opened in Step 3, not Step 5, and the checker is downloaded in Install, not Step 1. - `.fi_verify/` is relative to the working directory, so preflight and check run from different places fail with a reason that names neither. Now a troubleshooting row. - Said which track the end-to-end walkthrough is, since it is SDK only.
| <Prerequisites> | ||
| - Future AGI account → [app.futureagi.com](https://app.futureagi.com) | ||
| - API keys: `FI_API_KEY` and `FI_SECRET_KEY` (see [Get your API keys](/docs/admin-settings)) | ||
| - A voice agent you host yourself: LiveKit Agents, Pipecat, or your own STT plus LLM plus TTS loop. If your calls run on Vapi, Retell, ElevenLabs, Bland or Twilio, you write no code at all: see [If your calls come from a managed provider](#if-your-calls-come-from-a-managed-provider) |
There was a problem hiding this comment.
ElevenLabs is commented out of the Connect-a-provider dropdown (frontend constants.js) and Twilio isn't in it at all, both only have server-side parsers. Selectable today: Vapi, Retell, Bland.ai. Same list at L671
There was a problem hiding this comment.
Done, and you are right on both. VOICE_CHAT_PROVIDERS on origin/dev has ElevenLabs commented out, and Twilio appears nowhere in frontend/src outside the generated API contracts. The page now says Vapi, Retell or Bland.ai in the Prerequisites and in the managed-provider section.
| A voice call is not a trace with audio in it. The Voice tab finds a call by six conditions at once, and a span that misses any one of them is invisible there no matter how healthy it looks in Traces. This guide instruments a self-hosted voice agent, then runs `fi_verify_voice.py`, which checks twelve gates against the spans your agent really sent and exits 0 or names the gate that failed. The worked example runs with one model key, no LiveKit account, no phone number and no microphone. | ||
| </TLDR> | ||
|
|
||
| | Time | Difficulty | Package | |
There was a problem hiding this comment.
Every runnable cookbook ships a Colab notebook (ruled 2026-08-18), the sibling has one and this example runs on a single model key. Add the notebook, the badge row and code-repo-url
There was a problem hiding this comment.
Done. quickstart/instrument-and-verify-voice.ipynb is up as future-agi/cookbooks#15, and the page carries code-repo-url and the Colab + GitHub badge row in the same slot as the sibling.
The four listings the notebook writes out are extracted from this page's own fenced blocks byte for byte rather than retyped, so the two cannot drift.
|
|
||
| The call arrives with sixteen spans: the conversation span you wrote, and fifteen from LiveKit around it. `llm_node` and `llm_request` are the model call, `agent_turn` is one exchange, and the rest are session lifecycle. | ||
|
|
||
| <img src="/images/docs/cookbook-instrument-and-verify-voice/call-list.png" alt="The Future AGI trace list for the voice-cookbook-page-run project, showing eight voice.call traces and the checker's futureagi.preflight probe, with input, output, timestamp, status and latency" style={{ borderRadius: '5px' }} /> |
There was a problem hiding this comment.
That UNSET futureagi.preflight row is the text checker's probe, the page says the voice probe is futureagi.voice.preflight. Filter it out of the shot like last time
There was a problem hiding this comment.
Re-shot without it, same project, same seven calls.
Worth flagging what I found chasing it, because the row is not what it looks like. That trace is a single span, and in ClickHouse its name is futureagi.voice.preflight:
trace_id: 4bf92f35-77b3-4da6-a3ce-929d0e0e4736
n: 1
names: ['futureagi.voice.preflight']
The only futureagi.preflight on the box that day is in a different project entirely. So it was the voice probe, exactly what Step 1 writes. I then loaded that list in a headless browser and read the rendered text back, and the DOM says futureagi.preflight.
The trace list is dropping the middle segment of the span name when it renders it. Nothing rewrites the name in fi-collector or in the tracer serializers that I could find, so I have the symptom but not the line. Any span named a.b.c is worth a second look.
The row is out of the shot either way: it is the checker's probe, not a call, and the caption says one row per call.
There was a problem hiding this comment.
Correction to my reply above, and you were right.
I claimed the trace list drops the middle segment of a span name. It does not. I inserted probe spans named alpha.beta.gamma, one.two.three.four, zz.voice.qq, futureagi.a.b and a fresh futureagi.voice.preflight, and every one of them renders in full. No rendering bug.
The real cause is in the checkers. Both hardcode the same ids:
# public/fi_verify.py:53 and public/fi_verify_voice.py:80
span = {"traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", ...}default.spans sorts on (project_id, observation_type, service_name, toStartOfHour(start_time), trace_id, id) and replaces on it. So two preflights in one project in one hour collapse to a single row, and whichever landed last wins. The row in that capture really was the text checker's probe, exactly as you called it. By the time I queried ClickHouse the voice probe had replaced it, which is what sent me down the wrong path.
Fixed on cookbook/voice-preflight-unique-ids: fresh os.urandom ids per send, verified three sends give three distinct pairs. fi_verify.py still has it and I have not touched that one here.
Sorry for the noise on a thread you had already got right.
| user How much baggage can I bring? | ||
| assistant You may bring one checked bag up to 23 kg and one carry-on bag up ... | ||
| user And is a stroller counted separately? | ||
| assistant Yes—strollers are considered a free‑of‑charge personal item and do ... |
There was a problem hiding this comment.
Em-dash, even if the model wrote it. Trim the printed line before the dash
There was a problem hiding this comment.
Done, the line is trimmed before the dash.
It is still in the trace-list screenshot, in the Output column of the top row, because that is real model output in a real capture and I did not want to doctor the image. Say the word and I will re-run the example for an answer without one.
| | `check` says `no preflight receipt` right after `preflight` said it passed | `.fi_verify/` is relative to the working directory, so the two commands ran from different places | Run `preflight`, the agent and `check` from one directory, or set `FI_VERIFY_FILE` to an absolute path for all three | | ||
| | Spans stop arriving as soon as the mapper is enabled | Something wrapped the exporter before `enable_http_attribute_mapping()` replaced it | Call the mapper first, then anything that wraps an exporter, in the Step 2 order | | ||
|
|
||
| Instrument the model calls inside the call with [Instrument and Verify](/docs/cookbook/quickstart/instrument-and-verify), and keep one call across a service boundary with [Distributed Tracing](/docs/cookbook/quickstart/distributed-tracing). |
There was a problem hiding this comment.
One forward link, not two
There was a problem hiding this comment.
Done, dropped the Distributed Tracing link and kept Instrument and Verify.
| }, | ||
| { title: 'Setup alerts', href: '/docs/observe/guides/setup-alerts' }, | ||
| { title: 'Setup evals', href: '/docs/observe/guides/setup-evals' }, | ||
| { title: 'Connect a voice provider', href: '/docs/observe/features/voice' }, |
There was a problem hiding this comment.
Sidebar item and page title should match, the page is titled Voice Observability
There was a problem hiding this comment.
Done, the sidebar item is now Voice Observability.
| collapsible: true, | ||
| items: [ | ||
| { title: 'Instrument and Verify', href: '/docs/cookbook/quickstart/instrument-and-verify' }, | ||
| { title: 'Instrument and Verify a Voice Agent', href: '/docs/cookbook/quickstart/instrument-and-verify-voice' }, |
There was a problem hiding this comment.
Add the card to platform/index.mdx and bump the hub's Tracing & Debugging count to 9
There was a problem hiding this comment.
Done. The card is in cookbook/platform/index.mdx under Tracing & Debugging, and the count on cookbook/index.mdx is 9.
| --- | ||
|
|
||
|
|
||
| ## About |
There was a problem hiding this comment.
About is banned as a heading, rename it while you're in here
There was a problem hiding this comment.
Done, it is now "What voice observability does".
Worth knowing separately: ## About is still the opening heading on alerts, dashboard, evals, quickstart and users in the same folder. Happy to take those in one pass if you want them gone.
| |---|---| | ||
| | Vapi | [dashboard.vapi.ai](https://dashboard.vapi.ai) | | ||
| | Retell | [retellai.com](https://www.retellai.com/) | | ||
| | ElevenLabs | [elevenlabs.io](https://elevenlabs.io/) | |
There was a problem hiding this comment.
Drop the ElevenLabs and Twilio rows, neither is connectable in the UI. Same in the description and the feature bullet at L27
There was a problem hiding this comment.
Done. Both rows are out of the table, and out of the description and the L27 bullet with them.
Providers: only Vapi, Retell and Bland.ai are selectable in Connect a provider. ElevenLabs is commented out of VOICE_CHAT_PROVIDERS and Twilio never reaches the dropdown, so both come out of the page, the feature page and its description. Also: the Colab and GitHub badge row with code-repo-url, the sidebar item matched to the page title, the card added to the platform hub with the count bumped to 9, one forward link instead of two, a heading rename, and the printed transcript trimmed before the dash.
The row read futureagi.preflight, which is not a span name that exists: the span is futureagi.voice.preflight, and the trace list drops the middle segment when it renders the name. Same project, same seven calls, probe trace excluded, so the shot now shows one row per call as its caption says.
The problem
A voice call is not a trace with audio in it. Future AGI finds a call by six conditions at once, and the one people miss is that the call's span must have no parent. Every voice framework opens its own root span the moment a session starts, so an engineer who wraps their call in a span after that produces a trace that is complete, correctly shaped, fully populated, and absent from the Voice tab, from every voice filter and from every voice eval. Nothing errors. No dashboard reads differently. The only way to notice is to go looking for a call that was never there.
The other half is that nothing about a voice call is derived. Duration, turns, talk ratio and the transcript are all read from named attributes on that one span, and the instrumentor writes none of them. A name that is nearly right is a blank column rather than an error.
Why nobody catches it
The existing cookbook cannot cover this. Its checker opens on "at least one LLM span", which a pulled provider call never has, and none of its ten gates looks at whether a span has a parent, because on the text side that never mattered.
What
A voice-agent sibling to
instrument-and-verify, same contract section for section, plus the checker it depends on.src/pages/docs/cookbook/quickstart/instrument-and-verify-voice.mdx(653 lines): six steps, two tracks in Tabs, a real end-to-end run, an eval-binding table, a non-Python section and a troubleshooting tablepublic/fi_verify_voice.py(305 lines): twelve gates, a conversation-shaped preflight, andattach()src/lib/navigation.ts: the sibling sidebar entry next toInstrument and VerifyThree things in it are not in any existing page:
200proves the voice path rather than the route, and one call is in the Voice tab before the reader writes a line of agent codeattach()captures at the exporter, after export, not with a span processor.traceai-livekitrewritesspan._attributesinsideexport(), so a processor tee reports on attributes that were never sent: it shows a span kind ofNoneon every LiveKit spanfi.conversation.transcriptis the only one the call drawer reads on a self-hosted agent,conversation.transcriptis what the eval picker resolves, and the flattenedconversation.transcript.N.message.*is what the error feed walks. None falls back to anotherTwo docs defects fixed in the same branch
docs/integrations/traceai/livekit.mdxtaughtfi_span_kind="agent"in both listings. Its own example therefore produced a call that appears in Traces and in no voice surface at all. Now"conversation", with the reason inline and a closing sectiondocs/observe/features/voice.mdxlisted Vapi and Retell, and was unreachable from the sidebar. It now lists every provider a user can actually pick in Connect a provider, which is Vapi, Retell and Bland.ai. ElevenLabs is commented out ofVOICE_CHAT_PROVIDERSondevand Twilio never reaches the dropdown at all, so neither belongs on a page telling someone to connect one, even though both have server-side parsersScreenshots
Three, all 3024x1890 off the same run as the gate output below, annotated to the same house spec as the text recipe's three.
The trace list. The row is the conversation span itself, which is the whole reason Step 3 opens it before
session.start().The call read as a call. Duration, Latency, User / AI and Silence are blank on purpose and the page says why: those four are computed from per-turn speech timings on the transcript entries, which a text-mode run does not have. Turns, Words and the transcript come from the attributes Step 5 writes.
The Attributes tab filtered to
transcript, which is the only place the three transcript keys are visible side by side.Verification
The page was followed literally, twice, in a clean directory: every listing extracted from the
.mdxby its own first-line marker, no retyping, the checker copied frompublic/with a matchingsha256, then the page's own three commands.Read back through the product's own endpoints, not just the database:
list_voice_callstalk_ratio 0.727voice_call_detailtranscript_available: None, 0 turnstranscript_available: True,message_count: 4, all four turnsAnd the defect the page exists for, same agent, one line moved:
Eleven of twelve gates pass on the broken run.
V3is the only thing that tells them apart.The checker's own gates are proved to bind: one real broken run plus 14 mutations of the green capture, one per gate, 14/14 red on the gate they target.
Run against production, by an agent with no context
The runs above were against a local stack. The page was then handed to a coding agent that had never seen it, given the rendered URL and a key file and nothing else, pointed at
https://api.futureagi.comwith a real account. NoFI_BASE_URLoverride: the SDK and the checker both default there, and that default had never been exercised.GREEN on the first pass, no gate retried, about three and a half minutes from opening the page. The preflight's controls held against production too: the real keys got
200, a mutated key and a headerless send both got401, and a trailing slash got404.The last commit on this branch is everything that run had to work out for itself: the two
__init__.pyfiles no command created, the missing virtualenv against apythonevery listing assumes, a Step 6 command that could not reproduce Step 6's own printed result,LLM_API_KEYnamed nowhere outside a listing, an undeclared Groq default behind a variable calledOPENAI_BASE_URL, two wrong step cross-references, and a.fi_verify/path that is relative to wherever you happen to stand.One finding is not fixed and cannot be:
curl https://docs.futureagi.com/fi_verify_voice.pyis404until this merges and deploys. The siblinghttps://docs.futureagi.com/fi_verify.pyis live and200today, so the shape is right and the asset simply is not published yet. The agent worked around it and confirmed the file it got matched thesha256the page prints.Docs build: 1175 pages, 0 errors.
audit-links: 0 broken nav links, 0 broken content links, orphan pages 34 to 33.Review round 1
Nine comments from @khushalsonawat, all answered on the branch.
The provider list was the real one. I had read the backend parsers, which do cover ElevenLabs and Twilio; the question a reader is actually asking is which providers they can pick, and that is
VOICE_CHAT_PROVIDERSin the frontend. Right source, wrong field.The Colab notebook is now future-agi/cookbooks#15. Its four listings are extracted from this page's own fenced blocks byte for byte, so the notebook and the page cannot drift.
The row was the text checker's probe, and the cause is in the checker
I first read this as the trace list mangling the span name, and that was wrong. Probe spans named
alpha.beta.gamma,one.two.three.four,zz.voice.qqand a freshfutureagi.voice.preflightall render in full. There is no rendering bug.Both checkers hardcode the same ids:
default.spanssorts and replaces on(project_id, observation_type, service_name, toStartOfHour(start_time), trace_id, id), so two preflights in one project in one hour collapse to one row. The capture caught the text checker's probe winning that race. Fixed separately, with fresh ids per send;fi_verify.pystill carries it.Docs build after the round: 1175 pages, 0 errors.
audit-links: 0 broken nav links, 0 broken content links.Out of scope
traceai-livekitdoes not read LiveKit's current attribute names. LiveKit moved conversation content behind apiisegment; the mapper still reads the bare names, so LLM spans arrive with no prompt and no completion. Fixed separately infuture-agi/traceAI; until that ships, Step 2 carries an 11-line shim and says when to delete itgen_ai.cost.totalandllm.cost.totalonly. Documented, not changedCONVERSATIONspan kind. The page gives the attribute-level workaround. The SDK is not touched here