Hooks

Hooks let you run shell commands at session lifecycle events: when you submit a prompt, before/after a tool call, when the session ends. Useful for: linting, secret scanning, custom logging, prompt rewriting, blocking dangerous calls before they run.

A hook is just a shell command wired to one of four events with an optional matcher. It receives a JSON payload on stdin, can mutate tool args via stdout, and can block the call by exiting with code 2. Anything else exits cleanly.

Configuration

Hooks live in .dsc/settings.json at your project root (or ~/.dsc/settings.json for user-global). The schema:

{
  "hooks": {
    "UserPromptSubmit": [
      { "matcher": "*", "command": "echo $(date) >> .dsc/prompts.log", "timeout": 5 }
    ],
    "PreToolUse": [
      { "matcher": "Bash(git push*)", "command": "gitleaks detect --staged" }
    ],
    "PostToolUse": [
      { "matcher": "Edit(*.py)", "command": "ruff format" }
    ],
    "Stop": [
      { "command": "echo 'session ended' >> ~/.dsc/audit.log" }
    ]
  }
}

Three fields per entry: matcher (optional pattern for tool-scoped events), command (required shell string), timeout (optional, seconds — defaults to 30, capped at 600).

Events

EventWhen it firesCan block?
UserPromptSubmitAfter the user hits Enter in the REPL, before the agent starts a turn. The user's input is in user_input on stdin.Yes — exit 2 cancels the turn.
PreToolUseAfter the permission gate grants a call, before the tool's handler runs. Receives tool_name + tool_input on stdin.Yes — exit 2 blocks. Stdout JSON {"updatedInput": {...}} rewrites the args.
PostToolUseAfter the handler returns, before the result hits the model conversation. Receives the result in tool_output.Exit 2 surfaces an error to the model but can't un-run the tool.
StopOn session shutdown — REPL exit, one-shot completion, Ctrl+C cleanup.No (exit codes are advisory).

Stdin payload

The JSON object piped to your hook depends on the event:

// PreToolUse
{"event": "PreToolUse", "tool_name": "bash", "tool_input": {"command": "rm -rf /"}}

// PostToolUse
{"event": "PostToolUse", "tool_name": "str_replace", "tool_input": {...}, "tool_output": "..."}

// UserPromptSubmit
{"event": "UserPromptSubmit", "user_input": "fix the bug in auth.py"}

// Stop
{"event": "Stop", "reason": "repl_exit"}

Read it with jq, cat, or whatever your hook script speaks. Non-JSON output on stdout is silently ignored, so an echo blocked hook stays trivial to write.

Matchers

Matchers are patterns that decide whether a tool-scoped hook fires. Syntax:

  • * or empty — match any tool. Used for catch-all hooks.
  • Bash — match the bash tool by name (case-insensitive).
  • Bash(*) — same, with explicit "any command" body.
  • Bash(git *) — match bash calls whose command starts with git.
  • Edit(*.py) — match str_replace against .py files. (Edit, Read, Write alias the snake_case names — port settings from Claude Code without rewriting them.)
  • Bash(rm -rf *), Edit(prod_*.sql) — comma-separated; any sub-pattern matching wins.

The "specifier" inside the parens is per-tool: for bash it's the command, for file tools it's the path. Globs use * / ? with shell semantics. Non-tool events ( UserPromptSubmit, Stop) ignore the matcher entirely.

Exit codes

  • 0 — allow / continue. PreToolUse stdout JSON can carry an updatedInput payload to rewrite args.
  • 2 — BLOCK. Surfaces a structured error. The first blocking hook short-circuits the rest in declaration order.
  • Any other non-zero — warn-only. Logged to telemetry, doesn't disrupt the agent loop. Keeps a flaky hook from breaking your session.

Timeouts

Default 30 seconds per hook, capped at 600. Set per hook with the timeout field. Slow hooks log a warning and the tool dispatch continues regardless — a ruff format hook that goes wandering won't deadlock your REPL.

Use cases

Format on save. PostToolUse matching Edit(*.py) runs ruff format and ruff check --fix:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit(*.py), Write(*.py)",
        "command": "ruff format && ruff check --fix"
      }
    ]
  }
}

Secret scan before push. PreToolUse on Bash(git push*) exits non-zero if gitleaks finds anything:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(git push*)",
        "command": "gitleaks detect --no-banner || (echo 'secrets found' && exit 2)"
      }
    ]
  }
}

Audit log. Stop appends a session summary to a file:

{
  "hooks": {
    "Stop": [
      { "command": "echo \"$(date) session ended\" >> ~/.dsc/audit.log" }
    ]
  }
}

Pre-prompt context. UserPromptSubmit runs git status --short and writes the result somewhere the model can later read it via view:

{
  "hooks": {
    "UserPromptSubmit": [
      { "command": "git status --short > .dsc/last-status.txt" }
    ]
  }
}

Listing active hooks

At any prompt, type /hooks to see every loaded hook grouped by event. Useful when debugging "why did my push just block" — the answer is usually a hook you forgot you wrote.

Project + user merging

Both ~/.dsc/settings.json (user) and .dsc/settings.json (project) load. Project-level wins on conflict — a layer that defines an event REPLACES the lower layer's array for that event. A layer silent on an event leaves it untouched. The walk stops at the first .git boundary or your home directory.

Configuration — full .dsc/settings.json schema and env vars. Permissions — the gate hooks fire after. MCP servers — the other extension point that lives in the same settings file.

Heads up

Hooks run shell commands with your full environment, including FRED_API_KEY. Treat .dsc/settings.json like any other code — review changes before committing or letting Fred edit it. Don't register hooks from a settings file you didn't write.

Tip

Start with PostToolUse hooks — they're the safest (the tool already ran). Use UserPromptSubmit for blocking only when you really need to; a flaky hook there means a flaky REPL.