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.

How a hook runs

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.

Hook categories

Chord groups the 14 trigger points into three categories that decide what the hook can return:

CategoryPointsBehavior
syncon_tool_call, on_before_llm_call, on_before_tool_result_appendSynchronous interceptor. Stdout JSON {"action": "continue|block|modify", "message": "...", "data": {...}}. block aborts the action; modify replaces the data flowing downstream.
automationon_tool_batch_completeAsync background task. Stdout JSON {"status": "...", "summary": "...", "body": "...", "severity": "...", "append_context": bool, "notify": bool}. Result can be joined into the next context, optionally.
observerAll 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

PointCategoryWhen it firesCommon data fields
on_session_startobserverA session is created or resumedsession metadata
on_session_endobserverA session is closed cleanlysession metadata, summary stats
on_before_llm_callsyncJust before a request is sent to the modelmodel, messages
on_after_llm_callobserverAfter a model response (and any retries) completesmodel, usage, error (on failure)
on_tool_callsyncBefore a tool actually runstool_name, args, timeout_ms
on_tool_resultobserverAfter a tool returnstool_name, output, error
on_before_tool_result_appendsyncBefore a tool result is appended to context (last chance to redact / replace)tool_name, output, error
on_tool_batch_completeautomationAfter a batch of tools in a turn completes (typical for editing batches)changed_files, tool_calls
on_before_compressobserverBefore context compaction runsreason, current usage
on_after_compressobserverAfter context compaction finishesreason, before/after usage
on_idleobserverAgent transitions to idle (turn finished, awaiting input)agent_id
on_wait_confirmobserverA tool needs user confirmation (permission ask)tool_name, args
on_wait_questionobserverThe model asked a question and is waiting for an answerquestion
on_agent_errorobserverAn 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):

VariableSource
CHORD_HOOK_POINTEnvelope point
CHORD_HOOK_SESSION_IDEnvelope session_id
CHORD_HOOK_TURN_IDEnvelope turn_id
CHORD_HOOK_AGENT_IDEnvelope agent_id
CHORD_HOOK_AGENT_KINDEnvelope agent_kind
CHORD_HOOK_PROJECT_ROOTEnvelope project_root
CHORD_HOOK_SELECTED_MODELEnvelope selected_model
CHORD_HOOK_RUNNING_MODELEnvelope running_model
CHORD_HOOK_TOOL_NAMEConvenience: extracted from data.tool_name if present
CHORD_HOOK_TIMEOUT_MSConvenience: extracted from data.timeout_ms if present
CHORD_HOOK_ERROR_KINDConvenience: 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; 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).

Automation hooks (on_tool_batch_complete)

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

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 verbatim

Filters 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_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)

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:

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