Hooks
Hooks let you run external commands at well-defined points in Chord’s lifecycle: when a tool is about to run, when an LLM call returns, when an agent goes idle, etc. They are useful for notifications, auditing, automation gates, and post-batch checks.
This page is the complete reference. For higher-level usage advice, see Customization.
How a hook runs
Section titled “How a hook runs”When a registered point fires, Chord:
- Spawns the configured command (either a
shellline or anargvlist). - Sends a JSON envelope on stdin (see Envelope).
- Sets a small set of
CHORD_HOOK_*environment variables (see Env vars). - Sets the working directory to the project root.
- Reads the hook’s stdout as either a sync result, an automation result, or ignored output (depending on the point’s category).
- Enforces a timeout (default 30 seconds; configurable per hook).
Hook stdout that is not valid JSON is treated as a parse failure and the hook is logged as failed. Hooks that exit non-zero are logged as failed. Failures never crash Chord.
Hook categories
Section titled “Hook categories”Chord groups the 14 trigger points into three categories that decide what the hook can return:
| Category | Points | Behavior |
|---|---|---|
| sync | on_tool_call, on_before_llm_call, on_before_tool_result_append |
Synchronous interceptor. Stdout JSON {"action": "continue|block|modify", "message": "...", "data": {...}}. block aborts the action; modify replaces the data flowing downstream. |
| automation | on_tool_batch_complete |
Async background task. Stdout JSON {"status": "...", "summary": "...", "body": "...", "severity": "...", "append_context": bool, "notify": bool}. Result can be joined into the next context, optionally. |
| observer | All other points (on_idle, on_session_start, on_after_llm_call, …) |
Stdout is logged as a plain string, but cannot block or modify. Pure side-effect. |
Trigger points
Section titled “Trigger points”| Point | Category | When it fires | Common data fields |
|---|---|---|---|
on_session_start |
observer | A session is created or resumed | session metadata |
on_session_end |
observer | A session is closed cleanly | session metadata, summary stats |
on_before_llm_call |
sync | Just before a request is sent to the model | model, messages |
on_after_llm_call |
observer | After a model response (and any retries) completes | model, usage, error (on failure) |
on_tool_call |
sync | Before a tool actually runs | tool_name, args, timeout_ms |
on_tool_result |
observer | After a tool returns | tool_name, output, error |
on_before_tool_result_append |
sync | Before a tool result is appended to context (last chance to redact / replace) | tool_name, output, error |
on_tool_batch_complete |
automation | After a batch of tools in a turn completes (typical for editing batches) | changed_files, tool_calls |
on_before_compress |
observer | Before context compaction runs | reason, current usage |
on_after_compress |
observer | After context compaction finishes | reason, before/after usage |
on_idle |
observer | Agent transitions to idle (turn finished, awaiting input) | agent_id |
on_wait_confirm |
observer | A tool needs user confirmation (permission ask) |
tool_name, args |
on_wait_question |
observer | The model asked a question and is waiting for an answer | question |
on_agent_error |
observer | An agent reports an error (LLM error, tool failure, etc.) | error, error_kind |
The exact data fields can evolve. To stay future-proof, treat unknown fields as opaque and rely on the keys you actually need.
Envelope
Section titled “Envelope”Every hook receives this JSON document on stdin:
{ "point": "on_tool_call", "timestamp": "2026-05-08T12:00:00.000Z", "session_id": "20260508120000000", "turn_id": 7, "agent_id": "main", "agent_kind": "main", "project_root": "/path/to/project", "selected_model": "anthropic/claude-opus-5", "running_model": "anthropic/claude-opus-5", "data": { "tool_name": "shell", "args": { "command": "git status" } }}Environment variables
Section titled “Environment variables”In addition to stdin, Chord sets the following variables before exec’ing the hook command (empty values are not set):
| Variable | Source |
|---|---|
CHORD_HOOK_POINT |
Envelope point |
CHORD_HOOK_SESSION_ID |
Envelope session_id |
CHORD_HOOK_TURN_ID |
Envelope turn_id |
CHORD_HOOK_AGENT_ID |
Envelope agent_id |
CHORD_HOOK_AGENT_KIND |
Envelope agent_kind |
CHORD_HOOK_PROJECT_ROOT |
Envelope project_root |
CHORD_HOOK_SELECTED_MODEL |
Envelope selected_model |
CHORD_HOOK_RUNNING_MODEL |
Envelope running_model |
CHORD_HOOK_TOOL_NAME |
Convenience: extracted from data.tool_name if present |
CHORD_HOOK_TIMEOUT_MS |
Convenience: extracted from data.timeout_ms if present |
CHORD_HOOK_ERROR_KIND |
Convenience: extracted from data.error_kind if present |
Anything you put under the hook’s environment: map is also passed through verbatim.
Stdout contracts
Section titled “Stdout contracts”Sync hooks
Section titled “Sync hooks”{ "action": "continue", "message": "optional human-readable note", "data": null}continue(default if stdout is empty) — let the action proceed.block— abort the action;messageis shown to the user.modify— replace the data flowing downstream withdata. The exact shape ofdatamatches the original payload of that point (e.g. foron_tool_call,datashould be the modified tool args).
Automation hooks (on_tool_batch_complete)
Section titled “Automation hooks (on_tool_batch_complete)”{ "status": "success", "summary": "linted 12 files, 0 issues", "body": "details...", "severity": "info", "append_context": false, "notify": false}status:successorfailed.severity:info,warning,error. Defaults toinfo, orerrorwhenstatus == failed.append_context: truerequests Chord to feed the result into the next LLM call.notify: truesurfaces the summary to the user.
Observer hooks
Section titled “Observer hooks”Stdout is recorded in logs as a plain string. There is no schema — feel free to print whatever helps you debug.
HookDef fields
Section titled “HookDef fields”hooks: on_tool_call: - name: audit-shell command: ["./scripts/audit-shell.sh"] # or: shell: "./scripts/audit-shell.sh" timeout: 10 # seconds; default 30 tools: ["shell"] # glob match on tool name paths: ["src/**/*.go"] # glob match on relevant paths agents: ["main", "reviewer"] # glob match on agent name agent_kinds: ["main", "subagent"] # exact match models: ["anthropic/*"] # glob match on selected/running model min_changed_files: 0 # only run if at least N files changed only_on_error: false # only run when there is an error in payload join: background # automation only: background | before_next_llm result: notify_only # automation only: ignore | notify_only | append_on_failure | always_append result_format: summary # automation only: summary | tail | full max_result_lines: 50 # automation only max_result_bytes: 4096 # automation only debounce_ms: 0 concurrency: "" # serialize key retry_on_failure: 0 retry_delay_ms: 0 environment: AUDIT_LEVEL: strict # injected verbatimFilters are AND-ed: a hook runs only if every populated filter matches.
Examples
Section titled “Examples”1. Notify on idle (observer)
Section titled “1. Notify on idle (observer)”hooks: on_idle: - name: notify-idle command: - osascript - -e - 'display notification "Chord is idle" with title "Chord"'The hook ignores stdout; the side effect is the notification.
on_idle runs only after the main agent, every SubAgent, and all automatic continuation queues are idle.
2. Block destructive shell commands (sync)
Section titled “2. Block destructive shell commands (sync)”hooks: on_tool_call: - name: deny-rm-rf tools: ["shell"] shell: | # Read envelope, check the command, optionally block jq -e '.data.args.command | test("^rm -rf|^sudo")' \ && echo '{"action":"block","message":"Destructive command blocked"}' \ || echo '{"action":"continue"}'jq reads the envelope from stdin; if the regex matches, the hook prints {"action":"block",…} and Chord aborts the call.
3. Run lint after edit batches (automation)
Section titled “3. Run lint after edit batches (automation)”hooks: on_tool_batch_complete: - name: golangci-lint tools: ["edit", "write", "delete"] paths: ["**/*.go"] min_changed_files: 1 shell: | status=success append_context=false out=$(golangci-lint run ./... 2>&1) || { status=failed; append_context=true; } cat <<JSON { "status": "$status", "summary": "golangci-lint", "body": $(jq -Rs . <<<"$out"), "append_context": $append_context } JSON result: append_on_failure result_format: tail max_result_lines: 80 join: before_next_llmWhen the lint fails, the truncated tail is appended to the next LLM context so the model can react.
4. Strip API keys from tool output (sync, modify)
Section titled “4. Strip API keys from tool output (sync, modify)”hooks: on_before_tool_result_append: - name: redact-keys tools: ["shell", "web_fetch", "read"] shell: | envelope=$(cat) redacted=$(jq '.data.output |= (gsub("sk-[A-Za-z0-9_-]{20,}"; "sk-REDACTED"))' <<<"$envelope") echo "{\"action\":\"modify\",\"data\": $(jq '.data' <<<"$redacted")}"Debugging hooks
Section titled “Debugging hooks”Set CHORD_HOOK_DEBUG=1 before launching Chord — every hook invocation will be logged with input, output, exit code, and duration. See Environment variables.
When a hook misbehaves:
- Check
chord.logforhook execution status=failed/timed_out. - Run the command manually with the same envelope on stdin to reproduce.
- Verify the JSON output is valid (
echo "$out" | jq .).
Related
Section titled “Related”- Customization — higher-level recipes
- Configuration & Auth — full
config.yamlschema - Environment variables —
CHORD_HOOK_DEBUG - Permissions & Safety — when to use hooks vs permission rules