Plugin System Overview
Codumentor's plugin system lets you extend the agent with custom tools, UI components, lifecycle hooks, and HTTP endpoints.
See also: Architecture Overview for how plugins fit into the broader system.
Two-Part Architecture
| Component | Language | Purpose |
|---|---|---|
| Backend Plugin | Python | Intercepts agent lifecycle hooks, emits events, exposes API routes |
| UI Plugin | TypeScript | Renders custom elements in the web interface |
Most plugins need both parts. The backend handles logic and emits events; the UI renders those events.
Plugin Instances & State
Your plugin class is instantiated once per process. One PluginHost is
built at startup, it loads every configured plugin, and every conversation,
every subagent, every API route, every slash-command handler and every collect
point then runs on that object. self is process-wide.
This is the plugin-host unification
(design); before it there was a
server-level instance plus one per cached conversation and one per subagent,
and state written on one was invisible to the other. If you are reading older
plugin code, comments about "the server host" versus "the agent host" describe
that world, not this one.
Two things are deliberately not shared:
| Scope | Why | |
|---|---|---|
agent.plugins, set_plugin_host(host), ctx["plugin_host"], request.app.state.plugin_host | The process host — all four now resolve to the same object | One plugin instance is the whole point; the three spellings used to disagree |
agent.bus | Per agent, chained to the process bus | Core installs handlers that are about one agent — the access-profile capability filter, subagent containment, the summariser's force-empty-tools hook. On a shared bus, user A's filter would prune user B's ToolRegistry. See plugins/agent_bus.py |
You subscribe on the bus register() is handed, which is the process bus, so
your handlers fire for every conversation. An AgentEventBus emits the process
handlers and its own local ones interleaved by priority, so nothing about a
plugin handler's ordering changes.
The private-host fallback still exists for entry points that publish no
process host: a directly constructedAgent(...)(tests, embedders, the
OpenAI-compatible/v1surface) still builds and lazily loads its own host.
Write for the shared case — it is what every runtime does.
register() must only subscribe
register() and __init__ run once per host, and a plugin must not assume it
runs on the host: build nothing that opens a socket, starts a task, sweeps a
directory or registers a process-wide callback there. Start those from
onServerReady, which is emitted once per
process on the long-lived server/standalone host only, or lazily on first use.
Moving the start is not enough on its own. onServerReady is not emitted
by short-lived entry points (codumentor -p, codumentor ingest) — they publish
a process host so an agent and its subagents share one plugin instance, but a
two-second query has no business opening a Slack connection. So if a per-turn
code path reads something the start produced, it must build that thing lazily
instead of depending on the hook. slack_integration is the worked example: the
Socket-Mode listener starts from onServerReady, but the Bolt client the
outbound tools and the auto-reply hook need comes from an _ensure_client()
called on first use, because those run on turns that may never have seen the
hook.
Picking a slot for state
| Lifetime | Where it goes |
|---|---|
| One turn | ctx — see 1 |
| One conversation | session_state on the host — see 2 |
| The process | plain attributes on self — you are the only instance. shared_state only when the sharers are not one instance — see 3 |
| Longer than the process, or shared with other processes | a SQLite store — see 4 |
| One conversation, readable by core and other plugins | ctx["scratchpad"] — see 5 |
1. State scoped to one turn → ctx
Two conversations interleave on your one instance, so anything on self that
means "the turn in flight" is a race: the second turn overwrites the first
turn's value between its own awaits. The ctx dict is created per turn and
handed to every hook of that turn, so it is the one place no other turn can
reach.
- JSON-ish data →
ctx["kv"]. It is snapshotted for suspend/resume, and it is the namespace a future out-of-process plugin runtime can forward.inference_metricsis the reference consumer. - Live handles and contextvar reset tokens → a private
ctx["_myplugin_…"]key.
For contextvar tokens use the shared helpers rather than re-inventing the
list — park_token(ctx, KEY, clear_fn, token) in the publishing hook,
unwind_tokens(ctx, KEY) in onResponsePersist
(codumentor.plugins.ctx_tokens is the only implementation; thinking_modes
is where the idiom was invented and is the worked example to read):
from codumentor.plugins.ctx_tokens import park_token, unwind_tokens
_CTX_TOKENS_KEY = "_my_plugin_tokens" # private to this plugin
async def _on_context_ready(self, agent, ctx, payload):
park_token(ctx, _CTX_TOKENS_KEY, clear_thing, set_thing(value))
async def _on_response_persist(self, agent, ctx, payload):
unwind_tokens(ctx, _CTX_TOKENS_KEY)
One key per contextvar, not per instance. A ContextVar only accepts
resets in reverse order of its sets, so every publisher of the same
contextvar must unwind through one stack. mcp is the precedent: there is one
MCPPlugin instance per configured mcp: server, they all publish the same
process-wide oauth_identity contextvar, and they deliberately share
_mcp_caller_tokens. A key per instance would have each server resetting its
own token out of order and leaving the last caller identity in place. Check the
same for tokens two different plugins might both hold: model_profiles and
thinking_modes both override the user model, and only one of them may own the
reset.
2. State scoped to one conversation → session_state on the host
Do not keep a self._sessions[session_id] dict on your plugin. Nothing
drops the entry when the conversation goes away unless you subscribe to
onSessionEvict and remember to pop it there — historically the most repeated
bug in this tree (snapshot refs piling up in workspace_isolation, SSH pools
outliving their session in remote_ssh, an Office document worker stranded
after its conversation was evicted).
Ask the host for the object instead. It keys the entry on
(your plugin class, session id, a name you pick), builds it once with your
factory, and drops it itself when the session is evicted — so state parked
here cannot leak.
from codumentor.plugins.session_state import session_state
class DocEditorPlugin:
def set_plugin_host(self, host): # only needed for the evict handler below
self._host = host
async def register(self, bus):
bus.on("onPromptAssemble", self._on_prompt)
bus.on("onSessionEvict", self._on_evict)
async def _on_prompt(self, agent, ctx, payload):
# One dict per (DocEditorPlugin, this conversation, "open_docs").
open_docs = session_state(agent, ctx, self, "open_docs")
open_docs[payload["path"]] = ...
async def _on_evict(self, agent, ctx, payload):
# Optional: only if the state owns something closable. Runs *before*
# the host forgets the entry.
docs = self._host.peek_session_state(
self, payload["session_id"], "open_docs"
)
for handle in (docs or {}).values():
handle.close()
In
onSessionEvict, do not usesession_state(agent, ctx, …)— that
hook'sctxcarries noplugin_host, so the helper would fall back to the
agent's own host, which on the private-host paths is not the one holding your
state. Reach for the host that registered you:PluginHost.add()calls
set_plugin_host(host)and then registers your handlers on that host's bus,
so the host you were handed is always the one emitting the hook.
session_state(agent, ctx, owner, key, factory=dict) mirrors the hook
signature, so it drops straight into a handler. It is a thin wrapper over the
host method — use that directly when you already hold a host (an API route, a
set_plugin_host reference, a test):
state = host.session_state(self, session_id, "open_docs", dict) # get-or-create
state = host.peek_session_state(self, session_id, "open_docs") # or None
host.drop_session_state(session_id) # explicit release
What the arguments mean:
| Argument | Value |
|---|---|
owner | Pass self. The namespace is your plugin's class path, never its object identity — so a private host built by a test and the process host agree on the namespace, and a typo cannot land you in another plugin's namespace. A plain string works too, for non-plugin callers. |
key | A short name for what this entry is, so one plugin can keep several unrelated things apart. |
factory | Called at most once per entry; defaults to dict. Keep it cheap and non-blocking. |
Which host you land on: session_state(agent, ctx, …) prefers
ctx["plugin_host"] and falls back to agent.plugins (or, for the streaming
hooks that are emitted with the AgentStreamController, its .host). On the
shared-host paths all of these are the same object. CLI/TUI turns carry no
ctx["plugin_host"], and there the agent's own host is the one the hooks ran
on — nothing emits onSessionEvict on those single-conversation paths, so call
drop_session_state(session_id) yourself if you need an early release. Never
key state on id(agent) or on "my own self": those do not survive eviction, a
second conversation, or a future out-of-process plugin runtime.
Release order, so you can hold live objects here: your own onSessionEvict
handler runs first (at any normal priority), then the host forgets the entry.
The host only forgets — it never calls close()/cleanup() on your object.
The other release path is an explicit drop_session_state(session_id)
(idempotent). Note that PluginHost.shutdown() deliberately does not clear
the store — the CLI emits its session report after shutting the host down, and
token_report reads its accumulator there — so a host being torn down is not a
release signal you can rely on. Subagent sessions are evicted too, and they are
the release path for a subagent's state — see
onSessionEvict.
The store serialises creation per entry, so concurrent turns cannot build two
objects for one key; the object itself owns its thread-safety, exactly like
shared_state below.
Testing caveat.
session_state's host resolution is duck-typed: anything
with asession_stateattribute counts as a host. AMagicMockagent
therefore is accepted,mock.plugins.session_state(...)returns another
mock, and your assertions pass without a real store ever being touched. Give
the test a realPluginHost(or actx={"plugin_host": host, "session_id": …})
and assert throughhost.peek_session_state(...).
3. Process-wide state → self, or shared_state
Process-wide state is just an attribute on self now: a client, a pool, a
counter, a lazily built index. Build it lazily (see
register() must only subscribe) and guard it
with a threading.Lock if concurrent turns can touch it — they can.
Reach for shared_state only when the things that must share the state are
not one instance:
- several instances of your own class —
mcpgets one instance per configured server, and its client pool is a module singleton for exactly that reason; - non-plugin code that only holds a
Config— a core subsystem, a tool factory, a route helper; - a private host built by a test, an embedder or the
/v1surface.
shared_state returns one object per (config, key), created on first access
and reused afterwards. (It generalizes the built-in get_speech_service /
get_job_queue_registry singletons; the same Config object is threaded
everywhere, which is what makes the state shared.) **Do not reach for a JSON
file + lock** — the sharers are in one address space, so a file only adds I/O,
lock contention and serialization for data that never leaves the process.
import threading
from codumentor.plugins.shared_state import shared_state
class _Stats:
def __init__(self) -> None:
self.lock = threading.Lock()
self.inputs = 0
self.responses = 0
def get_stats(config):
return shared_state(config, "request_stats", _Stats)
shared_state serializes creation (so factory runs at most once per pair),
but the returned object owns its own thread-safety — hence the internal
threading.Lock. It is never dropped, so it is not a home for per-conversation
state; that is case 2.
4. Durable or cross-process state → a SQLite store
A file is only justified when the data must outlive the process or be shared by
separate processes (e.g. several API instances pointed at one data dir). In
that case use a SQLite store under your plugin's data dir with WAL — the same
mechanism jobs/goals/memory use — not an ad-hoc JSON file with flock, which
has no atomicity or crash-safety guarantees. See
Concurrency for the WAL + atomic-claim pattern.
5. Conversation state core and other plugins may read → the scratchpad
Shared between the main agent, its subagents, and all hook handlers in that one
session: use ctx["scratchpad"] — see Conversation Scratchpad
below. In-memory, cleared on session eviction, so not a substitute for (3) or (4).
Scratchpad or session_state? The scratchpad is one shared, plugin-agnostic
bag — use it to publish session data (and keep it JSON-ish). session_state
is yours alone, keyed per plugin, and may hold live objects.
Your tools are shared too
A plugin typically builds self._tools once and registers the same objects into
every agent's ToolRegistry, so two conversations run the same Python Tool
object concurrently. Three attributes look like per-call state and are safe to
read because Tool backs them with a task-scoped ContextVar that
Tool.call() sets and resets:
| Attribute | Read it for |
|---|---|
self._current_session_id | the session this call belongs to |
self._current_ctx | the turn's ctx |
self.progress_callback | streaming progress lines back to this caller |
Assigning them is a different matter: the setters write an instance-level
fallback that every conversation shares, so a plugin (or a hook) that assigns
one is back to the cross-conversation bug the ContextVar removed. Production
never assigns them — ToolRuntime passes the values into call() — and the
fallback exists only for the evaluation harness and for tests that drive
execute() directly.
Everything else you cache on a tool is process-wide. Rewriting a shared tool's
description, or assigning a per-conversation list onto it from
onPromptAssemble, changes it for every other conversation; build the value
locally, or pass it through the call.
What Plugins Can Do
- Observe & Modify — Intercept user input, prompts, LLM responses, and tool calls
- Contribute commands — Advertise slash commands through
command_contributions(); core owns discovery, authorization, and dispatch. See Plugin Commands. Built-in examples:/clear(conversation_commands, a builtin),/compact(context_summarization), and personal templates (slashcmds). - Add Tools — Register custom tools via
onPromptAssemble(see Registering Tools, and gate them per agent kind if they act on the outside world) - Extend UI — Provide custom web components for transcript cards, widgets, and message action rows
- Expose APIs — Add HTTP endpoints via FastAPI routers
- Control Flow — Short-circuit responses, retry tools, switch models dynamically
- Export Data — Contribute sections to evaluation reports
Plugin Bus & Lifecycle
The Plugin Bus dispatches events at each stage of agent execution. Plugins subscribe to hooks during register() and receive callbacks with (agent, ctx, payload).
Execution Flow
onInputReceived— Raw user input arrivesonContextReady— Conversation context loadedonPromptAssemble— Prompt built, tools registeredonLLMCallStart→onLLMStream→onLLMCallEnd— LLM interactiononToolInvokeStart→onToolExecuting→onToolInvokeEnd— Tool executiononResponsePersist→onBeforeSendResponse— Response finalization
Control hooks (onAfterInput, onAfterLLM, onAfterTool) run after each stage and can short-circuit the pipeline.
Context Object
All handlers receive a shared context. ctx is layered, not fixed: the
agent builds a small base, and each entry path (web UI, TUI/CLI, a subagent, a
background job) layers its own keys on top. Always use ctx.get(...) and handle
None — a key that is always present on the web path may be absent on the TUI
path or inside a subagent.
Built by the agent for every turn, on every path:
ctx = {
"run_id": str, # Unique ID for this agent run
"session_id": str, # Conversation session ID
"user_id": str, # User identifier
"step": int, # Current agent-loop iteration
"kv": dict, # Free-form storage for plugins
"scratchpad": ConversationScratchpad, # Cross-agent shared storage
"ui_type": str | None, # "web" / "tui" / "messaging" (Telegram,
# Slack) / "unattended" (scheduled, triggers,
# PR reviews); None for subagents.
# See codumentor.agent.ui_type
"agent_type": str, # "main" | "subagent"
"model_role": str | None, # Finer-grained role than agent_type: an
# AgenticTool's own name,
# "context_summarization"; None for main
"run_kind": str, # "scheduled" | "interactive"
}
run_kind answers "is anyone waiting for this answer?" — "scheduled" for a
scheduled agent run or a goal-worker tick, "interactive" for everything else.
Read it instead of loading the conversation record to look for
metadata["source_type"]: the runner derives it once from metadata it already
holds, so a hook costs a dict lookup rather than a synchronous SQLite read on
the onPromptAssemble path. It is fail-open by design — an unrecognized
value, or a ctx that never passed through the runner, reads as
"interactive", so use it to withhold work on unattended runs (skip a
per-tick LLM call, sit out turn-start recall), never as an authorization
signal. Helpers: codumentor.agent.run_kind.is_scheduled_run(ctx) /
run_kind_of(ctx).
A conversation's identity is a different question and still a metadata read —
which goal and worker, which PR, which agent profile. Use
codumentor.plugins.utils.conversation_metadata_from_ctx(ctx), which returns
{} whenever the record cannot be read.
Layered on by the UI/API path (agent_runner) — the keys most plugins actually
reach for, and the ones a TUI or subagent handler must not assume:
| Key | Type | What it gives you |
|---|---|---|
ui_event_emitter | async callable | Emit UI events — see Emitting UI Events. Absent ⇒ nothing to render to. |
conversation_id | str | The conversation. Prefer this over session_id for anything user-visible or persisted. |
turn_id | str | This turn (one user message + its whole agent loop). |
conv_service | ConversationService | Persistence operations, e.g. revise_message(...). |
plugin_host | PluginHost | Sibling plugins — the process host, the same object set_plugin_host() hands you. Absent on the TUI/CLI/subagent paths, so a hook that must work everywhere keeps a set_plugin_host back-reference as well. See Collect Points. |
access_profile | AccessProfile | This turn's resolved profile. Advisory only — every enforcing layer re-resolves through the policy singleton rather than trusting ctx. |
execution_strategy | strategy | How commands run (sandbox/overlay/host). Added at the call site. |
attachment_staging | handle | Staging keys — see Attachment Staging. Also present in the narrow onAtContentResolve ctx. |
content_store | ContentStore | Manual large-content storage — see Best Practices. |
Some hooks are dispatched with a deliberately minimal ctx: onSessionEvict
gets only {session_id, user_id} and no plugin_host, onServerReady gets only
{config} and agent=None, onToolProgress and onToolPermissionDecision get
a synthetic ctx with empty kv, and onAtContentResolve gets only
conversation_id, ui_event_emitter and the staging keys. The per-hook notes
in Backend Hooks call these out.
Store plugin data in ctx["kv"].setdefault("artifacts", {})[plugin_name].
Conversation Scratchpad
ctx["scratchpad"] is a thread-safe in-memory key-value store scoped to a single conversation. The same instance is shared by the main agent, all subagents, and every plugin handler in that session, so it's the right place to publish data that downstream hooks or other plugins need to read — for example, file reads tracked by the permalink plugin, or modified-file metadata tracked by file_links. By convention, plugins namespace their keys (e.g. local_files_modified) and use the typed helpers set / get / update (dict merge) / append (list append). The scratchpad is cleared automatically when the session is evicted.
Note: this is unrelated to the workspace_isolation plugin, which manages on-disk sandbox directories. If you need a filesystem sandbox, see workspace_isolation; for shared in-process state, use the scratchpad.
async def on_tool_invoke_end(self, agent, ctx, payload):
scratchpad = ctx.get("scratchpad")
if scratchpad is None:
return None
scratchpad.update("my_plugin.files_seen", {payload["path"]: True})
Shared Repo Context
Plugins that need the active branch for a repository (e.g. after a user selects a branch in the UI) can read it from the well-known repo_context key in ctx["kv"]. Helper functions in codumentor.plugins.repo_context provide typed access:
from codumentor.plugins.repo_context import (
set_repo_context, # Publisher — write context
get_active_branch, # Read active branch for one repo
get_all_active_branches,# Read all active branches as {repo: branch}
)
# Publishing (e.g. in a branch-switching plugin):
set_repo_context(ctx, "my-repo", branch="feature-x")
# Consuming (e.g. in an output filter):
branch = get_active_branch(ctx, "my-repo") # "feature-x" or None
all_branches = get_all_active_branches(ctx) # {"my-repo": "feature-x"}
Key design points:
- Loosely coupled — Publishers and consumers import only the accessor module, not each other.
- Graceful degradation — If no plugin publishes context,
get_active_branchreturnsNoneandget_all_active_branchesreturns{}. - Extensible —
set_repo_contextaccepts keyword args. Future fields (e.g.commit,workspace_path) can be added without changing existing consumers.
Shutdown & Cleanup
When the application exits, PluginHost.shutdown() runs while the event loop is
still active, giving plugins a chance to release resources cleanly. This is
especially important on Windows, where subprocess transports that outlive the
event loop cause RuntimeError: Event loop is closed.
Teardown happens in two phases:
- Instance cleanup —
cleanup()is called on every registered plugin instance. Use this to close connections, cancel background tasks, and release any resources the instance owns.
- Class-level shutdown —
shutdown_all()is called once per distinct plugin class. Use this when multiple instances share class-level resources (e.g. a shared subprocess pool or connection registry).
class MyPlugin:
_shared_pool = {} # Class-level shared resource
async def cleanup(self):
"""Release instance-owned resources."""
await self._connection.close()
@classmethod
async def shutdown_all(cls):
"""Tear down the shared pool (called once, after all instance cleanup)."""
for key, conn in cls._shared_pool.items():
await conn.close()
cls._shared_pool.clear()
Both methods are optional. Errors in either are logged and swallowed so one
plugin cannot prevent others from shutting down.
Source Context
When a conversation is linked to a non-default source (e.g., a log collection), the agent runs with a SourceContext that controls its working directory, available tools, and prompt variables.
Read it per call, never cache it on self — it is per conversation and your
instance is not. codumentor.plugins.source_context resolves both facts a
plugin needs, as pure functions of (agent, ctx):
from codumentor.plugins.source_context import (
turn_source_context, # the SourceContext for this turn, or None
disabled_tool_names, # frozenset of tool names this turn must not offer
apply_source_context, # point one core file/shell tool at the source's workspace
)
Each prefers an explicit ctx override (ctx["source_ctx"],
ctx["disabled_tools"]) and otherwise reads the turn's real source — agent._source_ctx,
where the runner stashes it. Note that SourceContext is a handle (it carries
a cleanup callable and tool factories), so a plugin that needs the workspace
itself is a trusted in-process plugin by definition.
For details on the source abstraction and available source types, see Source Abstraction.
Next Steps
| Guide | Description |
|---|---|
| Quick Start | Build a "hello world" plugin in 15 minutes |
| Backend Hooks | Complete hook reference and return values |
| UI Components | TypeScript components, SDK, widgets |
| User Gates | Per-user blocking modals (EULA, privacy updates, forced password change) |
| API Routes | HTTP endpoints and authentication |
| Best Practices | Error handling, performance, testing |
| Plugin Catalog | Existing plugins for reference |