Skip to main content

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_params on tool_start to inject a default parameter (timeout: 10) when the agent omits it.
  • inject_message on tool_end to add a system note visible to the agent on its next turn.

What you build​

HookEventActionObservable evidence
bash_default_timeouttool_starttransform_params: transformation: {timeout: 10}Tool params include timeout: 10 even though the user prompt did not request it
bash_trace_notetool_endinject_message: {role: system, content: "..."}A system message appears in the conversation right after every bash tool_result

The YAML​

app.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 bare on as a boolean, which makes the compiler reject the hook (the field becomes True: tool_end instead of 'on': tool_end).
  • transform_params/transform_result's transformation: map is merged directly, key by key, into the tool's own args/result object - transformation: {timeout: 10} sets timeout on the args as-is. There's no nested set: wrapper and no special sub-keys; whatever you put under transformation: 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​

bash
digitorn install tuto-hook-chain.yaml
digitorn chat tuto-hook-chain

Type this as your message:

text
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:

json
{
"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:

text
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:

ActionWhat it doesUse it for
transform_resultMerges transformation:'s keys directly into the tool's own result objectDecorating or correcting a dict-shaped result (bash, workspace, filesystem all return dicts)
inject_messageAdds a new message (default role user, or role: system) to the conversationA 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:

  • chain runs a list of actions sequentially. Useful when you want several side effects on one event.
  • pipe routes the current tool's output into another tool. Example pattern: pipe everyresult into memory.remember for an auto-trace.
  • gate blocks the tool call with a reason. The behavior engine's action: block is a higher-level version of the same idea; see Advanced 17.
  • lsp_diagnose runs the LSP module's notify_change on 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_params hook makes the default unforgettable.
  • Audit trails. Inject a system_message after every sensitive tool call so the next turn carries proof of what ran.
  • Lightweight policy. A transform_params.remove hook 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.