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

Her character4 of 8

Tool voice patterns

About 8 minutes to read

On this page

A tool takes time. If she went silent for it, a live call would sound like a hang. So each tool has four canned lines — one before it runs, some while it runs, one when it lands, one when it does not.

They live in ## Tool voice patterns in the soul file, and they are the one section of that file that does not go into the system prompt. They are read into a runtime table and spoken by the loop directly, without asking the model anything.

Read the exceptions before you spend an evening editing them. On the configuration Kotoba ships with, most of these lines never play.


The shape

yaml
shell:
  before: "Let me run that real quick."
  heartbeat: ["Working on it...", "Still running...", "Almost there..."]
  after: "Okay — here's how that went:"
  fail: "That command didn't go through — want me to try another way?"

A tool name at the left margin ending in :, then indented before / heartbeat / after / fail keys. heartbeat is a JSON list; the other three are strings, with or without quotes. Any other key on an indented line is ignored.

A heartbeat that is not valid JSON becomes an empty list, silently. Measured:

written                          parsed as
------------------------------   ---------------------
mytool:
  before: "Hi."                  'Hi.'
  heartbeat: ["a", "b"]          ['a', 'b']
  after: Unquoted works too      'Unquoted works too'
  fail: 'single quotes'          'single quotes'
  colour: ignored                dropped — unknown key
badlist:
  heartbeat: not-json            []  — no error, no beat, ever

The shipped file defines 27 tools this way. Measured with parse_tool_patterns:

web_search  web_extract  memory_write  session_search  todo  clarify  memory_recall
read_file  write_file  patch  search_files  shell  execute_code  mcp_install  cronjob
make_report  ask_user  delegate
discord_read_history  discord_remember_person  discord_people  discord_send_file
discord_guild_read  discord_act  discord_plan  discord_apply_plan  discord_voice

The built-in registry holds 40 tools, the nine Discord ones included whether or not the bot is running. The 13 with no entry in the soul file fall back to their module's own lines:

activate_tools  ask_secret  cancel_work  get_credential  mcp_find  open_link
recall_image  remember_image  request_credential  skill_list  skill_view
start_work  view_capture

Where a line comes from

core/loop.py:_voice_for() resolves in three steps:

  1. The soul file's override for that tool, if there is one.
  2. The tool module's own ANNOUNCE / HEARTBEAT / COMPLETE / FAIL constants.
  3. _GENERIC_VOICE — used by MCP tools and plugins, which have no module of ours:
python
_GENERIC_VOICE = {
    "before": "Okay, let me take care of that...",
    "heartbeat": ["Working on it...", "Almost there..."],
    "after": "Done! Here's what I found:",
    "fail": "Hmm, that didn't work — let me try another way.",
}

Overrides are per key, not per tool. build_soul_patterns starts from every module's four constants and then dict.update()s your keys over them, so writing only fail: for a tool keeps its other three module lines. The soul file says so in a comment, and the code matches.

Overriding a tool the registry does not have is harmless: the entry is created and nothing ever reads it.


What each phase means

KeyWhen it is saidGuaranteed?
beforeThe moment the loop dispatches the call, before the tool runsOnce per tool name per turn, and only when narration is on
heartbeatFirst at 9 s, then every 21 s while the tool is still runningOnly when narration is on; the list is walked in order and the last entry repeats
afterWhen the call ends okOnly when narration is on
failWhen the call ends failedOnly when narration is on

The cadence constants are in core/loop.py:

python
_HEARTBEAT_FIRST = 9.0
_HEARTBEAT_EVERY = 21.0
_HEARTBEAT_TICK  = 3.0

_HEARTBEAT_TICK is the polling granularity, so the first beat lands at the first 3-second boundary past 9 s. The docstring is blunt about where the numbers come from: "The cadence itself is taste, not protocol."

after is not "the tool finished"

A call has four possible endings, and only two of them speak. From _after_line:

OutcomeLine spoken
okafter
failedfail
refused — you declined the approval cardnothing
pending — deferred, waiting on a cardnothing

The reason is stated in the code: refused and pending "are not failures of hers, so a fail: pattern would blame the tool for the user's decision". Each of those endings already returns a first-person sentence she relays herself, in your language.

And ok is decided by the process's exit code, not by whether the tool handed back a string. A shell that exits 2 returns perfectly good text, and used to get "Okay — here's how that went".

Heartbeats pause for you

If the turn is blocked on a card you have to answer — an approval, a credential request, an OAuth sign-in — the beat clock resets instead of firing. _asking_human() asks core.interaction.has_pending(), which is registered by exactly the blocking cards. The reason: "the card already says everything, and narrating at it read as work."


The three exceptions — and why your edits may be inaudible

1. A reasoning model says it herself

If the answering model reasons, the canned English is suppressed for before and after. She narrates her own tool use, in your language, because the prompt asks her to:

"BEFORE A TOOL, ONE SHORT LINE. When you are about to call a tool, say what you are about to do FIRST, then call it — in the user's language, in your own words, one sentence."

The gate is is_reasoning_model(role="companion"), which is true when the configured model supports reasoning and reasoning_effort is set and not off.

Measured on the shipped defaults:

model            = 'gpt-5.6-luna'
reasoning_effort = 'low'
is_reasoning_model(role='companion') = True

So out of the box, before: and after: from your soul file are not spoken. Editing them changes nothing you can hear.

To hear them, either turn reasoning off —

/set reasoning_effort off

— or point her at a model that does not reason. The OpenAI rule is the regex ^(o1|o3|o4)|^gpt-5|gpt-5, so gpt-4o-mini and gpt-4.1 are non-reasoning and gpt-5.* and the o series are not. Measured:

gpt-5.6-luna   reasoning
o3-mini        reasoning
gpt-4o-mini    not
gpt-4.1        not

kotoba doctor tells you which side you are on. Its reasoning row says, in so many words, either:

reasoning   low on a companion turn, off in work mode — she narrates her own tool use in your language

or, when nothing reasons:

reasoning   off — she speaks the written narration lines, which are English whatever language you
            are in. Set reasoning_effort to `low` to change both.

The second is the configuration in which your soul-file lines are audible.

heartbeat is treated differently, because silence during a long tool is not a duplicate of anything. Instead of her lines you get a wordless hum — four fillers from core/loop.py:

python
_NEUTRAL_FILLER = ("Mmm...", "Hmm...", "Mm...", "Hmmm...")

They are wordless on purpose, so nothing English lands in a Spanish turn. Their trailing dots are ASCII rather than , because the spoken stream splits sentences on [.!?] and a real ellipsis would sit in the buffer unreleased — which is exactly the silence the hum exists to fill.

2. MCP tools always hum

A tool from a connected MCP server (toolset starting mcp:) gets the hum instead of words no matter what model is answering. It has no authored lines to say, and _GENERIC_VOICE in English is the thing being avoided.

3. A written register gets nothing at all

register is what she produces — spoken or written. It is not channel. A typed message in the browser is channel="text" and register="voice": it is still spoken, and it still gets the canned lines.

The terminal is register="text", and there the loop switches all narration off:

python
narrate_tools = narrate_tools and register == "voice"

with the reason above it:

"The canned before/after lines exist so a VOICE does not go silent under a slow tool. Written into a chat they are English sentences in front of the answer, in a conversation that was in another language: filler with nothing to fill, and the terminal draws a spinner anyway."

So in kotoba you see a spinner, never "Let me run that real quick."

The gate, measured

_heartbeat_lines() alone, with a two-line pattern, across every combination:

Model reasonsvoice_moderegisterMCPHeartbeat
nolocalvoicenothe tool's lines
nolocalvoiceyesthe hum
noagentvoiceyesnothing
yeslocalvoiceeitherthe hum
yesagentvoiceeithernothing
yesanytexteithernothing
noanytextno(the tool's lines — but see below)

The last row is a detail worth being exact about: _heartbeat_lines would return them, and then the loop's outer gate suppresses them anyway, because ctx.narrate_tools is false for a written register. Net effect on a terminal: silence, in every configuration.

agent voice mode is silent for reasoning models because ElevenLabs' own soft-timeout filler covers the gap there. In local mode nothing does — measured, a 10-second tool produced zero audio for 10.09 seconds, which is why the hum exists at all.


The block that never plays

web_search is labelled INERT in the soul file, and it is:

"It is an OpenAI BUILT-IN: the search runs server-side and comes back as an output item, not as a function call, so it never enters the branch of the loop that says before, hums, or lands after. A built-in search is silent by construction."

Its four lines are kept and labelled so nobody spends an afternoon wondering why her searches are quiet. What speaks over a search is her own pre-tool line, which the prompt asks for.


Editing them

  1. Find your soul file (kotoba doctor prints it).
  2. Edit under ## Tool voice patterns. Keep the tool name flush left with a trailing colon and the four keys indented.
  3. Restart. build_soul_patterns runs once, inside core.engine.start().

Two constraints the tests enforce on the lines that ship, and that you should keep for your own:

  • Every tool must have a non-empty fail. test_personality_contract.py asserts it for every registered tool. A failure is the one outcome that must never be silence or a raw error.
  • A fail line must survive the forbidden-phrase filter unchanged. That filter rewrites false "I can't access the web" claims, and it once mangled "I couldn't save that just now" into a random web quip. The test feeds every authored line through the real filter and asserts it comes back byte for byte.

Also pinned: no authored line may contain typed shorthand (lol, omg, btw, idk), because TTS reads it letter by letter; and no fail line may leak server__, Traceback, errno, stderr or None.

clarify shows the supported way to say nothing: empty strings and an empty list, because the question the tool returns is the utterance.

yaml
clarify:
  before: ""
  heartbeat: []
  after: ""
  fail: "Let me ask that a different way."