Her character4 of 8
Tool voice patterns
On this page
- The shape
- Where a line comes from
- What each phase means
- after is not "the tool finished"
- Heartbeats pause for you
- The three exceptions — and why your edits may be inaudible
- 1. A reasoning model says it herself
- 2. MCP tools always hum
- 3. A written register gets nothing at all
- The gate, measured
- The block that never plays
- Editing them
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
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:
- The soul file's override for that tool, if there is one.
- The tool module's own
ANNOUNCE/HEARTBEAT/COMPLETE/FAILconstants. _GENERIC_VOICE— used by MCP tools and plugins, which have no module of ours:
_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
| Key | When it is said | Guaranteed? |
|---|---|---|
before | The moment the loop dispatches the call, before the tool runs | Once per tool name per turn, and only when narration is on |
heartbeat | First at 9 s, then every 21 s while the tool is still running | Only when narration is on; the list is walked in order and the last entry repeats |
after | When the call ends ok | Only when narration is on |
fail | When the call ends failed | Only when narration is on |
The cadence constants are in core/loop.py:
_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:
| Outcome | Line spoken |
|---|---|
ok | after |
failed | fail |
refused — you declined the approval card | nothing |
pending — deferred, waiting on a card | nothing |
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:
_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:
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 reasons | voice_mode | register | MCP | Heartbeat |
|---|---|---|---|---|
| no | local | voice | no | the tool's lines |
| no | local | voice | yes | the hum |
| no | agent | voice | yes | nothing |
| yes | local | voice | either | the hum |
| yes | agent | voice | either | nothing |
| yes | any | text | either | nothing |
| no | any | text | no | (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 landsafter. 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
- Find your soul file (
kotoba doctorprints it). - Edit under
## Tool voice patterns. Keep the tool name flush left with a trailing colon and the four keys indented. - Restart.
build_soul_patternsruns once, insidecore.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.pyasserts it for every registered tool. A failure is the one outcome that must never be silence or a raw error. - A
failline 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.
clarify:
before: ""
heartbeat: []
after: ""
fail: "Let me ask that a different way."
