Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Architecture3 of 10

Register, channel, transport — three axes, and the two that get confused

About 3 minutes to read

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.

AxisQuestion it answersWhere it lives
registerDoes she produce speech or writing?agentic_loop(register=…)soul/prompt.py, loop._heartbeat_lines
channelHow long may a card stay on this surface?agentic_loop(channel=…)ToolContext.channelinteraction.approval_timeout
transportIs 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 on channel would 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

Surfacechannelregistertransport 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"defaultexplicitly 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:

python
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. sendUserActivity does 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.