Plugins2 of 7
Your first plugin
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).
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:
"""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
| Piece | Why it is there |
|---|---|
SCHEMA | The 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 = 45 | Your 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 / FAIL | What 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. |
EXPRESSIONS | Her face. Only focus and fail are ever emitted — a successful turn's face comes from the audio tag she actually speaks. |
execute | Must 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 execute | An 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.
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:
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:
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:
## 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.
