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
| Event | When it fires | Can block? |
|---|---|---|
UserPromptSubmit | After 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. |
PreToolUse | After 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. |
PostToolUse | After 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. |
Stop | On 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 withgit.Edit(*.py)— matchstr_replaceagainst.pyfiles. (Edit,Read,Writealias 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 anupdatedInputpayload 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.
Related
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.
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.
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.