Plugins are drop-in packages that extend protoAgent without forking it. A plugin contributes tools, bundled skills, FastAPI routes, background surfaces, subagents, middleware, knowledge backends/embedders, goal verifiers — plus its own config / secrets / Settings (ADR 0018/0019/0032). Plugins run in-process with the agent's privileges, so they're disabled by default and you opt in explicitly — only enable plugins you trust.
The first-party Telegram and GitHub integrations ship bundled as plugins (
plugins/telegram/,plugins/github/), opt-in viaplugins: { enabled: [telegram] }. Integrations like Discord, Google Gmail/Calendar, and Slack install as external plugins from their own repos (browse + install them in Settings ▸ Plugins ▸ Discover). To drive a CLI coding agent over ACP, enable the delegates plugin and declare anacpdelegate — see CLI coding agents over ACP.
Trust model. This is the in-process / trusted model (matching Hermes): an enabled plugin's
register()runs as the agent. Don't enable code you haven't reviewed. Untrusted third-party tools are better added via MCP (out-of-process).
A plugin is a directory with a manifest and a module exposing register(registry):
plugins/hello/
├── protoagent.plugin.yaml # manifest
├── __init__.py # def register(registry): ...
└── skills/ # optional bundled SKILL.md skills
└── greeting/SKILL.md
id: hello # required, unique
name: Hello Plugin # required
version: 0.1.0
description: One-line summary.
enabled: false # author opt-in; operators can also enable by id in config
entrypoint: "" # optional module filename (defaults to __init__.py / plugin.py)
requires_env: [] # env vars the plugin needs (missing → skipped + logged)
capabilities: # declarative, for transparency (not yet enforced)
network: []
filesystem: none
emits: [] # event-bus topics this plugin broadcasts (ADR 0039) — its public API.
# An entry is a bare topic name, or {topic, summary?, schema?} to
# declare the payload shape — see "Typed event contracts" below
subscribes: [] # topics it listens for (declarative — for discoverability)
public_paths: [] # auth-exempt prefixes under THIS plugin's own namespace
# (/plugins/<id>/… or /api/plugins/<id>/…) — for an inbound
# webhook (no bearer; you verify its signature). See below.Every field, at a glance — most have a dedicated section below or in a linked guide:
| Field | Meaning |
|---|---|
id · name |
Required. id is the slug that namespaces routes + config; reserved verbs (install, sync, …) are refused. |
version · description |
Metadata; version also drives update/pin resolution. |
enabled |
Author opt-in (true loads without an operator listing it). |
builtin |
Core runtime infra — always loads, ignores enable/disable, hidden from the Plugins panel (e.g. the delegate registry). |
entrypoint |
Module filename to import; defaults to __init__.py then plugin.py. |
requires_env |
Env vars that must be set or the plugin is skipped (a hard gate, vs required settings). |
config_section · config · secrets · settings |
Config declaration (below); settings[].required: true is the soft gate. |
test |
true → the console renders a Test connection button (POST /api/config/test-<section>), ADR 0029. |
guide_url |
A Setup guide link the console shows next to the plugin's settings (ADR 0059). |
views |
Console rail views (Building a plugin view); a view's palette may be a string or a dict with its own path. |
public_paths |
Auth-exempt prefixes under the plugin's own namespace — the escape hatch for an inbound webhook or a public asset (below). |
emits · subscribes · emits_schemas |
Event-bus contract (Typed event contracts). |
requires_pip · optional_pip |
Declared pip deps (publish guide) — the optional tier degrades gracefully when absent. |
repository · homepage · min_protoagent_version |
Provenance + the host-compat gate (publish guide). |
from langchain_core.tools import tool
@tool
async def hello(name: str = "world") -> str:
"""Return a friendly greeting."""
return f"Hello, {name}!"
def register(registry):
registry.register_tool(hello) # expose a LangChain tool
registry.register_skill_dir("skills") # bundle SKILL.md skills (relative to the plugin)register is called once at load. The registry accepts these contribution types
(plus console views, declared in the manifest — see Building a plugin view) —
a fork adds any of them as a plugin, never editing the core server/ package:
| Method | Contributes | Lifecycle |
|---|---|---|
register_tool(tool) / register_tools(iter) |
A LangChain tool | graph build (live-reloads) |
emit(topic, data) / on(topic, handler) |
Broadcast / subscribe on the event bus (ADR 0039) — emit auto-namespaces to <plugin>.<topic>; on takes */# wildcards |
any time (publish is fire-and-forget) |
navigate(view="") |
Ask the console to focus one of this plugin's own views (ADR 0044) — scoped to your plugin id, blank opens the first view | any time (fire-and-forget) |
live_config() |
The plugin's current resolved config, re-read from the host each call — use it inside a mounted router/surface so config edits take effect without a restart (registry.config is a register-time snapshot) |
any time |
register_skill_dir(path) |
A SKILL.md directory (procedural memory) |
graph build |
register_workflow_dir(path) |
A directory of *.yaml workflow recipes |
workflow-registry build |
register_a2a_skill(spec) |
An A2A card skill (what the card advertises; optional structured output) | agent-card build |
register_router(router, prefix=None) |
A FastAPI APIRouter |
mounted once at init (default prefix /plugins/<id>) |
register_surface(start, stop=None, name=None, reload=None) |
A background surface (a Discord-style gateway) | start in startup, stop in shutdown, reload(cfg) on config save |
register_subagent(config) |
A SubagentConfig (a delegate) |
added to SUBAGENT_REGISTRY |
register_middleware(factory) |
A LangGraph AgentMiddleware (per-turn before/after-model + tool hooks) — factory(config) → middleware | None |
graph build; appended before message-capture (ADR 0032) |
register_goal_verifier(name, fn) |
An in-process goal/watch verifier (ADR 0028) — dispatched by a {"type": "plugin", "check": "<plugin-id>:<name>"} goal or watch spec |
graph build (re-set on reload) |
register_goal_hook(on_achieved=, on_failed=) |
React when a goal reaches a terminal state (ADR 0028) — the GoalState in, push a notification / set the next goal |
graph build (re-set on reload) |
register_watch_hook(on_met=, on_expired=, on_stalled=) |
React when a watch trips (ADR 0067) — met / deadline passed / evidence stalled | graph build (re-set on reload) |
register_lifecycle_hook(on_app_loaded=, on_agent_active=, on_system_wake=) |
React to a system lifecycle event (ADR 0074) — see Lifecycle events | graph build (re-set on reload) |
register_knowledge_store(name, factory) |
A knowledge backend (ADR 0031) — factory(config) → KnowledgeBackend, selected by a fork's knowledge.backend: "<name>" (pgvector/Qdrant/…); degrades to the built-in SQLite store on error |
graph build |
register_embedder(name, factory) |
An in-process embedder (ADR 0031) — factory(config) → (text) → vector, selected by knowledge.embedder: "<name>" to skip the gateway round-trip; degrades to the gateway embedder on error |
graph build |
register_mcp_server(factory) |
A managed MCP server the agent connects to | factory(config) called at each graph build → entry dict or None |
register_thread_id_resolver(fn) |
A (request_metadata, session_id) → str checkpointer-scope resolver (e.g. per-project memory) |
each turn; one wins (last plugin) |
register_chat_command(name, handler) |
A user-only /<name> chat control command that short-circuits the turn (the generalized /goal) — token slugified+lowercased; goal and lifecycle are reserved core tokens (refused); see publish guide |
chat dispatch; first plugin to claim a token wins |
register_late_tool_factory(factory) |
A tool factory that runs after the full toolset is assembled — factory(all_tools, config) → tool | list | None, for meta-tools that must see every other tool |
graph build, appended last |
save_media(data, mime, meta=None) |
Persist a generated binary artifact (image/audio/video) into the core media store (#1929) → MediaRef {id, url, path, mime}. Embed ref.url in the tool's returned markdown () and the console renders it inline — no plugin route needed. The URL carries a per-file HMAC signature so it works under a bearer gate; media.public / media.retention_days (core config) control exposure and pruning; broadcasts media.saved on the bus. Inbound is bridged too (#1969): chat image attachments are auto-saved at turn entry (except incognito) and named by media id in a [attached-image refs] note, so a tool that accepts media-id refs works on user attachments directly |
any time (typically inside a tool) |
def register(registry):
registry.register_tool(hello)
registry.register_a2a_skill({"id": "greet", "name": "Greet", "description": "..."})
registry.register_router(_build_router()) # → GET /plugins/<id>/...
registry.register_surface(_start, stop=_stop, name="my-surface")
registry.register_subagent(_build_subagent()) # delegate via task/task_batch
registry.register_mcp_server(_server_factory) # a managed MCP server (e.g. an OAuth-gated surface)
registry.register_thread_id_resolver(lambda md, sid: f"proj:{md.get('project')}:{sid}")A surface's start runs on every server boot and after a plugin reload; stop runs on
shutdown/reload; reload(cfg) runs on a config save. So a surface that manages ongoing
background work — a trading loop, a poller, a long-running job — has a subtle obligation: a
reload/restart is not "the operator turned it off," so the surface must resume that work
rather than leave it stopped. Restarting the process alone doesn't bring the work back;
fleet.autostart restarts the agent, this pattern
restarts what it was doing. The recipe:
- Persist the operator's INTENT, not the run state. When the operator starts the work,
write a durable flag (
wanted: true); when they deliberately stop it, clear the flag. Module-level state resets on reload, so the flag lives on disk — a small JSON in the per-agent config dir (PROTOAGENT_CONFIG_DIR), the same store your watch/plan state uses. - Distinguish a lifecycle stop from an operator stop. The surface
stophook (shutdown/reload) must halt the task without clearing the intent — a reload isn't a decision. Only the operator's own stop clears it. (A singlestop_ops()that both halts and clears would make every reload read as "turned off," and it would never resume.) - Resume in
startwhen the intent stands. Thestarthook reads the flag and re-launches the work if it was wanted; otherwise it stays idle.
async def _start(): # runs on boot AND after a reload
if _intended(): # persisted flag — survived the module reset
_resume_work() # re-launch what the operator had running
async def _stop(): # runs on shutdown/reload
_lifecycle_halt() # stop the task, KEEP the intent (not an operator stop)Safety rule — the clear direction must fail safe. The failure modes are asymmetric: a
lost start-write leaves the flag absent → no resume → safe; a lost stop-write leaves it
true → a reload would resurrect deliberately-stopped work → not safe. So verify the clear
landed (read it back), and if it genuinely can't persist, make that loud rather than
silent — never let a write failure resume work the operator turned off. protoTrader's
SpaceTraders plugin is the worked example: st_autopilot_start persists autopilot_wanted, a
lifecycle_stop() halts the trading engine on reload while keeping the flag, and the surface
start resumes it — so a host restart brings the trading back, not just the process.
A plugin can ship a managed MCP server the agent connects to, instead of
making the operator hand-edit mcp.servers. The factory is called at every graph
build with the live LangGraphConfig; return a mcp.servers[] entry ({name, transport, command, args, env, ...}) when the server should run, or None when
it shouldn't (off / not yet connected) — so the server comes and goes with config.
A returned entry whose name matches a configured server replaces it, and a
factory that returns an entry activates MCP even when mcp.enabled is off. This
is how an integration plugin can ship an OAuth-gated MCP surface (e.g. a Google
Gmail/Calendar external plugin) without a core edit. For a frozen desktop build (no python on PATH),
launch via args: ["--mcp-plugin", "<id>"] and expose a mcp_main() in your
plugin module — the binary re-invokes itself and the shim runs it.
Under a token-gated deployment the auth middleware is default-deny: every path needs the
operator bearer. That breaks two legitimate cases — an inbound webhook (a third party POSTs
you and can't send your bearer; you verify its own HMAC/signature instead) and a public
asset/page a browser loads with a plain navigation. Declare those paths in the manifest's
public_paths to exempt them from the gate:
# protoagent.plugin.yaml
public_paths:
- /api/plugins/stripe/webhook # inbound POST — verify the Stripe signature yourselfEach entry must live under this plugin's own namespace — /plugins/<id>/… or
/api/plugins/<id>/… — with the trailing slash after the id segment. That scoping is the
security boundary: a plugin can exempt only its own routes, never a core path like /api/config
(the manifest parser drops anything else with a warning, and the auth layer re-checks it as
defence-in-depth). You still own the auth on an exempt route — validate the caller's
signature in the handler; the exemption only removes the bearer requirement. Console view
pages are auto-exempted (a view page is public chrome), so you don't list those here — only
webhooks and any non-view asset the browser must fetch anonymously. Its DATA stays gated under
/api/plugins/<id>/*.
A plugin can contribute a LangGraph AgentMiddleware — the per-turn hook layer
(before_model / after_model / wrap_tool_call / …) the core uses for knowledge
injection, enforcement, compaction, and audit. The factory gets the live config and
returns a middleware instance (or None to opt out); it's appended to the chain just
before the internal message-capture middleware, so its hooks run and the turn is still
captured. The full hook inventory, chain order, a worked summarize-and-ship example,
and the design rules live in the Middleware guide.
For per-request data (the A2A request's merged metadata — project scope, origin,
caller keys), read current_request_metadata() — a contextvar bound for the duration
of each turn. This is how a fork injects a per-turn directive without editing the core
executor:
from langchain.agents.middleware import AgentMiddleware
from graph.middleware.request_context import current_request_metadata
class ScopeBannerMiddleware(AgentMiddleware):
def before_model(self, state, runtime):
project = current_request_metadata().get("project")
if not project:
return None
banner = SystemMessage(content=f"Active project scope: {project}. Stay within it.")
return {"messages": [banner, *state["messages"]]}
def register(registry):
registry.register_middleware(lambda config: ScopeBannerMiddleware())A plugin can ground-truth its own domain state as a verifier — an async
(spec, ctx) -> VerifyResult that a {"type": "plugin", "check": "<plugin-id>:<name>"}
goal or watch dispatches to. args in the
spec are declarative data your verifier validates (no shell, no eval — which is why
plugin is the only verifier type an agent/plugin may set programmatically):
from graph.goals import VerifyContext, VerifyResult
async def verify_credits(spec: dict, ctx: VerifyContext) -> VerifyResult:
want = int(spec.get("args", {}).get("min", 0))
have = await current_credits() # in-process; state the plugin owns
return VerifyResult(have >= want, f"credits {have:,}/{want:,}", evidence=str(have))
def register(registry):
registry.register_goal_verifier("credits", verify_credits) # → <plugin-id>:creditsThe ctx contract (graph.goals.VerifyContext) is stable and grows only
additively — a verifier that ignores it keeps working:
| Field | Meaning |
|---|---|
config |
the live LangGraphConfig |
condition |
the goal/watch condition text |
last_text |
last assistant message of the turn (goals; "" for a watch tick) |
tool_summary |
short summary of the turn's tool calls (goals; "" for a watch tick) |
cwd |
working directory (used by the command/test verifiers) |
invoker |
who is polling — a VerifierInvoker, or None outside the goal/watch loops |
ctx.invoker (#1641) identifies the invoking controller, so one verifier can serve
many goals/watches without resorting to global state:
kind—"goal"or"watch".id— the invoker's id: a goal is keyed by its session (soid == session_id); a watch by its own watch id.session_id— the owning session: the goal's session, or the watch'srun_session(""when the watch targets no session).interval_s— the watch's effective polling cadence (itsinterval_soverride, else the configwatch_interval);Nonefor goals (they evaluate post-turn).
VerifierInvoker is a frozen, hashable dataclass — key per-invoker state by it.
E.g. a drawdown verifier keeping one high-water mark per watch instead of one
global mark:
from graph.goals import VerifierInvoker, VerifyContext, VerifyResult
_marks: dict[VerifierInvoker | None, float] = {}
async def verify_drawdown(spec: dict, ctx: VerifyContext) -> VerifyResult:
equity = await current_equity()
mark = _marks[ctx.invoker] = max(_marks.get(ctx.invoker, equity), equity)
frac = float(spec.get("args", {}).get("frac", 0.1))
tripped = equity <= mark * (1 - frac)
return VerifyResult(tripped, f"equity {equity:,.0f} vs mark {mark:,.0f}", evidence=str(equity))To react when a goal/watch finishes, pair with register_goal_hook /
register_watch_hook — see Goal mode ▸ Reacting to a goal
and Watches.
A surface or route often needs to call the agent or the event bus — host
services it can't build. registry.host exposes them (the server populates them
before any surface starts; guard for None):
host.invoke(prompt, session_id)— run a chat turn (one conversation persession_id), returns the assistant text.host.publish(event, data)/host.subscribe()— the server→client event bus.host.on(topic, handler)— subscribe an in-process handler to bus topics (ADR 0039); prefer theregistry.emit/registry.onwrappers, which namespace + guard for you.host.config()— the liveLangGraphConfig(current resolved values, incl.plugin_config), so a route reads fresh config instead of a load-time snapshot.host.apply_settings(patch)— persist a nested config patch + reload once (heavy — call viaasyncio.to_thread). Lets a route apply config (e.g. an OAuth Connect flow flipsenabledand reloads).
def register(registry):
host = registry.host
async def _on_message(text, sid):
return await host.invoke(text, sid) # call the agent
registry.register_surface(lambda: _gateway(_on_message), name="my-gateway")registry.host covers the common cases. For deeper capability, import the consumption
SDK directly — from graph.sdk import …, the stable surface plugins call into core
(so core can refactor underneath you; never reach into graph.agent internals). v1:
-
run_subagent(subagent_type, prompt, *, description, extra_tools=None, truncate=None)— run one subagent to completion (vshost.invoke, which runs a full lead-agent chat turn).extra_toolsdefaults to the host's plugin + MCP tools, so a subagent whose allowlist names a plugin tool still sees it — pass an explicit list (even[]) to override, but overriding with a set that omits a needed tool is why an SDK step can silently degrade to "No tools available". -
complete(prompt, *, system=None, model_name=None)— a single bare LLM completion: no tools, no agent loop, no persona, no memory. The clean primitive for a one-shot classify/summarize/answer (e.g. an interactive artifact calling back). Distinct fromrun_subagent(a full tool-using worker); uses the live config's model through the gateway. -
subagent_types()— the configured subagent ids. -
config()— the liveLangGraphConfig. -
gateway_client(timeout=…)— anhttpx.AsyncClientpre-configured for the model gateway (#1931):base_url= the configuredapi_base, bearer auth, a sane timeout, and the allowlistedprotoAgent/…User-Agent (the gateway's Cloudflare WAF 403s the default SDK UAs — hand-rolled httpx calls get blocked at the edge). For the OpenAI-compatible endpoints the chat model doesn't cover —/images/generations,/images/edits,/audio/*(core's own audio transcription rides the same client). Call gateway endpoints through this; never hit a provider backend directly — the configuredapi_basehost is auto-trusted by the egress guard and the OpenShell network policy (ADR 0008), while any other host is deny-by-default under an egress allowlist and a private backend IP is denied outright. Request relative paths and use it per call:from graph import sdk async with sdk.gateway_client(timeout=300) as client: resp = await client.post("/images/generations", json={"model": m, "prompt": p}) resp.raise_for_status()
-
multimodal_tool_result(text, images)— an opt-in envelope a tool returns so the model can see an image it just produced (#1930): on a vision-capable model (model.vision: true) the image rides the ToolMessage as content blocks; on a text-only model it degrades to the caption (described viaknowledge.image_describe_modelwhen configured). Each image is{"b64"|"path": …, "mime": …}; limitsMAX_IMAGES_PER_RESULT(3) /MAX_IMAGE_BYTES(2 MiB decoded) are enforced eagerly — downscale in the tool. Ordinary string-returning tools are untouched. Pairs withregistry.save_media(#1929): save for the user to see inline, envelope for the model to see — a generate → look → refine loop uses both.from graph.sdk import multimodal_tool_result @tool async def render_chart(spec: str) -> str: png = _render(spec) # bytes ref = registry.save_media(png, "image/png") # user sees it inline return multimodal_tool_result( # model sees it too f"Rendered chart: ", images=[{"b64": b64encode(png).decode()}] )
-
knowledge_search(query, *, k=5, domain=None, epoch=None)/knowledge_add(content, *, domain="general", heading=None, epoch=None)— the plugin↔knowledge channel: search the agent's knowledge graph (hybrid FTS5 + embeddings) and write chunks back, scoped to adomainbucket. Both degrade to a no-op ([]/None) without a store.epoch(#1634) tags a chunk with the era it was learned in (an opaque string — typically a reset date); passingepoch=toknowledge_searchfilters both rankings to exactly that era, so a plugin in a resettable world (spacetraders' weekly wipes) retires old lessons by just searching with the new tag — they stay stored for post-mortems but stop polluting retrieval. -
knowledge_purge(domain, *, before=None) -> int— the knowledge lifecycle primitive (#1634): hard-delete every chunk in a domain (optionally only those created before an ISO-8601 timestamp) and return the count. Deletes consistently from every index (rows, FTS, vectors); on a layered store only the private tier is purged (the commons is curated, never bulk-deleted). Refuses (returns 0) on an empty domain or an unparseablebefore. See Knowledge ▸ Plugin knowledge lifecycle. -
run_in_session(session_id, prompt, *, delay_seconds=0, job_id=None)— enqueue a non-blocking one-shot agent turn in a session (that session's memory + full tools). The primitive behind "when a goal fires, prompt the agent" — call it from aregister_goal_hookreaction. See Goal mode ▸ Reacting to a goal. -
create_watch(*, condition, verifier, run_prompt=…, …)— register a watch (ADR 0067): pollconditionon a cadence, and on met runrun_promptas a follow-up turn (run_in_session) + fireon_methooks. Plugin-verifier only; hold many at once (unlike a monitor goal). Pair withregistry.register_watch_hook(on_met/on_expired/on_stalled=…). -
list_watches(prefix="")/clear_watch(watch_id)— the watch lifecycle half (#1638): enumerate the registered watches (each{id, condition, status, verifier}, optionally id-prefix-filtered — e.g.list_watches("st-")for your own suite) and remove one by id (Trueif it existed). Together they make a plugin's arm step a reconcile — clear the suite ids no longer in your spec set, then create/replace the rest — so a renamed/dropped watch spec can't leak a zombie watch. See Watches. -
spawn_background(prompt, *, subagent_type, origin_session, label=None)— spawn a detached background subagent job (ADR 0050) that returns abg-…id immediately and rides the full ADR 0070 results pipeline (push-resume nudge intoorigin_session, KB-indexed report, console report card). The seam for long campaign work — never reach intoSTATE.background_mgrdirectly. -
background_status(task_id)— the status-query companion:{status, description, report?}for a spawned job (reportonce terminal), so a plugin can render progress on its own surface between launch and the completion nudge. -
react_on(topic, *, prompt, job_id, session=…, debounce_s=0)— reactive-rule sugar: when a bus event matchingtopicfires, build a prompt from the payload and enqueue a follow-up turn (run_in_session).prompt(event) -> str | None(None/empty skips the event),job_idmakes re-fires replace rather than stack,debounce_scoalesces a burst into ONE turn (trailing-edge; the last event's prompt wins),sessiondefaults to the Activity thread. Returns an unsubscribe fn. The one-call form of the canonicalregistry.on→run_in_sessioncomposition — see Events. -
schedule_recurring(prompt, cron, *, plugin_id, job_id, session="", timezone=None)— a plugin-owned recurring cadence (#1642): a cron job whose id is namespacedplugin:<plugin_id>:<job_id>so the host cancels it on disable/uninstall (no orphan cadence outlives its plugin). Idempotent by id (a re-call replaces), fires into Activity by default; passplugin_id=registry.plugin_id. One-shot turns stay onrun_in_session. Withcancel_scheduled(job_id, *, plugin_id)andcancel_plugin_jobs(plugin_id)— see Scheduler ▸ Plugin-owned recurring jobs. -
record_metric(name, value, *, ts=None, plugin_id)/metric_history(name, *, since=None, limit=500, plugin_id)/metric_last(name, *, plugin_id)— a plugin metric timeseries (#1632): small named numeric series (treasury, net worth, fleet size), namespaced<plugin_id>:<name>into one per-instance SQLite store (metrics.db), retention-capped per series (90 days / 10k points, trimmed on write — record freely from an engine tick). This is the history a live-state watch verifier (ADR 0067) can't get any other way — drawdown vs high-water mark, flatline detection — and the substrate for dashboard sparklines. Timestamps are Unix epoch seconds (ts=None→ now);metric_historyreturns the newestlimitpoints (optionally at/aftersince) oldest→newest as(ts, value)tuples;metric_lastreturns the latest(ts, value)orNone. Passplugin_id=registry.plugin_id(explicit, likeschedule_recurring— the SDK has no ambient plugin identity;':'is rejected so one plugin can't reach another's namespace). Point-in-time snapshots stay ontelemetry(); per-turn cost rollups stay on the operator telemetry store.
Re-exported host-free kits — the SDK also re-exports a few building blocks so a plugin
doesn't hand-roll them (from graph.sdk import …):
supervise/Supervisor/RetryAfter— a supervised, watchdog-backed background task runner for a self-perpetuating engine loop (instead of hand-rolled task/restart machinery).Knobs/make_knob_tools— a bounded, reversible set of tunable engine knobs + presets, with auto-generatedshow/tune/presetagent tools.DecisionLog/telemetry/render_html— the telemetry + decision-log kit for a standard observability surface (audit trail + point-in-time envelope + a themed panel).
The workflows plugin (plugins/workflows) is the reference consumer: its engine
injects run_subagent as the per-step runner. This is the pattern for plugins that tap
core, not just contribute to it.
Plugins coordinate by broadcasting events, never by importing each other. You publish under your own namespace and forget; anyone who cares subscribes by topic. This is the only inter-plugin channel — the no-cross-dependency rule.
def register(registry):
registry.emit("created", {"id": "a1"}) # → publishes "<plugin_id>.created"
registry.on("notes.*", on_notes) # subscribe to ANY topic; * / # wildcards- Publish is namespace-guarded —
emit("created")becomes<plugin_id>.created; you can only publish under your own namespace. Subscribing is read-only and may match any topic. - Declare your contract in the manifest (
emits:/subscribes:) — your events are your public API, discoverable in/api/runtime/status. - Type your contract (optional) — an
emits:entry may declare the payload shape so a cross-plugin consumer doesn't reverse-engineer your source. See Typed event contracts below. - A console view (sandboxed iframe) talks to the bus over the bridge — see
Building a plugin view. Any event under
<plugin_id>.*lights your plugin's rail icon (a notification dot) until the user opens that surface. - Fire-and-forget + topic-filtered + exception-isolated: a slow or broken subscriber can't affect the publisher or other subscribers. Ephemeral (a ring buffer covers SSE reconnects; no durable log).
- The most common subscriber is "when X happens, have the agent react" — that composition
(
on→ prompt-from-payload →run_in_session, with an idempotent job id and burst debouncing) ships as one consumption-SDK call:sdk.react_on(…)(above).
Cross-process note: under the ACP runtime, a tool runs in the operator-MCP process where the bus isn't wired, so
emitfrom a tool won't reach the server bus there. Under the default runtime (tool runs in-server) it does.
A names-only emits: list tells a consumer that a topic exists, not what the payload looks
like — the consumer reverse-engineers the emitter and silently breaks when a field changes. An
emits: entry may therefore declare its payload shape (#1636): a mapping with topic plus an
optional summary and/or schema (JSON Schema — inline, or a $ref to a file inside the plugin
repo, resolved relative to the plugin directory and read at load):
emits:
- spacetraders.window_closed # bare topic name — still fine
- topic: spacetraders.trade_executed
summary: A hauler completed a buy→sell leg
schema:
type: object
required: [route, profit]
properties: { route: {type: string}, profit: {type: integer}, ship: {type: string} }
- topic: spacetraders.ship_purchased
schema: { $ref: events/ship_purchased.json } # file in the plugin repo- Purely declarative (like
capabilities): the declared shapes ride/api/runtime/statusas a per-pluginemits_schemasmap (topic → {summary?, schema?}), so consumers and the console can discover payload shapes. Nothing validates payloads at publish time. (A dev-channel warn on mismatch validator is a possible later step, gated by a developer flag — deliberately not built yet.) - Backward compatible: bare-string entries keep working unchanged, and
emitsstays the names-only topic list everywhere it's already consumed. - Never load-bearing: a missing/invalid
$ref, a ref that escapes the plugin directory, or a malformedschemalogs a warning and degrades that entry to names-only — it never fails the plugin load.
The core console is deliberately lean: one push-based SSE connection, no always-on polling (its react-query refetches pause when the window is backgrounded). A plugin should be just as well-behaved — the only extra cost should be the one your plugin chooses to add, and it should go quiet when nobody's looking. This matters doubly for the desktop build.
- Prefer events over polling. Subscribe to the bus (
registry.on/protoagent:event) instead of polling an endpoint on a timer where you can. - If you must poll, pause when hidden. In a served view, guard the loop with the Page Visibility
API and refresh on return — don't poll a minimized window:
setInterval(() => { if (!document.hidden) refresh(); }, 1500); document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
- Clean up on unmount. The console unmounts a plugin view's iframe the moment you tab/collapse
away — your in-iframe timers/listeners die with it for free. For host-side work (a
registry.onhandler, a background surface), return/register a teardown so nothing lingers.
A configurable plugin declares its config in the manifest (data, so it's known
at config-load time before register() imports). It claims a top-level config
section (default: the plugin id) and gets a Settings group + secrets routing —
no config.py / settings_schema.py edit:
# protoagent.plugin.yaml
config_section: hello # top-level YAML section (default: the id)
config: { greeting: "Hello", api_key: "" } # defaults
secrets: [api_key] # → secrets.yaml (redacted in the UI)
settings: # System → Settings group (named after the section)
- { key: greeting, label: "Greeting word", type: string }
- { key: api_key, label: "API key", type: secret }Field types: string · text (multiline string — a system prompt / template) ·
number · bool · select (with options: [...]) · string_list · secret.
Conditional fields — add depends_on to show a field only once a sibling is set
(e.g. an "enable X" toggle gates X's options); reactive to the in-form value:
settings:
- { key: ask_enabled, label: "Interactive", type: bool }
- { key: ask_system, label: "Ask system instruction", type: text,
depends_on: { key: ask_enabled, equals: true } } # also: { key, in: [...] } | bare { key } = truthyRequired config & incomplete plugins (#1719): mark a setting required: true
to declare the plugin needs it to work. If an enabled plugin loads while a required
field is still blank, it stays loaded but is flagged incomplete — a soft gate,
not requires_env (which refuses to load). GET /api/runtime/status and
/api/plugins/installed then carry incomplete: true + needs_config: [{key, label}],
and the plugin's tools are swapped for same-signature stand-ins that return a
friendly "needs setup" notice instead of erroring mid-call — so the agent can point
the operator at configuration. Fill the field in; the next config reload restores the
real tools. (0 / false count as provided — only null / empty-string / empty-list
read as "unset".)
settings:
- { key: api_key, label: "API key", type: secret, required: true }Read the resolved config (manifest defaults ⊕ YAML ⊕ secrets) in register():
def register(registry):
greeting = registry.config.get("greeting", "Hello") # ADR 0019
registry.register_router(_build_router(greeting)) # close over itA plugin section colliding with a reserved built-in (model, mcp, plugins,
…) is ignored. (A plugin section like discord is not reserved — a plugin,
bundled or external, claims its own section the same way.)
A plugin declares its required config with required: true (above) and the console
surfaces the incomplete state so an operator knows to finish setup; a guided
install wizard over those fields is the frontend follow-up (#1719).
Routes now hot-reload; surfaces still don't. On a config reload a newly-enabled
plugin's routers, public paths, verifiers, hooks, tools, subagents, chat commands,
and MCP servers re-apply without a restart (#1752/#1890). A surface does not — the
startup hook already fired, so it (re)starts only on a full restart; a config reload just
calls each running surface's reload(cfg) callback. Everything is best-effort: a failing
plugin/route/surface logs and never breaks boot. The shipped plugins/hello
example demonstrates the contribution types. Plugin contributions show in
GET /api/runtime/status. The bundled plugins/telegram (the reference
ChatAdapter) and plugins/github first-party plugins are worked examples of the
contribution types; the external discord-plugin is a fuller surface + route + tools.
Two roots (like skills): bundled plugins/ (shipped, e.g. the hello example)
and live <config-dir>/plugins/ (your drop-ins; <config-dir> honors
PROTOAGENT_CONFIG_DIR, override with plugins.dir). Live overrides bundled by id.
A plugin loads only when enabled — either:
plugins:
enabled: [hello] # operator opt-in, by idor enabled: true in the plugin's own manifest (author opt-in for plugins you
wrote/dropped in). Discovered-but-disabled plugins still appear in runtime
status so you can see what's available.
From the console, the Plugins panel has a one-click Enable / Disable toggle per
plugin — it edits plugins.enabled and hot-reloads, so tools / middleware / MCP servers
apply immediately. A plugin that serves a console view or runs a background surface
(its router mounts at startup) needs a restart to finish — the toggle says so.
Plugin tools that would shadow a core or MCP tool name are skipped (logged).
Bundled skills load as disk-source skills, re-seeded each boot.
A git-URL-installed plugin is pinned in plugins.lock at the ref you installed
(a branch, a release tag, or an exact commit). By default updates are manual:
the console Plugins panel shows an Update available badge when a plugin is
behind its ref, and the Update button pulls the latest code + hot-reloads it
(POST /api/plugins/{id}/update).
To let chosen plugins update themselves in the background, opt them into an auto-update policy (#1720):
plugins:
autoupdate_interval_hours: 6 # sweep cadence; 0 disables the loop entirely
update_policy:
my-plugin:
track: main # arms auto-update (the ref itself comes from the lock)
when: idle # idle (default) | always
another-plugin:
track: main
when: alwaysEach sweep, for every plugin listed in update_policy, the runtime checks whether
it's behind its locked ref and — if so — pulls + hot-reloads it exactly like the
Update button, then emits plugin.updated on the event bus.
The gates:
- Opt-in only. A plugin is auto-updated only if it appears in
update_policywith a non-emptytrack. Everything else stays manual. - Never a pinned commit. A plugin pinned to an exact SHA is immutable and is skipped; a release-tag pin moves to the newest matching tag, a branch pulls its head.
when: idle(the default) defers a plugin's update while a chat turn is — or was just — in flight. A reload rebuilds tools/routers, which is safe between turns but disruptive during one.when: alwaysupdates on the next sweep regardless.
The default config has an empty update_policy, so nothing auto-updates until you
add a plugin to it.
- Loading is best-effort: a broken plugin (bad manifest, import error,
missing
requires_env) is logged and skipped — it never blocks boot. GET /api/runtime/statuslistspluginswith{id, name, enabled, loaded, tools, skills}.- Plugins are (re)loaded at startup and on config reload.
graph/plugins/testkit.py is a host-free test harness: it loads a plugin the way the
runtime does (as a package, so relative imports and deep engine modules work), stubs the
host-only graph.* / knowledge.* imports, and hands register() a FakeRegistry
that captures every contribution — so a plugin's real modules run under plain pytest
with no protoAgent server. It's stdlib-only by design: scaffold_plugin(with_tests=True)
vendors it verbatim into a standalone plugin repo as tests/_plugin_testkit.py; bundled
plugins import it directly (from graph.plugins.testkit import load_plugin, install_host_stubs, FakeRegistry).
install_host_stubs() # graph.* / knowledge.* resolve with no host
pkg = load_plugin("path/to/my-plugin") # loaded as a package, like the runtime
reg = FakeRegistry()
pkg.register(reg)
assert reg.tools and "issue" in reg.chat_commands # assert the captured contributionsThe parity contract: FakeRegistry mirrors every public PluginRegistry method
(register_*, emit, on, navigate, live_config) with the same parameters — a
missing method would make that seam silently untestable (plugins hasattr-guard these
calls, so a typo'd registration would ship green). A drift guard in
tests/test_plugin_testkit.py introspects both classes and fails when a new registry
seam isn't mirrored. One intentional divergence: where the real registry warns and
skips an invalid registration (degrade-safe live — e.g. a chat command named goal,
which is reserved), the fake raises ValueError so the mistake fails your test.
Enable the shipped example:
plugins:
enabled: [hello]Restart, then check GET /api/runtime/status — the hello plugin shows
loaded: true with its hello tool and greeting skill.
- Building a plugin view — give a plugin its own console surface — a left-rail view or a chat-slot panel (ADR 0026 / 0045).
- Install & publish plugins (git URLs) — install a
plugin from a git URL (
python -m server plugin install <url>) or publish one as a shareable repo. A repo is a full bundle: besides whatregister()adds, a conventionalskills/(SKILL.md) andworkflows/(*.yaml) are auto-discovered (ADR 0027).