Skip to main content

Hooks

A hook is a command the agent runs at a fixed point in its work: before a tool call, after one, when you submit a prompt, or when a turn ends. The command reads a JSON description of the event on stdin and answers with its exit code, and optionally with JSON on stdout.

Hooks are deterministic. An instruction in AGENTS.md asks the model to format files after editing them. A hook formats them every time.

The CLI and the VS Code extension run the same hooks from the same files. Hooks use Claude Code's format, so a hook script written for Claude Code runs here unchanged once you copy its configuration.

This page gets you started. Every event, field and exit code is in the Hooks reference.

Set up your first hookโ€‹

This hook stops the agent from running rm -rf. Add a hooks key to your user settings file, ~/.abacusai/config.json:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm -rf *)",
"command": "echo 'rm -rf is blocked by a hook. Delete specific files instead.' >&2; exit 2"
}
]
}
]
}
}
  • PreToolUse is the event: before a tool runs.
  • "matcher": "Bash" limits it to shell commands.
  • "if": "Bash(rm -rf *)" limits it further to rm -rf commands. The value is a permission rule, so it also catches cd build && rm -rf dist.
  • exit 2 blocks the call. What the command writes to stderr is the reason the agent gets.

Start a new abacusai session and try it:

> create a folder named scratch, then delete it with rm -rf

The delete is refused, the tool card reads Blocked by hook with your message, and the agent tries another way.

Where hooks liveโ€‹

hooks is a key in the same settings files as everything else.

ScopeFileRuns
User~/.abacusai/config.jsonIn every project
Project<project>/.abacusai/config.jsonIn that project, once you trust the folder. Commit it to share hooks with your team.
One sessionabacusai --settings <file>For that CLI session

Hooks from every file run. A PreToolUse hook in your user file and one in a project file both run.

Hooks are read when a session starts. After editing a file by hand, start a new session to be sure the change applies. Changes made through /import or the settings screens apply right away.

If the CLI or extension created the project's config.json, it also created a .abacusai/.gitignore listing it. Remove that line to commit the file.

How a hook worksโ€‹

Configurationโ€‹

{
"hooks": {
"<Event>": [
{
"matcher": "<which tools or sources>",
"hooks": [
{ "type": "command", "command": "<what to run>", "timeout": 30 }
]
}
]
}
}

Each event holds a list of matcher groups, and each group holds the commands to run when it matches. Leave matcher out, or set it to *, to match everything. Edit|Write matches either tool, and anything with other characters is a regular expression, such as mcp__github__.*.

Keep longer hooks in a script and point to it with $CLAUDE_PROJECT_DIR, which is set to the project folder:

{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.abacusai/hooks/check.sh" }

Make the script executable with chmod +x.

Inputโ€‹

Every hook gets the event as JSON on stdin. A PreToolUse hook for a shell command sees:

{
"session_id": "a1b2c3",
"cwd": "/Users/me/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_use_id": "toolu_01"
}

Outputโ€‹

The exit code is the answer:

Exit codeMeaning
0Carry on. If stdout is a JSON object, it is read as the hook's output.
2Block. stderr is the reason, shown to you and given to the agent.
Anything elseThe hook failed. You see a warning and the work carries on.

For finer control, exit 0 and print JSON. This hook makes the agent ask you before any git push, even in Auto and Unsupervised modes:

{
"type": "command",
"if": "Bash(git push *)",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Pushing needs a human\"}}'"
}

Recipesโ€‹

Each recipe is a complete hooks value for ~/.abacusai/config.json or a project's .abacusai/config.json. Several read stdin with jq, so install it first.

Format files after every editโ€‹

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx --no-install prettier --write --ignore-unknown",
"statusMessage": "Formatting"
}
]
}
]
}
}

file_path is always absolute. Use gofmt -w, ruff format or rustfmt for other languages.

Run the tests before a commitโ€‹

The if rule limits this hook to git commit. If the tests fail, the commit is blocked and the agent is told why:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "npm test >/dev/null 2>&1 || { echo 'Tests are failing. Fix them before committing.' >&2; exit 2; }",
"statusMessage": "Running tests before commit",
"timeout": 300
}
]
}
]
}
}

Keep the agent out of secretsโ€‹

Save this as .abacusai/hooks/protect-secrets.sh in the project:

#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
*.env|*.env.*|*.pem|*.key|*/secrets/*)
echo "$path is a secrets file. Ask the user for the value you need instead." >&2
exit 2
;;
esac
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.abacusai/hooks/protect-secrets.sh" }
]
}
]
}
}

For a fixed list of files, a deny rule such as Read(.env) in permissions does the same with no script. Use a hook when the decision needs logic.

Get a desktop notificationโ€‹

A Notification hook fires when the agent is waiting for your answer to a permission prompt or a question, and when it has been idle for a minute.

{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e \"display notification \\\"$(jq -r .message)\\\" with title \\\"Abacus AI\\\"\""
}
]
}
]
}
}

Set the matcher to permission_prompt, agent_needs_input or idle_prompt to hear about only one of them. The built-in turn-end notifications cover the common case without a hook.

Add context to every conversationโ€‹

What a SessionStart hook prints is added to the conversation as context. This one tells the agent the branch and the last few commits:

{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{ "type": "command", "command": "echo \"Branch: $(git branch --show-current)\"; git log --oneline -5" }
]
}
]
}
}
note

Context from SessionStart, UserPromptSubmit and PostModelSwitch hooks reaches the model only on a local provider. On Abacus.AI models the hook runs, but its context is not sent, and the conversation shows a warning.

Check the work before the agent stopsโ€‹

A Stop hook runs when the agent finishes a turn. Exit 2 and the agent keeps working, with your stderr as its next instruction. This one sends it back while the tests fail. Save it as .abacusai/hooks/tests-pass.sh:

#!/usr/bin/env bash
input=$(cat)

# Let the agent stop if it is already continuing because of this hook.
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0

if ! output=$(npm test 2>&1); then
echo "The tests are failing. Fix them, then finish:" >&2
tail -n 40 <<<"$output" >&2
exit 2
fi
exit 0
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.abacusai/hooks/tests-pass.sh",
"statusMessage": "Running tests",
"timeout": 300
}
]
}
]
}
}

After eight continuations in a row the agent stops anyway and tells you, so a hook that never passes cannot keep it running forever.

What you seeโ€‹

A hook that succeeds quietly leaves no trace. One that takes a moment shows a progress line with its statusMessage. Otherwise:

You seeIt means
Blocked by hook, with the reasonA hook refused the call. After a tool already ran, the same note reads Hook feedback.
Hook stopped the turnA hook returned "continue": false
Hook failed, with the causeThe hook timed out, exited with an error, could not start, or printed invalid JSON
Hook ยท messageA hook's systemMessage
Input changed by hookA hook rewrote the tool call's arguments
A hook asks you to confirm thisA hook turned the call into a permission prompt

In the CLI and VS Code these notes appear under the tool call they belong to. A prompt a hook blocked goes back into the composer so you can edit it. In print mode, blocks, failures and notes go to stderr; see Print mode.

If your config has a hook that cannot run, such as an unknown event or an invalid matcher, each new conversation opens with a warning listing it. Your other hooks still run.

Turn hooks offโ€‹

Set "disableAllHooks": true in any settings file. In a project file it counts only once the folder is trusted. To turn off one hook, delete it from the file.

Securityโ€‹

Hooks run with your user's permissions, so treat a hook like any script you run yourself.

  • A cloned repository's hooks do not run until you trust the folder, and the trust prompt lists them. Read them before you trust it. In VS Code, VS Code's Workspace Trust decides. See Workspace trust.
  • In print mode, a project's hooks run only in a folder you trusted before or with ABACUS_TRUST_WORKSPACE=1.
  • Outside Unsupervised mode the agent cannot edit .abacusai/config.json or add hook scripts under .abacusai/ without your approval, so it cannot give itself a hook.
  • Hooks inherit your environment, minus ABACUS_API_KEY and ROUTELLM_API_KEY.
  • Quote variables in commands, such as "$CLAUDE_PROJECT_DIR", so paths with spaces work.

Using your Claude Code hooksโ€‹

Claude Code's settings files are not read. Run /import in the CLI once to copy your hooks, along with the rest of your Claude Code setup:

Claude Code fileCopied into
~/.claude/settings.json~/.abacusai/config.json
<project>/.claude/settings.json and .claude/settings.local.json<project>/.abacusai/config.json

/import --dry-run shows what would be copied without writing anything. Running /import again skips hooks you already have. Scripts can stay in .claude/hooks/, since $CLAUDE_PROJECT_DIR still points at the project.

Hooks that cannot run here are skipped, and the report says why. Until you import, each new conversation shows a line counting the Claude Code hooks that are not in your config yet. To copy hooks by hand instead, paste each file's hooks object into the matching file above as it is.

What does not carry over is listed in Differences from Claude Code.

For further help, reach out at support@abacus.ai.