plugins
One file. One tool. You install it.
A plugin is a Python file with three top-level names: DESCRIPTION, SCHEMA, and a run function. Drop it in ~/.rig/plugins/ and it is a tool beside the built-ins on the next turn. No build step, no manifest, no framework.
The model can write one too — and that is exactly why the zones exist. Everything the model authors lands in pending/ and is invisible until you approve it. Installing a tool is the operator's verb.
the contract
Three names and a function
# ~/.rig/plugins/weather.py DESCRIPTION = "current weather for a city" SCHEMA = { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], } def run(args): return fetch_weather(args["city"])
The filename stem: weather.py → weather. The rule is ^[a-z][a-z0-9_]{0,63}$ — a lowercase letter, then letters, digits, underscores.
A string that reaches the model verbatim, through the door's schema action. Write it for the model.
The tool's JSON schema. The model never pays for it until it asks.
Receives the args dict; its return is the tool result. An exception is a tool error carrying the traceback tail, and the kernel stays alive — it is the model's kernel too.
The operator's first look is /plugins: the loaded ones with their descriptions and files, the skipped ones with their reasons, and the pending zone. From the model's side, the same door lists what is callable without pulling every schema:
plugin {"action": "list"} # loaded and skipped plugin {"action": "schema", "name": "weather"} # the contract, on demand plugin {"action": "run", "name": "weather", "args": {"city": "Oslo"}}
the zones
A directory is a trust level
The live set. Top-level *.py only, discovered in filename order. Presence here is the operator's approval.
The forge's landing zone. Everything the model writes lands here. Invisible to discovery, created at startup, silent and idempotent.
The off switch. A disabled or deleted plugin moves here: hidden, not callable, never deleted. /plugins enable brings it back.
The decision model's zone, beside plugins and sharing its machinery. Its own contract and interpreter — below.
A missing or empty plugins/ is a no-op that never starts the kernel: the wire is the built-in tools' bytes exactly. settings.json's plugins.max caps the live set; a load that would exceed it is skipped with the reason disabled: over the settings.json plugins.max cap, and the first N files by name win. The cap is read at startup, so raising it takes a restart.
the door
One door, not eleven schemas
The request carries the built-in tools plus one plugin door. The per-plugin schemas stay behind it, so a grown tool table stops blowing context: the live names ride the door's name enum, and a plugin's description and schema cross the wire only when you ask for them.
An unknown name runs the root's reload once and re-resolves before refusing — an out-of-band install is callable without a reload call. /plugins reload stays the operator's explicit verb; a failed reload leaves the table and the wire untouched.
Call a live plugin with its args.
Read one plugin's contract without running it.
The live set and the skipped set with reasons.
Take a name and source, check the name rule, native collision and contract, and write into pending/. Creating over an existing pending file updates it; the reply says which.
A move into disabled/, never an unlink.
Re-run discovery: the new list is live on the next turn.
plugins is not a tool: it folded into plugin in 2.8.2. A settings.json that still carries a plugins entry is dropped at the read with a notice.
promoting one
Pending to live is your call
Four doors write to the pending zone, and one verb installs: /plugins approve <name> moves a pending file to the top level, reloads, and says if it replaced an installed one of the same name.
Write the file into ~/.rig/plugins/pending/, review it as you would any Python you intend to run, then approve.
/plugins create <text> queues the authoring prompt — a command queues a line, never dispatches a turn. The model writes; you approve.
The door's create action writes the pending file through the same shared check the command door uses.
The plugin forge reads a plugin's source and saves the full contract file into pending (create or update); the plugin view lists all three zones with each file's DESCRIPTION, read without running it.
The rest of the operator's surface: /plugins pending, /plugins disabled, /plugins disable <name>, /plugins enable <name>, /plugins reload. The dashboard's forge is the same moves, with a source editor.
the allow-list
Presence is the approval
The default permits the built-in tools only; a plugin is not in it.
An installed plugin's presence in plugins/ root is its own allow-list entry — your approve put it there — and the live table admits it without an allow line.
A plugin still in pending/ is not live and stays refused until approved; the refusal names the tool and the allow-list.
A settings.json that writes its own allow key replaces the built-in default whole, so it must carry plugin or every door call is refused.
the shared kernel
One Python, on purpose
Discovery imports every file through the same persistent IPython kernel the python tool uses: one process, one namespace. The cost is named and accepted — the model's Python can call plugin functions directly, and plugin state persists across calls. A plugin is a plain import by its stem, and a reload replaces the module in both the live table and sys.modules.
A plugin call's default timeout is 120 s, charged from the kernel's turn; a queued call is never charged queue time. A kernel-level failure is the call's error; a per-file failure is a skipped report.
the failure voices
A file missing a name, or failing import, is a loud skip naming the file and the field — startup and reload continue, because a broken plugin must not brick the harness. A name colliding with a native tool refuses loud, before the file executes: native-wins would be silent shadowing, and plugin is a native, so that name is reserved. An invalid manually-installed filename is skipped before execution.
trust
The jail is the boundary
In an interactive session and a one-shot run, plugins run with rig's privileges, in your kernel: trust them as you trust your own Python. That is the honest model, and the provenance rule is why it holds: the model's write and edit are admitted only when the resolved and lexical paths agree the target sits in plugins/pending/. A write landing in plugins/ outside pending is refused, and so is a symlink whose two views disagree about the zone. The refusal teaches the shape.
A jailed or landlock scheduled worker gets a scratch home — RIG_HOME=<cwd>/.rig-job — and the operator's rig home is not bound inside it, so the operator's plugins are never discovered by a jailed worker: protection by absence, not by name. Only sandbox: "off" inherits the home, and its workers run with your plugins present.
the train zone
The same machinery, a different job
train/ sits beside plugins/ and shares its zones, filename rule, and the pre-execution contract check. A trainer exposes two callables:
# ~/.rig/train/laya.py def train(rows_path, out_dir): ... return {"checkpoint": "/abs/path", "questions": {...}} def evaluate(checkpoint, rows_path): ... return {"checkpoint": checkpoint, "questions": {...}}
The interpreter is separate: trainPython names the train zone's own venv (torch is two gigabytes, and the train kernel is its own process, never the plugins' shared kernel). Unset, a training run refuses. The runner is rig decision train <trainer>, headless, never inside a turn; /decision train <trainer> enqueues it as a one-shot scheduler job, and the nightly is your own cron line.
The rows, the split, the scoring and the promotion rule: the decision page.