Skip to content

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.

When a registered point fires, Chord:

  1. Spawns the configured command (either a shell line or an argv list).
  2. Sends a JSON envelope on stdin (see Envelope).
  3. Sets a small set of CHORD_HOOK_* environment variables (see Env vars).
  4. Sets the working directory to the project root.
  5. Reads the hook’s stdout as either a sync result, an automation result, or ignored output (depending on the point’s category).
  6. 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.

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.
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.

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" }
}
}

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.

{
"action": "continue",
"message": "optional human-readable note",
"data": null
}
  • continue (default if stdout is empty) — let the action proceed.
  • block — abort the action; message is shown to the user.
  • modify — replace the data flowing downstream with data. The exact shape of data matches the original payload of that point (e.g. for on_tool_call, data should be the modified tool args).
{
"status": "success",
"summary": "linted 12 files, 0 issues",
"body": "details...",
"severity": "info",
"append_context": false,
"notify": false
}
  • status: success or failed.
  • severity: info, warning, error. Defaults to info, or error when status == failed.
  • append_context: true requests Chord to feed the result into the next LLM call.
  • notify: true surfaces the summary to the user.

Stdout is recorded in logs as a plain string. There is no schema — feel free to print whatever helps you debug.

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 verbatim

Filters are AND-ed: a hook runs only if every populated filter matches.

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_llm

When 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")}"

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:

  1. Check chord.log for hook execution status=failed/timed_out.
  2. Run the command manually with the same envelope on stdin to reproduce.
  3. Verify the JSON output is valid (echo "$out" | jq .).