Catalyst Event Broker — Protocol Reference (CTL-303)
The broker daemon evolved from the catalyst-filter daemon (CTL-284). It adds:
- Structured agent identity —
agent.checkin/agent.checkoutevents so the broker knows
who is working on what. The broker auto-derives pr_lifecycle interests from check-in data.
ticket_lifecycleinterest type — deterministic routing for Linear webhook events keyed on
ticket identifiers. No Groq round-trip for state changes, comments, and PR links.
- Auto-correlation — when an agent checks in with a ticket, the broker auto-registers a
pr_lifecycle interest the moment a PR linking that ticket appears. Agents no longer need to call filter.register pr_lifecycle explicitly.
- Backward compat — all CTL-284
pr_lifecycleexplicit registration still works unchanged.
Groq prose classification remains for ambiguous / multi-condition interests.
Daemon Management
# Check status (broker and filter are aliases)
catalyst-broker status # → "running (pid N)" or "stopped"
catalyst-filter status # deprecated alias — delegates to catalyst-broker
# Start / stop / restart
catalyst-broker start
catalyst-broker stop
catalyst-broker restart
# View logs
catalyst-broker logs
Interest Types Summary
| Interest type | Routing | Use case | |---|---|---| | pr_lifecycle | Deterministic | Watch CI, reviews, merge, deployment for a known PR number | | ticket_lifecycle | Deterministic | Watch Linear state changes, comments, PR links for a ticket | | comms_lifecycle | Deterministic | Watch comms-channel messages (worker → orchestrator attention/done, orchestrator → worker directives) | | phase_lifecycle | Deterministic | Watch phase.<name>.complete.<ticket> / phase.<name>.failed.<ticket> / phase.<name>.turn-cap-exhausted.<ticket> events — orchestrator hand-off between phase agents | | (prose prompt) | Groq LLM (env-gated off; CTL-357) | Anything ambiguous, cross-cutting, or complex — set CATALYST_BROKER_PROSE_ENABLED=1 to re-enable |
1. Auto-Correlation (The Common Case — No Registration Needed)
When an agent's own ticket/PR is the concern, registration is automatic:
# catalyst-session.sh start emits agent.checkin automatically:
CATALYST_SESSION_ID=$(catalyst-session.sh start --skill oneshot --ticket CTL-275)
# ↑ The broker records: agent CTL-275 with no claimed_pr yet.
# When you later create the PR, update claimed_pr via agent.checkin:
cat >> ~/catalyst/events/$(date -u +%Y-%m).jsonl <<EOF
{"ts":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","event":"agent.checkin","detail":{"session_id":"$CATALYST_SESSION_ID","ticket":"CTL-275","claimed_pr":$PR_NUMBER,"orchestrator":"${CATALYST_ORCHESTRATOR_ID:-}"}}
EOF
# ↑ The broker sees claimed_pr → auto-registers pr_lifecycle for filter.wake.$CATALYST_SESSION_ID
The oneshot skill now uses this pattern instead of calling filter.register directly. The explicit filter_register_worker function is kept for backward compat but is no longer the recommended path for new work.
2. agent.checkin Event
Emitted by catalyst-session.sh start and optionally after PR creation. Shape:
{
"ts": "2026-05-08T07:00:00Z",
"event": "agent.checkin",
"detail": {
"session_id": "sess_20260508_abcd",
"agent_name": "ctl-275-worker",
"ticket": "CTL-275",
"orchestrator": "orch-2026-05-08",
"claimed_pr": 501,
"cwd": "/path/to/worktree"
}
}
Fields:
session_id— required. Primary key in the broker'sagentstable.agent_name— human label (defaults tosession_idif missing).ticket— Linear ticket identifier (e.g."CTL-275"). Enablesticket_lifecycleauto-correlation.orchestrator— parent orchestrator ID; enables stale-session watchdog routing.claimed_pr— if set, broker immediately auto-registerspr_lifecyclefor this agent.cwd— working directory; included for diagnostics.
3. agent.checkout Event
Emitted by catalyst-session.sh end. Shape:
{
"ts": "2026-05-08T09:00:00Z",
"event": "agent.checkout",
"detail": {
"session_id": "sess_20260508_abcd",
"status": "done"
}
}
On checkout, the broker:
- Marks the agent as
donein theagentsSQLite table. - Removes any auto-correlated
pr_lifecycleinterest (explicit registrations are preserved).
3a. worker.waiting / worker.resumed Events (CTL-403)
Emitted automatically by catalyst-events wait-for when $CATALYST_SESSION_ID is set. These events make wait loops visible to the broker so the watchdog can distinguish a legitimately waiting session from a silently dead one.
worker.waiting shape
{
"ts": "2026-05-14T16:30:00Z",
"event": "worker.waiting",
"detail": {
"session_id": "sess_20260514_abcd",
"orchestrator": "orch-foo",
"ticket": "CTL-275",
"wait_for": ".attributes.\"event.name\" == \"github.pr.merged\"",
"timeout_ms": 7200000,
"since": "2026-05-14T16:30:00Z",
"reason": "catalyst-events wait-for"
}
}
worker.resumed shape
{
"ts": "2026-05-14T18:00:00Z",
"event": "worker.resumed",
"detail": {
"session_id": "sess_20260514_abcd",
"orchestrator": "orch-foo",
"ticket": "CTL-275",
"outcome": "matched"
}
}
outcome is "matched" when the wait returned a result, "timed_out" when the deadline elapsed.
Broker behavior
On worker.waiting:
- Stores the session in the in-memory
waitingSessionsMap and thewaiting_sessionsSQLite table. - Resets the heartbeat timer so the session does not appear stale during the wait.
- During watchdog ticks, any session in
waitingSessionswhosetimeoutAt > nowis skipped — it
is "legitimately waiting" and should not trigger a stale-heartbeat wake.
- The
broker.state.jsonfile includes anwaitingSessionsarray with all currently active waits;
the HUD dashboard's worker list reads this to overlay wait:Xm in the STATUS column.
On worker.resumed:
- Removes the session from
waitingSessionsand the SQLite table. - Normal heartbeat-staleness tracking resumes.
Broker state file
~/catalyst/broker.state.json gains a waitingSessions array:
{
"waitingSessions": [
{
"sessionId": "sess_20260514_abcd",
"ticket": "CTL-275",
"orchestrator": "orch-foo",
"waitFor": ".attributes.\"event.name\" == \"github.pr.merged\"",
"timeoutAt": "2026-05-14T18:30:00Z",
"reason": "catalyst-events wait-for"
}
]
}
Empty array [] when no sessions are currently waiting.
3b. orchestrator.status Events (CTL-405)
Emitted by the orchestrate skill at each wave transition via orchestrate-status.sh emit. These events make the orchestrator's current phase visible to the broker, the HUD, and operators, and serve as a liveness heartbeat so the watchdog does not fire stale-session wakes for an orchestrator that is actively monitoring between waves.
Event shape
{
"ts": "2026-05-15T00:00:00Z",
"event": "orchestrator-status",
"orchestrator": "orch-foo",
"detail": {
"orchestrator": "orch-foo",
"phase": "monitoring",
"wave": 2,
"active_workers": 3,
"total_workers": 5,
"summary": "wave 2 monitoring (3/5 active)",
"session_id": "sess_20260515_abcd"
}
}
phase values:
| Value | Meaning | |---|---| | dispatching | Launching workers for a wave | | monitoring | Event loop watching workers for a wave | | reviewing | Post-merge verification (Phase 5) | | paused | Waiting for human gate |
Broker behavior
On orchestrator.status:
- Stores the entry in
orchestratorStatusMap[orchId](replaces any prior entry for that orch). - If
detail.session_idis present, resetslastHeartbeat[sessionId]to now — so the watchdog
treats the status event as a heartbeat and skips stale-session wakes while the orchestrator is in a monitoring loop.
- Calls
persistBrokerState()to flush the update tobroker.state.json.