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

Plugins4 of 7

Installing a plugin, and shipping one

About 5 minutes to read

On this page

Two paths. This page covers both from the installing person's side and from the author's side, and ends with what survives an upgrade.


Path 1: a folder in ~/.kotoba/plugins/

Installing one

bash
mkdir -p ~/.kotoba/plugins
cp -r ./some-plugin ~/.kotoba/plugins/some-plugin
# restart her

That is the whole procedure. There is no install command, no manifest, no registry. Read the code first — see the security position.

The directory can also be a single file:

bash
cp ./dice.py ~/.kotoba/plugins/dice.py

A loose dice.py becomes a plugin named dice. A directory weather/ containing forecast.py and alerts.py becomes a plugin named weather with two tools.

Where the directory is comes from core/plugins.plugins_dir():

python
Path(os.getenv("KOTOBA_PLUGINS_PATH", str(home_dir() / "plugins"))).expanduser()
  • Default: ~/.kotoba/plugins/.
  • KOTOBA_HOME moves the whole home, so the plugins directory moves with it.
  • KOTOBA_PLUGINS_PATH points the loader somewhere else entirely — the one to use while developing, so you never have to touch the real home.

Shipping one

Publish the directory. A README saying what it does, what it needs on PATH, and which environment variables it reads is the entire distribution.

Constraints to design around:

  • One tool per .py file. A file's SCHEMA is one tool; a second tool needs a second file.
  • No package semantics. The loader executes each file with importlib.util.spec_from_file_location and never puts it in sys.modules, so __package__ is empty. Relative imports do not work, and neither does import my_helper for a sibling file. If you need more than one file, either put the directory on sys.path yourself at the top of your module, or use Path 2.
  • You install your own dependencies. Nothing reads a requirements file.

The multi-file workaround, verified working:

python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))

from _helpers import fetch          # _helpers.py sits beside this file

Name the helper with a leading underscore. Files starting with _ are skipped by the loader, so it will not be scanned for a SCHEMA of its own.


Path 2: a distribution with a kotoba.tools entry point

Installing one

bash
pip install kotoba-plugin-dice
# restart her

Install it into the same environment Kotoba runs in. The loader reads entry points out of the running interpreter's installed distributions; a package in a different virtualenv is invisible.

Uninstalling is pip uninstall kotoba-plugin-dice and a restart.

Shipping one

kotoba-plugin-dice/
  pyproject.toml
  src/kotoba_dice/
    __init__.py
    roll.py            <- the tool module: SCHEMA, execute, the rest

pyproject.toml:

toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "kotoba-plugin-dice"
version = "0.1.0"
description = "A dice-rolling tool for Kotoba."
requires-python = ">=3.11"
dependencies = []

[project.entry-points."kotoba.tools"]
dice = "kotoba_dice.roll"

[tool.hatch.build.targets.wheel]
packages = ["src/kotoba_dice"]

Three things about that entry-point line:

  1. The group must be exactly kotoba.tools. Nothing else is scanned.
  2. The key is the plugin name. dice here means the toolset is plugin:dice, the Settings row reads dice, and the switch key is plugin:dice. It has nothing to do with the distribution name or the module path.
  3. The value is a module path, with no :attr suffix. The loader calls ep.load() and reads SCHEMA off whatever comes back, so it has to be the module itself.

The tool module inside the package is exactly the same file as a folder plugin's — same SCHEMA, same async def execute, same optional attributes. The only difference is that here you can use ordinary imports, ordinary sub-packages, and declared dependencies.

One entry point exposes one tool. The loader registers a single SCHEMA per entry point. Two tools means two entry points:

toml
[project.entry-points."kotoba.tools"]
dice = "kotoba_dice.roll"
cards = "kotoba_dice.draw"

which gives two independent plugins — plugin:dice and plugin:cards — with two Settings rows and two switches, from one pip install.

Which path to choose

Use a folder when the plugin is yours, is one file, and has no dependencies. Use a distribution the moment you hand it to somebody else: pip gives you dependency resolution, versioning, an uninstall command, and a name people can search for.


Turning a plugin on and off

Both paths land in the same switch, keyed plugin:<name>.

In the web app. Settings, then the Plugins section. Each row is <name> · N tools (folder|entrypoint) with a toggle. The toggle posts to POST /api/settings/toolset with {"name": "plugin:<name>", "enabled": true|false}.

The same plugin also appears as a row in the Toolsets section while it is on, named plugin:<name>. It is the same switch. Turning it off there removes its tools from the registry, so the Toolsets row disappears — and the Plugins row, which does not, is then the only way back on. That is what the Plugins section is for.

In the terminal. /settings plugins lists them. The listing is read-only; the toggle is in the web panel.

By hand. ~/.kotoba/settings.yaml:

yaml
disabled_toolsets:
- plugin:hackernews

Read directly by the loader before it opens anything (app_settings.saved_disabled_toolsets), so a plugin named there is never executed. Takes effect on the next start.

What the switch actually does

Not a filter. For a plugin family — and only for a plugin family — the switch moves third-party code in and out of the process (registry.set_toolset_enabled):

  • Off deregisters every tool the plugin registered, so the name no longer resolves for a model that still remembers it. The module object stays imported: nothing can un-run code that has already run.
  • On loads the plugin now, running its module body, rather than waiting for the next restart.
  • Off in settings.yaml at startup means the file is never opened at all. Its module body does not run. It still gets a row in Settings, with the switch off, because that row is the only way to turn it back on.

A built-in family behaves differently: switching web off filters it out of the schema list but deregisters nothing, because those modules are already imported and are ours.


Adding a plugin while she is running

You cannot. Discovery happens once, at import of kotoba.tools. A plugin directory you create while the process is up has no row in Settings, so there is nothing to toggle. Restart.

The one live path is re-enabling something that was already seen at startup.


Upgrades

Survives pip install -U kotoba-companion?
~/.kotoba/plugins/Yes. It is in your home, not in the package.
An entry-point distributionYes, as long as the environment is the same one. A rebuilt virtualenv loses it, like every other installed package.
~/.kotoba/settings.yaml, including disabled_toolsetsYes. Same home.

Both the plugins directory and the settings file resolve through paths.home_dir()KOTOBA_HOME, or ~/.kotoba — which is outside the installed package on every install shape. An upgrade replaces the code and touches nothing of yours.

What upgrades can break is the contract, not the file. Your plugin reads SCHEMA conventions, ToolContext fields, and helpers like validate_within_dir and note_tool_refusal out of the package. None of that is a stability-guaranteed public API. Pin the version you tested against in your distribution's dependencies if that matters to you, and keep a test that fails loudly when it stops being true.