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.
Plugin hooks
A plugin can declare hooks in its ai.mistral.vibe/hooks.toml file, with the same fields as hooks.toml. Vibe prefixes each one with the plugin name, such as release-tools:audit-bash, and lists it in /plugins.
Plugin hooks run like the hooks in hooks.toml. For each event, hooks run in this order: ./.vibe/hooks.toml, project plugins, ~/.vibe/hooks.toml, then user plugins. A plugin hook runs in the session's pinned copy of the plugin, with PLUGIN_ROOT and PLUGIN_DATA set as for the plugin's MCP servers. Hooks in plugins written for Claude Code or Kimi Code don't run: Vibe reports them as not supported.
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. On Windows, runs in Git Bash when available, otherwise in PowerShell, like the bash tool. |
match | pre_tool / post_tool | Tool name for tool hooks, agent name for post_agent hooks. 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).
tool_name is the full tool name, such as file_system.bash, or mcp_<server>.<tool> for MCP tools, for example mcp_linear.create_issue. match accepts both full and short names, so match = "bash" works. Commands the agent starts in the background go through process_start instead of bash, so match both in shell guards: match = "re:bash|process_start".
To act when the agent starts a subagent, use match = "agents_spawn".
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 doesn't fire when the tool never ran: pre_tool denial, denial at the approval prompt, or permission set to never.
Receives (in addition to the session context):
tool_name,tool_call_idtool_input(post-rewrite)tool_status—successorfailure. A shell command that exits with an error code still counts assuccess.tool_output— structured result dict;nullon failuretool_output_text— the running text the LLM will see, mutable by prior hookstool_errorduration_ms— not reported yet (always0)
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, for the main agent and its subagents. Set match to run the hook only after a given agent, such as a subagent named reviewer, or match = "main" to run it only after the main agent.
Receives (in addition to the session context): agent_name, the name of the agent that finished its turn.
Can return:
decision: "deny"+reason—reasonis injected as a new user message asking the agent to retry. Capped at 3 retries per turn across allpost_agenthooks; after that, Vibe accepts the answer. If apost_agenthook fails, Vibe accepts the answer and shows a warning.system_message— UI-only.
Example: block destructive shell commands
./.vibe/hooks.toml:
[[hooks]]
name = "deny-rm-rf"
type = "pre_tool"
match = "re:bash|process_start"
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.