Running list of non-obvious traps, platform constraints, and corrections hit while building the Voice Studio. Append a new entry whenever a design review, verification pass, or docs check turns up something that would surprise the next engineer. Keep each entry to: the trap → the consequence → what to do, with a source anchor.
Legend: ✅ verified against installed source/repo this session · 📄 from vendor docs.
- LiveKit's managed "Expressive mode" requires LiveKit Inference — we deliberately do NOT use it. 📄
The feature at
docs.livekit.io/agents/models/tts/expressiveneeds aninference.TTSand only supports Fish Audio / Inworld / Cartesia / Gemini / SpaceXAI — ElevenLabs is not available, and the TTS routes through LiveKit's hosted gateway. Plan 5 hand-rolls expressive tags instead: the Databricks LLM emits them, a customtts_text_transformspipeline protects them, and thelivekit-plugins-elevenlabsprovider plugin (BYOELEVEN_API_KEY→api.elevenlabs.io) performs them. Do not "simplify" by switching toinference.TTS— it breaks the Databricks-inference boundary and drops ElevenLabs. (Decision locked 2026-10-02; this is why the §7.8 tag pipeline exists.) filter_markdownstalls streaming TTS on a bare[. ✅ The stock filter holds back the rest of the reply when it sees a[that isn't a complete[text](url)link — so bare[whispers]would freeze audio until stream end. Run the tag-aware encode→filter_markdown→filter_emoji→decode pipeline (spec §7.8) so no bare[reaches it. (filters.py:79-85,124-130)- No runtime TTS swap. ✅
AgentSession.ttsis getter-only; there is noupdate_tts();Agent.update_options(tts=)does not exist at 1.5.6. Change voices by overridingtts_node, never by mutating the session. (agent_session.py:1597) - The session closes on the 4th consecutive LLM/TTS unrecoverable error, not the 3rd. ✅
max_unrecoverable_errors=3, but the guard closes only when the count exceeds 3 (off-by-one). Keep the Halloween TTS outside the session's error count (own its failures viamark_degraded) so a vendor hiccup can't kill the call. (agent_session.py:131,1370-1406) on_user_turn_completedis awaited before the reply, and an exception there drops the whole turn. ✅ The AI-Decide hook must be time-bounded (≤ cue-wait) and must never raise.turn_ctxis a per-reply temp copy: patch it for a same-turn switch, and also callupdate_instructionsfor later turns. (agent_activity.py:1964-1977,373-388)- Text transforms apply to the TTS branch only; the transcript branch gets raw LLM text. ✅ Tags must be stripped
separately in
transcription_node(and again client-side). (agent_activity.py:2407-2419,2495)
- RESOLVED 2026-10-02 — bumped the livekit stack to 1.8.3 (commit
18c0b54). 1.8.3 is the latest version common to all five livekit packages on the Databricks mirror<internal-pypi-mirror>; pypi.org is blocked from the sandbox, and the only "1.8.4" on the mirror is the unrelatedlivekit-plugins-bithuman. 1.8.3 adds the Text-to-Dialogue WebSocket, so the Halloween model iseleven_v3_conversational(streams audio tags at ~280 ms — meets the ≤400 ms gate), noteleven_v4_turbo(the 1.8.3 plugin has noeleven_v4*routing; v4 would need 1.8.4+, unavailable here).UG_HALLOWEEN_TTS_MODELdefault =eleven_v3_conversational. - The bump forced extra pins:
livekit1.1.5→1.1.18,livekit-api1.1.0→1.2.1 (hard deps of agents 1.8.3). Re-bumping the livekit stack later is a ~7-pin edit. openaiSDK was downgraded 3.14→2.54 (livekit-agents 1.8.3 requiresopenai<3). The gateway's Responses path runs on 2.x, on local calls and on a deployed app.- C1–C13 anchors now sit on 1.8.3 source — the 1.5.6 line numbers in spec §4 are stale; the re-verification against 1.8.3 is in the next section.
- Inline tags do NOT stream at
livekit-plugins-elevenlabs==1.5.6. 📄 The plugin's.stream()speaks only themulti-stream-inputWebSocket, which doesn't carry the tag-performing models.eleven_v3/eleven_v3_conversational/eleven_v4/eleven_v4_turboneed the Text-to-Dialogue WebSocket — added in livekit-agents 1.7.1 (v3/v3_conversational, ~280 ms TTFA) and 1.8.4 (v4/v4_turbo, ~100 ms). At 1.5.6 you can only stream Flash/turbo (no tags), or runeleven_v3over HTTP/streamper-sentence (~0.7–1.9 s TTFA — fails the ≤400 ms R8 gate). ⇒ Streamed inline tags require bumping agents+plugins to ≥1.7.1 / 1.8.4, which forces re-verifying the C1–C13 1.5.6 source anchors. (Done the same day: the stack is on 1.8.3, above.) - Missing
ELEVEN_API_KEYraisesValueErrorat construction — check the env before constructing, don't rely on a lazy failure. prewarm()is a no-op on the ElevenLabs plugin — the spec's "cue-time prewarm" has no effect; the WebSocket opens lazily on first.stream().- A profile-owned TTS is not wired to the session. The session subscribes
error/metrics_collectedand callsprewarm()only on its ownactivity.tts. For a profile-owned TTS: subscribe to itserror/metricsyourself, build theStreamAdapteronce, andaclose()it at shutdown (StreamAdapter.aclose()does NOT close the wrapped TTS). - Two
SPOOKY_TAGSare undocumented (nervously,inhales deeply); ElevenLabs documents[whispers]/[laughs]/[sighs]/[exhales]/[mischievously], and v3 sometimes speaks a tag aloud. Plan 6 tested both with the configured voice and model: both were performed and neither was read aloud (docs/discovery/expressive-tags-contract.md). - Low R13 risk: the plugin adds no new transitive deps beyond numpy (
avis already a core dep).
Re-verified against installed 1.8.3 (2026-10-02). C3–C13 + text-transform/PUA behavior all HOLD (new file:lines in the SDD ledger table); only C1 & C2 changed behavior, plus a new native subsystem:
- NEW native
expressivesubsystem — keep it OFF (expressive=False). 1.8.3 adds anexpressive: bool|ExpressiveOptionskwarg (default False) onAgentSession/Agent/update_optionsthat injects an LLM markup instruction, converts<expression>/<sound>XML tags →[...], and strips them from transcripts — but ONLY for providerscartesia/inworld/xai/fishaudio/gemini. ElevenLabs and OpenAI are NOT covered. ⇒ (a) our hand-rolled[whispers]tags +tts_text_transformsencode/decode +transcription_nodestripping are STILL REQUIRED for ElevenLabs; (b) leaveexpressive=Falseso the framework doesn't inject a competing markup block; (c) if the Halloween voice ever becomes one of those 5 providers, prefer the native pipeline. eleven_v3_conversational(a "dialogue" model) honors onlystabilityamong voice settings —similarity_boost/style/speedare dropped (plugin warns), as arechunk_length_schedule/streaming_latency/enable_ssml_parsing. ⇒ Task 5build_ttssets onlystability(=UG_HALLOWEEN_STABILITY). Routing:is_dialogue_model(m) = m.startswith("eleven_v3")→ text-to-dialogue WS (tags performed + streamed). Noeleven_v4*exists at 1.8.3.- C1 OBSOLETE:
Agent.update_options(tts=...)now EXISTS (live per-agent TTS swap via_update_models). Ourtts_nodeoverride stays the approach (per-utterance profile routing, no agent mutation); session-levelttsis still getter-only. - C2 OBSOLETE: Deepgram
update_optionsnow takes model/encoding/sample_rate/bit_rate AND invalidates the pooled WS — an in-place model change now takes effect. - §7.8 tag filter integration: TTS-branch filtering is now a configurable
text_transformslist (applied ingeneration.py) and plain callables are accepted — the encode/decode transforms plug in there. TheAgentSessionkwarg istts_text_transforms(app/agent.py). Transcript stripping still needstranscription_node. - C6:
preemptive_generation=is deprecated → set viaturn_handling=TurnHandlingOptions(...); default behavior unchanged (ON). - C3 nuance:
on_user_turn_completedexceptions are now caught (raise → drop turn + log, no session crash); "never raise / time-box" is still good practice but soft now. - The job's root span opens before the entrypoint, so the tracer provider must already exist (issue #35). ✅ 1.8.3 starts the
job_entrypointspan before our entrypoint runs. A provider installed inside the entrypoint never saw that span, so MLflow, which lists only traces that have a root, showed nothing. The provider is now installed once per worker process in_setup_process(app/agent.py), with per-trace enrichment and a root-span flush inapp/tracing.py. The unit tests cover the enrichment and flush logic, and the owner confirmed on a live call (2026-10-05) that traces now appear in MLflow with their root span. Tracing stays off unlessDATABRICKS_TRACE_CATALOG,_SCHEMAand_TABLE_PREFIXare all set.
- No native TTS, and no realtime STT. 📄 FMAPI is chat/reasoning/embedding/image only; the AI Gateway exposes no
audio paths. Batch STT exists (
ai_transcribe, Beta, SQL-only) but nothing streaming. Voice I/O must be vendor-direct (Deepgram STT, ElevenLabs TTS). The only Databricks-side TTS path is self-serving an OSS model on GPU Model Serving (cold starts "one to several minutes", no documented audio streaming) — impractical for a realtime turn. ai_decideREST response has noerror_messagefield. 📄 That field belongs to the SQL VARIANT envelope, not the REST response (which documents onlyresponse+metadata.version). Detect failure as: non-2xx HTTP, null/missingresponse, unknown label, or confidence outside 0–1.ai_decidequestion types arenoul/choice/score— there is nobool. 📄 The probability type isnoul. Never write"type":"bool"in code or the plan.ai_decideis Beta, Previews-gated, and region-limited. 📄 Must be enabled per-workspace (admin → Previews). Supported AWS regions include us-east-1/-2 and us-west-2 (our workspace is us-east-1).options.version:"1.0"pins the function API, not the model — the served model can change and probability calibration can drift without a version bump, so re-check thresholds after any change.- No official
ai_decidelatency or pricing. 📄 The 0.8–2 s figure circulating is measured on TypeSafe's Jev, not Databricks-hostedai_decide, and as of 2026-10-02 neither pricing page listedai_decide. Measured on our workspace: 500 to 720 ms across the four calls of onetools/ai_decide_check.pyrun, and 583 ms for one turn on a deployed app. - Claude models on the gateway reject
response_format: json_object(they acceptjson_schemaonly). 📄 Keep the world generator (UG_GEN_MODEL, which asks for JSON) on a GPT-family model. The generator calls/ai-gateway/openai/v1/chat/completions(src/services/uaig_chat.py), the voice LLM/responsesand retrieval/embeddings; all three work against the gateway, and the generator has produced the worlds in use.
src/services/gateway.post()is sync-only (blockingrequests). ✅ Don't call it (oruaig_chat.complete_json) from the async turn hook — it blocks the event loop. The decider must use its ownhttpx.AsyncClient(httpx is already pinned)._SPAN_TYPES(app/tracing.py) maps a span name to its MLflow span type, and the exporter JSON-encodes the value. ✅ To tag a span, add an entry to the map ("ug.ai_decide": "CHAIN"is there) — don't set a bare raw string.- The evidence router dispatches on fragment keys, not a type discriminator. ✅
handleEvidence(studio.js) checksobj.bind/obj.retrieval/obj.usage/obj.voice_mode. A new kind of payload needs its ownif (obj.…)branch. - The transcript is stripped of expressive tags on the client too. ✅
handleTranscription(studio.js) rendersstripExpressiveTags(seg.text), the belt-and-braces layer behind the server-side filter.
- Apps runtime is Python 3.11; local
.venvis 3.12. Recompileagent-requirements.txtfor 3.11 (including the ElevenLabs plugin + itscodecsextra) or the deploy build breaks. - The git stash stack is shared across all worktrees. Never use bare
git stash/git stash pop(you could pop another session's work) — prefer a WIP commit, orgit stash push -u -m "<unique-tag>"thenapply <sha>. - New secrets aren't declared in
app.yamldirectly. ✅valueFrom:points at an app resource key bound to theug-voice-studioscope out-of-band. AddingELEVEN_API_KEY= create the secret in the scope + attach an app resource (elevenlabs-api-key) + add thevalueFromentry inapp.yaml. - A second app on the same LiveKit project needs its own
AGENT_NAME. ✅ Workers that register under one name are candidates for the same dispatch (LiveKit's explicit-dispatch model; we never ran two under one name, since each app got its own). The web tier and the worker read the sameAGENT_NAME, so set it to something unique in the deploy copy ofapp.yaml.diva-v1registered asdiva-v1-agentnext to the older app'sug-agent, and its first call reached its own worker. - The worker log shows a few sub-second "event loop blocked" warnings on a first call. ✅ On 2026-10-05 a deployed
app logged five, 0.15 to 0.53 s each: the first import of
psycopg_poolin the job entrypoint, pydantic building the tool schemas on the first LLM turns (one of 0.53 s), and, after the call had ended, OpenTelemetry'sforce_flushmaking a synchronous OTLP export in the shutdown callback. None broke the call. Candidates if it ever matters: importpsycopg_pooland build the tool schemas inprewarm, and run the flush in a thread.
- The decision policy rule is a conservative safety-net; the ENGINE is the primary path.
explicit_command/has_cuedeliberately defer ambiguous phrasings to the engine. Accepted false-exit residuals (low-likelihood; an explicit exit rule beats the engine, so these matter — but the engine is enabled in the demo and Task 7 drops exit-rule verdicts in STANDARD): "please switch from airplane mode to normal mode" + short-gap "how do I switch the spooky voice to your regular voice" (older request-verb frame); "swap the voice plan for a regular voice plan"; "I prefer not to use the normal voice" (negation between verb + mention); a contrived 7–9-word move ("please switch the old main voice assistant on the app to regular mode"). Structural backstop: Task 7 drops any exit-rule verdict while in STANDARD (an exit is a no-op there, can only shadow an engine enter). - Expressive pipeline — G13 residual shapes (reach the TTS as text; low-likelihood, prompt-forbidden): nested
[a [b]], a newline inside brackets, a closed group ≥119 inner chars, and an allowed tag glued to a parenthetical[sighs](softly)(parsed as a markdown link → "sighs" spoken). By design, any closed non-link[...]of 33–118 chars is DROPPED from audio AND transcript even in standard mode (a legit aside like[Note: closes at 5 PM]is lost — fine for a voice assistant). Task 11's client strip must match: closed[...]≤120, not followed by(. - ElevenLabs
VoiceSettingsrequiressimilarity_boost.VoiceSettings(stability=…)alone raisesTypeError. Passsimilarity_boost=NOT_GIVEN(the plugin drops it → onlystabilityis sent, no "ignored option" warning). Foreleven_v3*dialogue modelsstabilityis the ONLY effective setting (style/speed/similarity are ignored + warned). - Expressive tags are gated on the MODEL, not just the vendor. Only
eleven_v3*models perform inline audio tags; flash/turbo/multilingual speak[laughs]literally. The halloween profile attachesSPOOKY_TAGSonly when the model starts witheleven_v3(mirrors the plugin'sDIALOGUE_TTS_MODEL_PREFIX). If Task 1 pins a turbo model for latency, tags are automatically off (dark voice, no inline cues) — fail-closed for G13. - Missing
UG_HALLOWEEN_VOICE_ID→ Deepgram fallback (NOT ElevenLabs' stock voice). So{ELEVEN_API_KEY set, no voice_id}→ the Halloween profile is the Deepgram dark voice..env.examplelistsUG_HALLOWEEN_VOICE_IDandapp.yamlcarries it as a commented placeholder; set it in your own deploy copy. build_ttscan raiseValueError(missing voice id / plugin key check) orImportError(plugin absent) → Task 8/10 wrap it and degrade. The ElevenLabs plugin callsPlugin.register_pluginon first import, which raises off the main thread — so import it at the TOP ofapp/agent.py(the entrypoint runs on the subprocess main thread), not lazily inside a worker thread.
- The openai plugin does NOT auto-set reasoning effort for non-
gpt-5-style model names. ✅_supports_reasoning_effort(model)matches only baregpt-5…names, so it returns False forsystem.ai.*anddatabricks-*— thereasoningfield is never sent unless you pass it. Setreasoning=Reasoning(effort="low")(fromopenai.types) onopenai.responses.LLMto keep server-side reasoning (and latency) in check. - The responses LLM's default read timeout kills long generations. ✅ A long reply ("tell me a scary story") blew past the plugin's default httpx read timeout →
httpx.ReadTimeout→ the turn died silently after 4 retries (the call just stops mid-conversation, no error to the user). Passtimeout=httpx.Timeout(connect=10.0, read=120.0, write=15.0, pool=10.0). Note the class isopenai.responses.LLM(acceptsreasoning+timeout);openai.LLMwould rejectreasoning. use_websocket=Falseon the responses LLM avoids the plugin's hand-rolled WS request serializer emittingmode: null, which the gateway 400s. The HTTP path is the safe default here.- 1.8.3
agent.py devremoved in-process auto-reload. ✅ The worker logs "in-process auto-reload has been removed from the Python CLI; uselk agent devfor hot-reload." Editing source files does NOT reload a running worker (contrary to the older 1.5.x assumption that edits drop in-flight calls) — restart the worker to pick up code changes. The web tier servesweb/public/*fresh per request, so UI edits land on the next page load with no restart. - CSS gradients can't be transitioned, so the Halloween cross-fade uses
@property. The studio registers palette color tokens with@propertyand transitions THOSE on<body>; gradient-painted surfaces cross-fade for ~420ms on enter/exit (one-shot; steady-state animation is transform/opacity only; all gated underprefers-reduced-motion). Transitioning the gradients directly won't animate.
- The allowlist stays closed on purpose. ElevenLabs v3 accepts free-form
[descriptive]tags, but nothing guarantees a given tag is performed instead of read aloud. Only tags thattools/expressive_bakeoff.pyprovedperformedfor the configured voice/model may be insrc/expressive_palette.py; anything else is dropped from audio and transcript (G13). Re-run the tool when the voice, model or plugin version changes. - "Performed" is judged by transcript and duration, not by ear. The bake-off sees only the words Deepgram hears and the clip length, which moves in steps of about 78 ms. Its noise bar comes from one pair of plain renderings per carrier, shared by every tag: 0.16 s in the full run, barely above the 0.15 s floor. It cannot hear timbre, loudness or mood, so a tag such as
whisper,softormenacingis judged by clip length alone; 18 of the 67 tags cleared the bar on one carrier only, six cleared it with no margin, and a borderline tag can flip between runs (whisperscame backno-audible-effectin a 4-synthesis smoke run). Its read-aloud check also matches whole words, so an inflected rendering ("whispered") would slip past it; none appeared in the recorded run.performedtherefore means "the voice did something other than read the word", not "did the intended thing": trustspoken-aloud=0, read the rest as "no tag failed", and finish with a listen (Halloween mode: Verify the voice). (docs/discovery/expressive-tags-contract.md, Limits) - The ElevenLabs API key may be scoped to speech. Account and model endpoints (
/v1/user/subscription,/v1/models) return 401missing_permissionswhile synthesis works, so quota cannot be read and tools must never call them. - A
.env.localmakes the live Lakebase test run.tests/test_integration_data_plane.pyis gated only onLAKEBASE_ENDPOINT, whichapp/agent.pyandapp/web_server.pyload from.env.localat import; the test then generates two real datasets and never cleans them up. With a.env.localpresent, runuv run --frozen pytest -q --ignore=tests/test_integration_data_plane.py. - Plain
uv runre-locksuv.lockto the internal proxy, and so does the commit hook.--no-syncdoes not stop it;--frozendoes. After eachgit commit,git restore -- uv.lockif the hook touched it. - The weakest tier model follows a long prompt least reliably. On the first live run Standard (
system.ai.gpt-5-nano) was the model that drifted: 90% of its cues were on the list (target 95%) and its stories ran to a median of 110 words. Premium and VIP failed differently (next two bullets). Wording fixed it, not a shorter palette: the rules now say "use only cues from the lists above" and "never end a sentence or a line with a cue", and the persona asks for a story of "four or five short sentences". Standard then scored 100% on the list, with no stage directions or stacked cues and a median story of 66 words. That pass rests on 2 samples per utterance, which is thin because several targets allow no misses (a stage direction, a stacked cue, a missing fact): re-runtools/expressive_llm_check.pywith its default 3 samples (69 calls) after any change to the prompt, a tier model, the palette or the voice. (src/agent_prompt.py; contract, "LLM compliance results") - "Answer ONLY from the tools" makes the stronger tier models refuse to tell a story. A story is not in the tools, so Premium (
system.ai.gpt-5-5) and VIP (system.ai.gpt-6-sol) answered "tell me a scary story" with a 17-24 word deflection ("I don't have a story from my tools"). It still met the density and compliance targets, with only 4-5 distinct cues, so a tier could pass on refusals. The persona now has one scoped carve-out: a spooky story is play, so the monster tells a made-up tale of four or five short sentences right away, with no tool, and keeps real orders, prices, policies and people out of it. Governance is unchanged and still the last block (G11), and business facts still come only from the tools (the "moon base" question still gets an "I don't have ..." abstention). Do not widen that line, do not drop it without re-running the LLM check, and listen to a story after any change to it. (src/agent_prompt.py,HALLOWEEN_PERSONA) gpt-6-soldoes not trust a lookup result pasted into the caller's message. The first harness run put each lookup result into the caller's turn as a note; VIP read it as the caller's own claim, answered "I don't have a verified order lookup", and missed the facts in all 9 factual replies. No cue tuning fixes that, and loosening "answer only from the tools" to make it pass would be the wrong fix. Evaluate tool-grounded behavior with real tool-call items, afunction_callplus itsfunction_call_output, the way the agent's tools answer; the gateway accepts them. (tools/expressive_llm_check.py,conversation())- Left alone, the models rarely laugh, and a soft rule is not enough for the small one. In the live run sound cues (laughs, gasps, sighs, breaths) were 1 to 12% of the cues; the models prefer moods and pauses. "Work in at least one sound" fixed Premium and VIP but not Standard (
gpt-5-nano), which also wrote[gasp]for[gasps], a cue the filter drops. A firm "Every story MUST include sounds: one at the scare, one on the last line", with the exact cues named, fixed it. The price is formulaic stories. - A model asked to laugh may write "ha ha", and the voice reads it aloud. The rule says laughs and noises only as cues from the "Breath and sounds" list, never as words or
*laughs*, and the live check counts laughter written out as words (none seen). The rule lives in the cue section, so it reaches only the ElevenLabs Halloween voice. gpt-5-nanoinvents near-miss cues and ends lines with one unless told not to. Standard wrotecalm(four times),gently,closingand a garbled[dramat ic pause], and twice ended a line with[pause], which then stacked with the next line's cue. The filter drops anything off the list (G13), so nothing is read aloud, but the reply loses the cue, and the tool counts the garbled one as a stage direction. Tell the model to use only listed cues and never to end a sentence or a line with one, then check it withtools/expressive_llm_check.py.