Deprecated (CTL-303, updated CTL-357): catalyst-filter has been superseded by
catalyst-broker. The broker daemon adds structured agent identity,ticket_lifecyclerouting
for Linear events, comms_lifecycle routing 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_lifecycle paths 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" == 42
needs 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 explicit filter.deregister is 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