Hooks and Triggers
Hooks are Cacophony's structured way to run small pieces of automation at well-known lifecycle points. They are used to keep managed agents safe and observable without relying on ad hoc prompt loops, hidden shell aliases, or profile-specific one-off nudges.
Use hooks for bounded, auditable reactions to events. Use crons for schedule-driven work. Use persistent agents when the work requires reasoning, stateful observation, or operator-facing judgement over time.
Hook families
Cacophony currently has three hook families with different trust boundaries:
- Daemon hooks live in top-level
hooksconfig. They are event-driven automation entries evaluated by each daemon for first-party daemon/project events. - Project hooks live under
projects.<name>.hooks. They fire for project lifecycle events such as checkout updates and tag detection. - Profile/runtime hooks live in profile frontmatter. They are materialized into managed agent runtimes and run in the agent context for lifecycle or tool events.
These are related, but not interchangeable. Daemon/project hooks run from daemon-owned context. Profile/runtime hooks run inside an agent runtime and must be compatible with the selected runtime bridge, such as Claude, Codex, or Pi.
Daemon hooks and crons
Top-level daemon hooks and crons are for infrastructure automation. Every entry needs a stable name so operator surfaces, logs, and future execution history can show what ran.
hooks:
- name: announce-completion-blocked
trigger:
event: agent_completion_blocked
action:
type: command
command: caco msg speak --project cacophony --body "An agent is blocked on completion"
timeout_secs: 15
cwd: '{{ env "CACOPHONY_DIR" }}'
roles_permitted:
- controller
nodes:
- ms-mac
Confirmed PR merges use the canonical pull_request_merged trigger.
It fires only after GitHub reports MERGED—not when a PR is opened or auto-merge is armed—and exposes pr_url / pr_number, agent_id, branch, target_branch, source, and autonomous fields in CACO_HOOK_DATA when known.
This covers synchronous PR-backed reintegration and PRs merged later by GitHub native auto-merge or by hand, which the post-merge reconciler observes.
Read-aloud announcements use the same daemon-hook command pattern as bead_closed: scope the hook to one playback node, derive bounded speech-safe text from CACO_HOOK_DATA, and invoke caco msg speak --project "$CACO_HOOK_PROJECT" --body "$message" --immediate.
The checked-in Cacophony automation includes this single-node PR-merge announcement alongside its webhook delivery.
Rules of thumb:
- use
nodeswhen exactly one node should execute the action; - omit
nodesonly when every configured node should independently execute it; - keep commands non-interactive and bounded by a timeout;
- prefer first-party
caco ... --jsoncommands for daemon state reads and writes; - include enough context in output for operators to diagnose failures without reading the hook source.
Cron entries are the schedule-driven sibling of daemon hooks and must declare a schedule instead of an event trigger.
crons:
- name: prune-completed-agents-preview
schedule: "0 */6 * * *"
action:
type: command
command: caco agent prune --dry-run --json
timeout_secs: 60
nodes:
- ms-mac
Do not use daemon cron for per-agent Pi self-checks when the profile already composes pi-loop, or another runtime-native wake path. Runtime-native wake paths avoid cluster-wide duplicate prompts and keep per-agent state inside the session.
Cron command actions also stamp nested first-party CLI calls with CACO_CRON_NAME, CACO_AUTOMATION_KIND=cron, CACO_AUTOMATION_ID=cron:<name>, and CACOPHONY_ACTOR=cron:<name> so cron-originated speech, chat, and feed rows render as automation rather than the host user.
Planned restarts and cron receipts
A planned daemon stop closes scheduled and manual cron admission, then gives already-admitted runs 30 seconds to finish and up to 5 seconds for forced termination and receipt flush. The shutdown endpoint’s timeout_secs overrides the run-drain budget. The service lifecycle gives caco-daemon a 60-second SIGTERM grace covering cron draining, the existing 15-second Git drain, and exit bookkeeping; other services retain their short stop grace.
Every command persists its CACO_CRON_RUN_ID before spawning and its terminal before releasing shutdown ownership. Forced planned stops record interrupted_by_restart, the original run_id, partial output, and any known operation_id. Crash/SIGKILL admissions are interrupted_unknown, never an inferred planned stop. The status endpoint reconstructs the last execution after restart.
The daemon never blindly replays an interrupted command. The additive reconciliation.successor_run_id and reconciliation.operation_id fields were filled by the retired Cara sync cron; no current command establishes reconciliation. The original terminal remains interrupted with original_effects_unknown=true: current convergence does not prove that every old mutation succeeded. Inspect shared records with caco cron logs --name <name> --json; clients may ignore additive reconciliation fields.
A daemon-targeting caco restart invoked inside a cron hands off to an independent process group, returning phase=delegated and restart_completed=false. The worker writes its outcome to daemon/scheduled-restart.log, avoiding both a self-drain deadlock and the loss of the worker that must start the replacement daemon. No second scheduler or merge actor is created.
Trunk breakage detector
caco trunk breakage reads the newest completed push run of the selected trunk workflow, defaulting to ci.yml.
--event schedule,workflow_dispatch points the same observer at a scheduled drift lane such as full-suite-drift.yml, which becomes its own stream with its own fixer bead.
A failed run gets one deduplicated fix-forward task with the red run link; a successful run resolves that CI signal.
Cancelled, skipped and unknown verdicts do not fall back to older red evidence.
A small persisted run-ID high-water mark rejects regressed provider windows before board changes.
The detector uses one source-scoped bead and the ordinary idempotent create/dispatch paths. It re-reads the canonical bead before dispatch; an assignee, dispatch reservation or merging state prevents another fixer. Fixers normally fix forward to a green commit and bisect only if needed. The scanner does not identify a culprit, reconstruct suspect ranges, bind an author, classify jobs, cancel or rerun CI, or plan a rollback.
Green marks the bead's CI signal recovered and defers it out of generic ready queues. The scanner then stops repeating work; source-delivery closeout remains with the owner and normal project lifecycle, not an inference that a fixer's in-flight PR has merged. A new red episode can reactivate the source task, while an unchanged result cannot undo a later close. Existing workers are never stopped or reassigned by the scanner.
The former attribution, escalation, descendant-validation and revert controls are removed. Fixer resource labels are optional; project dispatch defaults otherwise apply. See the simple trunk observer for deduplication, stale-data and rollout boundaries.
The trunk-breakage-cacophony cron, which scans trunk CI every 10 minutes, is the only trigger; the GitHub workflow_run signal to the tailnet hook was removed because the CI pool cannot reach the tailnet. No scheduler or merge actor is added.
Runtime configuration is separate: re-enable the containing runtime with the simplified command, without retired tuning flags.
caco trunk breakage --project cacophony --dry-run --json
caco trunk breakage --project cacophony --workflow full-suite-drift.yml --event schedule,workflow_dispatch --dry-run --json
caco bd list --project cacophony --label main-breakage
Project hooks
Project hooks react to project lifecycle events. The most common events are:
checkout_updated— the canonical checkout moved to a new commit;tag_pushed— a new git tag was observed.
projects:
cacophony:
hooks:
checkout_updated:
- type: command
command: ./scripts/install-git-hooks.sh
timeout_secs: 30
tag_pushed:
- type: notify_agent
agent: update-helper
message: "New tag $tag detected; check release freshness."
Project hooks are useful for lightweight repository maintenance such as installing local git hooks into fresh checkouts or nudging a release helper after a tag. They should not perform large builds directly on shared hosts; route heavyweight work through caco test run, caco build run, release workflows, or a dedicated worker.
Profile/runtime hooks
Profile hooks are declared in a profile's YAML frontmatter. They compose with other profile fields and are materialized into the launched runtime.
---
name: worker-example
hooks:
on_start:
- type: command
command: caco agent status set --state running
timeout: 10
before_tool:
- matcher: Bash
hooks:
- type: command
command: ./plugins/caco-agent/agents/bash-preflight.sh
timeout: 5
post_tool_use:
- matcher: Bash|Edit|Write|NotebookEdit
hooks:
- type: command
command: caco msg speak --skip
timeout: 5
reintegration:
mode: direct
---
Profile instructions go here.
Supported profile hook phases include:
on_starton_stopon_session_endon_notificationbefore_toolpost_tool_use/after_toolbefore_completeon_merge_commiton_completeon_failure
matcher narrows a hook to exact tool or event names. Use pipe-separated names such as Bash|Edit|Write|NotebookEdit. Nested hooks let one matcher group multiple command actions.
Webhook ingress (HTTP)
Separately from the lifecycle hooks above, an opt-in webhook ingress turns inbound HTTP POSTs into first-party actions (cacophony-spec §26). It has its own listener, isolated from the loopback API and the mutual-TLS cluster listener. Hooks live under top-level webhooks, served at /hooks/global/<id>, and per project under projects.<name>.webhooks, served at /hooks/<project>/<id>; the key is webhooks so it never collides with lifecycle hooks.
webhooks:
bind_host: tailnet
bind_port: 11300
entries:
omni-feedback:
type: bead
project: omni-cli
title_from: summary
token_file: tokens/omni-feedback-hook.token
- Auth: a per-hook or section bearer token from exactly one of
token,token_envortoken_file, presented asAuthorization: BearerorX-Caco-Hook-Token. Env and file sources are read per request, so rotating a secret needs no restart. Tokens are never logged. - Handlers:
beadfiles the body through the normal beads path;bashorcommandrunssh -cwith the body in$CACO_HOOK_BODYand on stdin, plus boundedCACO_HOOK_*metadata but never the auth headers;actionruns a configured action by name.agentanswers 501 until it lands. - Exposure: prefer
bind_host: tailnet;funnel: trueis opt-in, best-effort Tailscale Funnel for a public endpoint. - Inspect and test:
caco hooks list [--project P]andcaco hooks show <scope>/<id>redact tokens;caco hooks test <scope>/<id> [--body ...]invokes one locally without HTTP. - Only serving nodes need the token: validation checks that a token source is configured and handler fields are well formed, without reading
token_fileat load time, and requires a token only on nodes whosenodesfilter serves the hook. Another node's webhook never blockscaco config validate, the CLI orcaco updatehere; if it seems to, the binary predates that fix. - Self-recovery restarts:
caco update --restart, staged-binary applies and launcher rollbacks downgrade a config-validation failure to a warning so a stale binary can still restart onto a fixed one; the config must still parse, and the new daemon revalidates it and rolls back if it is really invalid. Plaincaco restartkeeps the hard error.
Trigger reference
The table below lists the currently documented first-party hook/trigger surfaces. Some event families are emitted today as feed or runtime events but do not yet have a fully declarative daemon hook matcher; when that is the case, use the existing feed/API surface or file a narrower implementation bead instead of hardcoding a profile wakeup.
| Surface | Trigger / phase | Owner | Routing | Structured fields |
|---|---|---|---|---|
| Daemon hook | trigger.event: <feed-event> | Local daemon | Local node unless nodes narrows execution | common feed envelope: node, project when present, target when present, event type, timestamp, payload |
| Daemon cron | schedule: <cron> | Local daemon | Local node unless nodes narrows execution | schedule name, node, start/finish/outcome, command summary |
| Project hook | checkout_updated | Project checkout manager | Project's local/canonical daemon context | commit_sha / $CACO_HOOK_COMMIT_SHA |
| Project hook | tag_pushed | Release/tag watcher | Project's local/canonical daemon context | tag / $CACO_HOOK_TAG |
| Profile hook | on_start | Runtime bridge | Agent runtime only | agent id, project, profile/env, managed checkout context |
| Profile hook | on_stop | Runtime bridge | Agent runtime only | stop reason when provided by runtime, agent id, project |
| Profile hook | on_session_end | Runtime bridge | Agent runtime only | session-end reason/status when provided by runtime |
| Profile hook | on_notification | Runtime bridge | Agent runtime only | runtime notification text/type when provided by runtime |
| Profile hook | before_tool / PreToolUse | Runtime bridge | Agent runtime only | tool name, tool input, current agent/project env, matcher result |
| Profile hook | post_tool_use / after_tool | Runtime bridge | Agent runtime only | tool name, tool result metadata, current agent/project env |
| Profile hook | before_complete | Lifecycle bridge | Agent runtime/lifecycle | agent id, project, checkout, bead context if known |
| Profile hook | before_reintegration | Lifecycle bridge | Agent runtime/lifecycle | agent id, project, checkout, reintegration mode |
| Profile hook | on_merge_commit | Lifecycle bridge | Agent runtime/lifecycle | landed commit/merge commit, target branch, project |
| Profile hook | on_complete | Lifecycle bridge | Agent runtime/lifecycle | terminal status, agent id, project, bead context if known |
| Profile hook | on_failure | Lifecycle bridge | Agent runtime/lifecycle | failure class/message, agent id, project |
| Pi extension event | input for [caco-heartbeat] | Pi runtime extension | Agent runtime only | heartbeat timestamp, interval, selector, source; plus a claimed-bead reminder block listing in-progress beads when the agent holds any |
| Pi extension event | inbox poll result | Pi runtime extension | Agent runtime only | message id/kind/sender/body excerpt/project |
| Pi extension event | /loop timer tick | Pi runtime extension | Agent runtime only | loop id, interval, prompt, delivery mode |
| STT/transcript feed | transcription_requested, transcription_finished | Daemon hook / STT feed | Local daemon hook via trigger.event; feed/API for history | source, transcript text/excerpt, timing, speaker/model fields when available |
| Test/build queue | job queued/running/terminal state | Queue manager today | Queue status/API today; declarative feed hook matcher pending | job id, project, state, command, cwd, exit code, retry metadata when present |
| Reintegration | agent_completion_blocked, plus profile lifecycle phases | Daemon hook / lifecycle feed | Local daemon hook for blocked completions; profile lifecycle hooks for completion/reintegration phases; accepted/rejected hook matchers pending | agent, project, blocker code, PR, head, base, next action, retry command |
| Release/update | release_triggered, release_building, release_push_started, release_completed, release_failed | Daemon hook / release feed | Local daemon hook via trigger.event; feed/API for history | tag/version, channel, phase, artifact/status payload |
| Messages/choices/beads | message_sent, message_broadcast, message_speak, choice_presented, choice_resolved, bead CRUD triggers | Daemon hook / domain feed | Local daemon hook via trigger.event; feed/API for history | domain id, project, actor/sender/target, state/status/payload summary |
For pending hook matchers, keep documentation and code honest: describe the existing feed/API payload and file implementation beads for declarative routing rather than pretending the hook syntax exists.
Payload conventions
Hook payloads should be structured JSON-like data even when the current action is a shell command. Shell actions receive selected fields as environment variables, while future non-shell actions should consume the same canonical field names directly.
Common field conventions:
node— daemon node that observed or executed the hook;project— project name when the event is project-scoped;agent_id— managed agent id when the event is agent-scoped;bead_id— bead id when the event is bead-scoped;eventortrigger— stable event name such ascheckout_updated;timestamp— RFC3339 event or execution time;delivery—local,routed,tmux,followUp,message_queue_suppressed, or another explicit delivery label;retryable,retry_after_secs, andretry_command— present when a queue/lifecycle failure is safe to retry;payload— bounded event-specific data, never unredacted secrets.
Shell commands should use CACO_HOOK_* event variables for stable hook fields; cron command actions additionally receive CACO_CRON_NAME and automation actor variables. Keep the variable set small and documented: for example, CACO_HOOK_COMMIT_SHA for checkout_updated and CACO_HOOK_TAG for tag_pushed.
Event routing examples
A structured transcript route should eventually be a daemon/project hook instead of a profile-specific polling loop. Until the declarative matcher is implemented, treat this as the target shape rather than live syntax:
hooks:
- name: route-sgu24-transcript-to-narrator
trigger:
event: transcript_arrived
source: sgu24
action:
type: notify_agent
project: cacophony
agent: transcript-narrator
delivery: lifecycle_only
message: "Transcript from ${payload.source}: ${payload.excerpt}"
nodes:
- helsinki
The important contract is not the exact YAML spelling; it is the separation of concerns:
- STT owns transcript capture and emits a structured event;
- the hook owns routing and rate-limiting;
- the narrator/phone agent owns interpretation;
- normal chat/inbox history is not used as a hidden transport unless the operator explicitly requested a message.
Runtime support and feature flags
Hook support depends on the runtime bridge:
- Claude-compatible workers receive generated agent/plugin material with Cacophony-managed hook scripts. They should keep the canonical
caco-agent:workeridentity so Stop, SessionEnd, PreToolUse, and PostToolUse hooks remain active. - Codex-managed profiles that declare lifecycle hooks must enable Codex's native hooks feature. User or project config typically sets
[features].hooks = trueand defines inline[hooks]orhooks.json. Managed fleet config can userequirements.tomlwith[features].hooks = true,[hooks].managed_dir, and managed hooks that are trusted by policy. - Pi-managed profiles use repo-owned extensions and skills via
pi_extra_config_dirsfor specific capabilities, such aspi-heartbeatand explicitpi-looptasks. Daemon-native delivery owns messages; independent inbox pollers, speech/tool-call gates, and automatic task-resume nudges are retired.
If a runtime cannot support a required profile hook feature, launch should fail or surface an explicit compatibility warning. Silently dropping hooks is unsafe because operators may assume the agent is guarded when it is not.
Trust model
Hooks can execute commands, so treat them as code:
- keep hook definitions in reviewed repository/config files;
- do not fetch and execute remote scripts at hook runtime;
- use stable
namevalues for daemon hooks and crons; - prefer
CACO_BINor the managedcacolauncher over ambient PATH guesses; - avoid secrets in command strings, logs, summaries, or hook output;
- use explicit timeouts;
- keep hooks idempotent because restart/retry windows can repeat event delivery;
- route privileged or destructive work through first-party approval surfaces such as
caco choices,sudo_bash, or operator-run actions.
For Codex, hook trust is runtime-enforced through its native hook review state. Regular users trust hook hashes through Codex config; managed requirements can mark managed hook directories as trusted by policy. Do not bypass that review flow with hidden shell wrappers.
Execution flow
A typical profile hook execution looks like this:
- Cacophony resolves the effective profile stack.
- Hook lists compose by phase in profile order.
- The daemon materializes runtime-specific hook assets into the managed agent workspace.
- The runtime reaches a lifecycle or tool event.
- The hook runner evaluates any matcher.
- Matching command hooks run with the configured timeout.
- Failure either blocks the action or is reported according to the hook phase and runtime contract.
For daemon/project hooks, the daemon evaluates the trigger, checks node eligibility, runs the configured action in daemon context, and records/logs enough information for operator diagnostics.
Recommended patterns
Use hooks to make startup and lifecycle behavior explicit:
- install repository git hooks in
checkout_bootstrapor acheckout_updatedproject hook; - block high-risk Bash commands in
before_toolinstead of relying on prompt instructions alone; - emit bounded liveness/status updates in
post_tool_use; - use
before_reintegration/before_completefor smoke validation gates; - use Pi extensions for Pi-runtime wake paths instead of daemon cron;
- prefer daemon hooks for daemon-owned events and persistent agents for reasoning-heavy follow-up.
Avoid these patterns:
- long-running builds or tests directly inside hooks;
- unbounded loops or sleeps;
- interactive prompts that can stall a headless daemon or agent;
- command strings that depend on operator-local aliases;
- hook output that leaks tokens or large logs into persistent state.
Troubleshooting
- If a worker ignores safety policy, inspect whether the resolved runtime actually materialized the expected hooks. For Claude workers, verify the generated plugin/agent identity and plugin directory. For Codex workers, inspect
/hooksand Codex hook trust state. - If a hook appears to hang, check its timeout and whether the command is waiting for stdin, sudo, a TTY, a network call, or a heavyweight compiler.
- If a hook fires on the wrong node, add or correct the
nodeslist for daemon hooks or use project/node routing explicitly. - If the hook belongs to Pi runtime behavior, prefer a small extension with persisted session state over a shell hook that sends normal prompt-turn messages.
See also: Agent Profiles, Daemon, Testing, and Reintegration Policy.