Skip to main content
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.
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.
run.toml
[[hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"

[hooks.headers]
Authorization = "Bearer $API_KEY"
FieldDescription
urlThe endpoint to POST to. Must use https:// unless tls = "off".
headersOptional HTTP headers. Values support $VAR interpolation from allowed_env_vars.
allowed_env_varsList of environment variable names that may be interpolated into headers.
tlsTLS 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.
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
FieldDescription
promptInstructions for the LLM evaluator.
modelModel 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.
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
FieldDescription
promptTask instructions for the agent.
modelModel alias or ID. Defaults to haiku.
max_tool_roundsMaximum tool call rounds before the agent gives up. Default: 50.

Lifecycle events

Each hook fires on a specific lifecycle event:
EventWhen it firesBlocking by default
run_startBefore the first node executesYes
run_completeAfter the run finishes successfullyNo
run_failedAfter the run failsNo
stage_startBefore a node handler beginsYes
stage_completeAfter a node handler finishes successfullyNo
stage_failedAfter a node handler failsNo
stage_retryingBefore a failed node is retriedNo
edge_selectedAfter an edge is chosen for traversalYes
parallel_startBefore parallel branches fan outNo
parallel_completeAfter parallel branches mergeNo
sandbox_readyAfter the sandbox environment is createdNo
sandbox_cleanupBefore the sandbox is torn downNo
checkpoint_savedAfter a checkpoint is written to diskNo
pre_tool_useBefore an agent tool call executesYes
post_tool_useAfter an agent tool call succeedsNo
post_tool_use_failureAfter an agent tool call failsNo

Configuration

Hooks are defined as [[hooks]] entries in a run configuration TOML file:
run.toml
[[hooks]]
name = "pre-check"
event = "stage_start"
command = "./scripts/pre-check.sh"
matcher = "agent"
blocking = true
timeout_ms = 30000
sandbox = false
FieldDescription
nameOptional display name. Auto-generated from event and type if omitted.
eventThe lifecycle event to listen for (required).
commandShell command shorthand — implies type = "command".
typeExplicit hook type: "command", "http", "prompt", or "agent".
matcherRegex pattern to filter which stages trigger this hook.
blockingWhether the hook must complete before execution continues. Defaults vary by event.
timeout_msHook timeout in milliseconds. Default: 60000 (60s) for most types, 30000 (30s) for prompt hooks.
sandboxRun 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:
DecisionEffect
proceedContinue normal execution.
skipSkip the current stage (with optional reason).
blockStop execution with an error (with optional reason).
overrideRedirect 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 codeBehavior
0Proceed. If stdout contains valid JSON decision, use that instead.
2Block/skip. If stdout contains valid JSON decision, use that instead.
Any otherBlock with reason “hook exited with code N”.
To return an explicit decision from a command hook, print JSON to stdout:
#!/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:
{"ok": true}
{"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 pattern that filters which stages trigger a hook. It is matched against:
  • The node ID (e.g., "implement", "test")
  • The handler type (e.g., "agent", "command", "prompt")
  • The edge source and edge target (for edge_selected events)
  • The tool name (for pre_tool_use, post_tool_use, post_tool_use_failure events)
If any of these fields match the regex, the hook fires. If matcher is omitted, the hook fires for all stages of the specified event.
run.toml
# Only fire for agent 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"

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:
VariableValue
FABRO_EVENTThe event name (e.g., stage_start)
FABRO_RUN_IDThe run’s unique identifier
FABRO_WORKFLOWThe workflow name
FABRO_NODE_IDThe current node ID (when applicable)
FABRO_HOOK_CONTEXTPath 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.
{
  "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
}
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:
FieldEventsDescription
tool_nameAll tool eventsName of the tool being called (e.g., shell, write_file)
tool_inputpre_tool_useJSON object with the tool’s input arguments
tool_call_idpost_tool_use, post_tool_use_failureUnique identifier for the tool call
tool_outputpost_tool_useThe tool’s output string
error_messagepost_tool_use_failureThe error message from the failed tool call
{
  "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"}
}

Timeouts

Each hook type has a default timeout:
Hook typeDefault timeout
Command60 seconds
HTTP60 seconds
Prompt30 seconds
Agent60 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

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"
blocking = false
sandbox = true