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
A backend plugin is instantiated in more than one place, and the instances
do not share in-memory state:
| Instance | Created | Lives as long as | Receives |
|---|---|---|---|
| Server-level | Once at API startup | The process | onServerReady; owns the plugin's API routers (/ui/plugins/<id>/…) |
| Per-conversation (agent-level) | Once per Agent | That conversation's session | The per-turn lifecycle hooks (onInputReceived, onContextReady, onResponsePersist, …) |
So a counter your onResponsePersist hook increments on the agent-level
instance is invisible to the API route served by the server-level instance
— they are different Python objects with different attributes. This is by
design: the server instance answers HTTP regardless of which conversation is
active, while each conversation gets its own isolated hook instance.
Crucially, all of these instances live in the same process (the API/TUI
process — see Concurrency). So sharing state
between them is a same-process problem, not a persistence problem. Pick the
narrowest mechanism that fits:
1. Across instances of your plugin → a singleton keyed by config
This is the case above (server instance ↔ agent hook instances). **Do not reach
for a JSON file + lock** — the instances share an address space, so a file only
adds I/O, lock contention, and serialization for data that never needs to leave
the process. Use the shared_state SDK helper: it returns one object per
(config, key), created on first access and reused by every instance
afterwards. (It generalizes the built-in get_speech_service /
get_job_queue_registry singletons; the same Config object is threaded to
every plugin instance, which is what makes the state shared.)
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)
# In the onResponsePersist hook (agent-level instance):
stats = get_stats(self.config)
with stats.lock:
stats.responses += 1
# In the /stats API route (server-level instance) — same object:
stats = get_stats(self.config)
return {"inputs": stats.inputs, "responses": stats.responses}
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 guarding the counters against concurrent turns. No file, no
fsync, no flock.
2. 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.
3. State scoped to a single conversation → 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 (1) or (2).
What Plugins Can Do
- Observe & Modify — Intercept user input, prompts, LLM responses, and tool calls
- 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"; None for subagents
"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 — but prefer set_plugin_host(), which also works on the TUI/CLI/subagent paths. 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 {} with 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. Plugin handlers receive the agent's context via ctx, and the agent instance carries agent._source_ctx when a source context is active.
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 |