fabro/docs/agents/hooks.mdx
brynary-fabro[bot] 139bf5cbef Move sandbox lifecycle into the engine (#22)
This PR moves sandbox lifecycle management (initialization, setup
commands, devcontainer phases, and cleanup) from the CLI's `run_command`
god function into the workflow engine. Two new engine methods are
introduced: `run_with_lifecycle()` orchestrates sandbox init, fires the
`SandboxReady` hook (now blocking by default), emits a new
`SandboxInitialized` event, handles remote git setup, runs setup
commands and devcontainer lifecycle phases, then delegates to the
existing `run_internal()` graph execution. `cleanup_sandbox()` fires the
`SandboxCleanup` hook and optionally tears down the sandbox. Both
`SandboxReady` and `SandboxCleanup` hook events were previously defined
but never fired — they now fire naturally within the engine alongside
all other hooks.

The CLI is simplified significantly: sandbox record persistence and
progress UI updates are handled via an event listener for
`SandboxInitialized` rather than inline code. The `run_from_branch`
resume path also benefits, gaining hook support and proper cleanup for
free. A new `LifecycleConfig` struct captures setup commands, timeouts,
and devcontainer phases, keeping the engine's API clean. The existing
`run()` method is unchanged, so API server and integration tests
continue working with pre-initialized sandboxes.

The `setup_remote_git` helper is moved from `cli/run.rs` into
`engine.rs` since it only depends on sandbox exec. Config is now passed
by mutable reference to `run_with_lifecycle` so the engine can fill in
remote git fields (base SHA, run branch) that downstream code needs.
Comprehensive tests verify event emission ordering, setup command
execution/failure, and cleanup behavior.

### Fabro Details

<details>
<summary>Ran 10 stages in 53m 27s for $14.42</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $6.22 | 0 |
| simplify_opus | 0s | $2.63 | 0 |
| simplify_gemini | 0s | $2.73 | 0 |
| simplify_gpt | 0s | $2.84 | 0 |
| verify | 0s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **53m 27s** | **$14.42** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (13 nodes and 16
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gemini   [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"]
    simplify_gpt      [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", goal_gate=true, max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:00:05 -04:00

402 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 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.toml`** — project-level hooks, apply to all workflows in the project
- **`workflow.toml`** — per-workflow hooks
- **`~/.fabro/cli.toml`** or **`~/.fabro/server.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/cli.toml`** or **`~/.fabro/server.toml`** — global defaults
2. **`fabro.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.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
```