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.

Plugin hooks

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

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. On Windows, runs in Git Bash when available, otherwise in PowerShell, like the bash tool.
matchpre_tool / post_toolTool name for tool hooks, agent name for post_agent hooks. 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).

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; 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 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_id
  • tool_input (post-rewrite)
  • tool_status — success or failure. A shell command that exits with an error code still counts as success.
  • 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 — not reported yet (always 0)

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, 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 — reason is injected as a new user message asking the agent to retry. Capped at 3 retries per turn across all post_agent hooks; after that, Vibe accepts the answer. If a post_agent hook fails, Vibe accepts the answer and shows a warning.
  • 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 = "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.