Plugin hooks

August 15, 2026 · View on GitHub

Every lifecycle, LLM, tool, sub-agent, and callback hook exposed to plugins. Hooks are defined by the Plugin protocol in kohakuterrarium.modules.plugin; BasePlugin gives you default no-op implementations. Wired in bootstrap/plugins.py.

For the mental model, read concepts/modules/plugin. For task-oriented walkthroughs, see guides/plugins and guides/custom-modules.

Return-value semantics

  • Transform hooks (pre_*, post_*): return None to keep the value unchanged, or return a new value to replace the input going into the next plugin / the framework.
  • Callback hooks (on_*): return value is ignored; they are fire-and-forget.

Blocking

Any pre_* hook may raise PluginBlockError to short-circuit the operation. The framework surfaces the error, the request does not proceed, and the matching post_* hook is not fired. Callback hooks cannot block.


Lifecycle hooks

HookSignatureFired whenReturn
on_loadasync on_load(ctx: PluginContext) -> NonePlugin is loaded into an agent.ignored
on_unloadasync on_unload() -> NonePlugin is unloaded or agent stops.ignored
should_applydef should_apply(ctx: PluginContext) -> boolEvaluated before each hook call.False skips this plugin for that context.
contribute_commandsdef contribute_commands() -> dict[str, BaseCommand]After load, during controller wiring.Mapping of controller command names.
contribute_termination_checkdef contribute_termination_check() -> Callable[[TerminationContext], TerminationDecision | None] | NoneDuring termination wiring.Checker function or None.

PluginContext gives the plugin access to the host agent, session store, scratchpad, registry, controller, subagent manager, and helper methods like switch_model(...), inject_event(...), and inject_message_before_llm(...). Plugins can also declare a static filter with applies_to = {agent_names, model_patterns}.


LLM hooks

HookSignatureFired whenReturn semantics
pre_llm_callasync pre_llm_call(messages: list[dict], **kwargs) -> list[dict] | NoneBefore every LLM request (controller, sub-agent, compact).None keeps the list; a new list replaces it. May raise PluginBlockError.
post_llm_callasync post_llm_call(messages: list[dict], response: str, usage: dict, **kwargs) -> str | NoneAfter the final assistant message is assembled.None keeps the text; a returned string rewrites it for the next plugin / final output.

When a post_llm_call rewrite changes the final assistant text, the runtime emits an assistant_message_edited activity marker so UIs can audit that rewrite.


Tool visibility contributions

Plugins can restrict the catalog exposed to the model on each native request without touching the registry. PluginManager intersects multiple contributions per category, so each plugin can only narrow the catalog.

HookSignatureFired whenReturn semantics
get_tool_visibilitydef get_tool_visibility(context: PluginContext) -> ToolVisibility | NoneBefore each native request's tool schemas and provider-native tools are built.None keeps the catalog unrestricted. A ToolVisibility(allowed_tools=..., allowed_subagents=...) keeps only the named members; an empty frozenset hides the whole category.

ToolVisibility fields:

  • allowed_tools: frozenset[str] | NoneNone means unrestricted.
  • allowed_subagents: frozenset[str] | NoneNone means unrestricted.

This contribution only affects native tool schemas; text-mode prompt filtering is left to prompt contributions.


Tool hooks

HookSignatureFired whenReturn semantics
pre_tool_dispatchasync pre_tool_dispatch(call: ToolCallEvent, context: PluginContext) -> ToolCallEvent | NoneAfter parsing, before executor submission.None keeps the call; a returned event rewrites tool name/args. May raise PluginBlockError.
pre_tool_executeasync pre_tool_execute(args: dict, **kwargs) -> dict | NoneJust before tool execution.None keeps args; a new dict replaces them. May raise PluginBlockError. kwargs include tool_name, job_id.
post_tool_executeasync post_tool_execute(result: ToolResult, **kwargs) -> ToolResult | NoneAfter a tool completes (including error results).None keeps the result; a new ToolResult replaces it. kwargs include tool_name, job_id, args.

Sub-agent hooks

HookSignatureFired whenReturn semantics
pre_subagent_runasync pre_subagent_run(task: str, **kwargs) -> str | NoneBefore a sub-agent is spawned and started.None keeps the task; a returned string replaces it. May raise PluginBlockError. kwargs include name, job_id, is_background.
post_subagent_runasync post_subagent_run(result: Any, **kwargs) -> Any | NoneAfter a sub-agent completes (its output is about to be delivered as a subagent_output event).None keeps the result; a returned value replaces it. kwargs include name, job_id.

Callback hooks

All callbacks are fire-and-forget. Their return value is ignored.

HookSignatureFired when
on_agent_startasync on_agent_start() -> Noneagent.start() completed.
on_agent_stopasync on_agent_stop() -> Noneagent.stop() begins.
on_eventasync on_event(event: TriggerEvent) -> NoneAny event is injected into the controller.
on_interruptasync on_interrupt() -> NoneThe user interrupts the agent.
on_task_promotedasync on_task_promoted(job_id: str, tool_name: str) -> NoneA direct task is promoted to background.
on_compact_startasync on_compact_start(context_length: int) -> bool | NoneBefore compaction. Return False to veto this compaction cycle.
on_compact_endasync on_compact_end(summary: str, messages_removed: int) -> NoneAfter compaction finishes.

Prompt plugins (separate category)

Prompt plugins run during system prompt assembly in prompt/aggregator.py. They are loaded independently from lifecycle plugins.

BasePlugin (in kohakuterrarium.prompt.plugins) has:

priority: int       # lower = earlier
name: str
async def get_content(self, context: PromptContext) -> str | None
  • get_content(context) -> str | None: Return the text block to insert, or None to contribute nothing.
  • priority: ordering key. Built-ins sit at 50/45/40/30.

Built-in prompt plugins are listed in Prompt plugins in builtins.md.

Register custom prompt plugins via the plugins field of a creature config (same as lifecycle plugins); the framework dispatches based on whether a plugin class subclasses the lifecycle Plugin protocol or the prompt BasePlugin.


Writing a plugin

Minimal lifecycle plugin:

from kohakuterrarium.modules.plugin import BasePlugin, PluginBlockError

class GuardPlugin(BasePlugin):
    async def pre_tool_execute(self, args, **kwargs):
        if kwargs.get("tool_name") == "bash" and "rm -rf" in args.get("command", ""):
            raise PluginBlockError("unsafe command")
        return None  # keep args unchanged

Register in a creature config:

plugins:
  - name: guard
    type: custom
    module: ./plugins/guard.py
    class: GuardPlugin

Enable/disable at runtime via /plugin toggle guard (see User commands in builtins.md) or the HTTP plugin toggle endpoint.


See also