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
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
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
| 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
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-4.7", "running_model": "anthropic/claude-opus-4.7", "data": { "tool_name": "Shell", "args": { "command": "git status" } }}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
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)
{ "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
Stdout is recorded in logs as a plain string. There is no schema — feel free to print whatever helps you debug.
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
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.
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)
hooks: on_tool_batch_complete: - name: golangci-lint tools: ["Edit", "Write", "Delete"] paths: ["**/*.go"] min_changed_files: 1 shell: | out=$(golangci-lint run ./... 2>&1) || status=failed cat <<JSON { "status": "${status:-success}", "summary": "golangci-lint", "body": $(jq -Rs . <<<"$out"), "append_context": ${status:+true,$0}false } 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)
hooks: on_before_tool_result_append: - name: redact-keys tools: ["Shell", "WebFetch", "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
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
- Customization — higher-level recipes
- Configuration & Auth — full
config.yamlschema - Environment variables —
CHORD_HOOK_DEBUG - Permissions & Safety — when to use hooks vs permission rules