The object your plugin's register(registry) receives — the whole Python extension
surface in one place. Generated from graph/plugins/registry.py.
def register(registry):
"""Called at plugin load and again on every config reload, before the graph is built."""
registry.register_tool(my_tool)Two properties of this surface are worth knowing before you use any of it:
Every seam is best-effort. A register_* call with bad arguments logs a warning and
returns — it never raises into the loader, because one malformed plugin must not stop an
agent from booting. If a seam seems to have done nothing, read the log.
Guard for older hosts. A plugin that must run against a host predating a seam should
hasattr(registry, "register_x") before calling it. That is also why
FakeRegistry mirrors this class method-for-method, with a
test that fails CI when it stops matching.
Read these in register() and close over them for your tools, routes, and surfaces.
The plugin's manifest id — the slug every namespaced thing derives from. Pass it to the SDK calls that take an explicit owner (sdk.schedule_recurring, sdk.record_metric): that attribution is how the host sweeps a plugin's jobs when it's disabled, and how one plugin is kept out of another's namespace.
The plugin's own directory on disk. The right base for reading files the plugin ships — templates, a view's HTML, seed data — since the process CWD is not it.
The plugin's resolved config section (ADR 0019) — manifest defaults ⊕ YAML ⊕ secrets. Read it in register() and close over it for your tools/routes/surface, e.g. registry.config.get("api_key"). This is a register-time SNAPSHOT; for a mounted router/surface that must reflect config edits without a restart, use live_config() instead.
The top-level config key this plugin's section lives under (config_section or the id) — the lookup key for live_config().
Host services (agent invoke + event bus) a surface/route can use — the server populates these before startup; guard for None (e.g. in tests).
| Method | Purpose |
|---|---|
emit() |
Broadcast an event on the bus (ADR 0039) — fire-and-forget |
live_config() |
The plugin's CURRENT resolved config, re-read from the host on each call |
navigate() |
Ask the operator console to open one of THIS plugin's views — plugin-driven UI navigation (ADR 0044) |
on() |
Subscribe an in-process handler to bus topics (ADR 0039) |
register_a2a_skill() |
Contribute an A2A card skill — advertised on the agent card and, when it declares output_schema + result_mime, enforced by the executor's structured finalizer… |
register_chat_command() |
Own a user-only chat control command — /<name> … short-circuits the turn with the handler's reply, like the core /goal (and the old, now plugin-owned, /issue) |
register_component() |
Contribute a component-v1 KIND (ADR 0051) the chat stream may carry — e.g. a chip that points into this plugin's console view (the artifact… |
register_embedder() |
Contribute an in-process embedder (ADR 0031 follow-up) — factory(config) -> (text: str) -> list[float] |
register_goal_hook() |
React when a goal reaches a terminal state (ADR 0028 D4) |
register_goal_verifier() |
Contribute an in-process goal/watch verifier (ADR 0028) — an async (spec, ctx) -> VerifyResult referenced by a {"type":"plugin", "check":"<name>"} goal or watch |
register_knowledge_store() |
Contribute a knowledge backend (ADR 0031) — factory(config) -> KnowledgeBackend (see knowledge.backend.KnowledgeBackend for the surface: pgvector, Qdrant… |
register_late_tool_factory() |
Contribute a tool factory that runs AFTER the full toolset is assembled |
register_lifecycle_hook() |
React to a system lifecycle event (ADR 0074) — on_app_loaded (boot finished: graph compiled + scheduler/surfaces/fleet up), on_agent_active (the agent went… |
register_mcp_server() |
Contribute a managed MCP server the agent connects to (ADR 0019) |
register_middleware() |
Add a plugin-contributed LangGraph AgentMiddleware (ADR 0032) |
register_router() |
Mount a FastAPI APIRouter on the server (ADR 0018) |
register_setup_step() |
Register a SETUP STEP: the server-side half of a plugin_setup setup-gap action |
register_skill_dir() |
Add a directory of SKILL.md skills bundled with the plugin |
register_subagent() |
Add a SubagentConfig to SUBAGENT_REGISTRY (ADR 0018) |
register_surface() |
Register a lifecycle-managed background surface (ADR 0018) |
register_thread_id_resolver() |
Override how the checkpointer thread_id is derived for each turn (#571): fn(request_metadata: dict, session_id: str) -> str |
register_tool() |
Expose a LangChain tool to the agent |
register_tools() |
Convenience: register an iterable of tools |
register_watch_hook() |
React when a watch trips (ADR 0067) — on_met (verifier passed), on_expired (deadline passed), on_stalled (evidence unchanged for stall_after checks… |
register_work_provider() |
Contribute this plugin's own open work to the agent's <working_state> block (ADR 0079, the Observe step) |
register_workflow_dir() |
Add a directory of *.yaml workflow recipes bundled with the plugin (ADR 0027) |
report_setup_gap() |
Tell the operator this plugin can't do its job until something is fixed (a missing binary, no coder delegate, an unauthenticated CLI) — or clear that notice with message=None |
save_media() |
Persist a generated binary artifact (image/audio/video/…) into the CORE media store and get back a MediaRef {id, url, path, mime} (#1929) |
registry.emit(topic: str, data: dict | None = None) -> NoneBroadcast an event on the bus (ADR 0039) — fire-and-forget.
Topics are namespaced to this plugin: emit("created") publishes
"<plugin_id>.created". A topic not already under this plugin's namespace is
auto-prefixed — a plugin may only publish under its own namespace (the
no-cross-dependency clause). Consumers subscribe by topic; nobody imports this
plugin to hear it.
registry.live_config() -> dictThe plugin's CURRENT resolved config, re-read from the host on each call.
self.config is a register-time snapshot, so a hot-reload after a config save
can't refresh it for an already-mounted router or running surface (FastAPI can't
re-mount a router). But the reload DOES rebuild STATE.graph_config — so a
handler that reads this on each request reflects config edits with no restart.
Falls back to the snapshot when the host state isn't available (unit tests, or a
section that resolved empty).
registry.navigate(view: str = '') -> NoneAsk the operator console to open one of THIS plugin's views — plugin-driven UI navigation (ADR 0044). Fire-and-forget.
Publishes a reserved host intent ui.navigate with {plugin, view}; the
console focuses plugin:<this plugin>:<view> when that surface exists (a blank
view opens the plugin's first view). Unlike emit, this is a host-level
navigation request, not a namespaced plugin event — and it is scoped: the
payload carries this plugin's id, so a plugin can only open its own views, never
hijack the console to another surface. The console honors it generically (one
handler, no per-plugin code), so any plugin gets agent-driven navigation for free.
registry.on(topic: str, handler) -> NoneSubscribe an in-process handler to bus topics (ADR 0039). topic may use
* (one segment) / # (tail) wildcards and match ANY plugin's namespace —
subscribing is read-only and safe. handler(payload) gets {event, data, seq},
may be sync or async; exceptions are isolated by the bus.
registry.register_a2a_skill(spec: dict) -> NoneContribute an A2A card skill — advertised on the agent card and,
when it declares output_schema + result_mime, enforced by the
executor's structured finalizer (#570). Distinct from
register_skill_dir (disk SKILL.md procedural memory): this is what
the card advertises to callers. spec is a dict with at least
id/name/description (+ optional tags/examples/
output_schema/result_mime). All three are required — the card
build hard-indexes them, so a spec missing any must fail HERE, attributed
to its plugin, not as a KeyError at boot inside the card build (#2754).
registry.register_chat_command(name: str, handler) -> NoneOwn a user-only chat control command — /<name> … short-circuits the
turn with the handler's reply, like the core /goal (and the old, now
plugin-owned, /issue).
handler is async (rest: str, session_id: str) -> str | dict | None:
rest is everything after the token; return the reply string to send (the
turn is NOT run through the agent), or None to pass the message through as a
normal turn. This is user-only by design — it is NOT an agent tool, so a
plugin can expose a write action (file an issue, open a PR) that the model
can't invoke autonomously. Read your own config in register() and close
over it, e.g. repo = self.config.get("default_repo").
To collect input, return a form request (#1701 Slice 2):
{"form": <request_user_input-shaped payload>, "on_submit": <async (answers: dict, session_id: str) -> str | dict | None>}. The runtime opens
the form in the composer (the same HitlForm the agent's HITL uses) and,
on submit, calls on_submit with the field values — which may itself return
a reply string or another form (a multi-step wizard).
The token is slugified + lowercased (/Issue == /issue). The reserved
core tokens goal / lifecycle are refused; a collision with a token another
enabled plugin already registered keeps the first and warns (resolved in the loader).
registry.register_component(name: str, validator) -> NoneContribute a component-v1 KIND (ADR 0051) the chat stream may carry — e.g. a chip
that points into this plugin's console view (the artifact plugin's artifact-ref,
#3617).
Your TOOL emits it: return graph.components.encode_component(name, props) after
the model-facing text, and the server lifts it into a component frame. The host
forwards a payload only when validator(props) returns None; return a short
reason string to drop it (a raise also drops it). Keep props a POINTER — ids,
numbers, short labels — never content: they persist in chat history. The console
renders the kind through a registerChatComponent renderer (apps/web/src/ext);
without one it shows a labeled "[unsupported component]" note.
name is lowercase kebab ([a-z][a-z0-9-]*, ≤ 64 chars) and can't be a core kind
(table/keyvalue/timeline/code-ref); an invalid name or non-callable
validator is refused with a warning. The kind is live only while the plugin is
loaded — disabling it stops extraction on the next reload. show_component never
builds a plugin kind. Guard with getattr(registry, "register_component", None) on
hosts older than this seam.
registry.register_embedder(name: str, factory) -> NoneContribute an in-process embedder (ADR 0031 follow-up) — factory(config) -> (text: str) -> list[float]. Selected by a fork with
knowledge.embedder: "<name>" for the built-in hybrid store, avoiding the
gateway round-trip (e.g. fastembed / sentence-transformers). On a None/error
return the agent falls back to the gateway embedder (degrade-safe).
registry.register_goal_hook(*, on_achieved=None, on_failed=None) -> NoneReact when a goal reaches a terminal state (ADR 0028 D4). on_achieved /
on_failed take the terminal GoalState (sync or async) — push a notification,
record a finding, or set the next goal. Provide either/both. A raising hook is logged +
swallowed. (A metric to watch over time is a watch — see register_watch_hook with
on_met/on_expired/on_stalled, ADR 0067.)
registry.register_goal_verifier(name: str, fn, description: str = '') -> NoneContribute an in-process goal/watch verifier (ADR 0028) — an async
(spec, ctx) -> VerifyResult referenced by a {"type":"plugin", "check":"<name>"} goal or watch. Name it <plugin-id>:<verifier> to
avoid collisions; args in the spec are declarative data your verifier
validates (no shell, no eval). This is the only verifier type safe to set
programmatically (D3). ctx is a graph.goals.VerifyContext;
ctx.invoker identifies the polling goal/watch (kind/id/session_id/
interval_s, #1641) so a verifier can keep per-invoker state.
description is a one-line human summary shown wherever a verifier is CHOSEN
(the console's goal/watch creators list it beside the core types). Optional and
additive — an older plugin that omits it just shows its <plugin-id>:<name>.
registry.register_knowledge_store(name: str, factory) -> NoneContribute a knowledge backend (ADR 0031) — factory(config) -> KnowledgeBackend (see knowledge.backend.KnowledgeBackend for the
surface: pgvector, Qdrant, Chroma, a managed vector DB…). Selected by a
fork with knowledge.backend: "<name>"; on a None/error return the agent
keeps the built-in SQLite store (degrade-safe). Name it simply (e.g.
pgvector); a collision keeps the first.
registry.register_late_tool_factory(factory) -> NoneContribute a tool factory that runs AFTER the full toolset is assembled.
factory(all_tools, config) -> BaseTool | list[BaseTool] | None receives the
fully-resolved, denylist-final tool list (core + subagent + plugin + MCP
tools, after the operator tools.disabled sweep — ADR 0103 S4: a proxying
factory may treat it as exactly what the model has bound) and the live
LangGraphConfig; its result is appended last (and denylist-swept itself),
before the deferred search_tools meta-tool (so the late tool stays
discoverable). For a
meta-tool that must see or wrap every other tool — e.g. programmatic
tool-calling that proxies the whole set — which a plain register_tool can't,
because plugin tools are registered before the set is complete. Built last, so
it can reference any tool but never itself. A raising factory is logged and
skipped, never breaking the graph build.
registry.register_lifecycle_hook(*, on_app_loaded=None, on_agent_active=None, on_system_wake=None) -> NoneReact to a system lifecycle event (ADR 0074) — on_app_loaded (boot finished:
graph compiled + scheduler/surfaces/fleet up), on_agent_active (the agent went
idle → active — the first turn after a quiet gap, debounced), on_system_wake
(reserved — the desktop shell woke; emitted in a follow-up). Each takes the event
payload dict (ts + previous_state + event-specific keys), sync or async.
Provide ANY of the three. A raising hook is logged + swallowed — a bad hook must never
break boot or a turn. (These are broadcast on the bus too, so registry.on(...) is
the zero-config alternative; a hook is the direct callback form.)
registry.register_mcp_server(factory) -> NoneContribute a managed MCP server the agent connects to (ADR 0019).
factory is a callable factory(config) -> dict | None returning a
mcp.servers[] entry ({name, transport, command, args, env, ...}) or
None when the server shouldn't start (off / not yet connected). It's
called at every graph build with the live LangGraphConfig, so the
server comes and goes with config — this is how a plugin ships an
OAuth-gated MCP server without a core edit. A returned entry whose
name matches a user-defined mcp.servers entry replaces it.
registry.register_middleware(factory) -> NoneAdd a plugin-contributed LangGraph AgentMiddleware (ADR 0032).
factory is (config) -> AgentMiddleware | None — it receives the live
LangGraphConfig and returns a middleware instance (or None to opt out).
Plugin middleware is appended to the chain just before the internal
message-capture middleware (so before/after-model + tool hooks run, and the
turn is still captured). For per-request data, read
graph.middleware.request_context.current_request_metadata().
This is the last core extension point that previously forced a fork to edit
graph/agent.py / executor.py.
registry.register_router(router, prefix: str | None = None) -> NoneMount a FastAPI APIRouter on the server (ADR 0018).
Defaults to the namespaced prefix /plugins/<id> so a plugin can't
silently shadow a core route. Pass prefix="" (or your own) to mount
elsewhere — an escape hatch, logged. The two canonical prefixes are the
public view /plugins/<id> and the bearer-gated data router
/api/plugins/<id> (the documented two-router pattern — docs/guides/
plugin-views.md, ADR 0026); a prefix outside both logs a WARNING (#870,
#1732). The default-deny auth middleware guards all non-public paths
regardless of prefix.
registry.register_setup_step(step: str, fn) -> NoneRegister a SETUP STEP: the server-side half of a plugin_setup setup-gap action.
For a fix that is a COMMAND rather than a setting — download a CLI, install a browser
— which a banner would otherwise tell the operator to go and run in a terminal.
Report the gap with action={"kind": "plugin_setup", "step": step, "label": "Download the CLI"} and the console renders a button on its banner; clicking it
POSTs /api/plugin-setup/<this plugin>/<step> — a core route outside the
/api/plugins/<id>/ subtree, so no manifest public_paths / federation_paths
can lower its operator-credential gate — and the host runs fn() off the event loop.
Only the callable held for exactly this (plugin, step) pair ever runs — the action is
data naming it, nothing more.
step is one lowercase identifier ([a-z0-9][a-z0-9_-]*, at most 64 chars; a
plugin may hold 8). fn takes no arguments and returns a short message string, or
a dict {"ok": bool, "message": str, "pending": bool}. Long work belongs on a
thread: start it, re-report the gap with its progress (and no action, so it can't be
clicked twice), return pending: True, and clear or re-report the gap when it
finishes — the console keeps refreshing runtime status while a step it started is
pending. An exception becomes ok: False with its message, never a 500. Steps are
dropped along with the plugin's gaps when it is disabled or uninstalled.
register_setup_step arrived with the plugin_setup kind; a plugin that must
also run on older hosts guards it with getattr(registry, "register_setup_step", None) — an older host drops the unknown action kind, so the banner degrades to its
message.
registry.register_skill_dir(path: str | Path) -> NoneAdd a directory of SKILL.md skills bundled with the plugin.
Relative paths resolve against the plugin's own directory.
registry.register_subagent(config) -> NoneAdd a SubagentConfig to SUBAGENT_REGISTRY (ADR 0018).
Picked up by every graph build, so the lead agent can delegate to it via
task / task_batch — no edit to graph/subagents/config.py.
registry.register_surface(start, stop=None, name: str | None = None, reload=None) -> NoneRegister a lifecycle-managed background surface (ADR 0018).
start (sync or async, no args) runs in the server's startup hook — so
it has the running loop, like the Discord gateway — and may return a task/
handle. stop (optional) runs in shutdown. reload (optional, called
with the new LangGraphConfig on a config reload) lets a running surface
reconfigure in place when its config changes. Best-effort: a failing surface
logs, never breaks boot.
register() re-runs on every config reload. What happens to a surface that
is still wanted afterwards depends on the two registrations (#3593):
- both declare
reload→ it keeps running andreload(cfg)is called; - otherwise, the same
stop(a module-level function, or a long-lived object's method) → it is the same surface and is left running untouched; - otherwise, a different
stop(fresh closures over fresh objects) → it is stopped, its task given a grace period (then cancelled), and started again from the new registration — so the running surface and the plugin's routes/tools never hold two different instances. If the old one will not end, it is kept and the replacement is not started.
registry.register_thread_id_resolver(fn) -> NoneOverride how the checkpointer thread_id is derived for each turn
(#571): fn(request_metadata: dict, session_id: str) -> str. Lets a
fork scope memory off request metadata (e.g. per-project working memory)
without editing server/chat.py. One resolver wins — last registration
across enabled plugins (a warning fires if more than one is contributed).
Unset ⇒ the template default (a2a:<session_id>).
registry.register_tool(tool) -> NoneExpose a LangChain tool to the agent.
registry.register_tools(tools) -> NoneConvenience: register an iterable of tools.
registry.register_watch_hook(*, on_met=None, on_expired=None, on_stalled=None, on_changed=None) -> NoneReact when a watch trips (ADR 0067) — on_met (verifier passed), on_expired
(deadline passed), on_stalled (evidence unchanged for stall_after checks, the
watch stays active), on_changed (a trigger: "change" watch observed a new value —
NOT the same as met: the value moved, the condition may still be false). Each takes the
Watch (sync or async). Provide ANY of them. A raising hook is logged + swallowed.
registry.register_work_provider(name: str, fn, label: str = '') -> NoneContribute this plugin's own open work to the agent's <working_state> block
(ADR 0079, the Observe step).
fn is () -> list[dict]; each item is {"id", "title", "state", "hint"}
with every field optional (a plain string is accepted and rendered verbatim). The
host renders them under a heading — label if you set one, else
OPEN WORK (<name>) — in the SAME block and the same line shape as OPEN TASKS,
so the agent reads one vocabulary for its commitments rather than two.
This is projection, not replication: your store stays the system of record and the host reads a bounded snapshot per turn, so there is no copy to keep in sync. Use it when your plugin owns a queue the agent is responsible for — a board, a review lane — not for reference data the agent merely consults (that is knowledge/RAG).
Your provider must be cheap and non-blocking: it runs inline on EVERY turn.
Return an in-memory snapshot — never shell out, hit the network, or take a lock. A
provider slower than graph.work_providers.SLOW_PROVIDER_S is logged once, and
one that raises is skipped without disturbing the turn.
registry.register_workflow_dir(path: str | Path) -> NoneAdd a directory of *.yaml workflow recipes bundled with the plugin
(ADR 0027). Relative paths resolve against the plugin's own directory. A
conventional <plugin>/workflows/ dir is auto-discovered without this
call; use it for a non-standard location.
registry.report_setup_gap(key: str, message: str | None, *, label: str | None = None, action=None) -> NoneTell the operator this plugin can't do its job until something is fixed
(a missing binary, no coder delegate, an unauthenticated CLI) — or clear that
notice with message=None. Surfaces in GET /api/runtime/status as a
structured record in setup_gaps[] (plugin, key, label, message, actions), which
the console renders as a dismissible banner, plus the legacy
"<label>: <message>" line in warnings[] for older consumers. It self-clears
live, so a plugin that re-checks each tick heals the banner without a restart.
key namespaces one gap per concern ("br", "auth"); label is the
display name for the banner (defaults to the plugin id). Plugins that also run
on older hosts guard with getattr(registry, "report_setup_gap", None).
action (optional, keyword-only, SINGULAR — there is no actions=; omit it and
storage/warnings are byte-for-byte what they always were) attaches a bounded,
DECLARATIVE remediation hint that the console renders as a button on the banner: a
single action dict or a list of them, e.g.
action={"kind": "plugin_config", "label": "Configure My Plugin", "fields": ["repo"]}
(a plugin_config action with no label reads "Configure <label>"). An action
is closed, server-validated DATA, never behavior — a fixed kind from
setup_gaps.ACTION_KINDS ("plugin_config" opens THIS plugin's config section
— the target is forced to this plugin, it can't aim at another;
"global_settings" opens global settings at an optional section target;
"plugin_setup" names a step THIS plugin registered with
register_setup_step and renders as a button that runs it; "install_deps"
renders as an "Install dependencies" button that installs THIS plugin's declared
requires_pip through the host's install-deps route) plus
optional bounded label/fields plain text; any other key is dropped. The host
sanitizes on the way in: an unknown kind, a callback, an arbitrary URL, HTML, or an
oversized payload is dropped, not stored, and a malformed action never raises into
plugin loading. It is the console's job to turn a kind into a known control —
never a plugin string into a URL or callback. action arrived in v0.162.0; a
plugin that must also run on older hosts feature-detects the keyword
("action" in inspect.signature(fn).parameters) and makes the plain call
otherwise.
A report from a registry whose plugin has since been disabled, uninstalled, or failed to reload is dropped — so a thread of the old load that finishes late (a download, an install) can't bring back a banner whose buttons no longer lead anywhere.
registry.save_media(data, mime: str, meta: dict | None = None)Persist a generated binary artifact (image/audio/video/…) into the CORE
media store and get back a MediaRef {id, url, path, mime} (#1929).
The convention: embed ref.url in the tool's returned markdown —
f"" — and the console chat renders it
inline, with no plugin-owned route and no UI change. data is the
raw bytes or a source file path to copy in; mime drives the file
extension and the served content type; meta is optional provenance
(prompt, model, …) kept in a hidden sidecar (this plugin's id is recorded
automatically). Files land under the instance data dir
(instance_paths().store("media")) and survive restart.
Access: the returned url carries a per-file HMAC signature, so it
renders even under a bearer-gated deployment (an <img> tag can't send
a header) while the store stays default-deny; media.public: true
(core config) opts the whole store public, and media.retention_days
prunes old files. A media.saved event is broadcast on the bus.
Services the server owns: calling the agent, the event bus, live config. The server
populates them before any surface starts, so they are None at import time and in
host-free tests — always guard. Generated from graph/plugins/host.py.
| Service | Type | Purpose |
|---|---|---|
host.invoke |
Callable[..., Awaitable[str |
async (prompt, session_id, *, tool_fence=None) -> str — invoke the agent as a chat surface (one conversation per session_id, the LangGraph thread key). tool_fence (#2972, optional keyword) is a per-turn tool allowlist for a turn that originated with an untrusted party — e.g. a surface relaying another operator's agent; the host blocks tool calls outside it. Surfaces that need it should feature-detect (inspect.signature) and refuse to run unfenced on an older host. |
host.invoke_delegate |
Callable[..., Awaitable[str |
async (name, prompt, conversation_key=None, *, permissions="readonly") -> str — invoke a configured delegate without reaching into its plugin package. The default host-enforced ceiling makes an ordinary plugin invocation read-only. |
host.publish |
Callable[[str, dict], Any |
(event: str, data: dict) -> None — publish to the server→client event bus. |
host.subscribe |
Callable[[], Any |
() -> subscription — subscribe to the event bus (e.g. return-address delivery). |
host.on |
Callable[[str, Callable], Any |
(topic, handler) -> unsubscribe — register an in-process handler for bus topics (ADR 0039). Lets a server-side plugin react to events (topic-filtered, sync/async) without ever importing the plugin that emitted them. |
host.config |
Callable[[], Any |
() -> LangGraphConfig — the live server config. A route handler reads this for the current resolved values (incl. plugin_config) rather than closing over a load-time snapshot, so it sees Settings changes without a restart. |
host.apply_settings |
Callable[[dict], Any |
(patch: dict) -> (ok, messages) — persist a nested config patch to YAML (secrets routed automatically) and reload the graph once. Heavy (a full reload) — a route should call it via asyncio.to_thread. Lets a plugin route apply config + reload (e.g. an OAuth Connect flow flipping enabled). |