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

Contributing and reference4 of 12

Adding a tool

About 6 minutes to read

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

NameRequiredWhat it is
SCHEMAyesthe tool schema handed to the model
execute(args, ctx)yesasync def, without exception in this tree; returns a string, a ToolResult, or None
TOOLSETdefaults to "core"the family it belongs to
RISKdefaults to "read"read, write or exec
BUILT_INdefaults to FalseTrue only when the provider runs it server-side
ANNOUNCE / HEARTBEAT / COMPLETE / FAILby conventionwhat she says before, during, after, and on failure
EXPRESSIONSoptionalthe face she wears while it runs
check()optionalis 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:

python
"""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:

ToolsetReaches a companion (voice) turnTools
coreyesask_user, cancel_work, clarify, open_link, start_work, todo, view_capture
webyesweb_search, web_extract
memoryyesmemory_write, memory_recall, session_search, remember_image, recall_image
fileyesread_file, write_file, patch, search_files
terminalyesshell
codeyesexecute_code
skillsyesskill_list, skill_view
cronyescronjob
reportyesmake_report
discordyesthe nine discord_* tools
browserno — work mode onlyask_secret, get_credential, request_credential
mcpno — work mode onlymcp_find, mcp_install, activate_tools
subagentno — work mode onlydelegate

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).

python
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:

python
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

python
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:

python
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

bash
pytest api/tests -q

And a test of your own that fails without the tool and passes with it.