Codumentor logo Codumentor

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

  1. Copy the template:

``bash
cp -r ui/plugins/_template ui/plugins/hello_plugin
``

  1. 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:

  1. 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:

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 / PropertyRequiredDescription
metaYesDict 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)YesSubscribe to lifecycle hooks via bus.on()
capabilities()NoReturn a dict describing plugin capabilities. Other code queries these via host.query_capability(name) / host.has_capability_flag(flag)
ui_manifest()NoReturn UI extension configuration. See Backend Hooks
api_router()NoReturn a FastAPI APIRouter for HTTP endpoints. See API Routes
cleanup()NoInstance-level teardown — close connections, cancel tasks, release resources. Called once per instance on application shutdown
shutdown_all()NoClassmethod. 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:

MethodDescription
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