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

Configuration file locations

Hooks are declared in hooks.toml. The CLI looks in this order:

  1. ./.vibe/hooks.toml in the working directory (project-level, loaded first, trusted folders only).
  2. ~/.vibe/hooks.toml in 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

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."
FieldRequiredDetails
nameyesUnique identifier. Used to deduplicate between project and user files.
typeyesOne of pre_tool, post_tool, post_agent.
commandyesShell command to spawn. Receives the hook payload on stdin.
matchpre_tool / post_toolTool-name matcher: fnmatch glob, or regex with the re: prefix. Case-insensitive.
timeoutnoSeconds before the hook is killed. Default: 60.
stricttool hooks onlyWhen true, parse and runtime failures become denials (pre_tool) or text clears (post_tool) instead of warnings. Default: false.
descriptionnoFree text shown in diagnostics.
Hook contract

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 / stdoutEffect
Exit 0, empty stdoutPassthrough.
Exit 0, valid JSON object on stdoutStructured response (see fields below).
Exit 0, non-empty but non-conforming stdoutHook failure. Warning by default; deny / clear under strict = true on a tool hook.
Non-zero exit, timeout, or spawn failureHook 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) — accompanies decision: "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_tool

pre_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; reason becomes 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_tool

post_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_id
  • tool_input (post-rewrite)
  • tool_statussuccess, failure, or cancelled
  • tool_output — structured result dict; null on failure
  • tool_output_text — the running text the LLM will see, mutable by prior hooks
  • tool_error
  • duration_ms

Can return:

  • decision: "deny" + reason — replaces tool_output_text with reason. The pipeline continues; subsequent hooks see the replacement.
  • hook_specific_output.additional_context (string) — appended (with a \n separator) to tool_output_text. Composes with a same-hook deny: deny replaces first, then additional_context is appended to the replacement.
  • system_message — UI-only.
post_agent

post_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" + reasonreason is 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

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.