On this page
Run commands on chat events (hooks)
A hook runs a command of yours at a point in a chat: before the agent runs a tool, when you send a message, when a turn ends. Use one to block a command you never want run, to add context to every prompt, to approve routine requests, or to send yourself a notification. Hooks use the same format as Claude Code's, so most hook scripts written for Claude Code work as they are.
Add a hook
Put a hooks section in ~/.config/haarness/haarness.json, or in a project's
.haarness/haarness.json to share it with the project:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/bin/check-command.sh", "timeout": 30 }]
}
]
}
}This runs check-command.sh before every shell command the agent runs. Haarness picks up the
change on its own once no turn is running.
You can also create hooks without editing the file: /hooks → Create hook in the terminal UI,
or Settings → Hooks → Add hook in Desktop; see
Run a command when something happens.
What your command receives
The command runs through sh -lc in the project folder, with HAARNESS_PROJECT_DIR and
CLAUDE_PROJECT_DIR set to that folder. It gets one JSON document on standard input with
hook_event_name, cwd, the chat's session_id and the event's own fields, such as tool_name
and tool_input for a tool, or prompt for a message.
#!/bin/sh
# check-command.sh: refuse any command that pushes to main
if jq -e '.tool_input.command | test("git push.*main")' >/dev/null; then
echo "Pushing to main is not allowed here; open a pull request." >&2
exit 2
fiBlock, allow or add context
- Block: exit with code 2. What the command wrote to standard error is the reason the agent
reads. Printing
{"decision": "block", "reason": "…"}does the same. - Allow or deny a tool call: a
PreToolUsehook prints{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}}(or"deny"or"ask").updatedInputthere changes the tool's input before it runs. - Add context:
additionalContextfrom aUserPromptSubmithook reaches the model with your message. - Answer an approval: a
PermissionRequesthook runs just before you would be asked. It can answer with"decision": {"behavior": "allow"},"deny", or"allow"with"always": true. Always lasts until the server stops and is never written to disk. - Answer the agent's question: a
PreToolUsehook with the matcherquestioncan hand back an answer, and nobody is asked. The chat shows Answered by hook.
Print nothing, or {}, to leave the decision to Haarness. Any other non-zero exit is logged and
the action goes on. A hook that runs longer than its timeout (60 seconds by default, at most
600) is stopped and changes nothing. Set "async": true for a hook that should never hold up the
chat.
A hook can approve only what you could approve with a rule: a deny rule still refuses, and requests that always need a person, such as an edit to Haarness's own settings or a Cloud action that spends money, still ask you.
Events
| Event | Runs | Matcher compares |
|---|---|---|
SessionStart |
A chat is created | startup |
UserPromptSubmit |
You send a message | — |
PreToolUse |
Before a tool runs | Tool name |
PermissionRequest |
Before you are asked to approve something | Permission name |
PostToolUse, PostToolUseFailure |
After a tool succeeds or fails | Tool name |
FileChanged |
After the agent writes or edits a file | Tool name |
Stop |
A turn completed | — |
TurnEnd |
After Stop, with the agent, model and finish reason |
— |
StopFailure |
A turn failed for good | The error, such as rate_limit |
PostCompact |
A long chat was summarised | manual or automatic |
Notification |
An approval is waiting, or a turn completed | permission_prompt or turn_completed |
SessionEnd |
A chat was archived | archived |
ConfigChange |
Settings were reloaded | — |
A matcher is * (or empty) for everything, names separated by | such as Bash|Edit, or a
regular expression. Claude Code's tool names work. Events marked — run every hook. A hook whose
matcher can never match its event is marked Never runs in the terminal UI and Desktop.
Before you add one
A hook runs as you, with your user's access, every time its event fires. Hooks in a project's
configuration load only while project configuration is on, so haarness --disable-project-config
skips them for a repository you do not trust. See
What can a hook do?.
Related
- Add a custom tool with an extension, for hooks written in TypeScript
- Permissions
- Configure Haarness