Advanced 18 - Composing hook primitives around a tool
The Hooks V2 engine exposes a small set of primitive actions
that compose into non-trivial workflows without writing any
middleware. This tutorial wires two primitives around a single
tool surface (bash.run):
transform_paramsontool_startto inject a default parameter (timeout: 10) when the agent omits it.inject_messageontool_endto add a system note visible to the agent on its next turn.
What you build
| Hook | Event | Action | Observable evidence |
|---|---|---|---|
bash_default_timeout | tool_start | transform_params: transformation: {timeout: 10} | Tool params include timeout: 10 even though the user prompt did not request it |
bash_trace_note | tool_end | inject_message: {role: system, content: "..."} | A system message appears in the conversation right after every bash tool_result |
The YAML
app:
app_id: tuto-hook-chain
name: Tuto - Hook Chain
version: "1.0"
runtime:
mode: conversation
workdir_mode: auto
max_turns: 4
timeout: 60
tool_injection: direct
direct_modules: [bash, memory]
hooks:
# Pre-tool: inject a default timeout into every bash call.
# transform_params runs ONLY on pre_tool_use (tool_start) events,
# and merges every key of `transformation` directly into the
# tool's own args - no `set:` wrapper, no nesting.
- id: bash_default_timeout
'on': tool_start
condition:
type: tool_name
match: [bash, bash.run]
action:
type: transform_params
transformation:
timeout: 10
# Post-tool: inject a system note after every bash result. We use
# inject_message (role: system) rather than transform_result
# because transform_result merges its `transformation` keys
# directly into the tool's own result object (useful for
# decorating a dict-shaped result), not into the conversation -
# inject_message is the primitive that actually adds a message.
- id: bash_trace_note
'on': tool_end
condition:
type: tool_name
match: [bash, bash.run]
action:
type: inject_message
role: system
content: "Last bash command was traced by the hook chain (timeout default 10s applied)."
agents:
- id: main
role: assistant
brain:
provider: openai
backend: openai_compat
model: gpt-5-mini
config:
api_key: placeholder
base_url: https://api.openai.com/v1
temperature: 0.1
max_tokens: 1024
system_prompt: |
You are a shell agent. Run the bash command the user
requests. The runtime auto-traces every bash call.
tools:
modules:
bash: {}
memory: {}
capabilities:
default_policy: auto
max_risk_level: high
grant:
- module: bash
actions: [run]
- module: memory
actions: [remember, set_goal]
Three YAML rules to know:
- Quote
'on':in the hook block. YAML 1.1 parses bareonas a boolean, which makes the compiler reject the hook (the field becomesTrue: tool_endinstead of'on': tool_end). transform_params/transform_result'stransformation:map is merged directly, key by key, into the tool's own args/result object -transformation: {timeout: 10}setstimeouton the args as-is. There's no nestedset:wrapper and no special sub-keys; whatever you put undertransformation:becomes real keys on the tool's args or result.match:accepts a list of tool names in either short (bash) or FQN (bash.run) form. Listing both is defensive.
Deploy and run
digitorn install tuto-hook-chain.yaml
digitorn chat tuto-hook-chain
Type this as your message:
Run echo "hello digitorn" via the bash tool. Then paste the EXACT tool output you received.
Sample flow
Hook 1: transform_params injects timeout: 10.
The user did not mention timeout. The agent's tool call nonetheless includes it:
{
"command": "echo \"hello digitorn\"",
"description": "Run echo to print hello digitorn",
"run_in_background": false,
"timeout": 10
}
The timeout: 10 field was added by transform_params on
tool_start, before the bash module saw the call.
Hook 2: inject_message adds a system note after the tool
result.
Right after each tool_result event for bash, a
system_message event appears with the configured note:
tool_call Bash(...)
system_message "Last bash command was traced by the hook chain (timeout default 10s applied)."
The agent sees this system message on its next turn,
identical to a system_prompt segment. role defaults to
user if you omit it - set role: system explicitly, like the
example does, to get a system-style note instead of a
synthetic user turn.
transform_result vs inject_message
Two different primitives, for two different jobs:
| Action | What it does | Use it for |
|---|---|---|
transform_result | Merges transformation:'s keys directly into the tool's own result object | Decorating or correcting a dict-shaped result (bash, workspace, filesystem all return dicts) |
inject_message | Adds a new message (default role user, or role: system) to the conversation | A note the agent should read on its next turn, separate from the tool's own output |
Reach for transform_result when you want the tool's result
itself to look different (add a field, mask a value). Reach for
inject_message when you want to say something about the
call without touching what the tool actually returned - that's
what this tutorial's trace note needs, so inject_message is
the right primitive here, not transform_result.
Other primitives in the toolbox
The Hooks V2 engine ships 13 action types. The ones not exercised in this tutorial:
chainruns a list of actions sequentially. Useful when you want several side effects on one event.piperoutes the current tool's output into another tool. Example pattern: pipe everyresult intomemory.rememberfor an auto-trace.gateblocks the tool call with a reason. The behavior engine'saction: blockis a higher-level version of the same idea; see Advanced 17.lsp_diagnoseruns the LSP module'snotify_changeon any write-like tool and injects the diagnostics into the result. See Advanced 16 for the inline workspace-side equivalent.compact_context,module_action,module_action_inject,shell,log,notify,inject_message: covered in the Hooks reference.
When to reach for this
- Defaults that the agent keeps forgetting (timeouts,
working directories, locale flags). One
transform_paramshook makes the default unforgettable. - Audit trails. Inject a
system_messageafter every sensitive tool call so the next turn carries proof of what ran. - Lightweight policy. A
transform_params.removehook can strip a dangerous flag (e.g.--force) without blocking the call outright.
Hooks are NOT the right tool for:
- Cross-turn pipelines where step B genuinely needs to wait for step A to land in chat. Use sub-agents (Advanced 15) or the agent loop itself.
- User-facing decision points. The agent should call
AskUser, not the hook. - Anything that mutates the model's response text. Hooks fire around tool calls, not LLM completions.