Write a plugin¶
Everything outside the governed loop and the governance kernel is a plugin: the email assistant, the channels, the research tool, the code sandbox. Yours registers through the same API and runs inside the same governance, with no side door.
Start from the scaffold¶
iris plugins new weather-now --kind tool
cd weather-now
pip install -e ".[test]"
pytest
iris plugins new NAME --kind KIND [--dir PATH] [--force] writes a package that installs
and passes its own tests offline: a pyproject.toml with the iris_harness.plugins
entry point, the package with its manifest.yaml and setup(api), a README, and
tests that mount the plugin in a real governed harness on the scripted model, plus a
check that it imports only the stable API.
--kind |
What it generates |
|---|---|
tool |
One read-only tool on the governed ReAct loop, called by the scripted model in its test. |
channel |
A delivery channel (IChannelConnector: name, send, healthy) on the harness's gateway. |
mail-provider |
A MailProvider the email sweep syncs: connecting an account, incremental sync, the mailbox-write gate. Needs iris-harness[email]. |
research-provider |
A web-search backend (SearchProvider returning SearchHits) in the research tool's provider chain. |
skill |
A declarative plugin: tools declared in the manifest and bound to plain functions, with no plugin.py. |
What a plugin is¶
A directory, or an installed package, with a manifest and a setup(api):
# manifest.yaml
name: weather-now
version: 0.1.0
description: One line.
entrypoint: plugin:setup
party: untrusted # who wrote it: first-party | trusted-third-party | untrusted
provides: [tool]
tools:
weather_now:
effect: read # read | write (asks once per run) | destructive (approved per call)
content: internal # external: a third party wrote it; marked untrusted and tripwire-scanned by default (governance.md)
# plugin.py
from iris_harness.sdk import PluginAPI
def weather_now(args: dict) -> str:
return "Sunny, 21 C" # what the model reads as the tool's observation
def setup(api: PluginAPI) -> None:
api.register_tool("weather_now", "Current weather. Args: {}.", weather_now)
A tool must be declared under tools: with its effect, or the plugin is refused when
it mounts. The declaration is what the governance kernel enforces: a write tool asks
the owner once before the first write of a run, a destructive one waits for approval
on every call, and an external tool's result reaches the model inside an untrusted-content envelope with
instruction-like text redacted (the always-on floor; the optional model guard adds a
classifier). See governance.
Code that calls an external tool through api.tools gets the redaction but not the
envelope (it may show the text to the owner). result.text is the owner-facing form. When
you put the result into a prompt of your own, use result.for_model(): it wraps the text in
the envelope when the tool is external (result.external says so) and returns it unchanged
when it is not, so you do not need to know which kind of tool you called:
result = api.tools.call("read_email", {"message_id": mid})
prompt = f"Summarise:\n{result.for_model()}"
The conformance suite (iris_harness.testing) checks, for every external tool you give an
example call, that its result says it is external and that for_model() wraps it. Nothing
checks what you do with result.text, so hand a model for_model(), not text. For text
that did not come from a tool call (a page or mailbox you read yourself), wrap it with the
same implementation the kernel uses:
from iris_harness.sdk.content import wrap_external_content
prompt = f"Summarise:\n{wrap_external_content(page_text, source='my_plugin', tool='fetch')}"
For text you show the owner, return to a channel or log (not hand to a model), use
redact_external_content(text) from the same module: it is the tripwire without the
envelope. It honours IRIS_GOVERNANCE_EXTERNAL_CONTENT_FLOOR (floor off: the text comes
back unchanged) and writes a counts-only ledger row (pattern ids and counts, never the
text) for each of the first five matching calls per scope (a session, unless you open a run
scope); later matches are still redacted, only not recorded one by one. Do not call it per
character; call it per line or per text. Streamed text needs care: a phrase can be split across chunks, so scan whole lines
(with an overlap), not each chunk.
The six registration kinds¶
| Kind | Call | What it is for |
|---|---|---|
| intercept | api.register_intercept(name, fn, ...) |
A deterministic handler: answer a turn with no model, or return None to pass it on. |
| tool | api.register_tool(name, description, call) |
A tool on the governed ReAct loop. |
| intent handler | api.register_intent_handler(agent_type, fn, ...) |
An agent: the turns the router sends to your intent. |
| heartbeat | api.register_heartbeat(name, fn, schedule="interval:60", ...) |
A scheduled job. |
| channel | api.register_channel(connector) |
A delivery surface (Telegram, web push, your own). |
| confirmation executor | api.register_confirmation_executor(kind, fn) |
What runs when the owner approves a pending action of your kind. |
Every registration is wrapped in one fault boundary: a failing plugin shows up as a
degraded or failed plugin:<name> row in System Health instead of taking the process
down.
A heartbeat's schedule can also live in the shipped config/heartbeats.yaml, keyed by the
handler name you register. Give that entry plugin: <your plugin name>: on a harness where
your plugin is not mounted the job is then skipped quietly (one startup line, shown as
unavailable in the app), while a missing handler with your plugin mounted, or with no
plugin: at all, logs a warning. plugin: is a free string, so a typo in it would read as
"not installed" and stay quiet: config/heartbeats.yaml therefore lists every owner name it
may use, in plugins_in_tree (a manifest ships in this repo; a test checks the name against
it) or plugins_external (shipped elsewhere; cannot be checked). Add your plugin to the
right list in the same change as its heartbeat. A plugin: in neither list, with its
handler missing, logs a warning naming it, and a test fails the build.
Run it in your IRIS¶
IRIS finds plugins in this order: built in, then installed packages (entry-point group
iris_harness.plugins), then $IRIS_HOME/plugins/<name>/ (a directory with a
manifest.yaml, no packaging needed). A profile says which ones mount and in what
order: the shipped one is chosen by IRIS_PROFILE (unset, it is email when the email
plugins are installed, else default), and $IRIS_HOME/profile.yaml adds yours:
# ~/.iris/profile.yaml
plugins:
- name: weather-now
iris --dump-config prints the effective profile, where each plugin was found and what
it provides, without starting anything. On a running IRIS, iris plugins shows each
plugin's status and iris plugins show NAME its registrations.
Calling a web service¶
Use api.http (a GovernedHttp), not httpx or requests, and declare the hosts in the
manifest:
egress:
hosts: [api.open-meteo.com]
reply = api.http.get("https://api.open-meteo.com/v1/forecast", params={"latitude": 52.5})
An undeclared host raises EgressDenied and nothing is sent; each request is a ledger row.
Test it with iris_harness.testing.fake_http and lint it with check_network_imports
(Test your plugin); the design and its limits are in
plugin egress.
What you may import¶
Only the stable tier: iris_harness.sdk, iris_harness.testing, and the mail-provider
facade iris_personal.email.provider_api. Everything else is internal and may change
in any release. The list, the deprecation rule and the check your own CI can run are
in Stable API.
Next¶
- Test your plugin against a real governed harness.
- The examples: a deterministic handler, a governed tool with approvals, your own agent, a scheduled routine, MCP.
- The full contract (capabilities, services, events, CLI commands, web console screens): the plugin contract.