Hooks

Lifecycle hooks and event handling in swarm.

Overview

Hooks are shell commands that execute in response to lifecycle events during a swarm session. They can observe events (logging, notifications), modify behavior (blocking tool calls), or perform side effects (running scripts). Hooks are configured in ~/.swarm/settings.json under the hooks section.

How Hooks Work

  1. An event fires (e.g., PreToolUse before a tool executes)
  2. Swarm finds matching hooks by checking each MatcherGroup for the event
  3. Matching hooks receive a HookContext JSON object on stdin
  4. Sync hooks block until they return a HookDecision; async hooks fire and forget
  5. If any sync hook returns Deny or Block, the action is prevented

Step 1: Add Hooks to Configuration

Hooks are organized by event type in the hooks section:

{
  "hooks": {
    "pre_tool_use": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-bash.sh",
            "timeout": 10
          }
        ]
      }
    ],
    "session_start": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/on-session-start.sh",
            "async": true
          }
        ]
      }
    ]
  }
}

Event Types

Swarm supports 13 hook event types:

Event TypeConfig KeyWhen It Fires
SessionStartsession_startA new agent session begins
PreToolUsepre_tool_useBefore a tool executes
PostToolUsepost_tool_useAfter a tool executes successfully
PostToolUseFailurepost_tool_use_failureAfter a tool execution fails
StopstopSession stop is initiated
SessionEndsession_endAn agent session ends
SubagentStartsubagent_startA subagent is spawned
SubagentStopsubagent_stopA subagent stops
TeammateIdleteammate_idleAn agent has been idle past the nudge threshold
TeammateIdleWarningteammate_idle_warningAn agent has been idle past the warning threshold
TaskCompletedtask_completedAn agent completes a task
NotificationnotificationA notification is generated
StallDetectedstall_detectedAgent stall detected (no heartbeat past threshold)

Step 2: Define Matcher Groups

Each event type has an array of MatcherGroup entries. A matcher group specifies:

  • matcher (optional) — A regex pattern matched against the tool name or event source. If omitted, matches all events of that type.
  • hooks — An array of hook handlers to execute when matched.
{
  "pre_tool_use": [
    {
      "matcher": "Bash",
      "hooks": [ ... ]
    },
    {
      "matcher": "Edit|Write",
      "hooks": [ ... ]
    },
    {
      "hooks": [ ... ]
    }
  ]
}

The first group matches only Bash tool calls, the second matches Edit or Write, and the third matches all tool calls (no matcher = match all).

Step 3: Write Hook Handlers

Each hook handler is a command type with these fields:

FieldTypeRequiredDefaultDescription
type"command"YesHandler type (only command is supported)
commandStringYesShell command to execute (run via sh -c)
timeoutu64No30Timeout in seconds
asyncboolNofalseIf true, fire-and-forget (no decision collected)
status_messageStringNonullDisplay message while hook runs

Example:

{
  "type": "command",
  "command": "./scripts/lint-check.sh",
  "timeout": 15,
  "status_message": "Running lint check..."
}

Step 4: Write Hook Scripts

Hook scripts receive a HookContext JSON object on stdin and optionally output a HookDecision JSON object on stdout.

HookContext (stdin)

The context object sent to every hook:

{
  "session_id": "20250115-a3f2",
  "cwd": "/home/user/my-project/.swarm/worktrees/backend",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  },
  "tool_response": null
}
FieldTypePresentDescription
session_idStringAlwaysActive session ID
cwdStringAlwaysAgent working directory
hook_event_nameStringAlwaysEvent name (e.g., "PreToolUse")
tool_nameStringTool events onlyName of the tool
tool_inputJSONTool events onlyTool input payload
tool_responseJSONPostToolUse onlyTool response payload

Fields that don't apply to the event type are omitted from the JSON (not set to null).

Specialized Event Inputs

Some events provide additional context beyond the standard HookContext:

TeammateIdleWarning:

{
  "event": "TeammateIdleWarning",
  "agent_name": "backend",
  "session_id": "20250115-a3f2",
  "idle_duration_secs": 300,
  "nudge_count": 2,
  "last_state": "Running"
}

StallDetected:

{
  "event": "StallDetected",
  "agent_name": "backend",
  "session_id": "20250115-a3f2",
  "stall_duration_secs": 900,
  "last_heartbeat_event": "PostToolUse"
}

HookDecision (stdout)

Sync hooks can output a decision JSON to control the action:

{ "decision": "allow" }
{ "decision": "deny", "reason": "Command not allowed by policy" }
{ "decision": "block", "reason": "Security violation detected" }
DecisionEffect
allowPermits the action to proceed
denyPrevents the action (with reason)
blockPrevents the action (with reason)

Both deny and block prevent the action. The reason field is optional and logged for diagnostics.

Exit Code Behavior

Exit CodeBehavior
0Parse stdout for decision JSON. No output = implicit allow
2Blocking error — treated as deny with stderr as reason
OtherNon-blocking error — logged but action proceeds

Sync vs Async Hooks

ModeBlocks ActionReturns DecisionUse Case
Sync (async: false)YesYesValidation, policy enforcement
Async (async: true)NoNoLogging, notifications, side effects

Sync hooks within a single event run in parallel. If any sync hook returns deny or block, the action is prevented and all denial reasons are aggregated.

Async hooks are spawned as background tasks. They don't contribute decisions and their success or failure doesn't affect the action.

Example Scripts

Pre-Tool-Use Validator

Block dangerous bash commands:

#!/bin/bash
# scripts/validate-bash.sh
# Blocks rm -rf and other dangerous commands

CONTEXT=$(cat)
COMMAND=$(echo "$CONTEXT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+/'; then
  echo '{"decision": "deny", "reason": "Refusing to run rm -rf on root paths"}'
  exit 0
fi

echo '{"decision": "allow"}'

Session Start Logger

Log session starts to a file:

#!/bin/bash
# scripts/on-session-start.sh
CONTEXT=$(cat)
SESSION_ID=$(echo "$CONTEXT" | jq -r '.session_id')
echo "[$(date)] Session started: $SESSION_ID" >> /tmp/swarm-sessions.log

Post-Tool-Use Notifier

Send a notification after tool failures:

#!/bin/bash
# scripts/notify-failure.sh
CONTEXT=$(cat)
TOOL=$(echo "$CONTEXT" | jq -r '.tool_name')
echo "[$(date)] Tool failed: $TOOL" >> /tmp/swarm-failures.log

Stall Detector

Auto-restart stalled agents:

#!/bin/bash
# scripts/handle-stall.sh
CONTEXT=$(cat)
AGENT=$(echo "$CONTEXT" | jq -r '.agent_name')
DURATION=$(echo "$CONTEXT" | jq -r '.stall_duration_secs')
echo "[$(date)] Agent $AGENT stalled for ${DURATION}s" >> /tmp/swarm-stalls.log

Complete Configuration Example

{
  "hooks": {
    "session_start": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/on-session-start.sh",
            "async": true
          }
        ]
      }
    ],
    "pre_tool_use": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/validate-bash.sh",
            "timeout": 10,
            "status_message": "Validating command..."
          }
        ]
      }
    ],
    "post_tool_use_failure": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/notify-failure.sh",
            "async": true
          }
        ]
      }
    ],
    "stall_detected": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/handle-stall.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

Troubleshooting

Hook not firing

  • Verify the event type key in config matches the event (e.g., pre_tool_use, not preToolUse)
  • Check the matcher regex matches the tool name or event source
  • Ensure the script is executable (chmod +x scripts/my-hook.sh)

Hook timing out

  • The default timeout is 30 seconds. Increase it with the timeout field.
  • On timeout, the child process is killed and the hook is treated as a non-blocking error.

Decision not being applied

  • Async hooks cannot return decisions — set async: false for policy enforcement
  • Ensure exit code is 0 and stdout contains valid JSON
  • Exit code 2 is a special blocking error; other non-zero codes are non-blocking

Multiple hooks conflicting

Sync hooks for the same event run in parallel. If any returns deny or block, the action is prevented. Reasons from all denying hooks are aggregated.