Voice and avatar2 of 9
The two voice modes
On this page
voice_mode is one setting with two values, local and agent. The default is local.
api/src/kotoba/core/app_settings.py
"voice_mode": ("KOTOBA_VOICE_MODE", "local", _one_of("agent", "local"))
Change it in Settings → Personality → Voice mode, which is labelled applies to the next call,
or with KOTOBA_VOICE_MODE. Both modes need an ElevenLabs key.
local — the default
Your browser opens a WebSocket to your own backend. Your backend talks to ElevenLabs.
browser ⇄ ws://<backend>/api/voice/<session_id> ⇄ backend ──outbound──> ElevenLabs
Microphone audio goes up the socket as raw PCM. The backend feeds it to ElevenLabs realtime speech-to-text, runs the turn, and streams synthesized audio back down the same socket.
What it needs from you: an ElevenLabs key. That is the whole list. Nothing has to be reachable from the internet, there is no agent to create, and the ElevenLabs Agent ID field in Settings is unused.
agent — ElevenLabs Conversational AI
An agent that lives in your ElevenLabs account holds the call. It does the listening and the speaking, and for the thinking it calls back into your backend as a Custom LLM.
browser ⇄ ElevenLabs agent (WebRTC) ──> https://<your-public-backend>/v1/chat/completions
What it needs from you, per the in-app guide in components/panels/SettingsPanel.tsx:
- An agent created at elevenlabs.io → Conversational AI → Agents.
- That agent's LLM set to Custom LLM, with
Server URL
https://<your-kotoba-backend>/v1/chat/completionsand API Key = yourKOTOBA_API_KEY. - A public URL for your backend — a tunnel, or a deployed server. This is the real cost of the mode.
- The agent's voice id pasted into Settings → Personality → Voice ID.
- The agent id (
agent_xxxxxxxx) pasted into Settings → ElevenLabs Agent ID.
If it will not connect, the two things to re-check are the agent id and whether the agent's Custom LLM URL and API key match this backend.
Why local exists at all
Because the two directions are not symmetric.
- ElevenLabs' outbound APIs — realtime speech-to-text, text-to-speech — are things your machine dials out to. A laptop behind a router can do that.
- An ElevenLabs agent in custom-LLM mode dials in. It has to reach
https://…/v1/chat/completionsfrom ElevenLabs' network, and it cannot reachlocalhost.
So agent mode requires a public address and local mode does not. That is the entire reason the
local path was written, and it is stated at the top of core/voice/config.py:
All traffic is OUTBOUND (local backend -> EL), which is what removes the public-URL/tunnel requirement of the old Conversational-AI agent.
voice_mode is intent, not fact
Worth knowing before you read anything else in the code: voice_mode says what you meant to set
up. It does not say who is on the other end of a given turn.
/v1/chat/completions is guarded by its own bearer and nothing else. If your tunnel is up, an
ElevenLabs agent turn arrives there whether voice_mode says local or agent. So the rule in the
code is blunt, and it is written into the tool context itself: nothing may relax a limit on
voice_mode — that would relax it for exactly the caller the limit was written for.
The fact travels per turn instead. /v1's producer is the only code that knows an agent is on the
other end, and it marks its own task; a ContextVar in api/src/kotoba/core/transport.py carries
the mark down through everything that turn spawns.
voice_mode is still read in two places. core/loop.py uses it to decide whether a tool run gets a
wordless filler hum, and — the one that matters — app_settings.audio_tags_enabled() consults it when
deciding whether audio tags are asked for at all. Both are choices about how she sounds, not limits.
One consequence you can see: background work gets a different default time budget depending on who
holds the call. core/work_runner.py picks it from the mark, not from the setting:
default = WORK_TIMEOUT_EL_BOUND_SECONDS (1500 s) if an ElevenLabs agent holds this turn
WORK_TIMEOUT_SECONDS (3600 s) otherwise
1500 sits under EL_MAX_DURATION_SECONDS (1800 s), which the code records as ElevenLabs' own cap on
one agent conversation, with headroom left for her to announce the result before the call dies. Off
that transport — local mode, or the terminal, which is neither voice mode — nothing external is
holding a clock, so the real bound is the iteration and tool-call caps and the wall clock is only a
backstop. The mark is captured when the job is created, because the runner is detached and the
setting stays changeable while it runs. work_timeout in Settings overrides either default.
The /v1 bearer
api/src/kotoba/server.py
if not api_key and not web_pw:
return # the endpoint is open
/v1/chat/completions accepts KOTOBA_API_KEY or KOTOBA_WEB_PASSWORD as its bearer. If
neither is set the endpoint is open. That is deliberate for a local install where the port is not
reachable, but if you expose the backend for agent mode, set one of them before you expose it.
