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]
- A list means alternatives (OR); different fields mean AND. An empty field = no restriction.
- Hooks attached to an agent (
hooks:) are still filtered bywhen/enabled. - Global hooks: put YAML in
config/hooks/*.yaml— the server will load them at startup and mark themglobal: true(unified DLP/audit across all agents).
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)
- For built-in hooks, the ports are defined in code and are wired automatically: for example,
condensepublishesretrieval.query, andragreads it (otherwise it takes the raw message). The agent author only needs to list the hooks. - Input resolution order: value in the bag → literal
default→ the hook's built-in fallback. - Auto-ordering: the producer always precedes the consumer;
priorityis the tie-breaker, and on a cycle it falls back topriority. - The bag is scoped to the process and cleared on a new user message.
- Script hooks read
ctx.inputsand returnoutputs({ "<ключ>": value }). - Schema and introspection:
GET /v1/hooks/bag(producers/consumers by key).
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:
parentanddepth— parent and depth in the process tree (subagents);tools— the process's tool allowlist;budget_total/budget_spentand thebudget_remaining()method;inputs— allowed composition input ports ({ port: value });bag— the per-turn bag for publishing values (see §6.24).
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