Skip to content
DocsHaarness CLI
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
fi

Block, 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 PreToolUse hook prints {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow"}} (or "deny" or "ask"). updatedInput there changes the tool's input before it runs.
  • Add context: additionalContext from a UserPromptSubmit hook reaches the model with your message.
  • Answer an approval: a PermissionRequest hook 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 PreToolUse hook with the matcher question can 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?.