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

Plugins2 of 7

Your first plugin

About 4 minutes to read

On this page

One file, one tool, no packaging. At the end of this page she can fetch the front page of Hacker News, announce it in her own voice, and show it in the work panel.

Everything below was written, loaded and run before it was published here.


1. Make the directory

Nothing creates it for you — the loader returns immediately if it is missing (core/plugins.py, _load_folder_plugins).

bash
mkdir -p ~/.kotoba/plugins/hackernews

The directory name is the plugin name. It becomes the toolset (plugin:hackernews), the row in Settings, and the key you switch on and off. Pick something you will recognise in a list.


2. Write the tool

~/.kotoba/plugins/hackernews/top.py:

python
"""hn_top — the current front page of Hacker News."""
from __future__ import annotations

import asyncio

SCHEMA = {
    "type": "function",
    "name": "hn_top",
    "description": "Get the current top stories on Hacker News, with their titles and links.",
    "parameters": {
        "type": "object",
        "properties": {
            "count": {
                "type": "integer",
                "description": "How many stories to return (1-10). Default 5.",
            },
        },
        "required": [],
        "additionalProperties": False,
    },
}

BUILT_IN = False
TOOLSET = "news"
RISK = "network"
TIMEOUT = 45

ANNOUNCE = "Let me pull up the front page..."
HEARTBEAT = ["Still fetching the stories..."]
COMPLETE = "Here's what's on top right now:"
FAIL = "Hacker News didn't answer just now — want me to try again in a moment?"
EXPRESSIONS = {"focus": "thinking", "fail": "confused"}

_API = "https://hacker-news.firebaseio.com/v0"


def check() -> bool:
    """Whether the tool is usable right now. Called often and cached ~30s, so keep it cheap: no
    network, no disk scan."""
    try:
        import httpx  # noqa: F401
    except ImportError:
        return False
    return True


async def _story(client, story_id: int) -> str | None:
    r = await client.get(f"{_API}/item/{story_id}.json")
    r.raise_for_status()
    item = r.json() or {}
    title = item.get("title")
    if not title:
        return None
    url = item.get("url") or f"https://news.ycombinator.com/item?id={story_id}"
    return f"- {title}{url} ({item.get('score', 0)} points)"


async def execute(args: dict, ctx) -> str:
    import httpx

    try:
        count = int((args or {}).get("count") or 5)
    except (TypeError, ValueError):
        count = 5
    count = max(1, min(10, count))

    try:
        async with httpx.AsyncClient(timeout=15) as client:
            r = await client.get(f"{_API}/topstories.json")
            r.raise_for_status()
            ids = (r.json() or [])[:count]
            lines = await asyncio.gather(*(_story(client, i) for i in ids))
    except Exception as e:
        # Never let the traceback reach her: the loop turns an exception into "The tool errored."
        # and logs nothing, so a sentence she can read out is strictly better than raising.
        return f"I couldn't reach Hacker News ({type(e).__name__}). Nothing was fetched."

    stories = [line for line in lines if line]
    if not stories:
        return "Hacker News answered, but the front page came back empty."
    return "\n".join(stories)

httpx is a hard dependency of kotoba-companion (api/pyproject.toml), so it is already in the environment — no extra install.

What each piece is doing

PieceWhy it is there
SCHEMAThe only thing the model sees. The description is your entire user interface: she decides whether to call the tool from that sentence.
SCHEMA["name"]The tool's name in the registry. Missing? The loader falls back to the file's stem.
TOOLSET = "news"Ignored. The loader replaces it with plugin:hackernews. Written here only because it is part of the module shape; you can leave it out.
RISK = "network"Makes the call an action: it draws a step row in the work panel and writes an audit-log row. read would draw only a status line. See the contract.
TIMEOUT = 45Your compute budget in seconds. Without it you get 30 (core/loop.TOOL_TIMEOUT). Overrun returns "The tool timed out."
check()Answered before every offer, cached ~30 seconds. Return False and the tool stays registered but is not offered to the model.
ANNOUNCE / HEARTBEAT / COMPLETE / FAILWhat she says before, during, after, and on failure. HEARTBEAT fires first at about 9 s, then about every 21 s (core/loop._HEARTBEAT_FIRST / _HEARTBEAT_EVERY). Leave them out and she says nothing at all — there is no generic fallback for a plugin.
EXPRESSIONSHer face. Only focus and fail are ever emitted — a successful turn's face comes from the audio tag she actually speaks.
executeMust be async def. A plain function raises TypeError: a coroutine was expected out of the tool runner instead of being handled as a tool failure.
The except in executeAn exception inside execute becomes the literal string "The tool errored." and is not logged anywhere. Returning a sentence is the only way to say what went wrong.

3. Restart

Plugins are discovered once, at import of kotoba.tools. There is no hot reload for a new plugin.

bash
kotoba serve      # or: kotoba

4. See that it loaded

Three places, in increasing order of detail.

The Settings panel. Open Settings and expand Plugins. A row reads:

hackernews · 1 tool (folder)          [on]

The terminal. In the CLI:

/settings plugins

The registry itself, which is the ground truth:

bash
python -c "
import kotoba.tools
from kotoba.core.plugins import loaded_plugins
from kotoba.tools.registry import registry
print(loaded_plugins())
s = registry()['hn_top']
print(s.name, s.toolset, s.risk, s.expressions)
"

which prints:

{'hackernews': {'source': 'folder', 'tools': ['hn_top'], 'loaded': True}}
hn_top plugin:hackernews network {'focus': 'thinking', 'fail': 'confused'}

If the plugin is not in that dict, it did not load. A plugin that fails to import leaves no row anywhere in the interface — only a line in the log. See How the loader works.


5. Call it

Remember the rule from the previous page: a plugin tool is work-mode only. Asking "what's on Hacker News?" in a conversation will not reach it — she has no such tool in a spoken turn.

Ask for something that becomes a job:

"Go and check the Hacker News front page and write me a short summary of the top three."

She calls start_work, and the job's loop is offered hn_top. Because RISK = "network" makes the call an action, you will see a step row appear in the work panel, headed with the tool's name. Whether she also says your ANNOUNCE line depends on the model: a reasoning model narrates its own tool use, and the loop suppresses the canned English in that case.

To confirm the plumbing without waiting on the model, run the tool through the loop's own runner:

bash
python -c "
import asyncio, kotoba.tools
from kotoba.tools import ToolContext
from kotoba.core.loop import execute_with_heartbeat

async def main():
    ctx = ToolContext(db=None, mode='work', channel='text')
    ok, out = await execute_with_heartbeat('hn_top', {'count': 3}, asyncio.Queue(), {}, ctx)
    print('ok:', ok); print(out)

asyncio.run(main())
"
ok: True
- The Navier–Stokes Millennium Prize Problem — https://... (102 points)
- Copyright does more harm than good and should be abolished — https://... (29 points)
- Muse – Meta's personal AI agent — https://... (465 points)

ok: True is what draws the green mark, and — for an action tool like this one — writes the executed audit row. ok: False comes back with one of three fixed strings: "The tool errored.", "The tool returned nothing.", or "The tool timed out." No detail, no traceback, nothing else.


6. Give her your own words for it

The lines in your module are the default. Anyone can override them without editing your file, in soul/default.md under ## Tool voice patterns:

markdown
## Tool voice patterns

hn_top:
  before: "Shaking the news out of the tubes..."
  heartbeat: ["Still reading..."]
  after: "Here's the front page:"
  fail: "The site's not answering."

The tool name sits at the left margin with a colon; the four keys are indented. Partial overrides are fine — a key you leave out keeps your module's value (core/voice_patterns.py).


Next

The tool module contract lists every attribute the loader and the loop read, with what happens when you leave each one out.