Plugin Commands
Plugins advertise slash commands; core owns discovery, parsing, authorization,
conversation locking, idempotency, and client presentation. Web and TUI share
the same catalog and invocation service. Do not rewrite /name in
onInputReceived — that path is for ordinary message text, not application
commands.
See also: Overview, Backend Hooks. Built-in
examples: /clear (conversation_commands, a builtin),
/compact (context_summarization),
/context (context_visualizer), personal templates
(slashcmds).
Contribute
Implement command_contributions(ctx) on the plugin class. It may be sync or
async. Discovery is side-effect free: a plugin that raises is skipped and
cannot break the catalog. Omit the method if you contribute nothing.
Return a list of CommandContribution objects (or (definition, handler)
pairs). Import from codumentor.commands.
from typing import Any, Mapping
from codumentor.commands import (
CommandArgument,
CommandContribution,
CommandDefinition,
CommandResult,
)
class StatusPlugin:
def __init__(self, config=None, **kwargs):
self.config = config
self.meta = {
"name": "status_plugin",
"version": "1.0.0",
"description": "Example /status command",
}
async def register(self, bus):
return None
def command_contributions(self, ctx: Mapping[str, Any]):
return [
CommandContribution(
definition=CommandDefinition(
id="status_plugin.status",
name="status",
title="Status",
description="Show a short notice without starting a turn.",
requires_conversation=False,
),
handler=self._status,
),
]
async def _status(self, invocation, ctx) -> CommandResult:
return CommandResult.completed(notice="ok")
ctx on discovery and invocation is layered: keys may be absent. Typical keys
are user_id, conversation_id, session_id, ui_type ("web" / "tui"),
and conv_service. Use ctx.get(...). Do not treat discovery as a place to
mutate state.
Definition
CommandDefinition fields plugin authors set:
| Field | Notes |
|---|---|
id | Stable namespaced id, e.g. status_plugin.status. Clients invoke this. |
name | Slash name without / (/status). Case-insensitive. |
title, description | Shown in the composer suggestion list. |
usage | Optional; defaults from the argument list (/name <arg> [opt]). |
aliases | Extra short names. Do not repeat name. |
arguments | Ordered list of CommandArgument (see below). |
requires_conversation | Default true. Set false if the command can run with no open conversation. |
execution_policy | "idle_only" (default) or "allow_when_active". Opt in only if it is safe while a turn is running. |
requires_confirmation / destructive | destructive forces confirmation. |
required_capabilities | Access-profile capability names the caller must have. |
accepts_attachments / allow_at_references | Default false. Core rejects composer attachments/@ mentions unless you opt in. |
Ids look like plugin_id.command_name (letter-led segments, [a-z0-9_]).
Short names start with a letter or underscore.
Two plugins with the same short name at the same short_name_priority (default
0) do not win by load order: the name is marked conflicted, omitted from
normal suggestions, and typed /name is rejected. Each command stays invocable
by stable id. Raise short_name_priority only when a typed name must keep
working during a migration (personal templates use 1). Duplicate ids are
a bug; the later contribution is dropped.
Arguments
Core binds typed slash text (or a structured map from the client) before the
handler runs. Version one types: string, choice (needs choices),
boolean, integer, and remainder (must be last; consumes the rest of the
line).
CommandArgument(
name="text", # lowercase identifier
description="Prompt to send",
type="remainder",
required=True,
)
Optional arguments may set default. choice needs a non-empty choices
list. secret is presentation metadata (mask in UIs); it is not encryption.
A command is recognized only when / is the first non-whitespace character.
The handler receives a CommandInvocation:
command_id,name,invocation_idvalues— bound argument mapargument_text— original remainder after/nameuser_id,conversation_id,session_id,ui_type(any may beNone)
Your handler runs on the same instance as your hooks
Both run on the process host's one plugin instance
(Overview → Plugin Instances & State), so
self._something_i_saved_in_a_hook is there inside the handler. This used to
be the opposite: hooks ran on a per-conversation instance and a command on the
process-wide one, they were different objects, and instance state saved in a hook
was silently empty in the handler. That is what the process-global caches in
context_summarization's summary_cache.py and context_visualizer's
snapshot_cache.py were built for; they are still correct, just no longer
required.
What is still true is the lifetime: a command runs outside a turn, so
ctx["kv"] (per turn) never reaches a handler, and anything the handler needs
must outlive the turn that produced it. Park it with
session_state
keyed by the conversation and read it back with
host.peek_session_state(self, invocation.conversation_id, key) — the host is in
the handler's ctx["plugin_host"], and it is also the one you were handed by
set_plugin_host.
Emitting a plugin event from a handler
A short outcome belongs in notice. A durable, transcript-visible one is a
normal namespaced plugin event — but the layered ctx a handler gets carries no
ui_event_emitter, because that key is assembled by the agent loop and a
command runs outside a turn.
Build one from the conversation service with the shared helper, then call your
existing card path unchanged:
from codumentor.commands import command_event_ctx
async def _handler(self, invocation, ctx):
emit_ctx = command_event_ctx(ctx, invocation.conversation_id)
await self._emit(emit_ctx, payload) # the same method your hook calls
return CommandResult.completed(notice="…")
It returns the ctx unchanged when there is nothing to emit into (no
conversation, no conversation service, or an emitter already present), so the
handler needs no branching and stays silent in a headless run.
Handler and results
async def handler(invocation, ctx) -> CommandResult. Return one of the
closed outcomes; do not return URLs, widgets, or JavaScript. Extra transcript
detail can still use normal namespaced plugin events.
| Kind | Factory | When |
|---|---|---|
completed | CommandResult.completed(notice=...) | Work finished; optional notice. No turn. |
start_turn | CommandResult.start_turn(prompt=...) | Core starts a normal user turn with that prompt. Same persistence, hooks, approvals, and streaming as typing the prompt. |
conversation_changed | `CommandResult.conversation_changed(conversation_id=..., effect="navigate"\ | "refresh")` |
rejected | CommandResult.rejected(reason=..., explanation=...) | Caller cannot run it. reason is a stable token (unavailable, forbidden, …); explanation is shown to the user. |
failed | CommandResult.failed(explanation=..., correlation_id=...) | Unexpected failure. Prefer letting an exception propagate: core turns it into failed and logs a correlation id. |
Do not call your own HTTP route or the agent runner to start a turn.
Return start_turn so locking, idempotency, access profiles, and streaming
stay on one path.
Clients retry by invocation_id. Handlers that create resources (a new
conversation, a job) must be safe to run twice for the same id, or ignore a
duplicate.
Commands are not an approval bypass. Anything that would need user permission
as a tool or button still goes through the same approval machinery.
What core already does
You do not implement catalog HTTP, slash parsing, or composer UI. Core:
- collects contributions from live plugin instances (the process host's — the same instances the turn hooks run on) for the current user and conversation
- parses
/name args, binds arguments, and re-checks access on invoke - serializes with conversation turns (
idle_onlywaits / rejects while busy) - emits command lifecycle/audit events
- applies only the host effects above
Unknown slash text that is not a catalog command continues as an ordinary
message.