Contributing and reference4 of 12
Adding a tool
On this page
A tool is one Python module. It declares a schema, says which family it belongs to and how risky it
is, provides execute, and gets named in a list. There is no plugin manifest and no decorator.
Everything below was read out of api/src/kotoba/tools/ and checked against the live registry, which
holds 40 tools at the time of writing.
Where the file goes
api/src/kotoba/tools/builtin/ mostly conversational — memory, skills, the web, asking a question
api/src/kotoba/tools/action/ capabilities that act on the system — files, shell, code, MCP, subagents
This is a registration split, not a safety one. What a tool is allowed to do comes from its
TOOLSET and RISK, never from its folder. The two directories differ only in how they register:
builtin modules are imported by name inside registry.discover(), action modules are listed in
tools/action/__init__.py's ACTION_TOOLS.
The folder really does not decide. patch sits in action/ and belongs to the companion-safe file
family; activate_tools sits in builtin/ and belongs to the work-only mcp family. Pick the folder
that matches what the module does, then set TOOLSET and RISK deliberately.
The module surface
| Name | Required | What it is |
|---|---|---|
SCHEMA | yes | the tool schema handed to the model |
execute(args, ctx) | yes | async def, without exception in this tree; returns a string, a ToolResult, or None |
TOOLSET | defaults to "core" | the family it belongs to |
RISK | defaults to "read" | read, write or exec |
BUILT_IN | defaults to False | True only when the provider runs it server-side |
ANNOUNCE / HEARTBEAT / COMPLETE / FAIL | by convention | what she says before, during, after, and on failure |
EXPRESSIONS | optional | the face she wears while it runs |
check() | optional | is this tool usable right now |
Anything absent is filled from registry._spec_from_module. A module with a SCHEMA that is not a
dict is skipped silently — it is not treated as a tool at all.
A worked outline
api/src/kotoba/tools/action/search_files.py is a good one to copy. Its shape, trimmed:
"""search_files — find text across files in the jailed working dir (ripgrep, walk fallback)."""
from __future__ import annotations
from kotoba.core.path_security import PathSecurityError, validate_within_dir
SCHEMA = {
"type": "function",
"name": "search_files",
"description": "Search the working folder for a text pattern and return matching file:line results.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to search for. …"},
"path": {"type": "string", "description": "Subfolder to search (default: whole workspace)."},
},
"required": ["query"],
"additionalProperties": False,
},
}
BUILT_IN = False
TOOLSET = "file"
RISK = "read"
ANNOUNCE = "Let me search through the files~"
HEARTBEAT = ["Looking everywhere...", "Almost done scanning..."]
COMPLETE = "Here's what I found:"
FAIL = "I couldn't find anything matching that."
EXPRESSIONS = {"focus": "thinking", "done": "happy", "fail": "confused"}
async def execute(args: dict, ctx) -> str:
...
Four things that module does that yours should too:
- It validates the path against the jail (
validate_within_dir) and returns a sentence, not an exception, when the path is outside it. - It never returns a technical error.
"That folder is outside my workspace."is what the model — and therefore the user — sees. - It bounds its own work, with a shorter budget on a live voice turn than on a background job.
- It says when a result is partial. A list cut at 80 matches says it was cut, because 200 matches returned as 80 reads exactly like 80 matches.
TOOLSET — the family
Every registered tool belongs to one. The families in the tree today, and whether they reach a voice turn:
| Toolset | Reaches a companion (voice) turn | Tools |
|---|---|---|
core | yes | ask_user, cancel_work, clarify, open_link, start_work, todo, view_capture |
web | yes | web_search, web_extract |
memory | yes | memory_write, memory_recall, session_search, remember_image, recall_image |
file | yes | read_file, write_file, patch, search_files |
terminal | yes | shell |
code | yes | execute_code |
skills | yes | skill_list, skill_view |
cron | yes | cronjob |
report | yes | make_report |
discord | yes | the nine discord_* tools |
browser | no — work mode only | ask_secret, get_credential, request_credential |
mcp | no — work mode only | mcp_find, mcp_install, activate_tools |
subagent | no — work mode only | delegate |
That table is the static registry, taken from a live import with no MCP server connected. The
browser_* tools themselves are not in it: they arrive at runtime from an MCP server and register
under the toolset mcp:browser. The three in-tree browser tools are the credential helpers that only
make sense while driving a website.
COMPANION_TOOLSETS in tools/registry.py is that list. A family outside it is never offered during a
live conversation, only inside work mode.
A toolset is also the unit the user switches off in Settings. For a built-in family that is a filter; for a plugin family it also loads and unloads the third-party code.
RISK — read, write or exec
Those are the three values in use. It drives mode gating, approval and the sandbox. The ToolSpec
docstring also names a fourth, network; no tool in the tree uses it, so treat it as unimplemented
rather than as a choice.
RISK is not the approval gate on its own. Anything that runs a command on the machine goes through
the approval layer as well.
check() — is it usable right now
Optional. Return False and the tool disappears from what the model is offered.
Eleven modules define one: the nine discord_* tools (which ask "is the bot process running"), plus
shell and execute_code (which ask whether an execution backend is available).
def check() -> bool:
from kotoba.core.sandbox import sandbox_available_sync
return sandbox_available_sync()
Three constraints, all from registry._passes_check:
- It runs on every loop iteration, inside the event loop. Keep it cheap and bounded.
- The result is cached for 30 seconds.
- If it raises, the last known answer is kept rather than flapping the tool in and out of the schema. A first raise with nothing cached counts as unavailable.
Voice patterns
Four attributes, and they are what she says out loud:
ANNOUNCE = "Let me search through the files~" # before
HEARTBEAT = ["Looking everywhere...", "Almost done..."] # while it runs
COMPLETE = "Here's what I found:" # after
FAIL = "I couldn't find anything matching that." # on failure
core/voice_patterns.py builds the runtime dict from these, then lets the ## Tool voice patterns
section of the personality file override any of them — partially is fine. The personality file wins.
They are optional at the level of the code — a minimal plugin is SCHEMA plus execute, and
build_soul_patterns uses getattr with defaults so an unguarded access cannot crash the boot. They
are not optional at the level of the product: a tool without them goes silent mid-turn.
EXPRESSIONS — the face
EXPRESSIONS = {"focus": "thinking", "done": "happy", "fail": "confused"}
24 of the 40 tools declare one. Only focus and fail are ever emitted. Success is deliberately
silent: the face for a finished turn comes from the tag she actually wrote, so voice, browser and
terminal read one source instead of three that can disagree. Tools still declare done; nothing reads
it, and adding an emission for it would fight the tag a moment later. Do not "fix" that.
fail is the asymmetry with a reason: a stumble has to show mid-turn, before she has said anything.
Registering it
This is the step that gets forgotten, and it fails silently. Importing the module is not registering it.
For an action tool, add it in two places in api/src/kotoba/tools/action/__init__.py — the import
block and the ACTION_TOOLS list:
ACTION_TOOLS: list = [
file_read, file_write, patch, search_files, shell, execute_code, ...,
your_tool,
]
For a built-in, add it in two places in registry.discover() — the import from
kotoba.tools.builtin and the list passed to _register_modules.
Both packages import by module object, never by a dynamically built name, so no untrusted string can load code.
Action-tool discovery fails loud: if the import raises, the process refuses to start rather than running with half a toolset. Plugin discovery is the opposite — it logs and continues.
api/tests/test_no_tool_module_is_left_unregistered.py checks the file system against the registry, so
a forgotten list entry is a red test rather than a tool nobody can call.
If you write dispatch code
Use registry.dispatchable(name), never registry.registry().get(name). schemas_for stops
offering a tool from a disabled family, but the model keeps every name it saw earlier in the same
turn, and a plain dict lookup reads nothing about which families are switched off. dispatchable
returns None for a disabled family. It deliberately skips the 30-second check() cache, so a switch
the user just flipped does not keep lying for half a minute.
Before you open the pull request
pytest api/tests -q
And a test of your own that fails without the tool and passes with it.
