Runtime Hooks

Hooks let you run custom bash scripts at key points during an agent run. They are a lightweight middleware system: a hook receives event data via stdin and can allow, deny, or modify what happens next.

Think of hooks as programmable guardrails. They complement the built-in permission policy by letting you enforce project-specific rules that the manifest schema cannot express — for example: "block rm -rf" or "always append a copyright header on Go files".

Supported events

Event When it fires What you can do
PreToolUse Before a tool call is executed. Allow, deny, or modify arguments.
PostToolUse After a tool call completes. Allow, deny (cannot modify output).
PermissionRequest When a permission prompt is about to be shown. Allow, deny, or modify the request.
SessionStart When a new TUI session begins. Informational only — no decision.

Hook file format

Hooks are configured in a JSON file. The file can be an object with a hooks key, or a bare JSON array of rules:

{
  "hooks": [
    {
      "id": "deny-rm-rf",
      "event": "PreToolUse",
      "matcher": "bash",
      "command": "if echo '$SPETTRO_HOOK_COMMAND' | grep -q 'rm -rf'; then echo '{\"decision\":\"deny\",\"reason\":\"rm -rf is not allowed\"}'; else echo '{\"decision\":\"allow\"}'; fi",
      "timeout_sec": 5
    }
  ]
}

Equivalent array form:

[
  {
    "id": "deny-rm-rf",
    "event": "PreToolUse",
    "matcher": "bash",
    "command": "if echo '$SPETTRO_HOOK_COMMAND' | grep -q 'rm -rf'; then echo '{\"decision\":\"deny\",\"reason\":\"rm -rf is not allowed\"}'; else echo '{\"decision\":\"allow\"}'; fi",
    "timeout_sec": 5
  }
]

Rule fields

Field Type Required Default Description
id string No global-1, project-1, ... Unique identifier for the rule. Auto-generated when empty.
event string Yes — One of PreToolUse, PostToolUse, PermissionRequest, SessionStart.
matcher string No * (all tools) Glob pattern or re:regex to match tool IDs (bash, file-write, file-edit, agent, ...).
command string Yes — Shell command to execute. Receives event data on stdin.
timeout_sec int No 15 Maximum execution time for the command. At the limit (or when the run is cancelled) the command and every process it started are killed, and the hook counts as failed.
enabled bool No true Set to false to disable a rule without deleting it.

Matcher syntax

Pattern Matches
* or "" Every tool.
bash Exactly the bash tool (the shell).
file-* Glob: file-write, file-edit, etc.
re:^git Regex: any tool ID starting with git.
re:(bash|pty-start) Regex: a shell command or an interactive terminal.

A matcher is tested against the tool's canonical ID and against the retired name the model called it by, if any (see Built-in tools). A rule for shell-exec or bash-output, which were the very same tool as bash, also fires on every bash call; a rule copied under both shell-exec and bash (same command) runs once per call. A rule for a retired name that was a narrower operation (multi-edit, repo-search, ls, task-*) fires only when the model calls that name, not on every file-edit, grep, glob or todo-write. The former language-server tools are the lsp tool's ops, so a rule for one fires on that op whatever name the model used: one for lsp-restart on lsp with op: "restart", one for references on ops references and definition, and neither on op: "hover". A name a tool of your own answers to (a hover or bash script) is that tool's: rules for it fire on its calls and on no built-in, and while your tool holds a canonical name, the built-ins standing unfolded under their retired names match only those names (see Built-in tools). The tool_id the hook receives is the ID the call runs under: the canonical ID for a call made under a retired name (bash, never shell-exec), so a script that checks tool_id itself must test the canonical name; only an unfolded built-in keeps its retired name.

What the hook receives on stdin

The hook command receives a JSON object on stdin:

{
  "event": "PreToolUse",
  "tool_id": "bash",
  "tool_args": {"command": "rm -rf /tmp/x"},
  "tool_output": "",
  "command": "rm -rf /tmp/x"
}

Fields:

Field Type Description
event string The event that triggered the hook.
tool_id string The canonical tool ID (bash, file-write, etc.).
tool_args object The arguments the tool was called with (varies by tool).
tool_output string The output of the tool call (only meaningful for PostToolUse).
command string Shortcut to the command string when the tool is a shell executor.

Environment variables are also set:

Variable Value
SPETTRO_HOOK_EVENT The event name (PreToolUse, PostToolUse, ...).
SPETTRO_HOOK_TOOL_ID The tool ID.
SPETTRO_HOOK_COMMAND The shell command being executed (when applicable).

Hook output / decision format

The hook must print its decision as a JSON object on the last non-empty line of stdout:

{"decision":"allow"}
{"decision":"deny","reason":"rm -rf is dangerous"}
{"decision":"deny","reason":"use --soft flag instead","message":"Please use --soft when deleting"}

For PreToolUse hooks on bash, you can also modify the arguments:

{"decision":"allow","updated_args":"pip install requests --no-cache-dir"}

Decision values:

Decision Effect
allow Proceed normally.
deny Block the operation. The reason is shown to the user.
(anything else, or no decision line) Treated as allow.

The message field (when present) is displayed as a banner or log entry but does not affect the decision.

If the hook exits with a non-zero exit code, the decision defaults to allow but the error is logged.

File locations

Hooks are loaded from two locations, merged by (event, matcher, id):

Scope Path Precedence
Global ~/.spettro/hooks.json Loaded first.
Project <cwd>/.spettro/hooks.json Overrides global by (event, matcher, id).

Project rules do NOT inherit from global — they replace the matching (event, matcher, id) tuple entirely.

Viewing active hooks

/hooks

Prints the merged effective configuration with source annotations and any validation issues:

Effective hooks (2 global, 1 project):
  global  PreToolUse    deny-rm-rf      bash           ✓
  global  PostToolUse   log-edits       file-write     ✓
  project PreToolUse    custom-approve  bash           ✗ unsupported event "foo"

Writing a hook

Example: block dangerous commands

File: ~/.spettro/hooks.json

{
  "hooks": [
    {
      "id": "block-force-push",
      "event": "PreToolUse",
      "matcher": "bash",
      "command": "if echo \"$SPETTRO_HOOK_COMMAND\" | grep -qP 'git\s+push\s+.*--force'; then echo '{\"decision\":\"deny\",\"reason\":\"force push is not allowed\"}'; else echo '{\"decision\":\"allow\"}'; fi",
      "timeout_sec": 3
    }
  ]
}

Example: log every file edit

File: .spettro/hooks.json (project-scoped)

{
  "hooks": [
    {
      "id": "log-file-edit",
      "event": "PostToolUse",
      "matcher": "file-write",
      "command": "read input; echo \"$input\" | jq -r '.tool_args.path' >> /tmp/spettro-edits.log; echo '{\"decision\":\"allow\"}'"
    }
  ]
}

Example: modify shell commands on the fly

{
  "hooks": [
    {
      "id": "add-cache-dir",
      "event": "PreToolUse",
      "matcher": "bash",
      "command": "read input; cmd=$(echo \"$input\" | jq -r '.command // empty'); if echo \"$cmd\" | grep -q 'pip install'; then echo '{\"decision\":\"allow\",\"updated_args\":\"'"$cmd"' --no-cache-dir\"}'; else echo '{\"decision\":\"allow\"}'; fi",
      "timeout_sec": 3
    }
  ]
}

Performance notes

  • Hooks run synchronously on the tool-call execution path. Every hook adds latency equal to the time your command takes to execute.
  • Keep hook commands fast (parsing JSON or grepping a string, not installing packages).
  • The default timeout is 15 seconds; set timeout_sec lower for simple decisions and higher for hooks that need to make network calls.
  • A slow or hanging hook blocks the agent run. Use timeout_sec generously and test your hooks before deploying them.

Security notes

  • Hook commands run as the same user as Spettro, with the same privileges.
  • They are not confined by the OS sandbox, even when --sandbox is active.
  • Do not read untrusted input into eval or dynamic source inside hooks.
  • Hooks in project .spettro/hooks.json are checked into version control — review them as part of your code review.