Plugin Quick Start
Build a working plugin in 15 minutes.
Step 1: Create the Backend Plugin
Create a Python class with a meta property, a register method, and at least one hook handler:
class HelloPlugin:
def __init__(self, config=None, **kwargs):
self.meta = {
"name": "hello_plugin",
"version": "1.0.0",
"description": "A hello-world plugin",
"priority": 50,
}
async def register(self, bus):
"""Subscribe to lifecycle hooks."""
bus.on("onInputReceived", self.on_input, priority=10)
async def on_input(self, agent, ctx, payload):
"""Handle the onInputReceived event."""
raw_input = payload["raw"]
return {"type": "continue", "patch": {"raw": f"[Hello] {raw_input}"}}
def capabilities(self):
"""Return plugin capabilities."""
return {"greeting": True}
Step 2: Enable in Configuration
Add the plugin to codumentor.yaml:
plugins:
- module: "my_plugins.hello"
class: "HelloPlugin"
args:
greeting: "Hello, world!"
Step 3: Create the UI Plugin
- Copy the template:
``bash``
cp -r ui/plugins/_template ui/plugins/hello_plugin
- Update
manifest.json:
``json``
{
"plugin_id": "hello_plugin",
"name": "Hello Plugin",
"version": "1.0.0",
"description": "A hello-world plugin for learning",
"event_types": ["plugin.hello_plugin.greeted"],
"ui": {
"bundle": "index.js",
"sdk_version": "1.0",
"custom_elements": ["x-hello-plugin-card"],
"placements": ["transcript-card"]
}
}
Optional: Register markdown code-block renderers
If your UI bundle wants to render specific fenced code blocks (for example, a custom diagram language), add markdown_renderers in the ui manifest:
{
"ui": {
"bundle": "index.js",
"sdk_version": "1.0",
"custom_elements": ["x-hello-plugin-card"],
"placements": ["transcript-card"],
"markdown_renderers": [
{
"id": "hello_plugin.diagram_renderer",
"languages": ["mydiagram"],
"export_name": "myDiagramRenderer",
"priority": 50,
"override_existing": false
}
]
}
}
Your bundle must export myDiagramRenderer with the markdown renderer contract (id, languages, and a React component). The host validates and registers it after the extension bundle loads.
Streaming-safety guidance:
- The host runtime already reduces churn (memoized markdown rendering and per-block routing), so unchanged old messages should not re-render during streaming updates.
- Renderer components should still be idempotent: avoid re-running expensive work on every render, and key effects only on the minimum inputs (
code,language,isStreaming). - If rendering is expensive, defer while
isStreaming === trueand render once the message is complete. - Keep visual state stable while async rendering completes (prefer updating content in place over unmount/remount) to avoid layout flashing.
- Create your component in
src/index.ts:
```typescript
import {
BasePluginElement,
bindExpandClick,
getBaseStyles,
escapeHtml,
} from "@codumentor/plugin-sdk";
interface HelloData {
message: string;
}
class HelloPluginCard extends BasePluginElement
render(): void {
const { message } = this._data;
this._shadowRoot.innerHTML = ;
<style>${getBaseStyles()}</style>
<div class="card ${this._status}">
<div class="header">
<span class="icon">${this.getStatusIcon()}</span>
<span class="title">Hello Plugin</span>
</div>
<p>${escapeHtml(message)}</p>
</div>
bindExpandClick(this._shadowRoot, ".header", () => this.toggleExpanded());
}
}
if (!customElements.get("x-hello-plugin-card")) {
customElements.define("x-hello-plugin-card", HelloPluginCard);
}
export { HelloPluginCard };
```
Step 4: Build
cd ui && npm run build:plugins
Step 5: Test
Run Codumentor and verify the plugin loads:
python -m codumentor
Check the logs for hello_plugin registration messages. Send a message through the UI and confirm the [Hello] prefix appears in the processed input.
Emitting trigger events
A plugin can emit an event that a task can fire on (doc/tasks.md,
doc/core-dev/design/triggers.md §9.4). Two halves, and both are required.
Declare what you emit, in meta:
self.meta = {
"name": "redmine",
"version": "1.0.0",
"emits": [
{
"name": "ticket_created",
"scope": "project id", # what `scope` will hold
"payload": {"ticket_id": "int", "priority": "str"},
"description": "A new Redmine ticket appeared in a watched project.",
},
],
}
Emit it:
from codumentor.core.tasks import emit_plugin_event
await emit_plugin_event(
plugin_id="redmine", name="ticket_created",
scope=str(project_id),
payload={"ticket_id": ticket_id, "priority": priority},
dedupe_key=f"redmine:{ticket_id}:{updated_on}", # idempotence
)
The event type is plugin.<plugin_id>.<name> — here
plugin.redmine.ticket_created.
Why the declaration is not paperwork: an event type is a **closed
vocabulary*, and only the shape of a plugin. type can be checked
generically. Without your declaration, plugin.redmine.tickte_created is a
perfectly well-formed trigger that silently never fires. With it, the typo is
rejected when the task is created, with your declared names in the message —
and list_event_types can show an author your real payload contract instead
of "some plugin event".
Two rules worth knowing:
- An undeclared emit is loud. Running from source it raises
UndeclaredEventError; in a packaged build it logs a warning naming your plugin and the event still goes out. A plugin shipping an event ahead of its manifest must not break, but it must not be quiet either. - Declared payload fields are treated as untrusted. Your payload usually carries values from whatever system you talk to, so
bind:fences them. Declaring buys an enumerable contract and typo rejection, not trust.
meta["emits"] is not ui_manifest()["event_types"]. The latter is the
SSE vocabulary your browser bundle listens for; they mean different things and
both can be present.
Plugin Interface Reference
| Method / Property | Required | Description |
|---|---|---|
meta | Yes | Dict with name, version, description, priority, optional dependencies (list of plugin names that must register before this one — see Plugin Load Order), optional emits (the trigger events this plugin emits — see Emitting trigger events) |
register(bus) | Yes | Subscribe to lifecycle hooks via bus.on() |
capabilities() | No | Return a dict describing plugin capabilities. Other code queries these via host.query_capability(name) / host.has_capability_flag(flag) |
ui_manifest() | No | Return UI extension configuration. See Backend Hooks |
api_router() | No | Return a FastAPI APIRouter for HTTP endpoints. See API Routes |
cleanup() | No | Instance-level teardown — close connections, cancel tasks, release resources. Called once per instance on application shutdown |
shutdown_all() | No | Classmethod. Tear down class-level shared resources (e.g. shared subprocess pools). Called once per plugin class, after all instance cleanup() calls. See Shutdown & Cleanup |
Every method above except meta and register() is **optional and
duck-typed** — implement it and the host finds it, omit it and nothing happens.
The rest of the surface:
| Method | Description |
|---|---|
set_plugin_host(host) | Called by PluginHost.add() with the host that registered you — the process host, holding every configured plugin. Keep the reference for the paths that carry no ctx["plugin_host"] (TUI, CLI, subagents) and for onSessionEvict, whose ctx has none either. See Collect Points |
collect_providers() | Return {point_name: callable} to answer questions other plugins ask. See Collect Points |
public_api_router() | Return an unauthenticated FastAPI router mounted at the app root, for webhooks and OAuth callbacks. You authenticate it yourself — see Unauthenticated Routes |
config_args_model() | Return the Pydantic model describing your args: block. Publishes your config schema in-process so the Configuration Assistant and settings APIs can describe your plugin without importing it |
sse_replay_events(conversation_id) | Return events to re-emit when a browser reconnects to the SSE stream — e.g. a pending RPC request that would otherwise hang until it times out |
Next Steps
- Backend Hooks — Complete hook reference and return values
- UI Components — TypeScript components, SDK, and widgets
- Best Practices — Error handling, performance, testing