Skip to main content

Advanced 7 - Hooks pipe (tool chaining)

The behaviour engine adds rules around tool calls; middleware wraps the LLM call. Hooks v2 sit at a third spot - they fire on tool lifecycle events and can modify, gate, or extend each call as it happens. The hook action that does the most work here is pipe: it routes one tool's output into another tool as soon as the first one finishes, without the agent ever asking.

This is real tool chaining in YAML. The agent runs Bash; the daemon automatically writes the result to a log file. The agent fetches a webhook payload; the daemon automatically forwards it to Slack. The agent compiles a draft; the daemon automatically deploys it. None of those involve the LLM deciding to call the second tool - the pipe is the daemon's job.

How pipe works​

A pipe action declares:

  • to - the destination tool name (module.action or an MCP tool id)
  • map - destination param names → templated values, with {{tool.params.X}} and {{tool.result.X}} placeholders
  • extra - literal params that don't need templating
  • on_error - ignore / log / raise

When the upstream tool finishes, the runtime renders the template against the upstream's tool_context, calls the destination tool with the rendered params + extras, and discards the destination's return value (or logs the error per on_error). The agent sees the upstream tool's result as usual; the pipe is invisible to the LLM.

The YAML​

Save as pipe-bot.yaml. After every Bash call, the daemon auto-writes the result to audit-log.txt in the workspace. The agent runs Bash; it does not need to call Write.

app.yaml
app:
app_id: pipe-bot
name: Pipe Bot
version: "1.0"

runtime:
mode: conversation
workdir_mode: auto
max_turns: 4
timeout: 90
hooks:
- id: log_bash_output
"on": tool_end
condition:
type: tool_name
match: bash.run
action:
type: pipe
to: filesystem.write
map:
path: "audit-log.txt"
content: "{{tool.result}}"
on_error: log

agents:
- id: main
role: assistant
brain:
provider: deepseek
model: deepseek-chat
backend: openai_compat
credential:
ref: deepseek_main
scope: per_user
provider: deepseek
config:
api_key: "{{env.DEEPSEEK_API_KEY}}"
base_url: https://api.deepseek.com/v1
temperature: 0
max_tokens: 200
system_prompt: |
Run Bash commands as requested. Reply with one short
confirmation. Do NOT call Write yourself; the hook handles it.

tools:
modules:
bash: {}
filesystem:
config:
workspace: "."
capabilities:
default_policy: block
max_risk_level: high
grant:
- module: bash
actions: [run]
- module: filesystem
actions: [write, read]

The system prompt explicitly tells the agent not to call Write - the hook does it. This isolates the demo: any Write that lands in audit-log.txt came from the pipe, not from the agent's own decision.

Live transcript​

Sample transcript:

text
> Run Bash: echo "hello pipe demo"

Done. Output: `hello pipe demo`

tool_calls_count: 1 (only the Bash call - the pipe doesn't count as an agent tool call).

After the turn, the workspace API returns audit-log.txt with the auto-written content:

json
{
"stdout": "hello pipe demo\n",
"stderr": "",
"exit_code": 0,
"cwd": "/home/user/.digitorn/workdirs/pipe-bot/<user>/<session>",
"shell": "sh",
"duration_ms": 6
}

The full bash result object landed in the file because {{tool.result}} serialises the whole object. For a clean stdout-only audit you narrow with a path:

yaml
content: "{{tool.result.stdout}}"

The dotted path walks the result object field by field (tool_result.stdout) and emits just that value. Combine with extra: {append: true} (if the destination tool supports it) to accumulate logs across multiple Bash calls in the same file.

What you can pipe to​

The to field accepts any tool the daemon can dispatch:

  • Native modules: filesystem.write, memory.remember, database.query, http.request.
  • MCP tools: any tool exposed by a connected MCP server, by its full id.
  • Sub-agents: agent_spawn.agent (not agent_spawn.spawn).

The pipe's to: is a fixed string in the app's own YAML - never templated from agent or tool output - so it isn't something the agent chooses at runtime; only whoever wrote the hook does. The capability gates that restrict what the agent can call don't apply to it: a hook-dispatched call runs with no LLM in the loop, and every gate's first check is "is this an LLM-originated call?" A pipe can therefore reach any tool the daemon can dispatch, regardless of the agent's own capabilities.grant. Scope what a pipe can reach the same way you'd scope any other line in the app's YAML: by only writing to: destinations you actually intend the app to call.

Other hook actions worth knowing​

pipe is one of about a dozen hook actions. The most useful:

ActionPurpose
pipeChain one tool's output into another
module_actionCall any module action (free-form), with templating
module_action_injectCall an action and inject the result into the agent's context
inject_messageAdd a system / user / assistant message into the loop
gateBlock the upstream tool call before it executes
transform_paramsRewrite the upstream tool's params before it runs
transform_resultRewrite the upstream tool's result before the agent sees it
lsp_diagnoseAuto-lint after a write tool, with optional self-correction
compact_contextSummarise old turns when context pressure crosses a threshold
shellRun a shell command (templated)
chainRun multiple actions in sequence
notifyPush a notification through the channels module
logWrite a structured log line, no side effect on the turn

Each is wired the same way: on: an event, condition: a predicate, action: one of the registered types. The full schema (events, conditions, actions, cooldowns, max_fires, priority) is in Tool hooks.

Hooks vs middleware vs behaviour​

The three runtime layers feel similar but each operates on a different scope:

LayerFires onScopeTypical use
MiddlewareLLM callPer request to the modelMask secrets, content filter, RAG inject
HooksTool call lifecycle (start / end)Per individual tool callPipe chaining, lint after write, gate
Behaviour engineTool call (with rule evaluation)Per tool call (rules)"Read before edit", "no rm -rf"

Pick by where your trigger lives: inside an LLM request → middleware. Around a tool call → hooks. Pattern-based on tool params → behaviour. They compose, not compete.

When to reach for pipe​

  • Audit / mirror: every shell call mirrored to a log; every database write mirrored to S3; every workspace edit pushed to git as a commit.
  • Notification: every channel.receive from Slack auto- acknowledged; every error event auto-paged; every payment_received event auto-emailed.
  • Composite tools: a tool that "fetches and stores" without the agent having to remember both steps. The pipe makes store-after-fetch the daemon's responsibility.

The pattern matters because the agent cannot forget the chained step. A pipe that writes audit logs runs every time; relying on the system prompt to tell the agent "always log after Bash" loses about 5-10% of the time.

Going further​

  • The full hooks v2 reference (events, conditions, actions, cooldowns, composability): Tool hooks.
  • The pipe-action page in the runtime reference, with every template placeholder and every error mode: Tool chaining.
  • For chaining at a different layer (middleware around the LLM call): Advanced 5 - Middleware.