Hooks

April 22, 2026 · View on GitHub

Hooks let you run arbitrary commands or HTTP callbacks at well-defined points in openHarness's execution. Configure them in .oh/config.yaml under the hooks: key.

Events

EventFiresCan block?Notes
sessionStartWhen a REPL session startsNo
sessionEndWhen a REPL session endsNo
preToolUseBefore any tool executesYesReturn {decision: "deny"} to block.
postToolUseAfter a tool executes successfullyNoDoes NOT fire if the tool errored — see postToolUseFailure.
postToolUseFailureAfter a tool throws OR returns isError: trueNoNotify-only. Mutually exclusive with postToolUse.
userPromptSubmitBefore the user's prompt reaches the LLMYesCan also prepend context — see below.
permissionRequestWhen a tool permission check says "needs approval"Yes3-state: allow / deny / ask.
fileChangedAfter a tool edits a fileNo
cwdChangedWhen cd changes the working directoryNo
subagentStartWhen an AgentTool invocation startsNo
subagentStopWhen an AgentTool invocation endsNo
preCompactBefore conversation history is compactedNo
postCompactAfter compaction completesNo
configChangeWhen .oh/config.yaml changes on diskNo
notificationReserved for future useNo
turnStartAt the start of each top-level agent turnNoReceives OH_TURN_NUMBER.
turnStopAt the end of each top-level agent turnNoReceives OH_TURN_NUMBER and OH_TURN_REASON (completed, max_turns, error, interrupted). Matches Claude Code's Stop hook.

Two hook modes

JSON I/O mode (jsonIO: true)

The hook receives a JSON envelope on stdin and writes a JSON response on stdout. This is the preferred mode for blocking hooks and for hooks that want to prepend context.

Stdin (input):

{
  "event": "userPromptSubmit",
  "prompt": "the user's prompt text",
  "sessionId": "...",
  "model": "anthropic/claude-opus-4-7",
  "provider": "anthropic",
  "permissionMode": "ask"
}

Fields vary by event — see the event reference below.

Stdout (response):

{
  "decision": "allow" | "deny",
  "reason": "optional string shown to the user on deny",
  "hookSpecificOutput": {
    "additionalContext": "string prepended to the prompt (userPromptSubmit only)",
    "decision": "allow" | "deny" | "ask"
  }
}

All fields are optional. Unknown fields are ignored. Malformed JSON is treated as an empty response.

Env-mode (default)

The hook is a shell command. Context fields are passed via OH_* environment variables. Exit code gates the decision:

EventExit 0Nonzero exit
preToolUseallowdeny (tool blocked)
userPromptSubmitallowdeny (prompt blocked with generic message)
permissionRequestfall through to interactive askdeny
postToolUseFailure(ignored — notify-only)(ignored)
all other events(ignored — fire-and-forget)(ignored)

Env vars set per event:

  • OH_EVENT — the event name
  • OH_TOOL_NAME, OH_TOOL_ARGS, OH_TOOL_OUTPUT, OH_TOOL_INPUT_JSON — for tool events
  • OH_PROMPT — for userPromptSubmit (capped at 8KB on all platforms)
  • OH_TOOL_ERROR, OH_ERROR_MESSAGE — for postToolUseFailure
  • OH_PERMISSION_ACTION — for permissionRequest (value: ask, allow, or deny)
  • OH_TURN_NUMBER — for turnStart/turnStop (zero-indexed within the session)
  • OH_TURN_REASON — for turnStop (value: completed, max_turns, error, interrupted)
  • OH_SESSION_ID, OH_MODEL, OH_PROVIDER, OH_PERMISSION_MODE

HTTP mode

Set http: <url> on the hook def. OH POSTs a JSON body {event, ...context} (same shape as JSON I/O stdin) and parses the response the same way stdout is parsed:

{ "decision": "allow" | "deny",
  "reason": "optional explanation",
  "hookSpecificOutput": {
    "decision": "allow" | "deny" | "ask",
    "reason": "optional",
    "additionalContext": "text to prepend"
  } }

Legacy {"allowed": false} responses are still honored (surface as a deny). Network errors, non-2xx statuses, and malformed JSON all fail closed (deny).

This is the wire contract that the Python SDK's can_use_tool callback uses — the SDK hosts an in-process HTTP server on 127.0.0.1 and injects a permissionRequest hook that POSTs to it.

Examples

Fail-log on every tool failure

hooks:
  postToolUseFailure:
    - command: >-
        sh -c 'echo "[$(date)] $OH_TOOL_NAME failed: $OH_ERROR_MESSAGE" >> /tmp/oh-fails.log'

Prepend a standing instruction to every user prompt

hooks:
  userPromptSubmit:
    - command: node ./prepend-context.cjs
      jsonIO: true

./prepend-context.cjs:

let d = "";
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => {
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      additionalContext: "[system: always respond in under 200 words unless asked otherwise]",
    },
  }));
});

Deny Bash in "ask" mode without prompting

hooks:
  permissionRequest:
    - command: node ./gate-bash.cjs
      jsonIO: true
      match: "Bash"

./gate-bash.cjs:

let d = "";
process.stdin.on("data", (c) => (d += c));
process.stdin.on("end", () => {
  process.stdout.write(JSON.stringify({
    hookSpecificOutput: { decision: "deny", reason: "Bash is disabled by policy" },
  }));
});

Multi-hook merge

You can configure multiple hooks per event. They run in order. Merge rules:

  • First deny (in decision or hookSpecificOutput.decision) short-circuits — remaining hooks do NOT run.
  • First hookSpecificOutput.decision: "allow" short-circuits — remaining hooks do NOT run.
  • Multiple hookSpecificOutput.additionalContext values concatenate in hook-list order, separated by \n\n.
  • If any hook returns "ask" and none deny or allow, the final permissionDecision is "ask" (fall through).

Pattern matching with match

Filter a hook by tool name: substring, glob-style, or regex:

hooks:
  preToolUse:
    - command: ./log.sh
      match: "Bash"           # substring
    - command: ./watch.sh
      match: "File*"          # glob
    - command: ./mcp-audit.sh
      match: "/mcp__.*/"      # regex

Claude Code compatibility

openHarness mirrors Claude Code's hook semantics where possible. Notable differences:

  • openHarness uses camelCase context field names (toolName, toolInput); Claude Code uses snake_case (tool_name, tool_input). Snake_case aliases are not yet supported.
  • openHarness does not yet support prompt rewriting via userPromptSubmit — only prepending via additionalContext.

Timeouts and gotchas

  • Default hook timeout: 10 seconds. Override per-hook with timeout: <ms>.
  • userPromptSubmit runs between keypress and LLM dispatch — keep it fast.
  • OH_PROMPT is truncated to 8KB to fit within Windows env-var length limits.
  • permissionRequest fires ONLY in the needs-approval && askUser branch. If the tool is pre-approved (permissionMode: trust, or matching allow rule) OR pre-denied, the hook does not fire.
  • Biome / lint / format: openHarness's pre-commit hook runs biome check — ensure your hook scripts don't trip it if they live in the repo.