Codumentor logo Codumentor

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:

FieldNotes
idStable namespaced id, e.g. status_plugin.status. Clients invoke this.
nameSlash name without / (/status). Case-insensitive.
title, descriptionShown in the composer suggestion list.
usageOptional; defaults from the argument list (/name <arg> [opt]).
aliasesExtra short names. Do not repeat name.
argumentsOrdered list of CommandArgument (see below).
requires_conversationDefault 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 / destructivedestructive forces confirmation.
required_capabilitiesAccess-profile capability names the caller must have.
accepts_attachments / allow_at_referencesDefault 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:

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.

KindFactoryWhen
completedCommandResult.completed(notice=...)Work finished; optional notice. No turn.
start_turnCommandResult.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")`
rejectedCommandResult.rejected(reason=..., explanation=...)Caller cannot run it. reason is a stable token (unavailable, forbidden, …); explanation is shown to the user.
failedCommandResult.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:

Unknown slash text that is not a catalog command continues as an ordinary
message.