fabro/docs/agents/hooks.mdx
Bryan Helmkamp a54d3876f5 Add cargo-fmt post_tool_use hook and improve matcher docs
Add a blocking cargo-fmt hook to fabro.toml that auto-formats Rust
files after write_file, edit_file, or apply_patch tool calls. Improve
the hooks documentation with a detailed matcher field reference table,
tool name catalog, cross-field matching caveat, and additional examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:34:23 -04:00

406 lines
14 KiB
Text

---
title: "Hooks"
description: "Run custom logic in response to workflow lifecycle events"
---
Hooks let you run custom logic at key points during a workflow — before a stage starts, after a run completes, when a sandbox is ready, and more. Use them for validation, notifications, guardrails, and orchestration without modifying the workflow graph itself.
## Hook types
Fabro supports four hook types, from simple shell commands to full agent sessions:
### Command
Run a shell command via `sh -c`. The simplest and most common hook type.
```toml title="run.toml"
[[hooks]]
event = "stage_start"
command = "./scripts/pre-check.sh"
```
### HTTP
POST the event context as JSON to an HTTP endpoint. Useful for webhooks, external APIs, and notification services.
```toml title="run.toml"
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
[hooks.headers]
Authorization = "Bearer $API_KEY"
```
| Field | Description |
|---|---|
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. |
| `headers` | Optional HTTP headers. Values support `$VAR` interpolation from `allowed_env_vars`. |
| `allowed_env_vars` | List of environment variable names that may be interpolated into headers. |
| `tls` | TLS mode: `"verify"` (default), `"no_verify"`, or `"off"`. |
### Prompt
A single-turn LLM call that evaluates the event context and returns an `ok`/`block` decision. The model responds with structured JSON.
```toml title="run.toml"
[[hooks]]
event = "stage_start"
type = "prompt"
prompt = "Should this stage proceed given the current context? Check for any red flags."
model = "haiku"
blocking = true
```
| Field | Description |
|---|---|
| `prompt` | Instructions for the LLM evaluator. |
| `model` | Model alias or ID. Defaults to `haiku`. |
### Agent
A multi-turn agent session with full tool access (shell, file read/write, grep, glob). The agent can investigate the workspace before making a decision.
```toml title="run.toml"
[[hooks]]
event = "run_complete"
type = "agent"
prompt = "Verify that all tests pass and the code compiles. Run the test suite."
model = "sonnet"
max_tool_rounds = 10
blocking = true
```
| Field | Description |
|---|---|
| `prompt` | Task instructions for the agent. |
| `model` | Model alias or ID. Defaults to `haiku`. |
| `max_tool_rounds` | Maximum tool call rounds before the agent gives up. Default: `50`. |
## Lifecycle events
Each hook fires on a specific lifecycle event:
| Event | When it fires | Blocking by default |
|---|---|---|
| `run_start` | Before the first node executes | Yes |
| `run_complete` | After the run finishes successfully | No |
| `run_failed` | After the run fails | No |
| `stage_start` | Before a node handler begins | Yes |
| `stage_complete` | After a node handler finishes successfully | No |
| `stage_failed` | After a node handler fails | No |
| `stage_retrying` | Before a failed node is retried | No |
| `edge_selected` | After an edge is chosen for traversal | Yes |
| `parallel_start` | Before parallel branches fan out | No |
| `parallel_complete` | After parallel branches merge | No |
| `sandbox_ready` | After the sandbox environment is created | No |
| `sandbox_cleanup` | Before the sandbox is torn down | No |
| `checkpoint_saved` | After a checkpoint is written to disk | No |
| `pre_tool_use` | Before an agent tool call executes | Yes |
| `post_tool_use` | After an agent tool call succeeds | No |
| `post_tool_use_failure` | After an agent tool call fails | No |
## Configuration
Hooks are defined as `[[hooks]]` entries in a [run configuration](/execution/run-configuration) TOML file:
```toml title="run.toml"
[[hooks]]
name = "pre-check"
event = "stage_start"
command = "./scripts/pre-check.sh"
matcher = "agent"
blocking = true
timeout_ms = 30000
sandbox = false
```
| Field | Description |
|---|---|
| `name` | Optional display name. Auto-generated from event and type if omitted. |
| `event` | The lifecycle event to listen for (required). |
| `command` | Shell command shorthand — implies `type = "command"`. |
| `type` | Explicit hook type: `"command"`, `"http"`, `"prompt"`, or `"agent"`. |
| `matcher` | Regex pattern to filter which stages trigger this hook. |
| `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. |
| `timeout_ms` | Hook timeout in milliseconds. Default: `60000` (60s) for most types, `30000` (30s) for prompt hooks. |
| `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |
## Blocking vs. non-blocking
Blocking hooks can affect workflow execution. Non-blocking hooks run for side effects only — their decisions are ignored.
**Blocking by default:** `run_start`, `stage_start`, `edge_selected`, `pre_tool_use`. These events represent decision points where a hook can prevent or redirect execution.
**Non-blocking by default:** All other events. Override with `blocking = true` if needed.
When multiple blocking hooks match the same event, they run sequentially. If any hook returns a `block` decision, execution short-circuits — remaining hooks are skipped.
## Hook decisions
Blocking hooks return a decision that controls what happens next:
| Decision | Effect |
|---|---|
| `proceed` | Continue normal execution. |
| `skip` | Skip the current stage (with optional reason). |
| `block` | Stop execution with an error (with optional reason). |
| `override` | Redirect to a different node by specifying `edge_to`. |
When multiple blocking hooks run, decisions are merged with this precedence: **Block > Skip/Override > Proceed**.
### Command hook decisions
Command hooks communicate decisions via exit code and stdout:
| Exit code | Behavior |
|---|---|
| `0` | Proceed. If stdout contains valid JSON decision, use that instead. |
| `2` | Block/skip. If stdout contains valid JSON decision, use that instead. |
| Any other | Block with reason "hook exited with code N". |
To return an explicit decision from a command hook, print JSON to stdout:
```bash
#!/bin/bash
if [ "$FABRO_NODE_ID" = "deploy" ]; then
echo '{"decision": "skip", "reason": "deploy disabled in CI"}'
exit 0
fi
```
### Prompt and agent hook decisions
Prompt and agent hooks return a JSON response:
```json
{"ok": true}
```
```json
{"ok": false, "reason": "Tests are failing, do not proceed"}
```
If the LLM fails to produce valid JSON, the hook **fails open** (proceeds). This fail-open behavior also applies to timeouts and LLM errors.
## Matchers
The `matcher` field is a regex that filters when a hook fires. The regex is tested against five context fields — **if any field matches, the hook fires**. If `matcher` is omitted, the hook fires unconditionally for its event.
The five fields are always the same regardless of event, but not all fields are populated for every event:
| Context field | Populated for events |
|---|---|
| `node_id` — the graph node ID (e.g., `"implement"`, `"test"`) | `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying`, `checkpoint_saved`, `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` |
| `handler_type` — the node's handler (e.g., `"agent"`, `"command"`, `"prompt"`) | `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying` |
| `edge_from` — the source node of the selected edge | `edge_selected` |
| `edge_to` — the target node of the selected edge | `edge_selected` |
| `tool_name` — the agent tool being called | `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` |
Run-level events (`run_start`, `run_complete`, `run_failed`) and sandbox/parallel events have no matchable fields — a matcher on these events will never match, so omit it.
<Note>
Because the regex is tested against **all populated fields**, a matcher can match unintended fields. For example, `matcher = "write_file"` on a `stage_start` hook would fire if a node happened to be named `"write_file"`. Use anchored patterns like `^write_file$` or scope matchers to tool-specific events to avoid surprises.
</Note>
### Tool names
For tool-level events, `tool_name` is the agent tool's internal name. The available tool names depend on the LLM provider profile, but the common set is:
| Tool name | Category | Description |
|---|---|---|
| `read_file` | read | Read a single file |
| `read_many_files` | read | Read multiple files (Gemini profile) |
| `grep` | read | Search file contents with regex |
| `glob` | read | Find files by glob pattern |
| `list_dir` | read | List directory contents (Gemini profile) |
| `write_file` | write | Create or overwrite a file |
| `edit_file` | write | Apply targeted edits to a file (Anthropic, Gemini profiles) |
| `apply_patch` | write | Apply a unified diff patch (OpenAI profile) |
| `shell` | shell | Execute a shell command |
| `web_search` | shell | Search the web |
| `web_fetch` | shell | Fetch a URL |
### Examples
```toml
# Only fire for agent handler nodes
[[hooks]]
event = "stage_start"
command = "./scripts/agent-guard.sh"
matcher = "^agent$"
# Fire for any node with "test" in its ID
[[hooks]]
event = "stage_complete"
command = "./scripts/report-test.sh"
matcher = "test"
# Auto-format Rust after file writes
[[hooks]]
event = "post_tool_use"
command = "cargo fmt"
matcher = "write_file|edit_file|apply_patch"
blocking = true
# Gate shell commands with a guardrail
[[hooks]]
event = "pre_tool_use"
type = "prompt"
prompt = "Is this shell command safe to run?"
matcher = "^shell$"
model = "haiku"
```
## Execution environment
### Sandbox vs. host
By default, command hooks run **inside the sandbox** (`sandbox = true`). This means they execute in the same environment as the agent's tools — same filesystem, same installed packages.
Set `sandbox = false` to run on the host machine. This is useful for hooks that need access to host-only resources (CI systems, local credentials, notification tools).
HTTP, prompt, and agent hooks ignore this setting — HTTP calls are always made from the host, and prompt/agent hooks use the LLM API directly.
### Environment variables
Command hooks receive these environment variables:
| Variable | Value |
|---|---|
| `FABRO_EVENT` | The event name (e.g., `stage_start`) |
| `FABRO_RUN_ID` | The run's unique identifier |
| `FABRO_WORKFLOW` | The workflow name |
| `FABRO_NODE_ID` | The current node ID (when applicable) |
| `FABRO_HOOK_CONTEXT` | Path to a JSON file containing the full event context (sandbox only) |
### Hook context
The full event context is available as a JSON payload. For command hooks, it is written to a temp file (path in `FABRO_HOOK_CONTEXT`) and piped to stdin. For HTTP hooks, it is the POST body.
<Accordion title="Example hook context JSON">
```json
{
"event": "stage_start",
"run_id": "run-abc123",
"workflow_name": "ci-pipeline",
"cwd": "/workspace",
"node_id": "implement",
"node_label": "Implement",
"handler_type": "agent",
"status": null,
"edge_from": null,
"edge_to": null,
"edge_label": null,
"failure_reason": null,
"attempt": 1,
"max_attempts": 3
}
```
</Accordion>
Fields vary by event — `edge_from`/`edge_to`/`edge_label` are only set for `edge_selected`, `failure_reason` for failure events, `attempt`/`max_attempts` for `stage_retrying`, etc. Null fields are omitted from the serialized JSON.
For tool-level events (`pre_tool_use`, `post_tool_use`, `post_tool_use_failure`), the context includes additional fields:
| Field | Events | Description |
|---|---|---|
| `tool_name` | All tool events | Name of the tool being called (e.g., `shell`, `write_file`) |
| `tool_input` | `pre_tool_use` | JSON object with the tool's input arguments |
| `tool_call_id` | `post_tool_use`, `post_tool_use_failure` | Unique identifier for the tool call |
| `tool_output` | `post_tool_use` | The tool's output string |
| `error_message` | `post_tool_use_failure` | The error message from the failed tool call |
<Accordion title="Example pre_tool_use context JSON">
```json
{
"event": "pre_tool_use",
"run_id": "run-abc123",
"workflow_name": "ci-pipeline",
"node_id": "implement",
"tool_name": "shell",
"tool_input": {"command": "rm -rf /tmp/build"}
}
```
</Accordion>
## Timeouts
Each hook type has a default timeout:
| Hook type | Default timeout |
|---|---|
| Command | 60 seconds |
| HTTP | 60 seconds |
| Prompt | 30 seconds |
| Agent | 60 seconds |
Override with `timeout_ms` on any hook definition. Prompt and agent hooks **fail open** on timeout — execution proceeds as if the hook returned `ok: true`.
## Fail-open behavior
Hooks are designed to be safe by default. Several failure modes result in the hook proceeding rather than blocking:
- **Prompt/agent LLM call fails** — proceeds
- **Prompt/agent hook times out** — proceeds
- **Prompt hook returns unparseable JSON** — proceeds
- **HTTP hook returns non-2xx** — proceeds
- **HTTP hook connection fails** — proceeds
Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) produces a `block` decision.
## Merging hook configs
When both the server config (`~/.fabro/server.toml`) and a run config define hooks, they are merged. On name collisions, the run config wins. This lets you define global hooks at the server level and override or extend them per run.
## Full example
```toml title="run.toml"
# Validate environment before the run starts
[[hooks]]
name = "env-check"
event = "run_start"
command = "./scripts/check-env.sh"
blocking = true
sandbox = false
# Notify Slack when a stage completes
[[hooks]]
name = "slack-notify"
event = "stage_complete"
type = "http"
url = "https://hooks.slack.com/workflows/T00/B00/abc123"
tls = "verify"
blocking = false
# LLM guardrail before agent stages
[[hooks]]
name = "safety-check"
event = "stage_start"
type = "prompt"
prompt = "Review the event context. Is there any reason this stage should not proceed?"
model = "haiku"
matcher = "^agent$"
blocking = true
timeout_ms = 15000
# Post-run verification agent
[[hooks]]
name = "verify"
event = "run_complete"
type = "agent"
prompt = "Run the test suite and verify all tests pass. Report any failures."
model = "sonnet"
max_tool_rounds = 20
blocking = true
timeout_ms = 120000
# Auto-format Rust files after writes
[[hooks]]
name = "cargo-fmt"
event = "post_tool_use"
command = "cargo fmt"
matcher = "write_file|edit_file|apply_patch"
blocking = true
```