Hooks — fundamentals

Lifecycle points, composition, hook context, and local testing. A list of all hooks is in 06-hooks-reference.


6.1 Lifecycle points

Point Signature What it does
before_user_message (ctx, msg) inject, rewrite, or drop a message or reply on its own
before_tool_call (ctx, tool, args) rewrite arguments, reject (deny), or substitute the result (result) before the tool is invoked
after_tool_result (ctx, tool, result) rewrite the tool result (redaction, success, data, visual)
before_inference (ctx, request) model routing, sampling parameters, repackaging of the prompt/tools
after_turn (ctx, question, answer) after the final answer; may emit a post-turn notification
after_assistant_message (ctx, answer) rewrite/suppress the final answer before saving (output-guardrail)
on_token (ctx, token, final_answer) intercept a streamed token: replace or suppress (on-the-fly moderation/redaction; output-only)
before_context_evict (ctx, evicted, summary) custom compaction: replaces the summary of messages evicted from the window
after_loop (ctx, reason) when the run loop stops (job_done, terminated, blocked, budget_exhausted, max_turns, …)
on_spawn (ctx, child_pid) after the process is created (the process's own hooks)
on_terminate (ctx, exit_code) when the process terminates (before waking waiters)
on_error (ctx, message) on a core error for the process (e.g., unrecoverable inference failure)
on_session_created (ctx, session_id) after the process session is registered
on_session_closed (ctx, session_id) when the session is closed/expires (before the record is deleted)
before_retry (ctx, attempt, error) retry decision: retry=false to forbid, delay_ms — backoff
before_syscall (ctx, syscall) syscall interception (including IPC Send/Recv): deny with a reason
on_budget_threshold (ctx, level, spent, max, ratio) budget crossed warn_at (or exhausted) — proactive escalation

before_tool_call outcome

All fields are optional; hooks run in registration order, and each sees the arguments left by the previous one:

Field Effect
arguments replace the call arguments
deny reject the call with a reason → failed result, the handler is not invoked
result fully substitute the result (cache/mock/precomputed answer), the handler is not invoked
notice text streamed to the chat UI; does not enter the LLM context

result takes priority over deny.

after_tool_result outcome

Field Effect
content replace the text for the LLM
success override the success flag
data replace the structured data
visual replace the UI block
notice notification to the chat UI

before_inference outcome

The hook receives the fully assembled request (request: { model, messages, tools, max_tokens, temperature }) and can return any of the fields model / temperature / max_tokens / messages / tools to override. It is called on every inference call in the turn loop (including retries), so conditions must be idempotent. For the llm(...) helper of scripts/hooks it is not called (so that a hook calling llm does not loop).

Additionally, you can control the outcome of the call:

Field Effect
deny refuse inference with a reason → the process is blocked (Blocked)
response synthetic assistant reply — the provider is not called

before_retry outcome

Called when a transient inference error is ready for retry (both on provider open and mid-stream). All fields are optional:

Field Effect
retry false — forbid the retry (move on to the next provider)
delay_ms override the backoff (ms)

before_syscall outcome

Called in handle_syscall before the capability check — a single interception point, including inter-agent Send/Recv:

Field Effect
deny reject the syscall with a reason (AccessDenied)

on_budget_threshold

Notification (no return): the budget has reached warn_at or is exhausted. Fires once per each layer. level — session | group | user, spent, max, ratio. For a session it is set in budget: { warn_at: 0.8 } of the agent; for a group and per-user — in config/budgets.yaml (see 03-server-configuration).

on_token outcome

Called on every chunk streamed to the client (final_answer: true — assistant response tokens, false — service/status). All fields are optional:

Field Effect
replace replace the token text
suppress do not send the token to the client

The hook affects only the stream to the client: the original text still enters the context and the transcript.

before_context_evict outcome

When the context window overflows, the core evicts old messages. The hook receives the evicted messages (evicted: a list of { role, content }) and the current summary, and can return its own:

Field Effect
summary replace the summary of the evicted context (custom summarization)

Tracing interventions

Every hook decision is written as an hook_intercepted event in logs/events.db and per-process JSONL (logs/process-*.jsonl) — for auditing and SQL queries. The points and their action:

Point action
before_tool_call rewrite_args / deny / short_circuit
after_tool_result transform_result
before_inference rewrite_request
on_token replace / suppress
before_context_evict compact
before_user_message reply / suppress / transform / inject / notice
after_assistant_message replace / suppress / notice
after_turn notice
after_loop after_loop (with reason)

Decisions before_tool_call / after_tool_result / before_inference additionally land in the session transcript as a line of kind hook (logs/messages.db) with metadata { point, hook, action, … } — visible via trace_tool and in the UI. A neutral (no-op) hook does not produce an event.

What is recorded is what the hook did, not the source data, so the redaction does not log what it hid.

Outcome before_user_message

A hook may return a map with any of the fields (all optional):

Field Effect
notice text streamed to the chat UI; does not enter the LLM context
reply full response — the LLM turn is skipped
suppress discard the user message entirely
inject additional messages before the user message
replace_message replace the user message

A hook error never breaks the turn — the exception is logged, and the hook returns a neutral result.

Outcome after_assistant_message

Output guardrail: called with the final response before it is written to the context and transcript. All fields are optional:

Field Effect
replace replace the assistant message entirely
suppress do not save the response (nothing is written to the context/transcript)
notice notification in the chat UI
rework reject the response and continue the turn: the core (optionally) clears the stream (replace) and injects a correction message, and the model responds again. No more than MAX_REWORK_ROUNDS (2) times per turn. Clearing is controlled by Rework::clear_stream (default true)

A hook may also simply modify answer in place (Rust) — the change is detected. If the response is modified or suppressed, the core re-emits the corrected response as an final_answer token (with replace: true) so that the client displays it instead of the streamed original. rework does not save the rejected response and does not call after_turn — control returns to the loop for a new round of inference.

Pipeline. On the first terminal decision (rework/suppress), the remaining output hooks are not called: the winner is determined by priority (registration order on ties). The same hooks also apply to completion via job_done: if the guardrail rejects the completion, the core does not terminate the process but returns the correction to the model as the result of job_done (veto) — no more than MAX_REWORK_ROUNDS (2) times per turn.

Isolation. Every hook call is wrapped in panic protection: a panicking hook is skipped with a neutral result and does not crash the turn. The call is also subject to a time limit — timeouts.hook_secs in the agent's YAML (overrides the core's global default, 300 s by default). On timeout, the hook is skipped with a neutral result; a warning is written to the log.

Metrics. Every hook call is tracked by the core in the (hook, point) metric: number of calls, panics, timeouts, total and average duration, last error. A snapshot is available to the administrator: GET /v1/hooks/metrics.


6.23 Attaching hooks to an agent

# config/agents/<agent>/agent.yaml
hook_files:
  - hooks/faq_cache.yaml
  - hooks/memory.yaml

At runtime, hooks can be attached/detached for a process via the Kernel API — parity with tools: grant_hook(pid, name) / revoke_hook(pid, name) / process_hooks(pid) (cf. grant_tool/revoke_tool/process_tools). Attached hooks take effect from the next call.

tool steps of a workflow go through the same before_tool_call / after_tool_result hooks of the launch process (Kernel::run_tool_for); the process allowlist does not apply to them — the set of steps is defined by the workflow author.


6.24 Composition and global hooks

Any YAML hook accepts the common composition keys:

priority: 10          # выше — раньше; при равенстве сохраняется порядок регистрации
enabled: true         # false = хук не вызывается
global: true          # применять ко ВСЕМ процессам, а не только тем, что ссылаются
when:                 # условия активации (все заполненные поля должны совпасть)
  templates: [ngu-agent]
  agents: [ngu-1]     # имена процессов
  users: [alice]
  sessions: [sess-42]

Data-driven composition (bag)

Hooks can pass named values to each other via a per-turn bag (HookBag): one publishes a key, another reads it as an input port. This does not depend on priority and does not require rewriting the user message.

inputs:
  query: { from: retrieval.query, default: "", required: false }  # читать ключ мешка
outputs:
  - retrieval.query            # публиковать ключ (или объект с key/doc)

Standard keys: retrieval.query, retrieval.source, intent.label, lang.user, model.route (agent_os_core::bag_keys). Custom ones are by default namespaced as <hook>.<port>.


6.25 Hook context

HookContext contains: pid, agent, template, session_id, user_id, config, as well as:


6.26 Testing hooks locally

agent-os hook <hook.yaml> info                    # показать распарсенный конфиг
agent-os hook <hook.yaml> user "Привет, мир"      # запустить before_user_message
agent-os hook <hook.yaml> turn "question" "answer" # запустить after_turn
agent-os hook <hook.yaml> loop "job_done"          # запустить after_loop
agent-os hook <hook.yaml> tool echo '{"msg":"x"}'  # запустить before_tool_call
agent-os hook <hook.yaml> result echo "text" false # запустить after_tool_result
agent-os hook <hook.yaml> inference '{"model":"m"}'
agent-os hook <hook.yaml> token "chunk" [true|false]   # запустить on_token
agent-os hook <hook.yaml> evict "summary"              # запустить before_context_evict
agent-os hook <hook.yaml> spawn                    # запустить on_spawn
agent-os hook <hook.yaml> terminate 0              # запустить on_terminate
agent-os hook <hook.yaml> error "boom"             # запустить on_error

# флаги
--db <path>       # перекрыть db_path (для тестов — временный файл)
--live            # судья/запоминатель — реальный дочерний процесс (нужен ключ API)
--agent <name>    # имя процесса в контексте хука
--template <name> # имя шаблона
--user <id>       # user_id
--session <id>    # session_id