Skip to content

dispatcher

Inspect the Unified Intent Dispatcher’s runtime decisions. These commands read the local decision-log files directly at ~/.snippbot/dispatch/dispatch-YYYY-MM-DD.jsonl, so they work even when the daemon is not running. health and inject additionally call the daemon’s REST endpoints when reachable.

Terminal window
snippbot dispatcher tail # Live-tail the decision log
snippbot dispatcher mismatches # Shadow-mode mismatches over last N hours
snippbot dispatcher stats # Per-source / per-tier counts over recent N decisions
snippbot dispatcher soak # PASS/FAIL verdict vs per-source soak budgets
snippbot dispatcher inject --tier N # Fire a synthetic envelope at a chosen tier
snippbot dispatcher health # Live snapshot from daemon /api/dispatcher/health

Live-tail recent dispatcher decisions. Prints one line per decision with a mismatch marker, timestamp, source, kind, tier reached, action chosen, and reason.

Terminal window
snippbot dispatcher tail # Last 20 decisions
snippbot dispatcher tail -n 100 # Last 100
snippbot dispatcher tail -f # Follow mode (Ctrl-C to stop)
snippbot dispatcher tail --source channel # Filter by IntentSource
snippbot dispatcher tail --mismatch-only # Only shadow-mode divergences
snippbot dispatcher tail -f --source autonomy # Follow autonomy producer only
OptionDescription
-n, --limitHow many recent entries to show first (default: 20)
-f, --followTail mode — keep reading new entries until Ctrl-C
--sourceFilter by IntentSource: user_message, channel, hook, scheduler, autonomy, device, proactive, api, internal
--mismatch-onlyOnly show entries where extra.shadow_mismatch == true

Sample output:

⚠ 2026-05-19T14:21:03 autonomy followup tier_2 suppress tier2:quiet_hours
2026-05-19T14:20:55 channel message tier_1 deliver_to_channel tier1:channel_binding
2026-05-19T14:20:48 user_msg message tier_1 deliver_to_chat tier1:room_id+msg
2026-05-19T14:20:30 proactive insight tier_2 schedule_for tier2:deferred:quiet_hours

A leading flags a shadow-mode mismatch (the dispatcher would have routed differently than the legacy code path).


List shadow-mode mismatches over the last N hours. A mismatch means the dispatcher’s decision diverged from the existing (legacy) code path that’s still doing the actual delivery — useful during producer migration soak.

Terminal window
snippbot dispatcher mismatches # Last 24h (default)
snippbot dispatcher mismatches --hours 48 # Last 48h (the per-plan soak window)
snippbot dispatcher mismatches --hours 168 # Last week
OptionDescription
--hoursWindow to scan in hours (default: 24)

Sample output:

Shadow Mismatches (3 in last 48h)
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓
┃ Decided At ┃ Source ┃ Envelope ID ┃ Reason ┃ Dispatcher Action ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩
│ 2026-05-19T14:21:03 │ autonomy │ env_a8f2c19b3d4e │ action_kind_diverged │ suppress │
│ 2026-05-19T11:02:55 │ scheduler │ env_3c1d09b5a8f1 │ target_room_diverged │ deliver_to_chat │
│ 2026-05-18T22:14:33 │ proactive │ env_91b22f4d7e08 │ action_kind_diverged │ schedule_for │
└─────────────────────┴────────────┴────────────────────┴────────────────────────┴──────────────────────┘

Interpreting reasons (set by the shadow controller):

ReasonMeaning
action_kind_divergedDispatcher picked a different DispatchAction.kind than the legacy path delivered
target_room_divergedSame kind, but target room/channel differs
target_agent_divergedSame kind/room, but agent_id differs
dispatcher_suppressed_existing_deliveredPolicy gate suppressed; legacy delivered anyway (common when quiet hours are active — expected during shadow)
dispatcher_deferred_existing_deliveredTier 2 scheduled for later; legacy delivered now

Per-source and per-tier counts over the most recent N decisions. Useful for spot-checking that Tier 1 is hitting its ≥80% target and that classifier calls are within budget.

Terminal window
snippbot dispatcher stats # Last 200 decisions
snippbot dispatcher stats --limit 1000 # Last 1000
OptionDescription
--limitSample size for aggregation (default: 200)

Sample output:

Dispatcher Stats (last 200 decisions)
┌────────────────┬───────┬───────┐
│ Source │ Count │ Share │
├────────────────┼───────┼───────┤
│ user_message │ 112 │ 56% │
│ channel │ 36 │ 18% │
│ autonomy │ 24 │ 12% │
│ proactive │ 16 │ 8% │
│ scheduler │ 8 │ 4% │
│ hook │ 4 │ 2% │
└────────────────┴───────┴───────┘
┌────────┬───────┬───────┐
│ Tier │ Count │ Share │
├────────┼───────┼───────┤
│ tier_1 │ 164 │ 82% │
│ tier_2 │ 22 │ 11% │
│ tier_3 │ 8 │ 4% │
│ tier_4 │ 6 │ 3% │
└────────┴───────┴───────┘
Suppressed: 28 (14%)
Classifier calls: 8 (4%)
Shadow mismatches: 2 (1%)

Evaluate per-source soak budgets and emit a PASS / FAIL verdict. Reads the decision log over the last N hours (default 48 — matching the per-plan soak window) and checks each migration against its budgets (envelope count, mismatch rate, p50 dispatch latency).

Terminal window
snippbot dispatcher soak # All sources, last 48h
snippbot dispatcher soak --hours 24 # Last 24h
snippbot dispatcher soak --source channel # One source only
snippbot dispatcher soak --json # Machine-readable, for CI
OptionDescription
--hoursSoak window in hours (default: 48, per migration plan)
--sourceRestrict to a single source: scheduler, channel, hook, user_message, autonomy
--jsonEmit JSON instead of a table (one record per source)
CodeMeaning
0Every source passed (or returned NO_BUDGET / INSUFFICIENT — informational)
1At least one source FAILED a budget

Derived from the soak plans in PLAN_DISPATCHER_PRODUCER_MIGRATION.md. Tuples are (min_envelopes, max_mismatch_rate, max_p50_latency_ms):

SourceMin envelopesMax mismatch rateMax p50 latency
scheduler105%
channel505%
hook2010%
user_message10002%2.0 ms
autonomy105%

Sample output:

Dispatcher Soak Report (window: last 48h, sources: 5)
┌──────────────┬──────────────┬──────┬──────────┬────────┬────────┬────────────────────┐
│ Source │ Status │ N │ Mismatch │ p50 ms │ p95 ms │ Reasons │
├──────────────┼──────────────┼──────┼──────────┼────────┼────────┼────────────────────┤
│ autonomy │ PASS │ 42 │ 2.4% │ 1.1 │ 3.8 │ all soak budgets… │
│ channel │ INSUFFICIENT │ 18 │ 5.6% │ — │ — │ only 18 envelopes… │
│ hook │ NO_BUDGET │ 0 │ — │ — │ — │ No soak budget… │
│ scheduler │ PASS │ 24 │ 0.0% │ — │ — │ all soak budgets… │
│ user_message │ FAIL │ 1240 │ 3.1% │ 1.3 │ 4.1 │ mismatch rate 3.1% │
└──────────────┴──────────────┴──────┴──────────┴────────┴────────┴────────────────────┘
✗ At least one source FAILED its soak budget — see above.
StatusMeaning
PASSBudget met — safe to flip DISPATCHER_ROUTE_<source>=enforce
FAILOne or more budgets exceeded — investigate before flipping
INSUFFICIENTBelow min_envelopes — soak longer before judging
NO_BUDGETSource has no defined budget — informational only

Fire synthetic envelopes engineered to hit a specific resolver tier. Producer migrations always hit Tier 1 (every real envelope carries deterministic hints), so to verify Tier 2 / Tier 3 / Tier 4 admin UI rows render correctly an operator needs synthetic traffic.

Terminal window
snippbot dispatcher inject --tier 1 # One T1 decision
snippbot dispatcher inject --tier 2 # One T2 decision
snippbot dispatcher inject --tier 3 --count 5 # 5 T3 decisions
snippbot dispatcher inject --tier 4 --count 10 # 10 T4 decisions
OptionDescription
--tier(required) Which resolver tier to exercise: 1, 2, 3, or 4
--countHow many synthetic envelopes to inject (default: 1, max: 100)

Decisions appear in the admin UI immediately and have payload field inject_test=true so they can be filtered out of soak statistics:

Terminal window
jq 'select(.envelope.payload.inject_test != true)' ~/.snippbot/dispatch/dispatch-*.jsonl

Sample output:

✓ Injected 1 envelope(s) for tier_2
┌───────────────────┬───────────┬──────────────────┬────────────┬────────────┬──────────────────┐
│ Envelope ID │ Tier Used │ Action │ Suppressed │ Latency ms │ Reason │
├───────────────────┼───────────┼──────────────────┼────────────┼────────────┼──────────────────┤
│ env_inj_a1b2c3d4 │ tier_2 │ schedule_for │ — │ 0.84 │ tier2:deferred… │
└───────────────────┴───────────┴──────────────────┴────────────┴────────────┴──────────────────┘
These decisions appear in /admin/dispatcher and `snippbot dispatcher tail`.

Requires the daemon to be running (calls POST /api/dispatcher/inject).


Print a live health snapshot from the daemon’s /api/dispatcher/health endpoint. Falls back to local decision-log aggregation when the daemon is unreachable.

Terminal window
snippbot dispatcher health

No options.

Sample output:

Dispatcher Health (scope: own)
Sample size: 200
Suppression rate: 14%
Classifier configured: True
Classifier budget: 50 calls/user/day
Classifier avg latency: 234ms

If the daemon is unreachable, the command transparently falls back to local stats (same output as snippbot dispatcher stats --limit 200).


Reasons and fixes:

  1. No producer is migrated yet — check the DISPATCHER_ROUTE_* env vars; until at least one is shadow or enforce, no envelopes are emitted. Today the autonomy producer at _deliver_chat_room is shadow-integrated by default — trigger an autonomous followup (long, reflective chat message) to generate one.
  2. Daemon not runningtail and stats read JSONL files directly, but no new entries appear until the daemon is up.
  3. Log directory missing — confirm ~/.snippbot/dispatch/ exists. If not, the daemon hasn’t initialized the dispatcher yet; restart with snippbot start --dev.

health and inject require the daemon. Verify:

Terminal window
snippbot status # Daemon up?
curl http://localhost:18781/api/dispatcher/health # Endpoint reachable?

If the daemon is up but the endpoint 401s, your ~/.snippbot/config.toml api_key may be stale — rotate with snippbot auth regenerate-key <id> and update the config.

stats shows Classifier calls at the daily cap (default 50/user/day). Tier 3 silently falls back to Tier 4 once exhausted. To raise: edit dispatcher settings under /settings?section=dispatcher (Classifier daily budget slider) or set SNIPPBOT_TIER3_CLASSIFIER=off if you’d rather force Tier 4 always.

If snippbot dispatcher soak returns FAIL for a source:

  1. Run snippbot dispatcher mismatches --hours 48 --source <src> to see the divergences
  2. Group by Reason column — most fall into a small number of buckets (e.g., all target_room_diverged mismatches typically share a root cause)
  3. Check the producer-migration plan for known divergences — quiet-hours suppression on autonomy followups is an expected mismatch and should not block enforce flipping
  4. Soak playbooks per source: SOAK_DISPATCHER_AUTONOMY

Default retention is 30 days; daily rotation. To shrink:

  • Change log.retention_days in dispatcher settings (range 7–365)
  • Or set SNIPPBOT_DECISION_LOG_SAMPLE_RATE=0.1 to log only 10% of decisions (off by default — full logging is the recommended posture)

Full schema reference: Unified Intent Dispatcher → Decision log.

Each line in ~/.snippbot/dispatch/dispatch-YYYY-MM-DD.jsonl is one JSON object with three top-level keys:

{
"envelope": { source, kind, user_id, room_id?, urgency, ... },
"result": { tier_used, actions[], suppressed, classifier_called, ... },
"extra": { shadow_mismatch?, reason? }
}