| name | building-plugins |
| description | Use this when asked to build, create, write, scaffold, or publish a protoAgent plugin — e.g. "make a plugin that …", "add a plugin for X", "package this as a plugin", "write a plugin that adds a tool/dashboard/workflow", "publish a plugin so others can install it". Covers the plugin contract (manifest + register()), the full contribution surface (tools, subagents, SKILL.md skills, workflows, console views, routes, MCP servers, config/secrets/settings), the conventional repo layout, testing, and distribution by git URL — with the safety model. Not for: using an already-installed plugin, or extending via a one-off SKILL.md skill or MCP server (smaller asks — see the Skills / MCP guides). |
Building a protoAgent plugin
A plugin is a self-contained directory (optionally its own git repo) that extends
a running agent without forking core. Authoritative refs: ADR 0018 (surfaces),
0019 (config/secrets/settings), 0026 (console views), 0027 (distribution), 0045
(chat slot); guides plugins, building-react-plugin-views, plugin-registry.
The shipped plugins/hello/ is the worked backend example — read it first. For a
console view, copy examples/plugins/chat_example — the gold-standard view
(the four rules + the init/theme handshake + slug-aware routing + the DS kit).
Scale to the ask
A one-tool plugin is ~15 lines (manifest + register()). A "full bundle"
(tools + subagents + skills + workflows + a console view + config) is a directory
of conventional subdirs. Build the smallest thing that satisfies the ask; don't
scaffold a dashboard for a single tool.
1. Decide what it contributes
Map the ask to the contribution surface:
- tool / subagent / route / MCP server → code, via
register(registry).
- SKILL.md skills /
*.yaml workflows → data, auto-discovered from
conventional skills/ and workflows/ subdirs (no code).
- console view (rail icon + page, or a
slot: "chat" panel) → declared in the manifest
views:; a sandboxed iframe of a page your plugin serves (ADR 0026/0038/0045). Copy
examples/plugins/chat_example; full guide: building-react-plugin-views.
- events (broadcast / react) →
registry.emit("x", data) / registry.on("topic.*", fn);
declare emits: / subscribes: in the manifest (ADR 0039). Plugins coordinate via the bus,
never by importing each other.
- config / secrets / Settings fields → declared in the manifest.
- chat integration (Discord/Slack/Telegram-style) → it's a communication
plugin — use
scaffold_plugin(..., with_comms=True) to get a ChatAdapter
skeleton on the shared wirer (ADR 0029); you implement only connect/receive/send.
See docs/guides/communication-plugins.md and plugins/telegram for a reference.
2. Lay out the directory
my-plugin/
protoagent.plugin.yaml # manifest (data — read without importing)
__init__.py # def register(registry): … (code contributions)
skills/ <name>/SKILL.md # optional — auto-discovered
workflows/ <name>.yaml # optional — auto-discovered
Place it in plugins/<id>/ (bundled with a fork) or install it from a git URL
into the live plugins dir (step 6).
3. Write the manifest (protoagent.plugin.yaml)
id: my-plugin
name: My Plugin
version: 1.0.0
enabled: false
config_section: my-plugin
config: { api_base: "https://…" }
secrets: [api_key]
settings:
- { key: api_base, label: "API base", type: string }
- { key: api_key, label: "API key", type: secret }
views:
- { id: board, label: "Board", icon: LayoutDashboard, path: /plugins/my-plugin/board }
emits: ["my-plugin.updated"]
subscribes: ["other-plugin.*"]
requires_pip:
- "httpx>=0.27"
- { pkg: "pillow>=10", optional: true }
repository: https://github.com/owner/my-plugin
Optional deps pair with lazy-import degradation. Mark a dep optional: true
only if the plugin genuinely runs without it: import it inside the tool body
(never at module top) and catch ImportError to return a readable error naming
the fix, so the plugin's other tools keep working:
@tool
async def resize_image(path: str) -> str:
"""Resize an image."""
try:
from PIL import Image
except ImportError:
return "resize_image needs pillow — run: python -m server plugin install-deps my-plugin"
...
A missing hard dep refuses the frozen (desktop) install outright; a missing
optional dep installs with a warning, and install-deps treats it best-effort
(a failed optional install warns instead of failing).
4. Write register(registry)
The registry collects code contributions (mounted once at init):
def register(registry):
cfg = registry.config
registry.register_tool(my_tool)
registry.register_subagent(my_subagent)
registry.register_router(my_router, prefix="/api/plugins/my-plugin")
registry.register_mcp_server(my_factory)
registry.emit("updated", {"n": 1})
registry.on("other-plugin.*", on_event)
A views: page is served by your router (e.g. @router.get("/board") returning HTML). Mount data
routes under /api/plugins/<id> so they inherit the operator bearer gate; serve the page itself
under the public /plugins/<id> prefix (an iframe page-load can't carry a bearer, so the page is
public chrome — its data calls are the gated part). The console iframes the page (sandboxed) and
postMessages it the bearer + theme; the page derives a slug-aware base and uses the DS kit's
apiFetch for authed same-origin calls — copy examples/plugins/chat_example, full guide
building-react-plugin-views. An event under <id>.* lights your plugin's rail icon (a notification
dot) until the user opens it.
5. Consume core capabilities (graph.sdk)
A plugin doesn't only contribute — it can call back into the host through the stable
graph.sdk surface (so core can refactor underneath you). Keep these imports lazy (inside
functions/tools) so your plugin still loads + tests host-free. The surface:
- Run the model / a subagent —
run_subagent(...) (a full tool-using subagent),
complete(prompt) (a bare LLM completion).
- A supervised background engine —
supervise(work, …): run a unit of work back-to-back
with a watchdog that re-kicks a crash, restarts a stall, and recovers via on_crash
(.start() / .stop() / .request_stop() / .status()). The deterministic heartbeat for a
long-running engine — don't hand-roll task/restart machinery.
from graph.sdk import supervise
engine = supervise(run_one_window, name="fleet", interval=90,
stall_check=lambda: not busy(), on_crash=recover)
engine.start()
- A tunable control surface —
Knobs() + make_knob_tools(knobs, prefix=…): declare typed
knobs (clamped, choices) + named presets, read live in your engine; auto-generate the
<prefix>_knobs / _tune / _preset agent tools.
from graph.sdk import Knobs, make_knob_tools
KNOBS = Knobs().define("min_margin", 30, lo=0).preset("trade-max", {"min_margin": 20})
registry.register_tools(make_knob_tools(KNOBS, prefix="fleet"))
- Hand the user a generated file (image/audio/video) —
registry.save_media(data, mime, meta=None) (#1929):
persists the bytes (or a source file path) into the core media store and returns a
MediaRef {id, url, path, mime}. Embed ref.url in the tool's returned markdown and the
console renders it inline — no plugin-owned route, no UI change. The URL carries a per-file
HMAC signature, so inline <img> rendering works even on a bearer-gated deployment; files
land under the instance data dir and survive restart (media.retention_days prunes,
media.public opts the store public). A media.saved bus event fires per save. Pairs with
multimodal_tool_result below: save for the user, envelope for the model.
@tool
async def generate_image(prompt: str) -> str:
"""Generate an image and show it in chat."""
png = await _generate(prompt)
ref = registry.save_media(png, "image/png", {"prompt": prompt})
return f""
- Return an image the model can SEE —
multimodal_tool_result(caption, images=[…]) (#1930):
a tool that just produced an image (a render, a chart, a screenshot) returns this instead of a
path string. On a vision model (model.vision: true) the image rides the ToolMessage as
content blocks the model actually looks at — enabling generate → look → refine loops; on a
text-only model it degrades to the caption (plus a description via
knowledge.image_describe_model, when configured). Each image is {"path": p} or
{"b64": data} + optional "mime" (default image/png). Limits: MAX_IMAGES_PER_RESULT
(3) images per result, MAX_IMAGE_BYTES (2 MiB) decoded per image — downscale in the tool.
from graph.sdk import multimodal_tool_result
@tool
async def render_chart(spec: str) -> str:
"""Render the chart and show it to the model."""
path = _render(spec)
return multimodal_tool_result("Rendered chart:", images=[{"path": path}])
- A self-driving goal loop (OODA) —
start_goal_loop(…) (#2060): arm a watch on the
goal, verified by your plugin verifier, and schedule the recurring tick that drives it —
in one call. Sugar over create_watch + schedule_recurring under a shared derived id
(goals themselves are drive-only since ADR 0067 — the "wait for the metric" half is a
watch). Register the verifier + an on_met watch hook in register() first; the hook
calling stop_goal_loop is what retires the cadence when the verifier passes. Pass
session_id from your tool's InjectedState.
from graph.sdk import start_goal_loop, stop_goal_loop
start_goal_loop(goal="reach 1M credits", verifier="fleet:credits",
verifier_args={"min": 1_000_000}, every="30m",
prompt="Run the OODA tick and report.",
plugin_id=registry.plugin_id, loop_id="credits-1m",
session_id=sid)
PREFIX = "fleet:goal-loop:"
async def on_met(watch):
if watch.id.startswith(PREFIX):
stop_goal_loop(plugin_id="fleet", loop_id=watch.id.removeprefix(PREFIX))
registry.register_watch_hook(on_met=on_met)
- Observability —
DecisionLog (an audit trail of what the agent changed, and why),
telemetry(…) (the standard status/metrics/hints/decisions envelope), render_html(env) (a
themed panel for your console view).
from graph.sdk import DecisionLog, telemetry, render_html
LOG = DecisionLog(); LOG.record("tune", "min_margin 30→15", reason="cr/hr falling")
return render_html(telemetry(status="running", metrics={"credits": cr}, decisions=LOG))
These generalize the patterns the SpaceTraders two-loop fleet proved (its manage-the-fleet
skill is the worked example: a deterministic engine steered by an agentic OODA loop).
6. Test it — live, no restart
You don't need to restart to try a plugin you built. With plugin-devkit enabled:
scaffold_plugin(...) already enabled it (the default) — its tools/view are
live on your next turn. Call its <id>_hello to confirm.
- Iterate: edit the plugin's
__init__.py, then call reload_plugins — the
loader re-execs the file, so your change is live next turn (no restart).
- Built a plugin some other way (CLI / by hand)? Call
enable_plugin("<id>") to
turn it on + hot-reload, or toggle it in the console Plugins panel (#822).
GET /api/runtime/status → the plugin shows loaded: true with its tools/views.
- Unit-test the tool/registration like
tests/test_plugins.py does.
- If it declares
requires_pip, python -m server plugin install-deps my-plugin
first (a missing dep gives a clear error on enable).
For a standalone-repo plugin (its own git repo, not bundled in protoAgent), pass
with_tests=True (scaffold_plugin) / --tests (the CLI) to also get a host-free
test suite + CI + requirements-dev + pyproject so the repo is green from birth, and
ruff + pytest run in GitHub Actions.
The suite ships a vendored testkit (tests/_plugin_testkit.py, a verbatim copy of
graph/plugins/testkit.py) so it depends on no running host. The testkit loads the plugin
as a package, so you can unit-test the REAL engine modules — relative imports and all —
not just register():
def test_engine(plugin):
import importlib
engine = importlib.import_module(plugin.__name__ + ".engine")
assert engine.classify([...]) == ...
install_host_stubs() (run in the conftest) registers stand-ins for graph.* / knowledge.*
so a module that imports the host loads with no host present — monkeypatch a seam to assert
its behaviour. So you no longer have to extract logic into dependency-free modules just to
test it; keeping host-only imports lazy is still good practice but no longer a hard
requirement. In-repo (bundled) plugins import the same testkit directly:
from graph.plugins.testkit import load_plugin, install_host_stubs, FakeRegistry.
FakeRegistry mirrors every public PluginRegistry method (a parity test in core
enforces it), so run register() against it bare — no hasattr guards — and assert every
contribution: reg.tools, reg.chat_commands["issue"], reg.subagents, … . Where the live
registry warns-and-skips an invalid registration (a chat command named goal — reserved,
an unslugifiable name, a duplicate token), the fake raises ValueError, so a typo'd
registration fails your suite instead of shipping green.
From the shell (no agent): python -m server plugin new "My Plugin" --view --skill --tests
scaffolds the skeleton; plugin new-bundle "My Stack" --member id=url@ref --builtin delegates
scaffolds an ADR-0040 bundle.
7. Distribute (optional)
Publish as a git repo; others install by URL:
python -m server plugin install <git-url> --ref <tag> (or the console Plugins
panel). Install pins a commit SHA in plugins.lock; plugin sync reproduces it.
install ≠ enable ≠ trust — installing only fetches code, never runs it; enabling
is the trust decision. For untrusted code, ship an MCP server instead (sandboxed).
Remove cleanly with plugin uninstall <id> (--purge also drops config + secrets).
Gotchas (learned the hard way)
config_section must be a string, never a list (reserved-section check).
- An
@tool's description comes from its docstring — use a plain string
literal, not an f-string (__doc__ is None for f-string "docstrings").
- Discovery reads the manifest as data; code runs only on enable. Keep the
manifest importable-free.
- Enabling a plugin is fully live: tools/subagents/middleware/MCP rebuild with
the graph and its router (which serves any view) hot-mounts (#822) — no restart.
Only disabling leaves a stale route behind (FastAPI can't unmount), so that path
alone wants a restart. The rail picks up a new view from
runtime-status without a
console rebuild.
- Don't edit core files to wire a plugin in — if you need to, you're missing a
seam; file it (see the operator-fork contract) instead of re-porting each sync.