Codumentor logo Codumentor

Backend Hooks

This is the complete reference for Codumentor plugin lifecycle hooks. Plugins intercept agent execution via hooks. Each hook handler receives:

See also: Overview, API Routes, Plugin Commands (slash commands are request/response dispatch, not a lifecycle hook).


Hook Reference

Lifecycle Hooks

HookPayloadPurpose
onServerReady{}Fired once per process after the server is fully initialized and accepting requests. Use for deferred, non-critical initialization that should not delay startup. agent is always None; the process Config is in ctx["config"].
onSessionEvict{session_id, user_id} (+ parent_session_id for a subagent)Fired when a session or cached agent is evicted from memory (LRU or explicit deletion) — for the conversation and then for each subagent it spawned. Use to release session-scoped resources such as connection pools, file handles, or external subscriptions.
onConfigReload{sections, config}Fired after a hot config reload applied its section changes. sections lists the changed section names, config is the new Config. See Config Hot-Reload — note that sections are applied before reactors run.

onServerReady

Fired once per process, on the long-lived server (or standalone/TUI) plugin
host. That makes it the place for anything process-level: background loops,
pollers, one-shot disk sweeps, registering process-wide callbacks. register()
itself must only subscribe (see
Overview → register() must only subscribe).

Not every entry point emits it. The short-lived ones (codumentor -p,
codumentor ingest) publish a process plugin host — so one plugin instance
serves the agent and its subagents — but deliberately do not emit
onServerReady: a two-second query has no business opening a Slack connection
or a health-check loop. So a resource that a per-turn code path also needs must
be built lazily on first use, with onServerReady starting only the parts
that are genuinely process-level (see the slack_integration example in the
Overview).

There is no agent. Both emit sites pass agent=None, because the hook
fires before (and independently of) any conversation. getattr(agent,
"full_config", None)
here silently yields None — a real bug that made the
workspace_isolation dormant overlay resolver resolve repos_dir relative to
the process cwd instead of the configured one.

The Config comes from ctx, under the key "config". Use the helper so
you match whatever the emit sites do:

from codumentor.plugins.server_ready import config_from_ctx

async def register(self, bus):
    bus.on("onServerReady", self._on_server_ready)

async def _on_server_ready(self, agent, ctx, payload):
    config = config_from_ctx(ctx)   # never `agent.full_config`
    if config is None:              # a test emitting `{}`, not a runtime
        return None
    ...

config_from_ctx is also the accessor for a turn ctx, where the config rides
under the private handle ctx["_full_config"] (CTX_TURN_CONFIG_KEY). The keys
stay separate on purpose — "config" is a published contract for a hook ctx that
holds nothing else, and far too collision-prone for a turn ctx every plugin
writes to — but the accessor reads either, so no caller has to know which kind of
ctx it was handed. codumentor.plugins.server_ready is the single source for
"the config in a ctx".

Handlers must be idempotent: onServerReady can be emitted again (a
re-created runtime, a test), so guard your task/registration on a flag rather
than assuming a single call. Note also that it is emitted after stranded-turn
recovery, so turns may already be running when your handler starts — a
"nothing is live yet" assumption is wrong (that is what made
SnapshotManager.clear_all_refs() scope its work by process start time).

onSessionEvict

Called when a session is evicted from an in-memory cache. This happens in two scenarios:

  1. LRU eviction — a new session pushes an old one out of the cache (both the stateless API session registry and the UI agent cache).
  2. Explicit removal — a user deletes a conversation.

Plugins that hold session-scoped resources (connection pools, open file handles, background tasks) should subscribe to this hook to clean them up.

It also fires for subagent sessions. One eviction emits the hook for the
conversation first, then once for each subagent that conversation spawned. A
subagent's session id is a session id like any other — it is what
session_state keys on and what an Office editor's document worker is
registered under — so without this, every subagent leaked one entry per plugin
for the process lifetime and a subagent that opened a document stranded its
worker. Parent first, because a handler may need to fold the children's numbers
into the conversation's (telemetry does) while the parent↔child mapping is
still intact.

A child evict carries parent_session_id in its payload; a conversation
evict does not. Tell the two apart with payload.get("parent_session_id") if
your handler cares — most do not, which is the point.

Session state is released for you. Anything you parked with
session_state (see Overview → State scoped to one conversation)
is dropped by the host after every plugin handler for this hook has run — so
your handler can still read it here, to close whatever it holds. You only need
a handler at all if the state owns something closable; keeping a
self._sessions[session_id] dict and popping it here is the anti-pattern that
motivated the slot: the instance you popped it on may not be the one that
filled it.

Payload:

{"session_id": "abc123", "user_id": "u42"}                       # a conversation
{"session_id": "sub-…", "user_id": "u42",
 "parent_session_id": "abc123"}                                  # one of its subagents

Example handler:

async def register(self, bus):
    bus.on("onSessionEvict", self._on_session_evict, priority=10)

async def _on_session_evict(self, agent, ctx, payload):
    session_id = payload.get("session_id")
    if session_id:
        await self.connection_pool.close_session(session_id)
    return None  # fire-and-forget, no payload mutation needed

If your plugin builds its own Agent, you own its release. The two agent
caches only evict conversations, and AgenticTool's subagent map only knows
subagents AgenticTool spawned — so a plugin that runs a full turn under a
synthetic session id of its own (websearch's research mode, webretrieve's
agentic mode, goals's setup agent, the summariser) is invisible to both and
its session is otherwise never evicted. Call
codumentor.plugins.session_eviction.dispose_subagent_session(agent, db_path=…)
from a finally — it emits the hook, calls close() and unlinks the temp DB —
or evict_agent_session(agent) if you only need the emit. An
onSessionEvict emit, not a bare drop_session_state: such a session may also
hold a snapshot ref, session-bound background processes, pooled SSH connections
or a document worker, and each needs its owner's handler to run. Both emit at
most once per agent, so two teardown paths may both call them.

Do not cache an Agent on your plugin or on a tool instance to avoid this.
Your instance is shared by every conversation, so such a cache is not
per-conversation state, and it pins a model, an httpx pool and a tool registry
for the life of the process.

Note: agent is the evicted agent where the cache still held one (the child Agent for a subagent evict) and None otherwise — never rely on it here. Read the ids from the payload. The emitters pass {session_id, user_id} in ctx too, but ctx carries no plugin_host, which is why an eviction handler reads its session_state off the host set_plugin_host gave it rather than through session_state(agent, ctx, …).

Input & Context Hooks

HookPayloadPurpose
onInputReceived{raw: str}Intercept/modify raw user input
onContextReady{session_id, user_id, messages}Access loaded conversation context
onPromptAssemble{messages, tools}Modify prompt before LLM call; register tools
onAfterPromptAssemble{messages, tools}Observe the final prompt after every onPromptAssemble handler has run. Fires once per agent-loop iteration, immediately before the LLM call. Use for inspection (token accounting, prompt capture); prefer onPromptAssemble for changes.

Prompt-patch lifetime: ephemeral, per turn

Terminology. A turn here is one user message plus the entire agent
loop it triggers, until the final response — the same sense the UI and
ctx["turn_id"] use. One LLM call within that loop is an iteration
(ctx["step"]): the iteration cap is max_iterations, the counter is
iterations_completed, and the per-iteration repeat guard is the
CrossIterationLoopDetector — all count iterations, not user rounds.
The legacy config key max_turns is still accepted as a deprecated alias
for max_iterations.

Anything a hook patches into messages is never persisted. The durable
transcript is written only at three explicit points — the user's input
(ConversationManager.prepare_conversation_context), the assistant's
response (store_assistant_response), and tool results
(ToolRuntime.store_results). Every new user turn rebuilds the prompt from
scratch: fresh system prompt from config, history loaded from storage, the
new user message. Hook patches decorate that in-flight list and vanish with
the turn — exactly like the system prompt itself.

Consequences:

Injecting context notes

add_context_note (from codumentor.agent.context_notes) is the single
primitive for putting standing or dynamic context in front of the model. It
owns placement, the synthetic_note tag, dedupe, and the caching/hijack
rules, so callers don't re-derive them:

from codumentor.agent.context_notes import add_context_note, content_freshness

async def _on_prompt_assemble(self, agent, ctx, payload):
    messages = payload.get("messages", [])
    note = add_context_note(
        messages,
        marker="[MyPlugin]",
        render=lambda: note_text,   # built only if the note is actually injected
        scope="prefix",             # "prefix" (static) | "pre_query" (dynamic)
        when=lambda a, c: bool(...),  # optional relevance gate; default always
    )
    if note is not None:            # None = gated out / already present this turn
        return {"type": "continue", "patch": {"messages": note}}

Pick the scope by whether the note's bytes are stable for the whole
conversation:

Both scopes create a user-role message (models like Qwen on vLLM reject
non-leading system messages) tagged metadata={"synthetic_note": True}. A
note is injected at most once per marker within a turn — patches are
ephemeral and re-applied every turn (see "Prompt-patch lifetime"), so the
marker dedupes across loop iterations, not user turns.

All note callers — core and plugin, static and dynamic — go through
add_context_note directly; there is no longer a separate insert_context_note
shim (retired in the prompt-assembly redesign, migration step 7). For a static
note use scope="prefix"; for per-turn/per-iteration content use
scope="pre_query".

LLM Hooks

HookPayloadPurpose
onLLMCallStart{model, messages, tools, params}Before LLM API call
onLLMStream{stream_id, delta_text, ...}Real-time streaming control
onLLMCallEnd{content_chunks, tool_calls}After LLM response received
onLLMError{model, messages, tools, params, error}The LLM call raised. Return {"type": "llm_retry", ...} to request one retry — this is how context_summarization recovers from a context-overflow error and llm_server recovers from a cold backend. Returning nothing lets the error propagate.

Tool Hooks

HookPayloadPurpose
onToolInvokeStart{tool, args, session_id}Before tool execution (includes permission wait). Return {"type": "veto_tool", "reason": …} to block the call.
onToolExecuting{tool, tool_call_id}When actual execution begins (after permission granted)
onToolResult{tool, tool_call_id, args, result}A call has returned, before its result is persisted, rendered for the model or shown on the UI tool card. Return {"type": "result_note", "text": …} to append one short line to the result, so all three carry it verbatim (see below). agent is None. Not fired for a call that never ran (vetoed, refused, denied or otherwise blocked) or that raised.
onToolInvokeEnd{tool, args, result, ms}After tool execution. The result is already persisted, so a handler must not edit it; use onToolResult to add to it.
onToolProgress{tool, text}A tool emitted an incremental progress line mid-execution. Fire-and-forget: dispatched as a background task, agent is None, and return values are ignored. Do not use it to influence the turn.
onToolPermissionDecision{tool, args, decision, reason}A permission decision was reached (e.g. decision: "deny"). Lets a plugin escalate a user denial into veto_tool or abort_turn instead of the default synthesized "blocked" tool result — this is what permission_bridge does.

Note: For tools requiring user permission (e.g., shell commands), there is a gap between onToolInvokeStart and onToolExecuting while waiting for user approval. Use onToolExecuting to measure actual execution time separately from wait time.

Result notes (onToolResult)

A result note tells the model something about a call's outcome that the tool
itself cannot know, at the moment it matters: a call that drew a Download card
gains "The user now sees a Download card for report.pptx in the chat.", and
only on a surface that shows one. Keep a note to one short, deterministic line,
and return nothing when it does not apply:

from codumentor.agent.tool_result_notes import TOOL_RESULT_HOOK, result_note

async def register(self, bus):
    bus.on(TOOL_RESULT_HOOK, self._on_tool_result, priority=70)

async def _on_tool_result(self, agent, ctx, payload):
    if payload["tool"] != "my_export" or not payload["result"].success:
        return None
    return result_note("The export is also mailed to the requester.")

Notes from every handler are appended in priority order, one per line. The
hook is skipped for a call that never ran (a veto, a pre-approval refusal, a
denial) and for a tool that raised: those get a synthesized result and no notes.
A cancel that lands while a handler runs still stores the result, without the
notes.

Download cards are not a note you write. Whoever draws a card declares it
on the result, declare_download_card(result, path) from a hook or
metadata=download_card_metadata(path) from a tool
(codumentor.agent.download_card). Core turns the declaration into the line and
decides which surfaces get one, so it works whether or not file_links is
loaded. See src/codumentor/agent/tool_result_notes.py.

Response Hooks

HookPayloadPurpose
onResponsePersist{assistant_text, final_message?}After response is ready
onBeforeSendResponse{assistant_text, tool_calls, segments?}Final check / revise the finished answer
onUsageTracked{iteration_usage, session_totals}Token usage telemetry
onReportCollect{report_type, ctx_snapshot}Export data to reports
onSessionReport{format, agent_summary}Contribute sections to end-of-session report
onSessionReportRender{format, sections, agent_summary}Render collected session report sections

Revising the finished answer (onBeforeSendResponse)

A plugin can tune the assistant's finished answer — translate leaked text,
inject permalinks, redact secrets. Return a replace whose value.assistant_text
is the corrected whole text:

async def on_before_send(self, agent, ctx, payload):
    new_text = transform(payload["assistant_text"])
    return {"type": "replace", "value": {"assistant_text": new_text}}

This is persisted and replayed as a first-class revision (the canonical UI event
is assistant.message.revision), so the change survives reload — you do not
emit a stream chunk yourself. The whole machinery is described in
doc/core-dev/design/response-revision-persistence.md.

Multi-segment answers. When the turn streamed text → tool → text, the answer
is stored per segment, and a single whole-text blob can't be mapped back onto
those segments (earlier ones keep their old text, the last is sliced at the wrong
offset). For those turns the payload carries segments — the in-order non-empty
content segments. Return a per-segment corrected list alongside the whole
text so each persisted segment is revised in place:

segments = payload.get("segments")          # present only for multi-segment turns
if segments:                                # len >= 2, "".join(segments) == assistant_text
    value["segments"] = [transform(s) for s in segments]
return {"type": "replace", "value": value}

The agent diffs the list against the originals and emits one revision per changed
segment. A whole-text-only replace (no segments) keeps the legacy behavior and
is correct for single-segment answers.

Deferring the revision (optional). If the transformation is slow (e.g. an LLM
call), doing it inside onBeforeSendResponse blocks the turn tail. Instead you can
hand it to a background job so the turn finishes immediately and the revision lands
post-turn (placeholders briefly visible, then replaced live via SSE; reload is
correct too). Record a request on ctx["deferred_revision_jobs"] and return None:

ctx.setdefault("deferred_revision_jobs", []).append({
    "job_type": "my_translation_job",      # register the handler on the job queue (cf. agentic_memory)
    "queue": "background_llm",
    "args": {"conversation_id": ctx["conversation_id"],
             "message_id": ctx["message_id"], "turn_id": ctx.get("turn_id"), ...},
    "metadata": {"run_id": ctx.get("run_id")},
})
return None

After store_assistant_response, the agent enriches each request with the turn's
persisted-history descriptors and enqueues it; your handler does the work and calls
ctx.conv_service.revise_message(...) (the same primitive, segment-addressable) plus,
if needed, an in-place history rewrite via ConversationStorage.update_message_content
(pass expected_content to keep the rewrite drift-safe and idempotent on job re-runs).
script_filter (plugins/script_filter/translation_job.py, gated by its
defer_translation arg) is the reference implementation.

@-Mention Hooks

HookPayloadPurpose
onAtQuery{query, conversation_id}Provide dynamic @-mention search results
onAtContentResolve{references: [AtMentionItem, ...]}Resolve content for @-mentioned items

Note: These hooks use a collector pattern — multiple plugins can contribute results that are aggregated. The query and conversation_id are passed via the ctx dict rather than payload.

onAtQuery

Called when the user types @ in the chat input and the frontend requests dynamic results from plugins (via /ui/at-mentions/search/dynamic). Each plugin can return matching items for the query.

Return type:

return {"type": "at_items", "items": [
    AtMentionItem(
        id="unique_id",
        label="Display name",
        path="folder/file.py",
        type="file",           # Item type (e.g., "file", "single_file")
        provider="my_plugin",  # Your plugin identifier
        score=0.85,            # Relevance score for sorting (higher = better)
    ),
]}

Items from all plugins are collected, sorted by score, and returned to the frontend.

onAtContentResolve

Called when the agent needs to resolve the actual content of @-mentioned items before sending them to the LLM. The plugin should read/fetch the content for references matching its provider field.

Return type:

return {"type": "at_content", "resolved": {
    "item_id_1": "The full text content of the item...",
    "item_id_2": "Another item's content...",
}}

Resolved content from all plugins is merged into a single dict keyed by item ID.

The ctx for this hook is narrow (conversation_id, ui_event_emitter), but
it carries the attachment-staging handle: if the reference is a file, stage
its bytes and append staged.hint() to the text you resolve, so the agent can
copy the file into a repo instead of only reading an extraction of it. See
Attachment Staging.

Error & Control Hooks

HookPayloadPurpose
onError{error, context}Handle uncaught exceptions
onAfterInput{messages}Control flow after input
onAfterLLM{content_chunks, tool_calls, messages}Control flow after LLM
onAfterTool{tool, args, result, ms, messages}Control flow after tool
onErrorDecision{where, error, messages, last_action}Deterministic error recovery

Turn Termination Hooks

A turn can end three ways, and only the normal one runs the response hooks. If
your plugin starts background work during a turn, subscribe to these or it will
outlive the turn that asked for it.

HookPayloadPurpose
onTurnAborted{reason, assistant_text}A plugin or tool raised an abort signal (see the abort_turn return type). The partial answer is in assistant_text.
onTurnInterrupted{}The user cancelled the in-flight turn. Fired from the CancelledError path, so treat it as a cancellation notice, not a normal completion — agentic_memory uses it to drop the storage job it would otherwise have queued.

Ingestion & Repository Hooks

Emitted by the ingestion pipeline on the app plugin host, not inside a turn.
agent is None. Several are extension points: core seeds a registry into
the payload, and a handler patches it back enriched.

HookPayloadPurpose
onIngestionStart{config, dry_run, recreate}Ingestion is beginning. A short_circuit return cancels the whole run with your message.
onResolveRepositories{repos}Rewrite the repository list before it is walked. Patch repos to add, drop, or rewrite entries.
onRegisterRepoProviders{providers, repos}Contribute a RepositoryProvider for a VCS core doesn't handle. providers is seeded with {"git": …}; patch your key in (this is how svn registers).
onRepositoriesReady{repo_dirs, repos}Every repo has been fetched/updated to disk, before embedding.
onIngestionComplete{stats, repo_dirs, knowledge_base, recreate}Ingestion finished successfully.
onIngestionError{error, type?}Ingestion failed. type is "bad_request" when the model provider rejected the batch.

Extension-Point Registries

The same seed-a-registry-and-patch-it-back shape, outside ingestion:

HookPayloadPurpose
onRegisterScmAdapters{adapters}Contribute an SCM adapter for branch listing/switching. Seeded with {"git": GitScmAdapter()}; emitted by branch_switching during register(), so a contributor must register before it — declare dependencies: ["branch_switching"] or a lower priority.
onRegisterSourceProviders{providers, metadata}Contribute a source provider (see Source Abstraction).
onRegisterOAuthProviders{providers, …}Contribute an OAuth provider definition to the oauth plugin.

OAuth Account Hooks

Emitted by the oauth plugin (constants in plugins/oauth/hooks.py, payloads
are TypedDicts there) so an integration can react to credential changes
without polling:

HookPayloadPurpose
onOAuthAccountAdded{user_id, provider, account_id, scopes, expires_at}A user connected an account. expires_at is an ISO-8601 UTC string, or None for providers issuing tokens without an expiry.
onOAuthAccountRemoved{user_id, provider, account_id}A user disconnected an account — drop any cached client or session for it.
onOAuthTokenRefreshed{user_id, provider, account_id, …}An access token was refreshed.

Task Run Hooks

Three hooks around one unattended run — a cron tick, a one-off, a goal tick, or
an event-fired trigger. They are the same three whatever started the run, which
is the point: a task fired by a webhook is bookkept exactly like a cron one.
Constants live in plugins/task_tools/run_handler.py.

Renamed. These were onScheduledRunGate / onScheduledRunFinished /
onScheduledRunAutoDisabled. A row stopped being a "schedule" when the clock
stopped being the only thing that could start it — see
triggers §19. Subscribe to the new names;
the old ones are not aliased.

Every one of the three carries the same run descriptor as its hook ctx —
{job_id, schedule_id, user_id, job_metadata} — where schedule_id is the
task id and job_metadata is the job's metadata dict, which for an
event-fired run also carries {task_id, event_id, chain_id, depth}. The
payload is what differs, and it is what you patch.

HookPayloadPurpose
onTaskRunGate{"skip": False, "skip_reason": None}Veto point, fired before the run's conversation is created. Patch {"skip": True, "skip_reason": "…"} to record a skipped run without burning an LLM turn (goals uses it for paused goals and STOP files). Nothing patching it means the run proceeds, so it is a no-op for ordinary user tasks.
onTaskRunFinished{"result": None} — the ctx additionally carries status, error_kind, skip_reason, error read back off the terminal run rowFired after the run is already terminal, and on the conversation path after the outcome is finalized and the agent evicted — so onSessionEvict work (a goal tick's commit) has already happened. Patch result with an opaque JSON object and the handler persists it on the run row without interpreting it. That object is then matchable: a chained task can say when: [[result.verdict, eq, "needs_review"]]. Skipped runs fire it too, so a health rule can see a gate-skip streak.
onTaskAutoDisabled{consecutive_run_failures, threshold}A task was auto-disabled after too many consecutive failures. Surface it where the operator will see it; the disable is otherwise only in the server log. The trigger router reuses this hook for its own rate-cap auto-pause, adding {"reason": "rate_limited"} — so a handler that reports "disabled" should read reason before saying why.

All three are best-effort and fail-open: a process with no plugin host or
bus (CLI and test paths) fires none of them, and a handler that raises is
logged and skipped. A gate fault must never wedge the task engine or silently
drop a legitimate run, and a finished-hook fault must never mask the
exception the run itself raised.

To make your own plugin something a task can fire on, see
Emitting Trigger Events below.


Hook Return Values

Handlers return a dict to control the pipeline.

Basic Control

# Continue with no changes
return {"type": "continue"}

# Patch the payload (shallow merge)
return {"type": "continue", "patch": {"raw": "modified"}}

# Replace entire payload
return {"type": "replace", "value": new_payload}

# Return None for no-op
return None

Advanced Control (specific hooks only)

# Short-circuit the agent response (control hooks)
return {"type": "short_circuit", "assistant_text": "Early response"}

# Transform response (onBeforeSendResponse)
return {"type": "replace", "value": {"assistant_text": "Modified response"}}

# Retry tool with modified args (onToolInvokeEnd)
return {"type": "retry_with", "patched_args": {...}, "backoff_ms": 1000}

# Dynamic model routing (onLLMCallStart)
return {"type": "param_patch", "model": "gpt-4", "params": {"temperature": 0.2}}

# Stream control (onLLMStream)
return {"type": "stream_control", "abort": True, "replace_delta": "[FILTERED]"}

Return Type Summary

Return TypeApplicable HooksEffect
continueAll hooksContinue pipeline, optionally with patch
replaceAll hooksReplace entire payload
short_circuitonAfterInput, onAfterLLM, onAfterToolEnd agent loop with given response
retry_withonToolInvokeEndRetry the tool with patched arguments
result_noteonToolResultAppend one line to the finished tool result: {"type": "result_note", "text": …} (collector).
param_patchonLLMCallStartModify model or parameters before the call
stream_controlonLLMStreamAbort or replace streaming content
at_itemsonAtQueryContribute @-mention search results (collector)
at_contentonAtContentResolveContribute resolved @-mention content (collector)
jsonpatchAll hooksApply RFC 6902 ops to the payload: {"type": "jsonpatch", "ops": [...]}. Silently skipped (with a warning) if the jsonpatch package isn't installed — prefer continue + patch unless you need surgical edits.
veto_toolonToolInvokeStart, onToolPermissionDecisionBlock this tool call: {"type": "veto_tool", "reason": "…"}. First veto wins — a later handler cannot override or un-veto.
abort_turnonToolInvokeStart, onToolPermissionDecisionStop the whole turn gracefully: {"type": "abort_turn", "reason": …, "assistant_text": …}. First abort wins, and abort outranks veto_tool when both are present. Fires onTurnAborted.
llm_retryonLLMErrorRequest a single retry of the failed LLM call.
insert_steponAfterPromptAssemble, onAfterToolAppend messages to the in-flight list: {"type": "insert_step", "messages": [{"role", "content"}]}.
enqueue_toolonAfterLLMQueue an extra tool call this iteration: {"type": "enqueue_tool", "tool": …, "args": {...}}.
requeue_toolonAfterToolRe-run a tool after it completed (error-recovery loops).
report_collectonReportCollect, onSessionReportContribute report sections: {"type": "report_collect", "sections": [...]} (collector).
abort / deferAll hooksStop dispatching this hook to any remaining handlers. Bus-level flow control — neither ends the turn; for that use abort_turn.
retryAll hooksSleep delay_ms, then continue with the next handler. Blocks the hook chain — use a background job for anything slow.

fallback_model is inert. The bus collects
{"type": "fallback_model", …} into actions, but no production code reads
it — only plugins/examples/error_recovery.py emits it. Treat it as
unimplemented; use param_patch on onLLMCallStart to switch models.

Anything not in this table is ignored. A returned dict without a "type"
key is treated as a whole-payload replacement, so a handler that accidentally
returns its working dict will silently clobber the payload — return None when
you mean "no change".


Emitting Trigger Events

A trigger event is not a UI event. A UI event draws a card in a
conversation; a trigger event goes into the instance-wide event ledger, where a
task can be authored to fire on it — a debounced run when your plugin notices
something, a chained follow-up, an ingestion. See
triggers §9.4.

A plugin that wants a trigger to be able to watch it does two things.

1. Declare what it emits, in meta["emits"]:

meta = {
    "name": "redmine",
    "version": "1.0.0",
    "emits": [
        {
            "name": "ticket_created",
            "scope": "project id",
            "payload": {"ticket_id": "int", "priority": "str"},
            "description": "A new Redmine ticket appeared in a watched project.",
        },
    ],
}

payload maps a dotted path to a kind — one of str, int, float,
bool, list[str], object. scope and description are free text shown to
whoever is authoring a trigger. The declaration is registered from
PluginHost.add — the one choke point both the builtin and the config-driven
load paths go through — and before your register(bus) runs, so a plugin
that emits from its own registration is already declared by then. A
malformed declaration is logged at ERROR and skipped; the plugin still
loads, because a typo in a metadata dict must not take a plugin down. The cost
is that no trigger can be authored against its events.

2. 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}",
)

That emits plugin.redmine.ticket_created. plugin_id is your meta["name"]
and may not contain a dot — the type is split back apart on the first dot
after the prefix, so a dotted id could never be recovered. dedupe_key is the
idempotence key: two emits sharing a (type, dedupe_key) are one event, and
leaving it unset means every emit is a distinct event. causation chains an
event to the run that caused it — from inside a job handler, build one with
causation_for_run_event, or the chain re-roots at every hop and the depth
budget never runs out. has_event_sinks() lets a hot path skip building an
event nothing is listening for.

Declaring is not paperwork. validate_event_type can only check the
shape of a namespaced type, so without a declared vocabulary
plugin.redmine.tickte_created is a perfectly well-formed trigger that
silently never fires. With one, the typo is rejected at create time on every
authoring surface (the create_task tool, config, the UI, a config reload) and
named alternatives are offered, and list_event_types can show the type with a
real payload contract. So an undeclared emit is loud: a hard
UndeclaredEventError when running from source — development and the test
suite, where the author is the one who sees it — and a logged warning naming
the plugin, once per process per type, in a packaged build, where the event
goes out anyway.

Everything a plugin event carries is untrusted. Declared fields are
registered bindable but with trust untrusted, and a namespaced type with no
registered descriptor falls back to an open-binding, untrusted_by_default
descriptor — any well-formed path binds, and every value is fenced. Declaring
must not be a way to loosen that: a declaration buys an enumerable contract and
typo rejection, not trust. In practice this means a bind: on your event never
inlines a value into an instruction; the value is quoted inside the binder's
nonce-sealed untrusted-content block (core/tasks/bind.py, triggers §12.3.4).
A plugin payload usually carries values from whatever external system the
plugin talks to, so that is the right default.

Do not confuse meta["emits"] with ui_manifest()["event_types"]. The
latter is the browser bundle's SSE vocabulary — which custom element renders
which conversation event — and means something else entirely. A plugin can
use both, and the two lists are unrelated.


Emitting UI Events

To display something in the UI, emit events via the ui_event_emitter:

async def _on_response_persist(self, agent, ctx, payload):
    ui_event_emitter = ctx.get("ui_event_emitter")
    if ui_event_emitter:
        await ui_event_emitter(
            "plugin.my_plugin.completed",  # Event type
            {                               # Event data
                "status": "success",
                "summary": "Operation completed",
                "details": {...},
            },
            None  # Optional status override
        )

The event type should follow the convention plugin.<plugin_name>.<event_name>. The UI will route the event to your custom element based on the event_types declared in your UI manifest.


UI Manifest

Return UI configuration from the ui_manifest() method:

def ui_manifest(self) -> dict:
    return {
        "plugin_id": "my_plugin",
        "name": "My Plugin",
        "version": "1.0.0",
        "event_types": [
            "plugin.my_plugin.started",
            "plugin.my_plugin.completed",
        ],
        "ui": {
            "bundle_url": "/ui/extensions/plugins/my_plugin/index.js",
            "sdk_version": "1.0",
            "custom_elements": ["x-my-plugin-card"],
            "placements": ["transcript-card"],
            "widgets": [...]  # Optional welcome page widgets
        },
    }
FieldRequiredDescription
plugin_idYesInternal identifier matching meta["name"]
nameNoHuman-readable display name shown on the settings page. Falls back to meta["name"] if not provided.
versionNoPlugin version string
event_typesNoList of event types this plugin emits
uiNoWeb UI bundle configuration (see below)
tuiNoTUI (terminal) widget configuration (see below)
settings_schemaNoJSON Schema for user-configurable settings. The value is stored per user, but the plugin must read it back (get_setting_value(user_id, PLUGIN_ID, key, default=<the args value>)) — a declared key nothing reads is a dead switch, and tests/test_plugin_settings_read_sites.py fails on one. A boolean marked "hides_cards": True is the exception: the web transcript honours it generically. A string may add "multiline": True (a textarea in the web, a TextArea in the TUI, stored on an explicit Save instead of per keystroke) and "maxLength": N (shown by the form and refused with 422 by PUT /ui/settings). See plugin_ui_widgets.md.
settings_widgetNo{"web_component": "x-my-plugin-settings"} — your own custom element as the plugin's settings UI, rendered in its Settings → Extensions row (list the tag in ui.custom_elements with the settings-panel placement). The TUI counterpart is a tui_settings_widget(user_id) method returning a Textual widget. See Settings panes.
settings_paneNo{"id", "title": {"en": …}, "icon", "order"} — put the plugin's settings in a root Settings pane instead of an Extensions row. For exceptional plugins only; read the rules first: Settings panes.
secret_requirementsNoList of per-user secrets this plugin needs (see below). Surfaced by the User Secrets pane; does not create a settings panel.
user_gatesNoArray of per-user blocking-modal templates (EULA, privacy updates, …). See User Gates.

Declaring required secrets

If your plugin resolves a ${secret:<name>} placeholder (an API key, an auth
header, …), declare it under secret_requirements so the User Secrets
settings pane can show the user exactly which secret to create — and under what
name — instead of making them guess. This is descriptive metadata only: it is
read when the settings pane renders, never during a turn, so it adds **no
dependency on the user_secrets plugin and no load-order concern**.

from codumentor.core.secrets import SecretRequirement

def ui_manifest(self) -> dict:
    return {
        "plugin_id": "my_plugin",
        "name": "My Plugin",
        "secret_requirements": [
            SecretRequirement(
                name=self._api_key_secret,          # use the *configured* name
                label="My Service API key",
                description="Where the user finds it / what it's for.",
                required=True,
            ).as_dict(),
        ],
    }
SecretRequirement fieldDescription
nameThe secret name as referenced via ${secret:<name>}. Use the instance's configured name, not a hard-coded literal, so a renamed secret is reported truthfully.
labelHuman-friendly label for the pane (falls back to name).
descriptionOptional help text — where to obtain the value, what it authorizes.
requiredWhether the plugin is unusable without it (informational; blocks nothing).

Returning only plugin_id/name/secret_requirements (no settings_widget
or settings_schema) keeps the plugin out of the plugin-settings list — the
requirement is surfaced solely through the User Secrets pane. The host
aggregates declarations across all plugins via
PluginHost.collect_secret_requirements() (deduped by secret name), and the
user_secrets plugin annotates each with whether the user has set it. See the
User Secrets catalog entry.

Hint vs. resolution are separate concerns. Declaring a requirement only
makes the secret discoverable. Actually resolving ${secret:...} at
runtime still requires the user_secrets plugin to be loaded — it is what
publishes the per-turn registry. A plugin that resolves secrets during a turn
(after onContextReady) needs user_secrets loaded, but does not need a
dependencies: [user_secrets] edge purely to surface a requirement.

TUI Widget Section

Plugins can optionally provide custom terminal UI widgets by declaring a "tui" section in their manifest. The TUI widget registry dynamically imports these at startup and uses them instead of the generic fallback card.

"tui": {
    "sdk_version": "1.0",
    "widget_module": "my_plugin.tui_widgets",    # Python import path
    "widget_classes": ["MyPluginCard"],           # Widget class names
    "placements": ["transcript-card"],            # Where to mount
}
FieldRequiredDescription
sdk_versionNoTUI SDK version (default: "1.0")
widget_moduleYesDotted Python import path to the module containing widget classes
widget_classesYesList of class names to import from the module
placementsNoWhere widgets can be mounted: "transcript-card", "message-actions", "status-bar", "welcome", "welcome-repos"

Widget classes should subclass PluginCard (from codumentor.plugins.tui_sdk) for collapsible transcript cards, or BaseTUIPluginWidget (from codumentor.plugins.tui_sdk) for fully custom widgets.

For external plugins, the backend/ directory is automatically added to sys.path, so widget_module uses the same dotted path as any other module in the package.

If no "tui" section is declared, plugin events still render in the TUI using a generic fallback card that shows the plugin ID and data payload.


Registering Tools

Adding a tool is the most common thing a plugin does. It happens in
onPromptAssemble, and it takes two steps that are easy to confuse:

  1. Register the executable with agent.tool_registry — this is what makes the tool runnable when the model calls it.
  2. Advertise the definition by patching payload["tools"] — this is what makes the model aware it exists.

Doing only (1) gives you a tool nothing ever calls; only (2) gives you a tool
the model calls and the runtime cannot find. Both are idempotent guards, because
the hook fires on every agent-loop iteration.

Writing the tool

Subclass Tool and implement the two abstract methods:

from codumentor.agent.tools import Tool
from codumentor.agent.types import ToolResult


class GreetTool(Tool):
    def __init__(self):
        super().__init__(
            name="greet",
            description="Greet someone by name.",   # the model reads this
        )

    def get_parameters_schema(self) -> dict:
        return {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Who to greet"},
            },
            "required": ["name"],
        }

    async def execute(self, arguments: dict) -> ToolResult:
        name = arguments.get("name", "world")
        return ToolResult(
            tool_call_id=arguments.get("tool_call_id", ""),
            function_name=self.name,
            content=f"Hello, {name}!",
        )

Override execute(), never call() — call() is the framework wrapper that
handles permissions, timing, events and the automatic ContentStore chunking
described in Best Practices.

On failure, set success=False and put a short machine-readable code in
error, keeping the human-readable guidance in content:

return ToolResult(
    tool_call_id=arguments.get("tool_call_id", ""),
    function_name=self.name,
    success=False,
    error="connection_not_found",
    content="No connection named 'prod'. Available: staging, dev.",
)

ToolResult.llm_content() is the single canonical rendering used by the LLM
history, the persisted tool message and the UI card alike — it composes those
two fields into Error (connection_not_found): No connection named 'prod'. ….
Dumping a payload or a traceback into error corrupts all three at once.

Wiring it into the turn

class MyToolPlugin:
    def __init__(self, config=None, **kwargs):
        self._tools = [GreetTool()]

    async def register(self, bus):
        bus.on("onPromptAssemble", self._on_prompt_assemble, priority=50)

    async def _on_prompt_assemble(self, agent, ctx, payload):
        current = payload.get("tools", [])
        known = {t.get("name") for t in current if isinstance(t, dict)}

        added = []
        for tool in self._tools:
            if not agent.tool_registry.get_tool(tool.name):   # (1) executable
                agent.tool_registry.register_tool(tool)
            if tool.name not in known:                        # (2) definition
                added.append(tool.get_function_definition())

        if not added:
            return None
        return {"type": "continue", "patch": {"tools": current + added}}

get_function_definition() emits {name, description, parameters} from the
schema you declared — you never hand-write the definition dict.

Note what this example does with self._tools: it registers the same Tool
objects into every agent's registry, so two conversations run one Python object
concurrently. That is fine — and it is why per-call state (_current_session_id,
_current_ctx, progress_callback) is task-scoped rather than kept on the
instance. Do not assign those from a hook, and do not mutate a shared tool's
description or attach a per-conversation list to it here; see
Overview → Your tools are shared too.


Agent-Specific Tool Registration

onPromptAssemble fires for subagents too, so by default every tool you
register is also offered to explore, agentic_developer, a memory curator and
any goal worker. For an outward-facing family — send an email, close a ticket,
delete a goal — that is usually not what you want: subagents run without a human
reading their tool calls.

Gating the whole family

from codumentor.plugins.utils import should_register_tools_for_agent

class MyToolPlugin:
    def __init__(self, config=None, **kwargs):
        self.target_agents = kwargs.get("target_agents", "all")

    async def _on_prompt_assemble(self, agent, ctx, payload):
        if not should_register_tools_for_agent(agent, self.target_agents):
            return None  # skip both registration and the context note
        ...

Accepted values — a single token, or any iterable of them meaning any of.
Tokens come at two grains: an agent kind, or a role naming one agent
within a kind.

Two contracts worth internalizing before you rely on this gate.

target_agents fails open. An unusable value — a bad string, a bad type,
an empty collection, an agent whose kind and role are both unreadable —
registers the tools with every agent and logs a warning once per distinct
value. A partially-valid collection keeps its valid tokens (("main",
"bogus")
gates on "main"). This is deliberate: a typo must not silently
disable a tool an operator asked for. It is also why the value must be clamped
at construction — see clamp_target_agents() below. (deny_agents, below,
fails the other way, and for the same reason read from the other side.)

It is not enforcement. This is a registration filter: it decides what a
plugin offers. To hold a subagent to a closed catalog regardless of what any
plugin offers, an AgenticTool declares a tool_allowlist
(tools/subagent_containment.py), which runs last and fails closed. Never
let should_register_tools_for_agent be the only thing between a subagent and
a destructive tool.

Getting this wrong is visible. Every subagent is audited against the tool
list it was built with (install_catalog_audit, installed for you by
create_agent), and anything the plugin stack adds on top is logged once per
subagent — reads at INFO, and writes at WARNING, naming your tool. A write
tool showing up in that line means your family reaches an agent whose tool calls
nobody is reading; the fix is write_target_agents, below.

Naming one subagent instead of all of them

An agent answers to its kind and its role at once, so a role token narrows
without a second knob: "subagent" still admits the explore reader,
"explore" admits only it. The role is the model_role the agent was created
with — AgenticTool passes its own tool name, so the explore subagent's role is
literally explore — and it reaches the gate via
behavior_config.model_role.

The role names a gate may use are a closed registry
(agent/agent_roles.py), for two reasons that are easy to miss:

Adding a role: define the constant in agent/agent_roles.py, reference it at
the site that creates the agent (never restate the literal — that is what keeps
a rename from leaving the registry behind), and list it above.

Naming who is left out

"Every subagent except the curator" has no allow-list. The generic
SubagentTool's own name is subagent — already the kind token — so it has no
distinct role to name in one. But that shape is a subtraction, and naming what
to leave out never needs a name for what is left:

should_register_tools_for_agent(agent, "subagent", ROLE_AGENTIC_MEMORY_STORE)

deny_agents is the third argument, in the same vocabulary as the first,
accepting the same string-or-iterable shapes. It is applied after
target_agents and wins, so ("all", deny_agents=("explore", "goal")) is
everyone but those two.

It fails the opposite way from target_agents, deliberately. An unusable
deny value, or an agent it cannot identify, withholds the tools from
everyone and warns — where the same input on target_agents registers
with everyone and warns. That is one rule, not two: underneath, both ask *does
this value name this agent?* and count anything unknowable as a match. The
caller supplies the meaning. And each direction is the safe one for what the
argument is for — a typo must not silently disable a tool an operator asked
for, and must not silently hand a tool to the agent an operator named an
exclusion for. A family that vanishes with a warning naming the value is a
typo you find; one that reaches the curator anyway is not.

None, "" and an empty collection all mean deny nobody, so a plugin may
compute its exclusions and pass the result without a special case for "none".

The second tier takes the same pair: write_deny_agents alongside
write_target_agents. With only a deny set, the write tier starts from every
agent and subtracts.

Splitting reads from writes

A whole-family gate forces a bad choice for integrations where reading is
routine and writing is not: a subagent investigating a bug has every reason to
read a ticket or a PR diff, and none to close one. select_tools_for_agent
adds a second tier over the tools that declare themselves writes.

The tool declares it, on the class, next to permission_request:

class MyPostCommentTool(Tool):
    """Post a comment (gated)."""

    is_write = True

If your family already routes its writers through a shared base — the way
google_workspace gives all fifteen of its editing tools a GoogleWriteTool
that adds the approval request — declare it there instead, once. A new
editing tool then inherits the answer by picking the base class it was going to
pick anyway, which is the one arrangement where forgetting is not possible.

The hook applies it:

from codumentor.plugins.utils import select_tools_for_agent

tools = select_tools_for_agent(
    agent,
    self._tools,
    target_agents=self._target_agents,            # family gate
    write_target_agents=self._write_target_agents,  # opt-in; default "main"
)
if not tools:
    return payload        # nothing survived — skip the context note too

Classify by capability, not by default arguments: a generic
*_api_request tool takes its HTTP method as a per-call argument, so a
registration-time filter cannot tell a lookup from a PUT. Count it as a write,
or it hands back every write the typed tools just withheld.

Proxying somebody else's tools? Then nobody here can declare anything, and
is_write has to be computed — mcp sets it per tool from the server's
readOnlyHint annotation. Two things change when you do that. An absent
answer flips direction: a first-party tool that declares nothing is an author
who forgot a line, but a third-party tool that says nothing is a question nobody
was asked, so silence must count as a write. And the "nothing declares
is_write" warning above stops being a signal — it catches a forgotten
declaration, which cannot happen when the flag is computed for every tool, so
pass write_target_agents=None when the computed set is empty rather than
tripping it. Be honest in your docs about what the tier then is: a claim by the
party whose tools you are proxying is hygiene over cooperative sources, not a
boundary against a hostile one.

Why a second declaration, when permission_request already knows? Because
it takes the call's arguments, and the gate runs before there is a call. The
two answer the same question to different audiences — one per call, to the
user; one at registration time, to the gate — and the way to keep them in step
is a test that compares them
(test_target_agents_gating.py::…::test_is_write_matches_which_tools_ask_for_approval),
not a third list. That is also the upgrade over the hand-maintained
WRITE_TOOL_NAMES constants this replaced: a name list sits in a different
file from the tool it describes, and nothing notices when a new write tool
misses it.

Declaring whose access a tool runs on

target_agents answers which agent may hold your tools. There is a second,
independent question — which principal may — and it has its own one-line
declaration:

class MySqlQueryTool(Tool):
    """Run a read-only query against a configured connection."""

    shared_credentials = True

Set it when your tool reaches a system with access the operator configured:
a connection string, a service token, an SSH key, an account that lives in the
instance config and is the same whoever asks. Calling such a tool launders
authorization — the caller gets whatever that credential can reach, and their
own entitlements in that system are never checked. An unprivileged access tier
(capabilities: chat_only) therefore holds none of them until the operator says
otherwise per profile, which is what keeps *installing a plugin for the
engineers* from quietly widening what an office tier can reach.

Leave it alone — the default — for the two shapes where the caller's own
authority is what runs:

The same notes as is_write apply: declare it on a shared base class if your
family has one, an undeclared tool counts as not shared (a forgotten line
leaves it exposed, not withdrawn), and a proxied third-party tool has to compute
it — mcp takes it from ServerSpec.per_user, which already knew whether the
connection belongs to one user or to the instance. plugins.tool_traits holds
the readers (declares_shared_credentials, shared_credential_names,
registry_trait_lookup).

Operators adjust the result per profile with access.profiles.<name>.tools —
see Access Profiles.

Declaring what a hook reaches

Everything above is about tools, and a profile's tool decision reaches only
the catalog. Your hooks are not in it: a note injected at onPromptAssemble, a
scan at onToolInvokeEnd, a background agent started at onResponsePersist
all run for every principal unless you say otherwise. That is how agentic
memory kept hinting org notes into a chat-only user's prompt after
memory_lookup had been withheld from them — its lexical recall reads the
notes off disk, not through anything the profile had switched off.

So declare the functionality as an access feature and register the hook
through its gate:

from codumentor.access.features import Feature

RECALL = Feature(
    name="my_plugin.recall",
    estate_wide=True,          # draws on content derived from the whole estate
    tools=("my_lookup",),      # …and is the automatic form of this tool
)

async def register(self, bus):
    bus.on("onPromptAssemble", RECALL.gate(self._on_prompt_assemble), priority=15)

Declare what is true of it; the principal's profile does the deciding:

A feature that declares neither is withheld from no one — declaring nothing
restricts nothing, the same direction as an undeclared shared_credentials.

What the gate does: for a principal who may not have the feature your handler
is not called and the bus sees None, so the pipeline continues as if you
had never subscribed. That is deliberate — absence is policy, not a failure —
so do not emit a card or a warning explaining it; the operator gets one log
line per (feature, profile). If deciding raises, the feature is withheld.

Declare one feature per separately deniable behaviour: agentic memory
declares recall and storage apart (plugins/agenticmemory/access_features.py),
because one discloses org knowledge into a conversation and the other distils a
conversation into it. Leave hooks ungated when they are safe for everyone —
cancelling your own pending work, a veto that protects files, process-level
sweeps. A queued job that outlives the turn that queued it should re-decide
when it runs, with FEATURE.available_for_turn(user_id, conversation_id): the
profile may have tightened in between.

The gate resolves the principal from ctx["user_id"] (plus
ctx["conversation_id"] for a reviewer's grant), which subagents inherit from
their parent — so a gated hook on a subagent is decided for the user who started
the turn.

The same declaration covers the rest of the plugin's surface:

Tag every route and element; an untagged one is served to everyone.
tests/test_agentic_memory_access_gating.py pins which of agentic memory's
routes are deliberately open, so a new route has to be classified to pass.

Can narrow instead? Then narrow. A feature is all-or-nothing; a hook that
can filter what it injects to the profile's own repositories
(profile_for_ctx(ctx).allowed_repo_names(...), the way repo_inventory
does) serves a scoped user better than one that withholds itself. Declare
estate_wide for the part that genuinely cannot be narrowed.

Declaring the setting in your config model

Don't hand-roll the field. Declare it with one of the shared types, which carry
both the accepted shape (a token or a YAML list of them) and the clamp:

from codumentor.plugins.utils import (
    TargetAgentsClampedToAll,
    target_agents_description,
)

class MyPluginArgs(BaseModel):
    target_agents: TargetAgentsClampedToAll = Field(
        "main", description=target_agents_description("tool"),
    )
TypeClamps an unrecognized value toUse for
TargetAgentsClampedToAll"all"Tool providers — a typo must not silently disable a tool the operator asked for
TargetAgentsClampedToMain"main"Every write_target_agents, and the chat bridges (telegram, slack) — a typo must not silently widen the outward-facing surface
TargetAgentsSettingnothingOnly where you want no clamp: an unrecognized value then reaches the gate, which warns and fails open

The suffix names the fallback, not the default. They are independent, and
several plugins differ on purpose — developer defaults to "main" and clamps
to "all". Picking the wrong type silently widens or narrows a gate, which is
why the choice sits in the annotation where a reviewer sees it rather than in a
validator body three fields away.

target_agents_description() derives its text from the role registry, so a new
role reaches every plugin's schema — and the Configuration Assistant that reads
those schemas — without a sweep. Pass the noun your plugin uses ("tool",
"tools", "notes") and the fallback you clamped to.

Shape in, shape out. A string returns a string and a list returns a list, so
widening the accepted config never reshapes a value an existing deployment
already had. Only an operator who writes a YAML list sees one back:

args:
  target_agents: ["main", "explore"]   # the main agent and the explore reader

clamp_target_agents(value, fallback) is still exported for a model that needs
its own validator, and is what the types above call.

Reference implementations: plugins/redmine/config.py and
plugins/gitea_pr/config.py (two-tier), plugins/_office_common/config.py
(family only, shared by three plugins).


Collect Points — asking other plugins a question

Hooks let a plugin participate in a turn. A collect point lets a plugin
ask a question that another plugin answers, without either one naming the
other. Use it when two independently-authored plugins have to agree on a fact
that only one of them owns.

The alternatives couple on identity — importing the other plugin, or
scanning config.plugins for its module name — and both break as soon as one
side is used without the other or replaced by a different implementation.

A point is a name (a string), a payload shape and an answer shape. Consumers
pull; providers answer.

# consumer: ask at the moment you need the answer
for answer in host.collect("repo.agent_managed"):
    print(answer.provider, answer.value)

# provider: declare what you can answer
class MyPlugin:
    def collect_providers(self):
        return {"repo.agent_managed": self._answer_agent_managed}

    def _answer_agent_managed(self, payload):
        # payload is a mapping; tolerate keys you don't know.
        # Return None for "nothing to declare".
        return [self.repo_name] if self.writes_the_repo else None

Getting the host — all three spellings resolve to the same process host
(Overview), so pick whichever the call
site has: a plugin receives it by implementing set_plugin_host(self, host)
(called by PluginHost.add); a route reads request.app.state.plugin_host; a
hook handler can use ctx["plugin_host"] where the runner sets one (the UI/API
turn path does; TUI/CLI turns, subagents and onSessionEvict do not, so keep
the set_plugin_host reference for those).

Contract:

RuleWhy
Pull at use time. Providers are rediscovered on every collect().No registration order to get right: a provider only has to exist when the question is asked. (Historically it also survived the per-agent plugin hosts being rebuilt; there is one host now, but pull remains the cheaper contract.)
None means "nothing to declare".Absent plugin, disabled plugin and nothing-to-say collapse into one empty result, which every consumer must handle anyway.
Order is not a contract.Two providers of one point are peers. Merge with a set/union, never "first wins".
Failures are isolated. A provider that raises, returns junk, or declares a non-callable is logged and skipped.One broken plugin must not break the consumer.
Synchronous. A provider returning an awaitable is an error and its answer is dropped.The consumers are sync (snapshot keys, ingestion walks, routes). Precompute and answer from state.

host.provide(point, fn, provider="label") registers an answerer imperatively,
for the cases that are not plugin instances (a core subsystem, third-party glue,
a test that wants one answer without building a plugin).

Prefer dissolving the coordination first. If the fact is structural, a
self-contained predicate beats a point: memory recall defines a note as "a file
under my own notes-root" rather than enumerating what other plugins keep in the
repo, and thereby needs zero knowledge of them. Reach for a collect point for
the irreducible remainder — a dynamic or relational fact a self-predicate cannot
express.

Naming a sibling directly

Sometimes the question genuinely is "is that specific plugin here?" —
model_profiles yielding its settings pane to thinking_modes, or reading a
store only one plugin owns. Do not hand-roll
for p in host.plugins: if p.meta["name"] == …; there is one implementation of
that lookup:

CallUse it when
host.plugin(name)you hold a real PluginHost
plugin_bus.plugin_named(plugins, name)you hold a plugin list
plugins.utils.host_plugin(host, name)you hold something host-ish and don't know what (ctx["plugin_host"], None, a test stand-in)
plugins.utils.sibling_plugin(host, name, exclude=self)you want "a loaded and enabled collaborator, that isn't me"
plugins.utils.plugin_active(plugin)you already have the object and only need the enabled check
plugins.utils.isolation_active_on_host(host) / isolation_active_in_roster(plugins)the specific "is workspace_isolation live?" question, which must give one answer process-wide

Matching is on meta["name"], the plugin's declared identity — stable across
the two module-path spellings a source checkout and an installed build use. A
missing plugin is None, not an error: an absent plugin is the ordinary answer
"that feature is not installed", and every caller degrades around it.

*But prefer a collect point whenever the question has a role* rather than an
owner** — naming a plugin couples the two, which is the coupling collect points
exist to avoid. Use a by-name lookup for the irreducible "that one, specifically"
cases only.

Ad-hoc vs blessed points. Two plugins can agree on any name and coordinate
with no core change at all. The blessed points — the ones core documents,
reads, and parses in one place — live in codumentor/plugins/collect_points.py:

PointQuestionAnswerRead by
repo.agent_managedWhich configured repos does a plugin write and maintain on the agent's behalf, rather than being the user's source?A repo name, or an iterable of namesworkspace_isolation (excluded from the snapshot key), scm_push (hidden from the push dialog)

Mechanism and rationale: codumentor/plugins/collect.py.