Operations

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:

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:

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:

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:

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
An external caller POSTs to /hooks/<scope>/<id> on a dedicated webhook listener, separate from the loopback API and the mutual-TLS cluster listener, presenting a bearer token. The token is compared in constant time and the check fails closed: a wrong, missing or unreadable token is answered with 401. An accepted request goes to the hook's handler: bead files the body as a bead through the normal beads path, bash or command runs sh -c with the body in CACO_HOOK_BODY and on stdin, and action runs a configured action by name; an agent handler is not implemented yet and answers 501. GET /hooks/health needs no token.
Webhook ingressThe token is the authentication, not the bind address; a bad or missing one is refused before any handler runs.

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.

SurfaceTrigger / phaseOwnerRoutingStructured fields
Daemon hooktrigger.event: <feed-event>Local daemonLocal node unless nodes narrows executioncommon feed envelope: node, project when present, target when present, event type, timestamp, payload
Daemon cronschedule: <cron>Local daemonLocal node unless nodes narrows executionschedule name, node, start/finish/outcome, command summary
Project hookcheckout_updatedProject checkout managerProject's local/canonical daemon contextcommit_sha / $CACO_HOOK_COMMIT_SHA
Project hooktag_pushedRelease/tag watcherProject's local/canonical daemon contexttag / $CACO_HOOK_TAG
Profile hookon_startRuntime bridgeAgent runtime onlyagent id, project, profile/env, managed checkout context
Profile hookon_stopRuntime bridgeAgent runtime onlystop reason when provided by runtime, agent id, project
Profile hookon_session_endRuntime bridgeAgent runtime onlysession-end reason/status when provided by runtime
Profile hookon_notificationRuntime bridgeAgent runtime onlyruntime notification text/type when provided by runtime
Profile hookbefore_tool / PreToolUseRuntime bridgeAgent runtime onlytool name, tool input, current agent/project env, matcher result
Profile hookpost_tool_use / after_toolRuntime bridgeAgent runtime onlytool name, tool result metadata, current agent/project env
Profile hookbefore_completeLifecycle bridgeAgent runtime/lifecycleagent id, project, checkout, bead context if known
Profile hookbefore_reintegrationLifecycle bridgeAgent runtime/lifecycleagent id, project, checkout, reintegration mode
Profile hookon_merge_commitLifecycle bridgeAgent runtime/lifecyclelanded commit/merge commit, target branch, project
Profile hookon_completeLifecycle bridgeAgent runtime/lifecycleterminal status, agent id, project, bead context if known
Profile hookon_failureLifecycle bridgeAgent runtime/lifecyclefailure class/message, agent id, project
Pi extension eventinput for [caco-heartbeat]Pi runtime extensionAgent runtime onlyheartbeat timestamp, interval, selector, source; plus a claimed-bead reminder block listing in-progress beads when the agent holds any
Pi extension eventinbox poll resultPi runtime extensionAgent runtime onlymessage id/kind/sender/body excerpt/project
Pi extension event/loop timer tickPi runtime extensionAgent runtime onlyloop id, interval, prompt, delivery mode
STT/transcript feedtranscription_requested, transcription_finishedDaemon hook / STT feedLocal daemon hook via trigger.event; feed/API for historysource, transcript text/excerpt, timing, speaker/model fields when available
Test/build queuejob queued/running/terminal stateQueue manager todayQueue status/API today; declarative feed hook matcher pendingjob id, project, state, command, cwd, exit code, retry metadata when present
Reintegrationagent_completion_blocked, plus profile lifecycle phasesDaemon hook / lifecycle feedLocal daemon hook for blocked completions; profile lifecycle hooks for completion/reintegration phases; accepted/rejected hook matchers pendingagent, project, blocker code, PR, head, base, next action, retry command
Release/updaterelease_triggered, release_building, release_push_started, release_completed, release_failedDaemon hook / release feedLocal daemon hook via trigger.event; feed/API for historytag/version, channel, phase, artifact/status payload
Messages/choices/beadsmessage_sent, message_broadcast, message_speak, choice_presented, choice_resolved, bead CRUD triggersDaemon hook / domain feedLocal daemon hook via trigger.event; feed/API for historydomain 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:

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:

Runtime support and feature flags

Hook support depends on the runtime bridge:

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:

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

Profile hooks run inside the agent's runtime. on_start runs when it starts; before_tool and post_tool_use run around each tool call that matches; on_notification on runtime notifications. On completion, before_complete runs first, then before_reintegration, whose failure blocks the merge or PR; after a successful merge commit on_merge_commit runs, and its failure is only logged. on_complete or on_failure follow, and on_stop and on_session_end run when the runtime stops. Separately, the daemon runs daemon hooks on matching feed events such as bead_closed or pull_request_merged, project hooks on checkout_updated and tag_pushed, and crons on a schedule, in daemon context on the nodes they name.
When hooks runbefore_reintegration can stop a merge; on_merge_commit cannot. Daemon and project hooks run in daemon context, not inside the agent.

A typical profile hook execution looks like this:

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:

Avoid these patterns:

Troubleshooting

See also: Agent Profiles, Daemon, Testing, and Reintegration Policy.