Backend Hooks
This is the complete reference for Codumentor plugin lifecycle hooks. Plugins intercept agent execution via hooks. Each hook handler receives:
agent: The Agent instancectx: Shared context dict (see Overview)payload: Hook-specific data
See also: Overview, API Routes
Hook Reference
Lifecycle Hooks
| Hook | Payload | Purpose |
|---|---|---|
onServerReady | {} | Fired after the API server is fully initialized and accepting requests. Use for deferred, non-critical initialization that should not delay startup. |
onSessionEvict | {session_id} | Fired when a session or cached agent is evicted from memory (LRU or explicit deletion). 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. |
onSessionEvict
Called when a session is evicted from an in-memory cache. This happens in two scenarios:
- LRU eviction — a new session pushes an old one out of the cache (both the stateless API session registry and the UI agent cache).
- 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.
Payload:
{"session_id": "abc123"}
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
Note:
agentisNonefor this hook since eviction is not tied to a specific agent turn. Thectxdict is empty. Onlypayload["session_id"]is meaningful.
Input & Context Hooks
| Hook | Payload | Purpose |
|---|---|---|
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 ismax_iterations, the counter is
iterations_completed, and the per-iteration repeat guard is the
CrossIterationLoopDetector— all count iterations, not user rounds.
The legacy config keymax_turnsis still accepted as a deprecated alias
formax_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:
- A "skip if my marker is already in
messages" check does not mean "inject once per conversation". It only dedupes across agent-loop iterations within one turn (the patched list is carried between iterations). Your patch will re-apply on every turn — design for that. - Re-injection per turn is the desired behavior for static context: config changes take effect on the next turn, and the stored transcript stays clean for export, sharing, and summarization.
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:
scope="prefix"— inserted directly after the leading system message. Static content only. A stable prefix keeps the assembled prompt prefix-stable across turns so provider prompt caching keeps working; a per-turn-varying prefix note invalidates that cache for the entire conversation (the framework logs a warning if a prefix note's bytes change turn-to-turn, whenctxis passed).scope="pre_query"— inserted immediately before the current user query (the last non-syntheticusermessage). Use for anything that varies per turn or per loop iteration. It leaves the prefix byte-stable, and — because the real user query (and any tool work that follows) always comes after the note — the model never mistakes the note for the message it must answer. Do not append dynamic notes at the absolute end of the list: the moment a turn's real work finishes, a trailinguser-role note becomes the dangling last message and the model "answers" it (this is exactly what bitrepo_inventory— the agent acknowledged the repo list instead of confirming the completed task). Thesynthetic_notetag only protects internal last-user-message extractors (agentic-memory RDC retrieval skips tagged messages); the LLM never sees the tag, so position is the real safeguard. To let a note refresh during a turn when its content changes, passfreshness=content_freshness(note_text): the dedupe key becomesmarker#freshness, so it re-injects when the content changes and is suppressed when it doesn't. (scope="tail"is a deprecated alias for"pre_query".)
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
| Hook | Payload | Purpose |
|---|---|---|
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
| Hook | Payload | Purpose |
|---|---|---|
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) |
onToolInvokeEnd | {tool, args, result, ms} | After tool execution |
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
onToolInvokeStartandonToolExecutingwhile waiting for user approval. UseonToolExecutingto measure actual execution time separately from wait time.
Response Hooks
| Hook | Payload | Purpose |
|---|---|---|
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
| Hook | Payload | Purpose |
|---|---|---|
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
queryandconversation_idare passed via thectxdict rather thanpayload.
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
| Hook | Payload | Purpose |
|---|---|---|
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.
| Hook | Payload | Purpose |
|---|---|---|
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.
| Hook | Payload | Purpose |
|---|---|---|
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:
| Hook | Payload | Purpose |
|---|---|---|
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:
| Hook | Payload | Purpose |
|---|---|---|
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. |
Scheduled Run Hooks
Emitted by scheduler_tools around a scheduled/goal tick:
| Hook | Payload | Purpose |
|---|---|---|
onScheduledRunGate | {…run descriptor} | Veto point, fired before the tick's conversation is created. Patch {"skip": True, "skip_reason": "…"} into the payload to record a skipped run without burning an LLM turn (goals uses it for paused schedules and STOP files). Nothing patching it means the run proceeds, so it is a no-op for ordinary user schedules. |
onScheduledRunAutoDisabled | {…run descriptor} | A schedule 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. |
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 Type | Applicable Hooks | Effect |
|---|---|---|
continue | All hooks | Continue pipeline, optionally with patch |
replace | All hooks | Replace entire payload |
short_circuit | onAfterInput, onAfterLLM, onAfterTool | End agent loop with given response |
retry_with | onToolInvokeEnd | Retry the tool with patched arguments |
param_patch | onLLMCallStart | Modify model or parameters before the call |
stream_control | onLLMStream | Abort or replace streaming content |
at_items | onAtQuery | Contribute @-mention search results (collector) |
at_content | onAtContentResolve | Contribute resolved @-mention content (collector) |
jsonpatch | All hooks | Apply 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_tool | onToolInvokeStart, onToolPermissionDecision | Block this tool call: {"type": "veto_tool", "reason": "…"}. First veto wins — a later handler cannot override or un-veto. |
abort_turn | onToolInvokeStart, onToolPermissionDecision | Stop 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_retry | onLLMError | Request a single retry of the failed LLM call. |
insert_step | onAfterPromptAssemble, onAfterTool | Append messages to the in-flight list: {"type": "insert_step", "messages": [{"role", "content"}]}. |
enqueue_tool | onAfterLLM | Queue an extra tool call this iteration: {"type": "enqueue_tool", "tool": …, "args": {...}}. |
requeue_tool | onAfterTool | Re-run a tool after it completed (error-recovery loops). |
report_collect | onReportCollect, onSessionReport | Contribute report sections: {"type": "report_collect", "sections": [...]} (collector). |
abort / defer | All hooks | Stop dispatching this hook to any remaining handlers. Bus-level flow control — neither ends the turn; for that use abort_turn. |
retry | All hooks | Sleep delay_ms, then continue with the next handler. Blocks the hook chain — use a background job for anything slow. |
fallback_modelis inert. The bus collects
{"type": "fallback_model", …}intoactions, but no production code reads
it — onlyplugins/examples/error_recovery.pyemits it. Treat it as
unimplemented; useparam_patchononLLMCallStartto 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 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
},
}
| Field | Required | Description |
|---|---|---|
plugin_id | Yes | Internal identifier matching meta["name"] |
name | No | Human-readable display name shown on the settings page. Falls back to meta["name"] if not provided. |
version | No | Plugin version string |
event_types | No | List of event types this plugin emits |
ui | No | Web UI bundle configuration (see below) |
tui | No | TUI (terminal) widget configuration (see below) |
settings_schema | No | JSON Schema for user-configurable settings |
secret_requirements | No | List of per-user secrets this plugin needs (see below). Surfaced by the User Secrets pane; does not create a settings panel. |
user_gates | No | Array 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 field | Description |
|---|---|
name | The 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. |
label | Human-friendly label for the pane (falls back to name). |
description | Optional help text — where to obtain the value, what it authorizes. |
required | Whether 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 theuser_secretsplugin to be loaded — it is what
publishes the per-turn registry. A plugin that resolves secrets during a turn
(afteronContextReady) needsuser_secretsloaded, 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
}
| Field | Required | Description |
|---|---|---|
sdk_version | No | TUI SDK version (default: "1.0") |
widget_module | Yes | Dotted Python import path to the module containing widget classes |
widget_classes | Yes | List of class names to import from the module |
placements | No | Where 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:
- Register the executable with
agent.tool_registry— this is what makes the tool runnable when the model calls it. - 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.
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.
"main"— the main agent only"subagent"— any subagent"all"— every agent (the default)- a role —
"explore","agentic_developer","agentic_memory_store","web_search","web_retrieve","goal","context_summarization" ("main", "explore")— a collection mixing the grains: the main agent plus the explore reader, which neither grain says on its own
Two contracts worth internalizing before you rely on this gate.
target_agentsfails 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",gates on
"bogus")"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 — seeclamp_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, anAgenticTooldeclares atool_allowlist
(tools/subagent_containment.py), which runs last and fails closed. Never
letshould_register_tools_for_agentbe 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:
- A typo has to stay a typo. The gate fails open on an unrecognized value. If any string were a legal role,
"explor"would match no agent and silently withhold the tool — the inversion of the documented behaviour, and invisible in a log. model_rolenames two different things. It also selects a model profile, and that half is open-ended: a scheduled run or a goal-worker tick is a main agent carrying whatever profile name the operator configured. Because the registry is closed, those contribute no role token and gate exactly as before. The honest caveat is the converse — an operator who names a model profile after a blessed role makes that agent match a gate written against it.
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 ontarget_agentsregisters
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
- Failing the family gate returns
[]. Skip your context note as well: the agent has none of the tools the note talks about. - Passing the family gate but failing the write gate returns the read tools with the write ones filtered out. Render your context note per-agent to match — a read-only variant must not point a subagent at a write tool as the workaround for something it is missing.
write_target_agents=Noneandwrite_deny_agents=None— the defaults — apply no second tier.- An undeclared tool counts as a read, so a forgotten
is_writeleaves a write registered. A write tier over a family where nothing is declared logs a warning, since that gate can never remove anything. - Need the names elsewhere (
gitea_prnarrows a headless review turn against an auto-run allow-list, working from tool dicts)?plugins.utils'write_tool_names(tools)is the same question, asked once.
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_requestalready 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_NAMESconstants 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 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"),
)
| Type | Clamps an unrecognized value to | Use 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 |
TargetAgentsSetting | nothing | Only 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: 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.
Contract:
| Rule | Why |
|---|---|
Pull at use time. Providers are rediscovered on every collect(). | No registration order to get right, and it survives the per-instance plugin reload (every Agent builds its own host). |
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.
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:
| Point | Question | Answer | Read by |
|---|---|---|---|
repo.agent_managed | Which 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 names | workspace_isolation (excluded from the snapshot key), scm_push (hidden from the push dialog) |
Mechanism and rationale: codumentor/plugins/collect.py.