Deprecated (CTL-303, updated CTL-357):
catalyst-filterhas been superseded bycatalyst-broker. The broker daemon adds structured agent identity,ticket_lifecyclerouting for Linear events,comms_lifecyclerouting for shared comms channels, and auto-correlation of ticket↔PR interests. The Groq prose path described in this doc is now env-gated off by default (CATALYST_BROKER_PROSE_ENABLED=0); prose interests on disk are accepted for backward compat but never fire. Thefilter.register/pr_lifecycle/ticket_lifecycle/comms_lifecyclepaths are all deterministic and unchanged. See [[broker]] for the full updated reference.
catalyst-filter — Semantic Event Routing Protocol
The catalyst-filter daemon sits between the raw catalyst event log and orchestrators. Instead
of requiring a precise jq filter at registration time, callers describe their intent in plain
language. The daemon batches incoming events, calls Groq Llama 3.1 8B to classify relevance, and
emits filter.wake.{id} events that orchestrators wait for with catalyst-events wait-for.
When to Use
- Orchestrator Phase 4 — replacing the poll loop with an event-driven wake signal
- Any long-running wait where the trigger condition is complex, multi-condition, or better expressed in words than jq
- Multi-PR / multi-worker scenarios — "wake me when any of my 4 PRs gets a CI failure or changes-requested review"
When NOT to Use
- Very short waits (< 1 min) — direct
catalyst-events wait-forwith a jq filter is simpler and has lower latency - Single, precisely expressible conditions —
.attributes."event.name" == "github.pr.merged" and .attributes."vcs.pr.number" == 42needs no LLM GROQ_API_KEYis unavailable — use the jq fallback described at the end of this doc
Prerequisites
The daemon must be running and GROQ_API_KEY must be set:
# Check status
catalyst-filter status # → "running (pid N)" or "stopped"
# Start if stopped
catalyst-filter start
# View startup log
catalyst-filter logs
GROQ_API_KEY is read from the environment. Set it in your shell profile or Layer 2 config
(~/.config/catalyst/config-{projectKey}.json).
Protocol Overview
Orchestrator broker daemon Event log
│ │ │
│── emit filter.register ────────────►│ │
│ │ polls every 200ms ◄─────────────│
│ │ │
│ │ deterministic match? │
│ │ ├─ pr_lifecycle ──┐ │
│ │ ├─ ticket_lifecycle ┐ │
│ │ ├─ comms_lifecycle ─┤ │
│ │ └─ phase_lifecycle ─┤ │
│ │ ▼ │
│ │── append filter.wake.{id} ──────►│
│ │ │
│ │ prose path (env-gated; OFF │
│ │ by default since CTL-357): │
│ │ • batches events (100ms │
│ │ debounce) │
│ │ • calls Groq for prose │
│ │ interests │
│ │ │
│◄── catalyst-events wait-for ─────────────────────────────────────────-│
│ (.attributes."event.name" == "filter.wake.{id}") │
│ │ │
│── emit filter.deregister ──────────►│ │
Step 1 — Register
Emit a filter.register event to the catalyst event log before entering your wait:
STATE_SCRIPT="/path/to/plugins/dev/scripts/catalyst-state.sh"
"$STATE_SCRIPT" event "$(jq -nc \
--arg orch "$ORCH_ID" \
--arg prompt "Wake me when: CI fails on any of my PRs, a PR gets changes-requested review, a PR merges, or a worker crashes (no heartbeat)." \
--argjson prs '[408, 409]' \
--argjson tickets '["CTL-253", "CTL-254"]' \
--argjson branches '["orch-ctl-253-2026-05-05-CTL-253", "orch-ctl-253-2026-05-05-CTL-254"]' \
'{
event: "filter.register",
orchestrator: $orch,
detail: {
notify_event: ("filter.wake." + $orch),
prompt: $prompt,
persistent: true,
context: {
pr_numbers: $prs,
tickets: $tickets,
branches: $branches
}
}
}')"
Registration event schema
| Field | Type | Description |
|---|---|---|
event | string | Always "filter.register" |
orchestrator | string | Orchestrator ID — used as the routing key if detail.interest_id is absent |
detail.notify_event | string | Event name the daemon will emit when relevant events arrive ("filter.wake.{id}") |
detail.prompt | string | Natural-language description of what to wake on (see Prompt Writing below) |
detail.persistent | boolean | true — keep interest active after each match (continuous monitoring). false (default) — auto-deregister after first wake (one-shot wait). |
detail.context | object | Optional focus hints: pr_numbers, tickets, branches, workers |
detail.interest_id | string | Optional override for the routing table key; defaults to orchestrator |
detail.session_id | string | Optional session ID ($CATALYST_SESSION_ID). The daemon's watchdog uses this to clean up registrations whose session has gone stale (>3 min without heartbeat). Set this for any non-orchestrator agent. |
detail.interest_type | string | Optional discriminator for built-in deterministic routing. When set (e.g. "pr_lifecycle"), prompt is ignored and the daemon uses typed field comparison instead of Groq classification. See "Built-in interest types" below. |
The daemon picks up filter.register from the live log within one poll cycle (~200ms).
On daemon restart, it scans the last 1000 lines of the log to recover active registrations,
and emits a filter.daemon.startup event so subscribers can re-register if they want
belt-and-suspenders coverage.
Choosing persistent:
- Use
persistent: truefor continuous monitoring — the orchestrator's Phase 4 loop where you want to be woken on every CI event, every PR update, every worker status change throughout the run. - Use
persistent: false(the default) for one-shot waits — "tell me when this specific PR merges" or "wake me when the next CI run completes". The interest is removed automatically after the first wake, so no explicitfilter.deregisteris needed.
Built-in interest types (CTL-284)
Some interest categories are common enough that the daemon ships with deterministic
routing for them — no Groq round-trip, no semantic prompt required. Set
detail.interest_type to opt in.
When interest_type is set, the daemon ignores detail.prompt for that interest and
matches events using pure field comparison against the schema-v2 envelope. Unmatched
events still fall through to Groq for any prose-prompt interests in the same table —
so you can mix typed and prose interests freely.
pr_lifecycle
Built-in deterministic routing for the PR lifecycle: CI events, reviews, comments, thread resolution, merges, deployments, and base-branch pushes that would put a PR BEHIND. Replaces hand-written prose prompts for the common case.
{
"event": "filter.register",
"orchestrator": "$O