Hook callback errors are isolated and logged rather than crashing the agent. Hooks are not all passive: directive/control hooks can change flow, transforms can replace content, and a shell
pre_tool_call hook can block or fail closed.
Gateway Event Hooks
Gateway hooks fire automatically during gateway operation (Telegram, Discord, Slack, WhatsApp, Teams) without blocking the main agent pipeline.Trust model: placing the files is the opt-in
The hooks directory is a trusted-by-placement extension point — the documented contract since3988c3c245f (April 2026), when the comparison table below first recorded its consent model as “Implicit (dir trust)”. It has no enable list, and plugins.enabled / plugins.disabled do not apply to it — gateway hooks are not plugins. Exactly what loads:
- When: once, at gateway startup (
HookRegistry.discover_and_load(), called fromgateway/run_startup.py). Under multi-profile gateways, each served profile’s ownhooks/is loaded the first time an event fires inside that profile. The CLI, TUI, Desktop and cron never load gateway hooks. - What: every subdirectory of
<profile home>/hooks/(~/.mibyan/hooks/for the default profile) that contains both aHOOK.yamlparsing to a mapping with a non-emptyeventslist and ahandler.py. Directories missing either file are skipped silently; an invalid manifest or an emptyeventslist is skipped with a[hooks] Skipping …log line. - How:
handler.pyis imported in-process — its module body runs at import, and itshandlefunction is registered for the declared events. It runs as the gateway process with the same access as the gateway itself (loaded credentials, tools, plugin state). There is no sandbox, no first-use prompt, andmibyan_SAFE_MODEdoes not skip this loader.
HOOK.yaml or the directory is the opt-out. Anyone who can write into your profile home can already run code as you through config.yaml shell hooks or plugins.enabled, so the directory sits inside the same trust envelope as the rest of ~/.mibyan/ — see Trusted-by-placement extension points on the security page. Review a hook’s handler.py before you place it, exactly as you would a plugin before enabling it.
Creating a Hook
Each hook is a directory under~/.mibyan/hooks/ containing two files:
HOOK.yaml
events list determines which events trigger your handler. You can subscribe to any combination of events, including wildcards like command:*.
handler.py
- Must be named
handle - Receives
event_type(string) andcontext(dict) - Can be
async defor regulardef— both work - Errors are caught and logged, never crashing the agent
Available Events
Wildcard Matching
Handlers registered forcommand:* fire for any command: event (command:model, command:reset, etc.). Monitor all slash commands with a single subscription.
Examples
Telegram Alert on Long Tasks
Send yourself a message when the agent takes more than 10 steps:Command Usage Logger
Track which slash commands are used:Session Start Webhook
POST to an external service on new sessions:Tutorial: BOOT.md — Run a Startup Checklist on Every Gateway Boot
A popular pattern from the community: drop a Markdown checklist at~/.mibyan/BOOT.md, and have the agent run it once every time the gateway starts. Useful for “on every boot, check overnight cron failures and ping me on Discord if anything failed,” or “summarize the last 24h of deploy.log and post it to Slack #ops.”
This tutorial shows how to build it yourself as a user-defined hook. Mibyan does not ship a built-in BOOT.md hook — you wire up exactly the behavior you want.
What we’re building
- A file at
~/.mibyan/BOOT.mdwith natural-language startup instructions. - A gateway hook that fires on
gateway:startup, spawns a one-shot agent with your gateway’s resolved model/credentials, and runs the BOOT.md instructions. - A
[SILENT]convention so the agent can opt out of sending a message when there’s nothing to report.
Step 1: Write your checklist
Create~/.mibyan/BOOT.md. Write it as if you were giving instructions to a human assistant:
Step 2: Create the hook
~/.mibyan/hooks/boot-md/HOOK.yaml
~/.mibyan/hooks/boot-md/handler.py
_resolve_gateway_model()reads the gateway’s currently-configured model._resolve_runtime_agent_kwargs()resolves provider credentials the same way a normal gateway turn does — including API keys, base URLs, OAuth tokens, and credential pools.
AIAgent() falls back to built-in defaults and will 401 against any non-default endpoint.
Step 3: Test it
Restart the gateway:Running BOOT.md (N chars) followed by either boot-md completed: ... (summary of what the agent did) or boot-md completed (nothing to report) when the agent replied with an exact silence token such as [SILENT].
Delete ~/.mibyan/BOOT.md to disable the checklist — the hook stays loaded but silently skips when the file isn’t there.
Extending the pattern
- Schedule-aware checklists: key off
datetime.now().weekday()inside BOOT.md’s instructions (“if it’s Monday, also check the weekly deploy log”). The instructions are free-form text, so anything the agent can reason about is fair game. - Multiple checklists: point the hook at a different file (
STARTUP.md,MORNING.md, etc.) and register separate hook directories for each. - Non-agent variant: if you don’t need a full agent loop, skip
AIAgententirely and have the handler post a fixed notification directly viahttpx. Cheaper, faster, and has no provider dependency.
Why this isn’t a built-in
An earlier version of Mibyan shipped this as a built-in hook and silently spawned an agent with bare defaults on every gateway boot. That surprised users with custom endpoints and made the feature invisible to users who didn’t know it was running. Keeping it as a documented pattern — built by you, in your hooks directory — means you see exactly what it does and opt in by writing the files.How It Works
- On gateway startup,
HookRegistry.discover_and_load()scans~/.mibyan/hooks/ - Each subdirectory with
HOOK.yaml+handler.pyis imported in-process — no enable list is consulted (see Trust model) - Handlers are registered for their declared events
- At each lifecycle point,
hooks.emit()fires all matching handlers - Errors in any handler are caught and logged — a broken hook never crashes the agent
Gateway hooks only fire in the gateway (Telegram, Discord, Slack, WhatsApp, Teams). The CLI does not load gateway hooks. For hooks that work everywhere, use plugin hooks.
Plugin Hooks
Plugins can register hooks that fire in both CLI and gateway sessions. These are registered programmatically viactx.register_hook() in your plugin’s register() function.
For plugin packaging and registration details, see
the Plugins guide.
- Callbacks receive keyword arguments. Always accept
**kwargsfor forward compatibility. - Callback exceptions are logged and skipped; later callbacks continue. A callback that fails the same way on every call (typically a signature naming a field the hook does not send, e.g.
tool_datainstead oftool_name/args) is reported once at WARNING — the message lists the fields the hook provides — and identical repeats go to DEBUG, so a mis-declared plugin cannot flood the log. - If a Python plugin callback on a timeout-bounded hook (hot-path observers such as
post_tool_call/pre_llm_call, plus the policy hookpre_tool_call) blocks longer thanplugins.hook_callback_timeout(default 30s, set0to disable, max 600), it is abandoned without joining the worker so the agent loop continues. Timed-out or still-runningpre_tool_callcallbacks fail closed (block the tool); other bounded hooks fail open (skip). Hooks with a documented caller-thread contract (subagent_stop) are never moved onto a timeout worker. Shell hooks keep their own per-entrytimeout. - The catalog below is descriptive: observers ignore returns, transforms accept the first valid string replacement, and directive/control hooks consume documented return shapes. Plugin middleware is a separate registry and surface, not another hook category.
- Correlation fields such as
turn_id,api_request_id,task_id,session_id, andapi_call_countare hook-specific and may be absent. Treat IDs as opaque. - Runtime event-name validity comes from
mibyan_cli.plugins.VALID_HOOKS.mibyan hooks listlists configured shell/outbound hooks, not every available event;mibyan hooks test <event>reports the valid set only when an invalid event is supplied.
Cache-safe system prompt sections
Plugins that need durable, always-on guidance can register a bounded system prompt section instead of injecting the same text throughpre_llm_call on
every turn:
- IDs are global, stable, 1–128 character lowercase identifiers using only
letters, numbers,
.,_, and-. Duplicate IDs are rejected. after_memoryis the only placement anchor. Sections are sorted by ID, rendered after memory/profile context and before session metadata; plugins cannot reorder or replace core prompt content.- A callable receives a read-only mapping with
session_id,model,provider,platform,profile_name, andcwd. It runs once for a new session. Its rendered bytes are frozen on compression and recovered from the already-persisted full system prompt after a process restart/resume; plugin state is not re-read for an existing session. max_charsis capped at 4,000 characters. All plugin sections together, including their audit headings, are capped at 8,000 characters and 32 sections. Empty, non-string, oversized, aggregate-over-budget, or raising sections are skipped with a warning; prompt construction continues.- Every accepted section is named in the prompt and logged at session start with its plugin, position, and character count.
pre_llm_call for truly dynamic per-turn context. There is intentionally
no plugin environment-hints hook in this contract: changing cwd, branch, or
other environment data must not silently mutate a session’s cached prompt.
Such a hook needs a concrete consumer and the same frozen/resume-safe semantics
before it can be added.
Shipped plugin-hook catalog
Payload fields below are the exact event-specific fields supplied by each call site. For backward compatibility,PluginManager also adds telemetry_schema_version="mibyan.observer.v1" to every plugin-hook callback. That legacy envelope marker does not mean all hook payloads share one semantic schema; new versioned contracts belong to their concrete event or capability family.
Streaming output hooks
These observer-only hooks let plugins consume streaming LLM output for telemetry, live dashboards, or TTS pipelines without changing the response. They are delivered through host-owned bounded queues with one background worker per registered callback, so plugin callbacks never run inline on the token path. If one callback stalls, only that callback’s queue can fill and drop its oldest pending observer event; other observers continue receiving events independently. Register them like any other plugin hook:
Additional fields:
on_interim_message can also fire after a non-streaming response, so registering only that hook does not force a provider call onto streaming transport.
Reasoning deltas are not exposed to plugins by default. Opt in explicitly:
pre_tool_call
Fires immediately before every tool execution — built-in tools and plugin tools alike.
Callback signature:
Fires: In
model_tools.py, inside handle_function_call(), before the tool’s handler runs. Fires once per tool call — if the model calls 3 tools in parallel, this fires 3 times.
Return value — block or require approval:
block > approve > no directive, regardless of registration order: any plugin’s valid block wins over an earlier plugin’s approve, and among approve directives the first valid one wins (Python plugins are registered first, then shell hooks). block requires a non-empty message and short-circuits the tool with that text as the error returned to the model. approve escalates the call to the existing human-approval gate; message and rule_key are optional, and denial, timeout, or gate error fails closed. Other return values are ignored, so existing observer-only callbacks keep working unchanged.
Return value — rewrite the tool’s arguments:
args dictionary is shallow-merged over the original tool arguments before the tool executes. Multiple modify hooks accumulate — each hook’s keys are merged into one accumulated dict built from the original args, so hook A changing path and hook B changing content both survive. If two hooks modify the same key, the later hook wins.
Shell hooks also accept the Claude Code-compatible format:
{"action": "modify", "args": {...}}.
If a pre_tool_call callback exceeds plugins.hook_callback_timeout (or is still running from a previous timed-out fire), Mibyan fails closed: the tool is blocked with a timeout message rather than proceeding without a policy decision. The same applies to a callback that raises: the block message names the callback and the error. A hung callback is skipped for a 60s suppression window; after that a new tool call runs it again (up to three abandoned workers per callback, so a permanently hung plugin blocks tool calls with a warning naming it instead of silently wedging the agent until restart).
Use cases: Logging, audit trails, tool call counters, blocking dangerous operations, rate limiting, per-user policy enforcement, argument sanitization, path rewriting, injecting default parameters.
Example — tool call audit log:
post_tool_call
Fires immediately after every tool execution returns.
Callback signature:
Fires: In
model_tools.py, inside handle_function_call(), after the tool’s handler returns. Fires once per tool call. Does not fire if the tool raised an unhandled exception (the error is caught and returned as an error JSON string instead, and post_tool_call fires with that error string as result).
Return value: Ignored.
Use cases: Logging tool results, metrics collection, tracking tool success/failure rates, latency dashboards, per-tool budget alerts, sending notifications when specific tools complete.
Example — track tool usage metrics:
pre_llm_call
Fires once per turn, before the tool-calling loop begins. All valid callback returns are aggregated in plugin order and injected into the current turn’s user message.
Callback signature:
Fires: In
agent/turn_context.py (turn preparation for run_conversation() in agent/conversation_loop.py), after context compression but before the main while loop. Fires once per run_conversation() call (i.e. once per user turn), not once per API call within the tool loop.
Return value: If the callback returns a dict with a "context" key, or a plain non-empty string, the text is appended to the current turn’s user message. Return None for no injection.
content remains unchanged. For replay and prompt-cache stability, Mibyan may persist the exact API-bound message, including plugin-injected context, in the row’s api_content sidecar.
On a multimodal turn (the user message is a list of content parts — an image attachment, or text sent as parts) there is no string sidecar: the joined context is appended to that turn’s content as one extra {"type": "text"} part, before the first request, and the part is persisted with the turn so a resumed session, compaction and replay all see the same message the model saw. Earlier messages and the system prompt are never touched.
When multiple plugins return context, their outputs are joined with double newlines in plugin discovery order (alphabetical by directory name).
Use cases: Memory recall, RAG context injection, guardrails, per-turn analytics.
Example — memory recall:
post_llm_call
Fires once per turn, after the tool-calling loop completes and the agent has produced a final response. Only fires on successful turns — does not fire if the turn was interrupted.
Callback signature:
Fires: In
agent/turn_finalizer.py (finalize_turn(), called by run_conversation() in agent/conversation_loop.py), after the tool loop exits with a final response. Guarded by if final_response and not interrupted — so it does not fire when the user interrupts mid-turn or the agent hits the iteration limit without producing a response.
Return value: Ignored.
Use cases: Syncing conversation data to an external memory system, computing response quality metrics, logging turn summaries, triggering follow-up actions.
Example — sync to external memory:
pre_verify
Fires once per turn when the agent edited code, just before it finishes (after the built-in verify-on-stop guard). This is a user/plugin policy gate: a callback can keep the agent going — run a check, defer it, tidy the diff — instead of letting it stop.
Mibyan’ shipped verification guidance is not a default pre_verify hook. It is appended to the evidence-based verify-on-stop nudge when edited code lacks fresh verification evidence, so it does not create a second default continuation path. Set agent.verify_guidance: false to keep that built-in evidence nudge terse.
Callback signature:
Scope a hook to the coding context by checking
coding and make it one-shot with attempt (shell hooks read both from .extra), the same way a pre_tool_call hook scopes on tool_name — so you can register several pre_verify hooks, each firing only where it should.
Fires: In agent/conversation_loop.py, at the point the agent would accept a final answer, immediately after the verify-on-stop check — but only when the agent edited code this turn and at least one pre_verify hook is registered.
Return value — keep the agent going:
message is appended as a synthetic user turn and the loop runs again. The Claude-Code Stop shape ({"decision": "block", "reason": "..."}, where blocking the stop means keep going) is accepted too. A directive with no message — or any other return — lets the turn finish.
Bounded: consecutive continue directives in one turn are capped by agent.max_verify_nudges (default 3), so a hook that always says continue can never trap the loop. The attempted answer is kept in history but not surfaced to the user while the agent is being nudged.
Make it idempotent: the hook re-fires after each nudge, so gate on attempt (if attempt: return None) — otherwise it just nudges until the bound is hit.
Use cases: defer tests/lints during creative iteration, require green checks for certain paths, block “done” until a changelog entry exists, run a project-specific verification checklist.
Example — defer checks on creative UI work, scoped + one-shot:
agent.verify_guidance. For broader coding posture rules that don’t need to gate verification, prefer agent.coding_instructions in config.yaml — it rides the coding brief and costs no extra turn.
transform_api_error_classification
Fires once per failed API call, at the top of agent/error_classifier.classify_api_error(), before the built-in pipeline. Provider plugins use it to own their provider’s error quirks without core patches. It is behavior-changing (transform family): the returned classification drives retry, compression, credential rotation, and fallback routing.
Callbacks receive the parsed error context as kwargs — provider (self-scope on this), model, status_code, error_type, error_code, error_message, error_body, error, approx_tokens, context_length, num_messages. Return None to decline, or a dict to claim the error:
error_message and error_body may carry unredacted provider data. Python plugins only — shell registrations are refused at config parse with a warning.
on_session_start
Fires once when a brand-new session is created. Does not fire on session continuation (when the user sends a second message in an existing session).
Callback signature:
Fires: In
agent/conversation_loop.py, inside run_conversation(), during the first turn of a new session — specifically after the system prompt is built but before the tool loop starts. The check is if not conversation_history (no prior messages = new session).
Return value: Ignored.
Use cases: Initializing session-scoped state, warming caches, registering the session with an external service, logging session starts.
Example — initialize a session cache:
on_session_end
Fires at the very end of every run_conversation() call, regardless of outcome. Also fires from the CLI’s exit handler if the agent was mid-turn when the user quit.
Callback signature:
Fires: In two places:
agent/turn_finalizer.py— at the end of everyrun_conversation()call (agent/conversation_loop.py), after all cleanup. Always fires, even if the turn errored.cli.py— in the CLI’s atexit handler, but only if the agent was mid-turn (_agent_running=True) when the exit occurred. This catches Ctrl+C and/exitduring processing. In this case,completed=Falseandinterrupted=True.
on_session_start.
Example — flush and cleanup:
on_session_finalize
Fires when the CLI or gateway tears down an active session — for example, when the user runs /new or the CLI quits with an active agent. Resource-only idle cache eviction does not finalize the durable conversation. Use it to flush state tied to the outgoing session ID. On gateway reset, the replacement session already exists before this callback runs.
Callback signature:
Fires: In CLI/TUI teardown and in gateway reset or shutdown paths. Gateway shutdown can finalize without a matching
on_session_reset.
Return value: Ignored.
Use cases: Persist final session metrics before the session ID is discarded, close per-session resources, emit a final telemetry event, drain queued writes.
on_session_reset
Fires at a CLI or TUI session boundary, or when the gateway swaps in a new session key for an active chat. This lets plugins react to cleared conversation state without waiting for the next on_session_start.
Callback signature:
Fires: CLI supplies
session_id, platform, and reason; TUI supplies session_id and platform; gateway adds reason, old_session_id, and new_session_id after allocating the replacement key. On gateway reset, the order is: create and persist the replacement → on_session_finalize(old_id) → on_session_reset(new_id) → on_session_start(new_id) on the first inbound turn.
Return value: Ignored.
Use cases: Reset per-session caches keyed by session_id, emit “session rotated” analytics, prime a fresh state bucket.
See the Build a Plugin guide for the full walkthrough including tool schemas, handlers, and advanced hook patterns.
agent_loop_stopped
Fires when the gateway interrupts a running agent turn — the user ran /stop while the loop was working, or the running-agent fast-path inside /new cleared the in-flight run before swapping the session. Unlike on_session_finalize, this fires earlier, while a turn is mid-flight, so plugins can drop per-turn external resources the agent loop will never consume (e.g. an outbound RPC that was waiting for a tool result).
Fires on both interruption surfaces: the messaging gateway (/stop, /new fast-path) and the TUI/desktop session.interrupt path (platform is reported as "tui"). Does not fire in the plain CLI; there is no equivalent interruption surface there.
Callback signature:
Fires: In
gateway/run_agent_cache.py::_interrupt_and_clear_session, immediately after request_hard_interrupt() interrupts the running agent. Only when a real agent was running — the pending-sentinel /stop path (no agent loop yet started) does not fire this hook, since there is no in-flight work to drop. On the slow /new reset path, on_session_finalize fires later in _handle_reset_command instead.
Return value: Ignored.
Use cases: Cancel external requests blocked on a tool result the loop will never consume, notify a connected voice/realtime client that a tool call was abandoned, release per-turn credentials or locks held only for the duration of an active turn.
subagent_start
Fires once per child agent after delegate_task has constructed the child AIAgent and before that child is run. Whether you delegate a single task or a batch of three, this hook fires once for each child.
This hook is specific to delegation/subagent lifecycle. It is not a universal “before any agent invocation” gate for gateway, CLI, cron, batch, MoA, or other runner-originated agent executions.
Callback signature:
Fires: In
tools/delegate_tool.py, inside _build_child_agent(), after the child AIAgent has been constructed and annotated with subagent identity metadata, and before _run_single_child() runs the child.
Return value: Ignored. This is an observer hook only; returning a value does not block or mutate the child agent run.
Use cases: Logging subagent creation, mapping parent/child session relationships, tracking nested delegation trees, emitting pre-run audit records, pre-allocating per-child observability resources.
Example — log subagent creation:
subagent_start is useful for delegation observability, but it is not a blocking policy hook. To block delegation before a child is built, use pre_tool_call to block the delegate_task tool call.subagent_stop
Fires once per child agent after delegate_task finishes. Whether you delegated a single task or a batch of three, this hook fires once for each child. Dispatch is serialised on the parent thread after child futures drain, and each Python callback body runs on that same caller thread (not on a timeout worker).
Callback signature:
Fires: In
tools/delegate_tool.py, after ThreadPoolExecutor.as_completed() drains all child futures. invoke_hook("subagent_stop", ...) is marshalled to the parent thread so authors don’t see child-pool re-entrancy, and callbacks stay on that caller thread.
Return value: Ignored.
Use cases: Logging orchestration activity, accumulating child durations for billing, writing post-delegation audit records.
Example — log orchestrator activity:
With heavy delegation (e.g. orchestrator roles × 5 leaves × nested depth),
subagent_stop fires many times per turn. Keep your callback fast; push expensive work to a background queue.pre_gateway_dispatch
Fires once per incoming MessageEvent in the gateway, after the internal-event guard but before auth/pairing and agent dispatch. This is the interception point for gateway-level message-flow policies (listen-only windows, human handover, per-chat routing, etc.) that don’t fit cleanly into any single platform adapter.
Callback signature:
Fires: In
gateway/run.py, inside GatewayRunner._handle_message(), immediately after is_internal is computed. Internal events skip the hook entirely (they are system-generated — background-process completions, etc. — and must not be gate-kept by user-facing policy).
Return value: None or a dict. The first recognized action dict wins; remaining plugin results are ignored. Exceptions in plugin callbacks are caught and logged; the gateway always falls through to normal dispatch on error.
Callbacks may be async def: they are awaited on the gateway’s own event loop, so awaiting loop-bound work (an asyncio.Event, an aiohttp session, asyncio.to_thread) makes progress and other inbound messages keep flowing while the callback runs. The hook is intentionally not bounded by plugins.hook_callback_timeout — dropping or passing a message on timeout are both wrong for a policy gate — so a callback that never returns holds up dispatch of that message.
Use cases: Listen-only group chats (only respond when tagged; buffer ambient messages into context); human handover (silent-ingest customer messages while owner handles the chat manually); per-profile rate limiting; policy-driven routing.
Example — drop unauthorized DMs silently without triggering the pairing code:
gateway_platform_event
Fires for supported platform-native events only after the gateway’s normal, profile-scoped authorization check succeeds. The callback receives plain dictionaries; raw SDK objects, adapter handles, bot clients, and callback contexts are never part of this stable contract.
Telegram message reactions were the first supported event; message edits, deletes, and thread lifecycle events followed:
Every payload is additive and event-specific; there is no monolithic gateway payload version. All ids are strings; missing/unavailable fields are
None, never guessed. Malformed events and events whose source cannot be authorized are dropped (fail closed). A transient Telegram Application rebuild re-registers the observer together with the core handlers.
Per-event payload contracts (v1, additive):
The bot’s own progressive message edits (streaming) never fire
message_edited on Discord — bot-authored events are dropped at the fire-site.
This hook is observer-only: it does not add raw-event access or adapter access. Raw SDK payload access is deliberately not shipped — adapter SDK objects change shape without notice and would become un-evolvable API surface; where genuinely needed it requires its own explicit capability (gateway.raw_events) with a “no stability guarantee” label and its own design (tracked in #64228). For acting on a platform (adding a reaction, renaming a thread), use the capability-gated ctx.platform_actions facade documented in the plugins guide — it is gated off by default behind the gateway.platform_actions capability. PluginContext.dispatch_tool() can only call tools registered in the tool registry; send_message is intentionally not registered there (its transport is reserved for explicit CLI, cron, kanban, and MCP delivery paths). A future outbound-delivery contract must first provide stable delivered content/handles across all adapters; this slice does not pre-register an inert gateway_message_delivered hook.
pre_approval_request
Fires before an approval decision is requested. It covers prompted surfaces—interactive CLI, Ink TUI, gateway platforms, and ACP clients—and approvals.mode=smart decisions made without a human prompt (surface="smart"). In smart mode, the hook runs before the auxiliary LLM is called.
This is the right place to wire a custom notifier — for example, a macOS menu-bar app that pops an allow/deny notification, or an audit log that records every approval request with context.
Callback signature:
Return value: ignored. Hooks here are observer-only; they cannot veto or pre-answer the approval. Use
pre_tool_call to block a tool before it reaches the approval system.
Use cases: Desktop notifications, push alerts, audit logging, Slack webhooks, escalation routing, metrics.
Example — desktop notification on macOS:
post_approval_response
Fires after a prompted or smart approval decision, after a prompt times out or is withdrawn (turn interrupted or ended before an answer), or when the gateway cannot deliver the approval notification. Notification failure emits choice="notify_failed" before any approval decision exists.
Callback signature:
pre_approval_request, plus:
Return value: ignored.
Use cases: Close the matching desktop notification, record the final decision in an audit log, update metrics, roll forward a rate limiter.
on_room_member_activity
Fires while a hosted Group Chat member turn runs. A member executes on a hidden Group: <room> session that no client is attached to, so between the room log’s turn.started and turn.settled the turn is a black box. This hook projects the runtime events that session already produces — tool start/complete, approval requests, streamed text and reasoning, errors — stamped with the room coordinates, so a client (Mibyan Crew, a dashboard, an audit log) can render tool cards, approval prompts and live member status without inferring anything from text. The Group Chat runtime keeps ownership of execution, scheduling and the durable log; plugins only observe.
Callback signature:
Delivery: each registered callback gets its own bounded queue and worker thread (the
on_stream_* mechanism); a slow callback drops its oldest pending event and never delays the member’s turn. Nothing is written to the room log — deltas are not durable and do not replay; clients that need durability persist what they receive. Local members only: a member seated from another machine runs on that machine’s gateway, whose plugins see it.
Return value: ignored.
pre_transcription
Fires inside the STT dispatcher (tools.transcription_tools.transcribe_audio) after the provider has been resolved and before any backend is invoked, whether that backend is built-in, a type: command provider, or a plugin-registered provider. Lets a plugin steer the transcription request itself instead of only observing the transcript afterwards.
Callback signature:
Return value: a
dict with any of "prompt", "language", "model" mapped to strings, or None to leave the request unchanged. Non-string values, unknown keys, and file_path are ignored (file_path attempts are logged as a warning). Results are applied in registration order, last-writer-wins per field, on top of the stt.prompt config value. Returning "" for prompt clears the configured prompt for that request.
Use cases: Inject a per-user or per-chat vocabulary list before the audio is uploaded, force language from the caller’s locale, downgrade model for long recordings, route noisy sources to a different model.
local maps it to faster-whisper’s initial_prompt; openai, groq, mistral, and deepinfra send it as prompt; xai, elevenlabs, local_command, and type: command providers log at DEBUG and transcribe without it. See the provider support table for the full matrix and the privacy boundary. Hook-plumbing errors are fail-open: the dispatch continues with the unmodified request.
transform_tool_result
Fires after a tool returns and before the result is appended to the conversation. Lets a plugin rewrite ANY tool’s result string — not just terminal output — before the model sees it.
Callback signature:
session_id, tool_call_id, turn_id, api_request_id, duration_ms, status, error_type, and error_message. result is the final result returned by tool dispatch; it and args can contain arbitrary user/tool content and secrets.
Return value: The first str replaces the result (including an empty string); None leaves it unchanged.
Use cases: Redact organization-specific PII from web_extract output, wrap long JSON tool responses in a summary header, inject retrieval-augmented hints into read_file results, rewrite delegate_task subagent reports into a project-specific schema.
transform_terminal_output below — it is narrower, runs before transform_tool_result, and its replacement is still subject to the terminal tool’s final output limit.
transform_terminal_output
Fires inside the terminal tool after foreground process capture has already been bounded by the environment, and before the final output limit. It lets plugins replace the captured stdout/stderr; the replacement is still subject to the final output limit.
It also fires for background-process output on its way to the model or the chat: the process_manage poll / wait / log / kill results (and list previews) and the completion, heartbeat and watch-pattern notifications. There returncode is None while the process is still running and env_type is an empty string (the environment is not recorded per process). In both cases the hook runs before secret redaction, so a replacement that still carries a credential is masked.
Callback signature:
Return value: First
str replaces the output; None leaves it unchanged. Command and output can contain credentials or other sensitive data.
transform_tool_result, which runs afterward for every tool, including terminal.
transform_llm_output
Fires once per turn after the tool-calling loop completes and the model has produced a final response, before that response is delivered to the user (CLI, gateway, or programmatic caller) and before the assistant row is persisted — the replacement is what the session stores, what /resume shows and what the next turn replays, so the transcript never diverges from what the user saw. Mibyan’ own trailers (the file-mutation warning, the abnormal-exit note) are appended afterwards and are not part of response_text. Lets a plugin rewrite the assistant’s final text using classical-programming methods — no extra inference tokens burned on SOUL flavor text or a skill-driven transform.
Callback signature:
Return value: Non-empty
str to replace the response text, None or empty string to leave it unchanged. First non-empty string wins when multiple plugins register. Unlike the tool and terminal transforms, an empty string is not accepted as a replacement.
Use cases: Apply a personality/vocabulary transform (pirate-speak, Spongebob), redact user-specific identifiers from the final text, append a project-specific signature footer, enforce a house style guide without burning tokens on SOUL instructions.
When CLI streaming is enabled, an append-only transform is printed after the
streamed body. A transform that replaces the response is printed in full after
the streamed body, labeled as a post-stream transformation, so replacement
content is never silently lost.
API-request observer hooks
pre_api_request
Fires for each provider attempt immediately before sending it. This is observer-only. The legacy user_message, conversation_history, and request_messages fields are raw and intentionally unsanitized for compatibility; new consumers should prefer the sanitized request envelope.
post_api_request
Fires after a provider response has been normalized successfully. This is observer-only. Prefer the sanitized response; assistant_message is the raw normalized message, and usage contains accounting data.
api_request_error
Fires for a failed provider attempt with status/retry timing, an error object, and sanitized request. This is observer-only. Error messages may still contain provider or user data.
Auxiliary-call observer hooks
pre_auxiliary_call / post_auxiliary_call
Auxiliary LLM calls — session titling, context compression, MoA advisors and the aggregator, vision, approval classification, memory and other side tasks — run outside the main tool-calling loop and do not fire pre_api_request / post_api_request (those stay turn-scoped, so a trace-per-turn plugin never sees side traffic by accident). Subscribe to pre_auxiliary_call / post_auxiliary_call instead: they fire once per physical provider attempt (retries and fallbacks included) with the same payload shape plus aux_task (the task name, e.g. title_generation, compression, moa_aggregator, vision). session_id / task_id / turn_id are the parent turn’s when the call runs under one, empty otherwise; api_request_id (aux-…) is shared by every attempt of one logical call and retry_count distinguishes them. Both are observer-only and fail-open: a raising or timed-out callback is logged and the auxiliary task proceeds. post_auxiliary_call carries error / error_type when the attempt raised and streaming: True (with usage/response None) when the response is handed back as a stream.
on_skill_lifecycle
Fires after an authoritative skill-usage state change. It is observer-only and exposes the local skill_name, provenance, correlation IDs, usage count, and reuse flags.
Kanban lifecycle observers
kanban_task_claimed
Fires after the claim commit in the dispatcher process, immediately before worker spawn.
kanban_task_completed
Fires after completion and cleanup, usually in the worker process. Its summary can contain project or user content.
kanban_task_blocked
Fires after a normal blocked transition. The dependency-wait path invokes it before that write transaction exits. Its reason can contain project or user content.
All three kanban hooks are observer-only and carry task_id, profile_name, board, assignee, and run_id; completed adds summary, and blocked adds reason.
Kanban worker-lifecycle, task-mutation, and dispatch observers
Five additional observers (RFC #58548) extend the kanban family. All are observer-only, fire after the relevant transaction commits, and short-circuit onhas_hook — with no subscriber, dispatch behavior is unchanged. Task-scoped hooks carry the same common fields as the hooks above.
on_kanban_worker_spawned— afterspawn_fnreturns and the worker PID is persisted. Addsworker_pid(may beNone) andworkspace_path. Runs inside the dispatch lock; keep callbacks fast.on_kanban_worker_exited— tick-derived, whendetect_crashed_workersreclaims a dead-PID task. Addsworker_pid,exit_kind,exit_code,outcome,retry_status.on_kanban_worker_stale_claim— when a TTL-expired claim is reclaimed; live-PID extensions don’t fire. Addsworker_pid,heartbeat_stale,retry_status.on_kanban_task_updated— after a committed task-field write outside the claim/complete/block lifecycle (assign_task, model/reasoning overrides, dashboard editors). Addschanged_fields— field names only, never values.on_kanban_dispatch_tick— once per dispatcher tick, strictly after the dispatch lock is released, including idle and lock-contended ticks. Payload:board,profile_name,dry_run,outcome,result.
Shell Hooks
Declare shell-script hooks in your profile’sconfig.yaml and Mibyan will run them as subprocesses whenever the corresponding plugin-hook event fires — in CLI, gateway, Desktop, TUI, and dashboard chat sessions. No Python plugin authoring required.
Desktop, TUI, and dashboard chat register hooks when building an agent, using that session’s profile configuration and consent allowlist. Switching profiles does not reuse another profile’s hooks. Existing hook consent requirements and safe-mode behavior still apply; unapproved hooks are skipped rather than silently approved.
Use shell hooks when you want a drop-in, single-file script (Bash, Python, anything with a shebang) to:
- Block or modify a tool call — reject dangerous
terminalcommands, enforce per-directory policies, require approval for destructivewrite_file/patchoperations, or rewrite arguments (sanitize paths, inject defaults) before the tool runs. - Run after a tool call — auto-format Python or TypeScript files that the agent just wrote, log API calls, trigger a CI workflow.
- Inject context into the next LLM turn — prepend
git statusoutput, the current weekday, or retrieved documents to the user message (seepre_llm_call). - Observe lifecycle events — write a log line when a subagent completes (
subagent_stop) or a session starts (on_session_start).
agent.shell_hooks.register_from_config(cfg) at both CLI startup (mibyan_cli/main.py) and gateway startup (gateway/run.py). They compose naturally with Python plugin hooks — both flow through the same dispatcher.
Comparison at a glance
Configuration schema
command is a skip-with-warning. timeout > 300 is clamped with a warning. fail_closed: true on an event other than pre_tool_call warns and is ignored (only blocking-capable events can fail closed).
On Windows, a command that starts with an existing script file — the ~/.mibyan/agent-hooks/x.sh shape the examples below use — is spawned through that file’s own interpreter (Git Bash for .sh/.bash, the running Mibyan Python for .py), because CreateProcess has no shebang support and rejects a bare script with WinError 193. Every other command, and every POSIX platform, passes argv straight to Popen, where the kernel already honours the shebang.
JSON wire protocol
Each time the event fires, Mibyan spawns a subprocess for every matching hook (matcher permitting), pipes a JSON payload to stdin, and reads stdout back as JSON. stdin — payload the script receives:profile names the Mibyan profile that fired the hook ("default" outside profiles), so one
script can serve every profile behind a multiplexed gateway; the subprocess also runs with that
profile’s mibyan_HOME. tool_name and tool_input are null for non-tool events (pre_llm_call, subagent_stop, session lifecycle). The extra dict carries all event-specific kwargs (user_message, conversation_history, child_role, duration_ms, …). Unserialisable values are stringified rather than omitted.
stdout — optional response:
Exit code 2 = block (Claude Code / Cursor compatible)
Apre_tool_call hook that exits with code 2 blocks the tool call even when its stdout carries no block JSON. The block message is resolved in priority order:
- stdout block JSON (
reason/message), when present; - the first 400 characters of stderr;
- a generic
"Blocked by shell hook."default.
pre_tool_call), exit 2 is treated like any other non-zero exit: a warning is logged and stdout is still parsed.
Fail-open vs fail-closed
By default shell hooks fail open: a spawn error, timeout, or unparseable stdout logs a warning and the action proceeds. That is the right default for observability hooks — but wrong for security gates. A crashed secret-scanner must not silently allow the tool call it was supposed to vet. Setfail_closed: true (or failClosed: true, the Cursor/Claude Code spelling) on a pre_tool_call entry to invert that:
fail_closed: true, each of these now blocks the tool call with hook <command> failed closed: <reason>:
fail_closed only applies to blocking-capable events (pre_tool_call today); setting it on any other event logs a warning at config-parse time and is ignored. mibyan hooks test reflects these semantics — the parsed line shows exactly the block shape the dispatcher would receive.
Worked examples
1. Auto-format Python files after every write
read_file calls pick up the formatted version.
2. Block destructive terminal commands
3. Inject git status into every turn (Claude-Code UserPromptSubmit equivalent)
UserPromptSubmit event is intentionally not a separate Mibyan event — pre_llm_call fires at the same place and already supports context injection. Use it here.
4. Log every subagent completion
Consent model
Each unique(event, command) pair prompts the user for approval the first time Mibyan sees it, then persists the decision to ~/.mibyan/shell-hooks-allowlist.json. Subsequent runs (CLI or gateway) skip the prompt.
Three escape hatches bypass the interactive prompt — any one is sufficient:
--accept-hooksflag on the CLI (e.g.mibyan --accept-hooks chat)mibyan_ACCEPT_HOOKS=1environment variablehooks_auto_accept: truein~/.mibyan/config.yaml
mibyan hooks doctor flags mtime drift so you can spot edits and decide whether to re-approve.
Manual allowlisting
Manual allowlisting is useful for non-TTY or service-account deployments where an operator cannot answer the first-use prompt interactively. The allowlist file is~/.mibyan/shell-hooks-allowlist.json, and the expected format is an approvals array. Each approval records the hook event and the exact command string:
sha256 field is not the expected format and will not approve the hook. Verify manual entries with mibyan hooks list.
The mibyan hooks CLI
Security
Shell hooks run with your full user credentials — same trust boundary as a cron entry or a shell alias. Treat thehooks: block in config.yaml as privileged configuration:
- Only reference scripts you wrote or fully reviewed.
- Keep scripts inside
~/.mibyan/agent-hooks/so the path is easy to audit. - Re-run
mibyan hooks doctorafter you pull a shared config to spot newly-added hooks before they register. - If your config.yaml is version-controlled across a team, review PRs that change the
hooks:section the same way you’d review CI config.
Ordering and precedence
Both Python plugin hooks and shell hooks flow through the sameinvoke_hook() dispatcher. Python plugins are registered first (discover_and_load()), shell hooks second (register_from_config()), so Python pre_tool_call decisions take precedence in tie cases. The first valid block wins — the aggregator returns as soon as any callback produces {"action": "block", "message": str} with a non-empty message — and a block anywhere in the list outranks an approve returned earlier.
Outbound Webhooks
Outbound webhooks are the push-side mirror of the inbound webhook platform: inbound webhooks wake Mibyan when the world changes; outbound webhooks tell the world when Mibyan does something. Configure a list of HTTP endpoints and the lifecycle events they care about, and Mibyan POSTs a signed JSON payload to each endpoint whenever a matching event fires — no polling on the receiving end. Typical uses:- Notify a CI system or dashboard when an agent turn finishes (
on_session_end) - Track subagent completions across a fleet (
subagent_stop) - Feed tool activity into external monitoring (
post_tool_callwith amatcher) - Wake another Mibyan instance: point the URL at that instance’s inbound webhook
Configuration
Add ahooks.outbound: list to ~/.mibyan/config.yaml:
pre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end, subagent_start, subagent_stop, …). Malformed entries warn and are skipped — a broken webhook never crashes the agent. Changes take effect on the next CLI session / gateway restart.
Secrets: prefer secret_env (the name of an environment variable, typically set in ~/.mibyan/.env) over an inline secret: literal, so the config file stays free of credentials. Entries without a secret are delivered unsigned (flagged as UNSIGNED by mibyan hooks list).
Wire format
Each firing POSTs a JSON body with the same top-level shape as shell hooks’ stdin, plus delivery metadata.profile names the Mibyan profile that emitted the event ("default" outside profiles), so receivers behind a multiplexed gateway can tell profiles apart:
Verify the signature exactly as you would a GitHub webhook:
delivery_id and timestamp live inside the signed body, a verified receiver also gets replay protection for free:
- Dedupe on
delivery_id(or the matchingX-Mibyan-Deliveryheader) — remember recently seen ids and skip duplicates. Mibyan retries failed deliveries once, so the same id can legitimately arrive twice. - Reject stale events by checking
timestampagainst your clock with a tolerance window (5 minutes is the common default). An attacker replaying a captured request can’t forge a fresh timestamp without the secret.
Delivery semantics
- Fire-and-forget, off the hot path. Events are serialized and queued instantly; a single background thread performs the HTTP POSTs. A slow or dead endpoint can never stall a tool call or an agent turn.
- Notify-only. Unlike shell hooks, outbound webhooks cannot block tool calls or inject context — the response body is ignored. They observe, never steer.
- Bounded retries. Connection errors and 5xx responses are retried once with backoff; 4xx responses are not retried (the receiver said the request itself is wrong). Failures are logged and dropped — delivery is best-effort, not guaranteed.
- Redirects are never followed. A 3xx response is treated as a misconfiguration and logged — following a redirected POST would silently drop the signed payload. Point the
urlat the final endpoint. - Bounded queue. If the queue backs up (dead endpoint, event storm), new events are dropped with a warning rather than consuming unbounded memory.
- No consent prompt. Outbound targets execute no code on your machine — they receive data at a URL you configured.
mibyan_SAFE_MODE=1still skips registration, same as plugins and shell hooks. Note that payloads include tool inputs and event metadata, so only point targets at endpoints you trust, and preferhttps://.
mibyan hooks list shows configured outbound targets alongside shell hooks, including whether each target is signed.
