fabro/docs/public/agents/hooks.mdx
2026-07-28 18:04:54 -04:00

403 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]
X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}"
```
| Field | Description |
|---|---|
| `url` | The endpoint to POST to. Must use `https://` unless `tls = "off"`. Supports `{{ vars.NAME }}` interpolation. |
| `headers` | Optional HTTP headers. Values support `{{ vars.NAME }}` interpolation. A token that is still unresolved when the hook fires blocks it (fail-closed), so a header is never sent half-rendered. |
| `tls` | TLS mode: `"verify"` (default), `"no_verify"`, or `"off"`. |
`{{ vars.NAME }}` is substituted when the run is created. Use variables only for non-sensitive metadata. Do not store tokens, API keys, or other credentials in variables or literal hook configuration. `{{ env.NAME }}` and `{{ secrets.NAME }}` are not available in hooks.
### 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 is initialized and ready | Yes |
| `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 any of these TOML config files:
- **`.fabro/project.toml`** — project-level hooks, apply to all workflows in the project
- **`workflow.toml`** — per-workflow hooks
- **`~/.fabro/settings.toml`** — global defaults for all runs
See [Merging hook configs](#merging-hook-configs) for how these layers combine.
```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`, `sandbox_ready`. 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. If stdout contains valid JSON decision (e.g., `skip`), 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. Omit `matcher` to match all occurrences of the event. Each event type matches against different context fields:
| Event | What the matcher filters | Example matcher values |
|---|---|---|
| `stage_start`, `stage_complete`, `stage_failed`, `stage_retrying` | node ID and handler type | `implement`, `^agent$`, `test` |
| `edge_selected` | edge source and edge target node IDs | `implement`, `deploy` |
| `pre_tool_use`, `post_tool_use`, `post_tool_use_failure` | tool name and node ID | `write_file\|edit_file`, `^shell$` |
| `checkpoint_saved` | node ID | `implement` |
| `run_start`, `run_complete`, `run_failed`, `parallel_start`, `parallel_complete`, `sandbox_ready`, `sandbox_cleanup` | no matcher support | always fires on every occurrence |
The matcher is a regex, so `write_file|edit_file` matches either tool and `^agent$` matches exactly the handler type `agent`. When an event has multiple matchable fields (e.g., tool events match against both `tool_name` and `node_id`), the hook fires if **any** field matches the regex.
For tool events, the matcher is tested against the tool's internal name (e.g., `shell`, `write_file`, `edit_file`). See [Tools](/agents/tools) for the full list. To match all file-writing tools across providers, use `write_file|edit_file|apply_patch`.
### Examples
This example auto-formats Rust code whenever the agent writes or edits a file:
```toml
[[hooks]]
name = "cargo-fmt"
event = "post_tool_use"
command = "cargo fmt"
matcher = "write_file|edit_file|apply_patch"
blocking = true
```
More 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"
# 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 running in the sandbox, it is written to a temp file (path in `FABRO_HOOK_CONTEXT`). For command hooks running on the host (`sandbox = false`), it is 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
Hooks from multiple config files are merged in this order (later layers win on name collisions):
1. **`~/.fabro/settings.toml`** — global defaults
2. **`.fabro/project.toml`** — project-level overrides
3. **`workflow.toml`** — per-workflow overrides
This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow.
## 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
```