Fix hook documentation configuration examples

This commit is contained in:
Scott Werner 2026-10-20 14:35:21 -04:00
parent 8f01f9f41d
commit d1144615bf
3 changed files with 207 additions and 53 deletions

View file

@ -5,31 +5,43 @@ 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.
The examples below are configuration fragments for `workflow.toml`. Add them to a workflow configuration using the current [run configuration schema](/execution/run-configuration).
## 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.
Use `script` for a shell command string. This is the simplest and most common hook type.
```toml title="run.toml"
[[hooks]]
```toml title="workflow.toml"
[[run.hooks]]
event = "stage_start"
command = "./scripts/pre-check.sh"
script = "./scripts/pre-check.sh"
```
Alternatively, `command` accepts an array of strings:
```toml title="workflow.toml"
[[run.hooks]]
event = "post_tool_use"
command = ["cargo", "fmt"]
matcher = "write_file|edit_file|apply_patch"
```
Use either `script` or `command` for a command hook.
### HTTP
POST the event context as JSON to an HTTP endpoint. Useful for webhooks, external APIs, and notification services.
```toml title="run.toml"
[[hooks]]
```toml title="workflow.toml"
[[run.hooks]]
event = "run_complete"
type = "http"
url = "https://hooks.example.com/done"
[hooks.headers]
[run.hooks.headers]
X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}"
```
@ -45,10 +57,9 @@ X-Deployment-Environment = "{{ vars.DEPLOY_ENV }}"
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]]
```toml title="workflow.toml"
[[run.hooks]]
event = "stage_start"
type = "prompt"
prompt = "Should this stage proceed given the current context? Check for any red flags."
model = "haiku"
blocking = true
@ -63,10 +74,10 @@ blocking = true
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]]
```toml title="workflow.toml"
[[run.hooks]]
event = "run_complete"
type = "agent"
agent = "enabled"
prompt = "Verify that all tests pass and the code compiles. Run the test suite."
model = "sonnet"
max_tool_rounds = 10
@ -75,10 +86,13 @@ blocking = true
| Field | Description |
|---|---|
| `agent` | Set to `"enabled"` to select an agent hook. |
| `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`. |
The `agent = "enabled"` marker selects an agent hook; `prompt` supplies its instructions. Without that marker, `prompt` selects a single-turn prompt hook.
## Lifecycle events
Each hook fires on a specific lifecycle event:
@ -104,34 +118,41 @@ Each hook fires on a specific lifecycle event:
## Configuration
Hooks are defined as `[[hooks]]` entries in any of these TOML config files:
Hooks are defined as `[[run.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
Each entry selects one transport: `script` or `command`, `url`, `prompt`, or `agent = "enabled"` together with `prompt`. Do not add a `type` field; the transport fields select the hook type.
See [Merging hook configs](#merging-hook-configs) for how these layers combine.
```toml title="run.toml"
[[hooks]]
name = "pre-check"
```toml title="workflow.toml"
[[run.hooks]]
id = "pre-check"
name = "Pre-check script"
event = "stage_start"
command = "./scripts/pre-check.sh"
script = "./scripts/pre-check.sh"
matcher = "agent"
blocking = true
timeout_ms = 30000
timeout = "30s"
sandbox = false
```
| Field | Description |
|---|---|
| `name` | Optional display name. Auto-generated from event and type if omitted. |
| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. |
| `name` | Optional display name; does not control merging. If omitted, the hook uses `id` or an auto-generated label. |
| `event` | The lifecycle event to listen for (required). |
| `command` | Shell command shorthand — implies `type = "command"`. |
| `type` | Explicit hook type: `"command"`, `"http"`, `"prompt"`, or `"agent"`. |
| `script` | Shell command string. |
| `command` | Command and arguments as an array of strings, such as `["cargo", "fmt"]`; an alternative to `script`. |
| `url` | Selects an HTTP hook. |
| `prompt` | Instructions for a prompt hook, or for an agent hook when `agent` is set. |
| `agent` | Set to `"enabled"` to select an agent hook. |
| `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. |
| `timeout` | Duration string with a unit, such as `"500ms"`, `"30s"`, or `"2m"`. Default: `"30s"` for prompt hooks, `"60s"` for other types. |
| `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |
## Blocking vs. non-blocking
@ -211,34 +232,33 @@ For tool events, the matcher is tested against the tool's internal name (e.g., `
This example auto-formats Rust code whenever the agent writes or edits a file:
```toml
[[hooks]]
```toml title="workflow.toml"
[[run.hooks]]
name = "cargo-fmt"
event = "post_tool_use"
command = "cargo fmt"
script = "cargo fmt"
matcher = "write_file|edit_file|apply_patch"
blocking = true
```
More examples:
```toml
```toml title="workflow.toml"
# Only fire for agent handler nodes
[[hooks]]
[[run.hooks]]
event = "stage_start"
command = "./scripts/agent-guard.sh"
script = "./scripts/agent-guard.sh"
matcher = "^agent$"
# Fire for any node with "test" in its ID
[[hooks]]
[[run.hooks]]
event = "stage_complete"
command = "./scripts/report-test.sh"
script = "./scripts/report-test.sh"
matcher = "test"
# Gate shell commands with a guardrail
[[hooks]]
[[run.hooks]]
event = "pre_tool_use"
type = "prompt"
prompt = "Is this shell command safe to run?"
matcher = "^shell$"
model = "haiku"
@ -327,7 +347,7 @@ Each hook type has a default timeout:
| 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`.
Override with `timeout` on any hook definition, using a duration string such as `timeout = "30s"` or `timeout = "2m"`. Supported units are `ms`, `s`, `m`, `h`, and `d`. Prompt and agent hooks **fail open** on timeout — execution proceeds as if the hook returned `ok: true`.
## Fail-open behavior
@ -343,61 +363,61 @@ Command hooks do **not** fail open. A non-zero exit code (other than 0 or 2) pro
## Merging hook configs
Hooks from multiple config files are merged in this order (later layers win on name collisions):
Hooks from multiple config files are merged in this order (later layers replace hooks with the same `id`):
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.
Hooks with matching `id` values replace in place. Hooks without an `id` append after inherited hooks, even when their `name` matches. Use `id` when a workflow should override an inherited hook.
This lets you define global hooks at the server level, project-wide hooks in `.fabro/project.toml`, and override or extend them per workflow. See [configuration precedence](/execution/run-configuration#precedence) for the complete layer order.
## Full example
```toml title="run.toml"
```toml title="workflow.toml"
# Validate environment before the run starts
[[hooks]]
[[run.hooks]]
name = "env-check"
event = "run_start"
command = "./scripts/check-env.sh"
script = "./scripts/check-env.sh"
blocking = true
sandbox = false
# Notify Slack when a stage completes
[[hooks]]
[[run.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]]
[[run.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
timeout = "15s"
# Post-run verification agent
[[hooks]]
[[run.hooks]]
name = "verify"
event = "run_complete"
type = "agent"
agent = "enabled"
prompt = "Run the test suite and verify all tests pass. Report any failures."
model = "sonnet"
max_tool_rounds = 20
blocking = true
timeout_ms = 120000
timeout = "2m"
# Auto-format Rust files after writes
[[hooks]]
[[run.hooks]]
name = "cargo-fmt"
event = "post_tool_use"
command = "cargo fmt"
script = "cargo fmt"
matcher = "write_file|edit_file|apply_patch"
blocking = true
```

View file

@ -582,11 +582,11 @@ sandbox = false
| `id` | Optional merge identity. Hooks with the same `id` replace each other across layers. |
| `name` | Optional display name for the hook. |
| `event` | Lifecycle event: `run_start`, `run_complete`, `stage_start`, `stage_complete`, etc. |
| `script` | Shell-evaluated command (equivalent to the old `type = "command"` shorthand). |
| `command` | Argv-style command (alternative to `script`). |
| `script` | Shell command string. |
| `command` | Command and arguments as an array of strings, such as `["cargo", "fmt"]`; an alternative to `script`. |
| `matcher` | Regex matched against node ID or handler type. Limits which stages trigger this hook. |
| `blocking` | Whether the hook must complete before execution continues. Defaults vary by event. |
| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"60s"`. |
| `timeout` | Human-readable hook timeout (e.g. `"30s"`, `"1m"`). Default: `"30s"` for prompt hooks, `"60s"` for other types. |
| `sandbox` | Run inside the sandbox (`true`, default) or on the host (`false`). |
Hook merge semantics: hooks with matching `id` values replace in place. Hooks without an `id` from a higher-precedence layer append after the fully merged inherited hook list.

View file

@ -457,6 +457,140 @@ mod tests {
)
}
fn hook_documentation_toml(markdown: &str) -> Vec<&str> {
let snippets: Vec<_> = markdown
.split("```toml")
.skip(1)
.map(|fence| {
let (_, body) = fence.split_once('\n').expect("TOML fence has a body");
body.split_once("```").expect("TOML fence is closed").0
})
.collect();
assert!(
!snippets.is_empty(),
"hook documentation must contain TOML examples"
);
snippets
}
#[test]
fn hook_documentation_examples_register_and_resolve() {
use std::collections::{BTreeMap, BTreeSet};
use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::HookType;
let guide = include_str!("../../../../docs/public/agents/hooks.mdx");
let reference = include_str!("../../../../docs/public/execution/run-configuration.mdx");
let reference = reference
.split_once("### `[[run.hooks]]`")
.expect("hook reference section exists")
.1
.split_once("\n## ")
.expect("hook reference section has an end")
.0;
let catalog: SettingsLayer = "[environments.default]\nprovider = \"local\"\n"
.parse()
.unwrap();
let mut transports = BTreeMap::<_, usize>::new();
let mut timeouts = BTreeSet::new();
let mut saw_argv = false;
let mut saw_headers = false;
for (document, markdown) in [("guide", guide), ("reference", reference)] {
for (index, snippet) in hook_documentation_toml(markdown).into_iter().enumerate() {
version_with_config(snippet, []).unwrap_or_else(|error| {
panic!(
"{document} TOML example {} must register: {error:?}",
index + 1
)
});
let layer: SettingsLayer = snippet.parse().unwrap();
let entries = &layer.run.as_ref().expect("example has run settings").hooks;
assert!(!entries.is_empty(), "example must define hooks");
let settings = WorkflowSettingsBuilder::new()
.server_manifest_defaults(RunLayer::default(), catalog.environments.clone())
.workflow_toml(snippet)
.unwrap()
.build()
.unwrap_or_else(|error| {
panic!(
"{document} TOML example {} must resolve: {error:?}",
index + 1
)
});
assert_eq!(settings.run.hooks.len(), entries.len());
for (entry, hook) in entries.iter().zip(&settings.run.hooks) {
match hook
.resolved_hook_type()
.as_deref()
.expect("hook has a transport")
{
HookType::Command { command } => {
*transports.entry("command").or_default() += 1;
if let Some(script) = &entry.script {
assert_eq!(command, script);
} else {
assert_eq!(entry.command.as_ref().unwrap().len(), 2);
assert_eq!(command, &InterpString::parse("cargo fmt"));
saw_argv = true;
}
}
HookType::Http { url, headers, .. } => {
*transports.entry("http").or_default() += 1;
assert_eq!(Some(url), entry.url.as_ref());
if !entry.headers.is_empty() {
assert_eq!(headers.as_ref().unwrap(), &entry.headers);
saw_headers = true;
}
}
HookType::Prompt { prompt, .. } => {
*transports.entry("prompt").or_default() += 1;
assert!(entry.agent.is_none());
assert_eq!(Some(prompt), entry.prompt.as_ref());
}
HookType::Agent {
prompt,
max_tool_rounds,
..
} => {
*transports.entry("agent").or_default() += 1;
assert!(entry.agent.is_some());
assert_eq!(Some(prompt), entry.prompt.as_ref());
assert_eq!(max_tool_rounds, &entry.max_tool_rounds);
}
}
if let Some(timeout) = hook.timeout_ms {
timeouts.insert(timeout);
}
}
}
}
assert_eq!(
transports,
[("agent", 2), ("command", 9), ("http", 2), ("prompt", 3)].into()
);
assert_eq!(timeouts, [15_000, 30_000, 120_000].into());
assert!(saw_argv, "guide must demonstrate vector-valued command");
assert!(saw_headers, "guide must demonstrate nested HTTP headers");
}
#[test]
fn hook_documentation_obsolete_fields_remain_rejected() {
for source in [
"[[hooks]]\nevent = \"stage_start\"\nscript = \"true\"\n",
"[[run.hooks]]\nevent = \"stage_start\"\ntype = \"prompt\"\nprompt = \"Check\"\n",
"[[run.hooks]]\nevent = \"run_complete\"\ntype = \"http\"\nurl = \"https://example.com\"\n",
"[[run.hooks]]\nevent = \"stage_start\"\ncommand = \"true\"\n",
"[[run.hooks]]\nevent = \"stage_start\"\nscript = \"true\"\ntimeout_ms = 30000\n",
] {
assert!(matches!(
version_with_config(source, []),
Err(WorkflowVersionError::Config { .. })
));
}
}
fn version_with_goal_file(
reference: &str,
) -> Result<ValidatedWorkflowVersion, WorkflowVersionError> {