Architecture3 of 10
Register, channel, transport — three axes, and the two that get confused
On this page
Three separate facts travel with a turn. They are frequently collapsed into one another, and each collapse has a name in the bug history.
| Axis | Question it answers | Where it lives |
|---|---|---|
register | Does she produce speech or writing? | agentic_loop(register=…) → soul/prompt.py, loop._heartbeat_lines |
channel | How long may a card stay on this surface? | agentic_loop(channel=…) → ToolContext.channel → interaction.approval_timeout |
| transport | Is an ElevenLabs agent holding this turn's clock open? | core/transport.py ContextVar → ToolContext.el_call_bound |
register is not channel
register is what she writes. channel is the approval clock.
A message typed into the browser during a live call is channel="text" — because nothing is timing
that turn out, so a person gets the roomy 180-second window to read a gated command — and
register="voice", because she still speaks the answer aloud.
Two things read register, and both would be wrong if they read channel instead:
soul/prompt.build_system_prompt. The written register gets markdown, fences, URLs and digits; the spoken one gets none of them. Keying that onchannelwould make the browser read markdown asterisks and bullet points aloud on every typed turn.loop._heartbeat_lines. The spoken register gets phrases (or, on a reasoning model, a wordless hum); the written register gets nothing at all, because the terminal draws a spinner instead.
The voice prompt is frozen byte-for-byte against a snapshot
(api/tests/test_register_voice_unchanged.py) precisely so that text work cannot leak into it. The
written register's extra rules ride on blocks that expand to nothing for voice.
Two things read channel:
interaction.approval_timeout(channel, el_agent=…)— 180 s for"text"; for voice, 60 s when the turn is known not to be an agent's and 25 s otherwise.loop._tool_budget— an approval-gated tool's compute budget must outlive the approval window its card will actually get, or the loop cancels the tool mid-wait and abandons the card on the user's screen. Because the window depends on the transport as well as the channel, both are passed down, never the resulting number.
What each surface sets
| Surface | channel | register | transport mark |
|---|---|---|---|
ElevenLabs agent → /v1 | "voice" (the default) | "voice" (the default) | set |
| Browser, local voice WS | "text" if typed, else "voice" | "voice" | not set |
| Terminal | "text" | "text" | not set |
| Discord text channel | "text" | "text" | not set |
| Discord voice channel | "voice" | "voice" | not set |
| Background work runner | "text" | default | explicitly dropped |
Transport is the third axis, and it is not voice_mode
voice_mode is a declaration of intent stored in settings.yaml. /v1 is guarded by its own
bearer and nothing else, so an ElevenLabs agent turn still arrives there with voice_mode=local
set. Any if voice_mode == "local" that relaxes a limit relaxes it for exactly the caller it was
written to exclude.
So the fact travels per turn, not per setting. core/transport.py holds one ContextVar, and
only /v1's producer may set it:
with transport.el_call_turn():
await agentic_loop(...)
asyncio.create_task copies the context, so everything the turn spawns inherits the mark.
ToolContext reads it once at construction (el_call_bound: bool = field(default_factory=…)) and
passes it explicitly to child contexts. interaction.el_agent_turn(ctx) is the one name anything
else reads it by; it returns None when nobody said, which is a bare test double and buys nothing —
both unknowns fall to the tight side.
The module is named for the constraint, not the setting, because the terminal is neither voice
mode and must read False.
Three numbers live in core/transport.py for the same reason — they had been written down in three
places with three different values:
EL_MAX_DURATION_SECONDS = 1800— ElevenLabs' hard cap on one agent conversation.sendUserActivitydoes not reset it, so the frontend's work keepalive cannot extend it either.WORK_TIMEOUT_SECONDS = 3600.0— compute-seconds a background job gets when nothing external holds a clock over it.WORK_TIMEOUT_EL_BOUND_SECONDS = 1500.0— the same budget when the job was born inside an ElevenLabs call: under the 1800 with headroom for the announce turn.
What the transport decides, and what it may not, is Inline versus deferred.
