Skip to main content
Hooks fire shell commands at lifecycle events — before/after tool calls, on session start/stop, around compaction. Wire-compatible with Claude Code, so your existing .claude/settings.json hooks work as-is.

Quick example

Auto-format every TypeScript file the agent edits:
.soulforge/config.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "prettier --write $(echo $HOOK_TOOL_INPUT | jq -r '.path // empty')",
            "async": true
          }
        ]
      }
    ]
  }
}
/hooks inside SoulForge lists active hooks and toggles them per session.

Events

EventFiresMatches
PreToolUseBefore a tool runsTool name
PostToolUseAfter a tool succeedsTool name
PostToolUseFailureAfter a tool failsTool name
UserPromptSubmitOn user message
Stop / StopFailureTurn ends
SessionStart / SessionEndSession boundary
PreCompact / PostCompactAround compaction
SubagentStart / SubagentStopSpark/ember spawn/exit
NotificationSystem notifications

Rule schema

{
  "matcher": "Bash|Edit",       // tool name, pipe-separated or regex
  "hooks": [
    {
      "type": "command",
      "command": "my-hook.sh",
      "async": false,             // run in background
      "timeout": 10,              // seconds (default 10)
      "once": false,              // fire once per session
      "if": "Bash(git *)"        // extra glob filter
    }
  ]
}
The if field narrows by first string arg: Bash(rm *), Edit(*.ts), etc. * matches any sequence, ? one char. Tool names use Claude Code conventions (Bash, Edit, Write, Read, Grep, Glob, WebSearch, Agent). SoulForge maps its internal names automatically.

Config sources (merged in order)

  1. ~/.claude/settings.json
  2. .claude/settings.json
  3. .claude/settings.local.json
  4. ~/.soulforge/config.json
  5. .soulforge/config.json
Set "disableAllHooks": true in any file to kill all hooks.

Recipes

{
  "matcher": "Bash",
  "hooks": [{
    "type": "command",
    "command": "echo '{\"decision\":\"block\",\"reason\":\"rm -rf blocked\"}' && exit 2",
    "if": "Bash(rm -rf *)"
  }]
}
Goes under PreToolUse. Exit code 2 denies the call.
{
  "hooks": [{
    "type": "command",
    "command": "jq -r '.tool_name' >> /tmp/sf-tools.log",
    "async": true
  }]
}
Goes under PreToolUse. No matcher = all tools.
{
  "hooks": [{
    "type": "command",
    "command": "osascript -e 'display notification \"Done\" with title \"SoulForge\"'",
    "async": true,
    "once": true
  }]
}
Goes under Stop.

Protocol

Hooks read JSON from stdin, optionally write JSON to stdout. Exit code 0 = success, 2 = block, anything else = log warning. stdin
{
  "session_id": "abc",
  "cwd": "/proj",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "git status" }
}
stdout (optional — PreToolUse can deny, modify input, inject context)
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Blocked by policy",
    "updatedInput": { "command": "git status --short" },
    "additionalContext": "This repo uses trunk-based dev"
  }
}
PostToolUse can inject additionalContext — no deny.

Safety

  • Hooks die on Ctrl+X along with the agent.
  • Default timeout 10s — hooks should be fast.
  • Non-blocking errors (non-zero exit ≠ 2) log a warning, don’t stop the agent.