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

Voice and avatar6 of 9

What she never says aloud

About 5 minutes to read

On this page

A voice cannot read a link. It cannot read a code block, a file path, a leaked tool call or a citation marker. So the text on its way to speech goes through a chain of filters that rewrite it, and what you see in the transcript is the rewritten version.

The chain is built in three places from the same five stages, in this order (api/src/kotoba/core/stream.py):

ToolCallLeakFilter → CodeFenceFilter → UrlFilter → AudioTagFilter → ForbiddenPhraseFilter

Everything below is measured by feeding the real chain in 7-character chunks and flushing.

URLs and file paths

"[happily] Lo tienes en https://example.com/report ahora mismo."
      ->  "[happily] Lo tienes en su web oficial ahora mismo."

"Guardado en /home/user/informe.md, listo."
      ->  "Guardado en tus archivos, listo."

A link is not read out and it is not simply deleted either. Deleting it beheads the sentence: "the address is —" and silence. So when the removed span was completing a copula or a preposition, a stand-in phrase takes its place — "su web oficial" / "its official site", "tus archivos" / "your files", chosen by the language of the words before it.

Elsewhere a plain drop reads fine, and that is what happens:

"lol eso es genial btw, mira README.md."   ->  " eso es genial, mira."

A whole parenthetical around a link is removed as one aside, because removing only the URL leaves the orphan (Reuters:. The filter releases text only up to the last whitespace so a URL is always seen whole — otherwise the sentence splitter downstream would cut it at its dots and miss it.

The prompt tells her the same thing from the other side: name the site in words, offer to open it on screen with open_link, or offer to leave the link in the user's files. Never type the address, "not even when the user explicitly asks for the exact address, with the https and everything".

Code

"Mira el codigo: ```python\nprint('hola')\n``` y ya esta."
      ->  "Mira el codigo: y ya esta."

Fenced blocks are suppressed and inline backticks are dropped, keeping the word and losing the backtick noise. This is a net, not the fix: the real answer is work mode, where she writes code to a file instead of dictating it.

At a tool-call boundary the fence filter is deliberately not flushed — flushing clears its "inside a fence" state, an open block reopens as prose, and the code gets read aloud.

Leaked tool calls

'Claro {"query":"tokio"}to=functions.web_search ya lo miro.'
      ->  'Claro ya lo miro.'

Models sometimes emit raw tool-call syntax into the text channel. It was captured live in 3 of about 19 runs and read aloud, JSON and all. It cannot be prevented at the source, so the filter is the net.

It is anchored on the recipient, which is what keeps it from eating ordinary sentences: a JSON object or a CJK run is only removed when it is glued to a to=functions.…. Text with no trigger in it streams through with no added latency, and a fragment that could still grow into a leak is held back rather than spoken — a half-arrived to=functions. must never reach the engine.

A false claim about the web

"I can't access that page right now. But here is the rest."
      ->  "[nervous] Ah, that link's being shy and won't open... no worries,
           I'll find it elsewhere!  But here is the rest."

The replacement is drawn at random from four written lines, so the same refusal does not come back word for word every time. What is fixed is that the false claim never survives.

The first time she wrongly claims she cannot reach a page, the claim becomes a playful in-character line; later ones in the same turn are dropped. It takes the whole sentence, not just the claim, because cutting the claim alone left the lead-in — "Lo siento, " — and she spoke the fragment.

Two guards keep this narrow. The sentence must also be about the web, or a refusal that is true and prompt-mandated gets rewritten: "I can't connect Notion, press Sign in in Settings" would lose its instruction and gain a promise of a search the prompt forbids. And the canned line is English, so a claim she made in another language is dropped rather than answered in two languages at once.

Everything else

The URL stage also removes two things that are not links. Citation markers, which OpenAI web search injects as private-use-area runs and which a voice would read as "cite turn zero search zero". And anything a page could have smuggled in as text — bidi overrides, C0/C1 control characters — since the outgoing stream is a caption surface Kotoba does not own.

The last stage removes chat shorthand: eighteen words, lol lmao lmfao rofl omg omfg btw idk imo imho fyi tbh ngl smh jk wtf xd uwu, matched only as whole words. A voice reads them out as "el-oh-el". Laughter is deliberately kept — haha and jaja voice fine. It also removes emoji, ~, **, __, markdown bullets, numbered list markers and headings.

LaTeX is unwrapped rather than deleted:

"La formula es \beta \cdot I_B = I_C."   ->  "La formula es beta I B = I C."

That is a net too, and an imperfect one — the = survives. The prompt carries the real rule, and it is strict: never write an equation line, never use single letters as variables, say the relationship in words.

Three things worth knowing about the result

A reply the filters emptied is not silence. If the model really produced text and the chain left nothing, she speaks one safe in-character line instead: "One sec — let me put that a better way for you." It bypasses the chain, because it is Kotoba's own line and there is nothing in it to strip.

Captions are filtered too, but differently. The transcript in the browser is fed by its own AudioTagFilter(keep_valid=False), so you see her words without the bracket cues even while the engine is performing them.

The terminal does not use this chain. These five stages are the spoken chain, and they are identical in three places: the local voice socket, /v1/chat/completions, and Discord's spoken turn. A written surface has different needs, so cli/session.py builds a shorter one — CitationFilter → ToolCallLeakFilter → AudioTagFilter → HeadTagFilter — with no code-fence, URL or false-claim stage. Code blocks and links are things a terminal should show.

The bracket rule differs with it. A voice must not read a bracket it cannot perform, so it drops every one. A terminal shows markdown, where most brackets are prose — [Forbes](url) is a link and arr[0] an index — so there a bracket counts as a tag only when every word in it is one, nothing is glued to its left, and no ( follows.