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"
}
]
}
]
}
}
PreToolUseis the event: before a tool runs."matcher": "Bash"limits it to shell commands."if": "Bash(rm -rf *)"limits it further torm -rfcommands. The value is a permission rule, so it also catchescd build && rm -rf dist.exit 2blocks 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.
| Scope | File | Runs |
|---|---|---|
| User | ~/.abacusai/config.json | In every project |
| Project | <project>/.abacusai/config.json | In that project, once you trust the folder. Commit it to share hooks with your team. |
| One session | abacusai --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 code | Meaning |
|---|---|
0 | Carry on. If stdout is a JSON object, it is read as the hook's output. |
2 | Block. stderr is the reason, shown to you and given to the agent. |
| Anything else | The 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.
- macOS
- Linux
- Windows
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e \"display notification \\\"$(jq -r .message)\\\" with title \\\"Abacus AI\\\"\""
}
]
}
]
}
}
{
"hooks": {
"Notification": [
{
"hooks": [
{ "type": "command", "command": "notify-send 'Abacus AI' \"$(jq -r .message)\"" }
]
}
]
}
}
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"shell": "powershell",
"command": "$m = ([Console]::In.ReadToEnd() | ConvertFrom-Json).message; Add-Type -AssemblyName System.Windows.Forms; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(5000, 'Abacus AI', $m, 'Info'); Start-Sleep 6; $n.Dispose()"
}
]
}
]
}
}
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" }
]
}
]
}
}
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 see | It means |
|---|---|
| Blocked by hook, with the reason | A hook refused the call. After a tool already ran, the same note reads Hook feedback. |
| Hook stopped the turn | A hook returned "continue": false |
| Hook failed, with the cause | The hook timed out, exited with an error, could not start, or printed invalid JSON |
| Hook ยท message | A hook's systemMessage |
| Input changed by hook | A hook rewrote the tool call's arguments |
| A hook asks you to confirm this | A 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.jsonor add hook scripts under.abacusai/without your approval, so it cannot give itself a hook. - Hooks inherit your environment, minus
ABACUS_API_KEYandROUTELLM_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 file | Copied 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.