Skip to main content

Tool Chaining - Runtime Primitive

Route the output of any tool (native module or MCP server) into any other tool. Pure YAML. Works the same whether the upstream tool is filesystem.write, mcp_github.create_pr, or a custom module an app-dev wrote yesterday.

This is the single most important feature for building real workflows on Digitorn. Read this once, then build anything.

The 10-second mental model​

  1. A hook fires on tool_end (or any other event).
  2. The hook action (pipe, module_action, bash, ...) sees the upstream tool's input, output, and metadata via a shared template syntax.
  3. It calls a downstream tool with params computed from that data.

Example - turn a GitHub PR fetch into a Slack notification:

yaml
hooks:
- "on": tool_end
condition:
type: tool_name
match: ["mcp_github.get_pull_request"]
action:
type: pipe
to: mcp_slack.send_message
map:
channel: "#dev"
text: "PR #{{tool.result.number}} - {{tool.result.title}} by {{tool.result.user.login}}"

That's the whole feature. The rest of this doc is details.

The placeholder syntax​

Every hook action's string / dict / list parameter is scanned recursively and {{...}} placeholders are resolved from the upstream tool's context:

PlaceholderReturns
{{tool.name}}The tool's canonical dotted name (e.g. "mcp_github.create_pr", "filesystem.write") - there's no separate short-name form.
{{tool.params.X}}Tool input param X - supports dotted paths
{{tool.result.X}}Tool output field X - same syntax
{{tool.result}}The entire result serialized as JSON
{{tool.error}}Error string, or "" when the tool succeeded

Path syntax​

Applies to both {{tool.params.X}} and {{tool.result.X}}:

text
user.login                   # dict access
files.0.path # list index (0-based, non-negative only)
deeply.nested.3.metadata.tag # mix at any depth

Safe navigation: any missing or out-of-range segment (including a negative index) renders as an empty string. Templates never raise - if an MCP server changes its response shape, your hook degrades gracefully to empty values instead of crashing the agent turn.

The pipe action​

The clean API for the 90% case: route one tool's output into another.

yaml
action:
type: pipe
to: <destination.tool> # required
map: # param name → template
foo: "{{tool.result.bar}}"
nested:
deep: "{{tool.params.x}}"
extra: # literal params (no templating)
flag: true
on_error: log # log (default, and same effect as explicit "log") | ignore | raise
  • map is templated recursively - nested dicts and lists are walked.
  • extra is merged into the final call as-is; nothing gets interpreted. Use it for booleans, integers, enums that would otherwise become strings through templating.
  • on_error controls what happens when the downstream tool fails:
    • unset, or log - warning-level log line naming the action; the turn continues.
    • ignore - swallow silently, no log line.
    • raise - propagate, aborts an enclosing chain.

Advanced composition with chain​

Use chain to run multiple actions in order. Combine with pipe for multi-step pipelines. There's no chain-level "keep going past a failure" switch - an action's own error either propagates and stops the chain right there, or it doesn't, which is controlled per-action via on_error (raise propagates, ignore/log swallow it and the chain continues):

yaml
action:
type: chain
actions:
- type: lsp_diagnose # step 1: lint
inject_result: true # agent sees errors → can retry

- type: pipe # step 2: if lint passed, deploy
to: ci.trigger_build
map:
sha: "{{tool.result.commit.sha}}"
on_error: raise # abort the chain on failure

- type: pipe # step 3: notify
to: slack.send_message
map:
channel: "#deploy"
text: "Build queued for {{tool.result.commit.sha}}"

Working with MCP tools​

Every MCP tool flows through the exact same tool_context shape. The canonical name is mcp_<server_id>.<tool_name> - underscore between mcp and the server id, a single dot before the tool name (the agent-facing form is mcp_<server_id>__<tool_name>, double underscore; hooks see it canonicalized to the dotted form). Use the canonical form in the condition.tool_name list and in the pipe.to field.

MCP tools often have irregular param names (filepath vs file_path, contents vs content). Two defense mechanisms:

  1. Template the param name you need explicitly - no magic guessing:

    yaml
    map:
    # The MCP tool uses `filepath`, but downstream expects `path`.
    path: "{{tool.params.filepath}}"
  2. Use lsp_diagnose for the specific post-write-lint case - it takes a cascade of candidate field names, so one hook covers most MCP conventions without per-server tuning.

Debugging​

A failed action logs a warning ("hook: <action> action failed", tagged with the firing event) at the daemon's default log level - no need to raise DIGITORN_LOGGING__LEVEL to see it. That's the behavior for pipe whenever on_error is left unset or set to log; set it to ignore on a specific pipe to silence a failure you've decided is expected and not worth a log line.

Patterns​

1. Lint every file write - regardless of source​

yaml
hooks:
- "on": tool_end
condition:
type: tool_name
match: ["filesystem.write", "filesystem.edit",
"mcp_github.create_or_update_file"]
action:
type: lsp_diagnose
inject_result: true

2. Persist external data to memory​

yaml
hooks:
- "on": tool_end
condition:
type: tool_name
match: ["mcp_notion.get_page"]
action:
type: pipe
to: memory.remember
map:
content: "Notion page {{tool.params.page_id}}: {{tool.result.title}} (last edited {{tool.result.last_edited}})"

3. Push search results to a preview channel​

yaml
hooks:
- "on": tool_end
condition:
type: tool_name
match: ["mcp_search.query"]
action:
type: pipe
to: preview.set_resource
map:
channel: search_results
id: "{{tool.params.query}}"
payload:
hits: "{{tool.result.hits}}"
took: "{{tool.result.took_ms}}"

4. Forward tool errors as user-facing notifications​

notify is a hook action, not a tool - call it directly, not through pipe:

yaml
hooks:
- "on": tool_end
condition:
type: tool_failed
action:
type: notify
title: "Tool failed: {{tool.name}}"
message: "{{tool.error}}"
level: error

5. Trigger a build on commit - stop the chain on lint failure​

yaml
hooks:
- "on": tool_end
condition:
type: tool_name
match: ["mcp_github.create_commit"]
action:
type: chain
actions:
- type: lsp_diagnose
inject_result: true
- type: pipe
to: mcp_ci.trigger_build
map:
ref: "{{tool.result.sha}}"
on_error: raise

When NOT to chain​

  • Multi-step LLM reasoning - use sub-agents (Agent(prompt=...)), not hooks. Hooks are deterministic.
  • User-facing approval flows - hooks can't block for interactive input. Use the capabilities.approve policy + the approval queue.
  • Heavy computation - hooks run inline on the turn's event loop. For long tasks (>2 s), chain into a background task via module_action on agent_spawn.agent.

Registered by default​

These hook actions all support the full template syntax described here:

  • pipe - the main one.
  • module_action / module_action_inject - low-level alternatives.
  • shell - for system commands.
  • lsp_diagnose - specialized post-write LSP trigger.
  • transform_result - for inline result modification.

Reference​