Hooks
Hooks wire arbitrary shell commands into the Vibe Code CLI's lifecycle to gate, audit, or rewrite agent behavior. Use them to block dangerous tool calls, redact arguments before a tool runs, append context after a tool returns, or force the agent to retry an unsatisfactory answer.
Declare them in a hooks.toml file and they take effect on the next run.
Configuration file locations
Hooks are declared in hooks.toml. The CLI looks in this order:
./.vibe/hooks.tomlin the working directory (project-level, loaded first, trusted folders only).~/.vibe/hooks.tomlin your home directory (user-level, loaded second).
When the same name appears in both files, the project entry wins. Project-level hooks load only when the working directory is trusted.
Subagents inherit the parent's hook configuration, so policies apply transitively.
Declare a hook
Each hook is a [[hooks]] table:
[[hooks]]
name = "deny-rm-rf"
type = "pre_tool"
match = "bash"
command = "uv run python /path/to/guard-bash"
timeout = 60.0
strict = false
description = "Reject dangerous shell commands."| Field | Required | Details |
|---|---|---|
name | yes | Unique identifier. Used to deduplicate between project and user files. |
type | yes | One of pre_tool, post_tool, post_agent. |
command | yes | Shell command to spawn. Receives the hook payload on stdin. |
match | pre_tool / post_tool | Tool-name matcher: fnmatch glob, or regex with the re: prefix. Case-insensitive. |
timeout | no | Seconds before the hook is killed. Default: 60. |
strict | tool hooks only | When true, parse and runtime failures become denials (pre_tool) or text clears (post_tool) instead of warnings. Default: false. |
description | no | Free text shown in diagnostics. |
Hook contract
Every hook is spawned with a UTF-8 JSON object on stdin containing the session context: session_id, parent_session_id, transcript_path, cwd, and hook_event_name. Tool hooks add tool-specific fields (see below).
Hooks respond through their exit code and stdout. Use stderr for diagnostics and debug logs.
| Exit / stdout | Effect |
|---|---|
Exit 0, empty stdout | Passthrough. |
Exit 0, valid JSON object on stdout | Structured response (see fields below). |
Exit 0, non-empty but non-conforming stdout | Hook failure. Warning by default; deny / clear under strict = true on a tool hook. |
| Non-zero exit, timeout, or spawn failure | Hook failure. Diagnostic taken from stderr (fallback to stdout, then the exit code). |
Universal top-level fields in the JSON response:
system_message(string, optional) — shown to the user in the UI.decision("allow"|"deny", optional, default"allow") — the effect of"deny"depends on the hook type.reason(string, optional) — accompaniesdecision: "deny".hook_specific_output(object, optional) — event-specific payload.
Unknown JSON fields are tolerated at every level (forward-compatible). Fields that are not meaningful for the current hook type are silently ignored.
pre_toolpre_tool
Fires per tool call, before the user permission prompt. The first deny short-circuits the remaining pre_tool hooks for that call.
Receives (in addition to the session context): tool_name, tool_call_id, tool_input (the model's raw arguments).
Can return:
decision: "deny"+reason— denies the tool call;reasonbecomes the tool error that the LLM sees.hook_specific_output.tool_input(object) — full replacement of the model's arguments. The replacement is re-validated against the tool's schema; a validation failure becomes a synthesized denial. Rewrites compose left-to-right across hooks. The rewritten arguments are also what the permission prompt displays, what the tool runs with, and what subsequent LLM turns see on the assistant message.system_message— UI-only.
post_toolpost_tool
Fires per tool call if and only if the tool body actually ran. It does not fire when the tool never executed: pre_tool denial, user denial at the approval prompt, permission set to NEVER, or cancellation before the body started. Cancellation during the tool body is shielded, so audit hooks still run.
Receives (in addition to the session context):
tool_name,tool_call_idtool_input(post-rewrite)tool_status—success,failure, orcancelledtool_output— structured result dict;nullon failuretool_output_text— the running text the LLM will see, mutable by prior hookstool_errorduration_ms
Can return:
decision: "deny"+reason— replacestool_output_textwithreason. The pipeline continues; subsequent hooks see the replacement.hook_specific_output.additional_context(string) — appended (with a\nseparator) totool_output_text. Composes with a same-hook deny: deny replaces first, thenadditional_contextis appended to the replacement.system_message— UI-only.
post_agentpost_agent
Fires after every assistant turn that ends without pending tool calls.
Receives (in addition to the session context): no extra fields.
Can return:
decision: "deny"+reason—reasonis injected as a new user message asking the agent to retry. Capped at 3 retries per hook per user turn; further denies become terminal warnings.system_message— UI-only.
Example: block destructive shell commands
./.vibe/hooks.toml:
[[hooks]]
name = "deny-rm-rf"
type = "pre_tool"
match = "bash"
command = "python ./.vibe/hooks/guard-bash.py"
strict = true
description = "Reject rm -rf and other destructive shell commands."./.vibe/hooks/guard-bash.py:
import json
import sys
payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
if "rm -rf" in command:
print(json.dumps({
"decision": "deny",
"reason": "rm -rf is blocked by the deny-rm-rf hook.",
}))
sys.exit(0)
# Passthrough: empty stdout, exit 0.With strict = true, any script crash or malformed JSON output also denies the call instead of degrading to a warning.