Unified Intent Dispatcher
Every signal that enters Snippbot — a chat message, an inbound webhook from Slack or Discord, a cron tick from the scheduler, a hook that fired, an autonomous followup the thinking daemon scheduled, a proactive insight the generator just produced, an event from a paired device, a CLI or MCP call — passes through one routing layer before reaching whatever subsystem acts on it. That layer is the Unified Intent Dispatcher.
It is a four-tier resolution pipeline (deterministic → policy gate → LLM classifier → fallback), wrapped around a single envelope type, with a complete JSONL audit trail of every decision.
Why it exists
Section titled “Why it exists”Before the dispatcher shipped, Snippbot had six independently-wired routing layers — channel adapter, device tool router, hook engine, scheduler delivery, autonomy bridge, multi-agent turn orchestrator. Each owned its own dispatch logic. There was no single place that said: “given this signal, figure out which subsystems should handle it, with what urgency, under what user-policy constraints.”
Three concrete consequences of that:
- The proactivity slider in settings (silent → cautious → balanced → active → eager) only controlled how often the heuristic engine generated insights. There was no single point where quiet hours, daily caps, and urgency thresholds were applied across every proactive signal — webhooks, scheduler reminders, and the insight generator each had their own gate (or no gate at all).
- Adding a new producer meant wiring it into every downstream subsystem that might receive it. Adding a new executor meant similar.
- There was no audit trail of “what would the system have routed this to, and why?” — only logs of what each subsystem did after it was already called.
The dispatcher fixes all three: one envelope type for any signal, one policy surface for every proactive intent, one decision log for every route.
Architecture
Section titled “Architecture”┌──────────────────────────────────────────────────────────────────────────────┐│ Intent producers ││ ││ Channel webhook Hook event Scheduler tick InsightGenerator ││ (Slack, Discord, (HookEngine (cron fires) (proactive scan) ││ Telegram, etc.) delivery) ││ ││ Device event User chat msg Autonomy bridge API/CLI request ││ (status/result) (HTTP POST) (defer followup) │└──────────────────────────────┬───────────────────────────────────────────────┘ │ emits ▼ ┌──────────────────────────────┐ │ IntentEnvelope │ │ │ │ source, kind, user_id, │ │ agent_hint?, room_id?, │ │ payload, urgency, │ │ proactive_value, │ │ can_interrupt, idempotency │ └──────────────┬───────────────┘ │ ▼ ┌──────────────────────────────────────────────────┐ │ IntentDispatcher.dispatch() │ │ │ │ Tier 1 — Deterministic resolver │ │ Explicit hints route ~80–90% of envelopes │ │ directly (channel binding, device pin, │ │ scheduler delivery_method, @mention). │ │ │ │ Tier 2 — Policy gate (ProactivityManager) │ │ can_deliver_insight(urgency, mode, │ │ is_interrupt) for proactive envelopes. │ │ Reactive envelopes pass through untouched. │ │ │ │ Tier 3 — LLM classifier (only when ambiguous) │ │ Uses get_default_internal_model() to pick │ │ destination(s). Per-user daily budget. │ │ │ │ Tier 4 — Fallback │ │ Deliver to insight tray with low priority, │ │ or defer via autonomy.schedule_followup, │ │ or suppress with logged reason. │ │ │ │ Returns: DispatchResult = [DispatchAction, ...] │ └──────────────┬───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────┐ │ DispatchAction executors │ │ │ │ DeliverToChat → TurnOrchestrator │ │ DeliverToChannel → channel_adapter.dispatch │ │ DeliverToDevice → DeviceToolRouter │ │ DeliverAsInsight → InsightQueue + SSE │ │ DeliverAsPush → push notification service │ │ ScheduleFor → autonomy.schedule_followup│ │ TriggerHook → HookEngine.dispatch │ │ Suppress(reason) → telemetry only │ └──────────────────────────────────────────────────┘The dispatcher is synchronous at the decision level (cheap; deterministic logic + cached classifier calls) and asynchronous at execution (each DispatchAction runs as an asyncio.Task). It is not a network service — it’s an in-process function in the same memory space as the Event Bus and ProactivityManager.
The IntentEnvelope
Section titled “The IntentEnvelope”Every producer emits the same envelope shape. Source-specific data lives in payload; optional hints populate the deterministic resolver inputs.
from dataclasses import dataclass, fieldfrom enum import Enumfrom typing import Any, Optional
class IntentSource(str, Enum): USER_MESSAGE = "user_message" # HTTP POST /api/chat/messages CHANNEL = "channel" # external platform webhook HOOK = "hook" # HookEngine event dispatch SCHEDULER = "scheduler" # cron tick AUTONOMY = "autonomy" # thinking daemon defer DEVICE = "device" # device agent status/result PROACTIVE = "proactive" # InsightGenerator / strategic API = "api" # explicit API call (CLI, MCP) INTERNAL = "internal" # one subsystem talking to another
class IntentKind(str, Enum): MESSAGE = "message" # text content for an agent to respond to COMMAND = "command" # imperative action (run tool, exec workflow) SIGNAL = "signal" # state change worth surfacing INSIGHT = "insight" # proactive observation FOLLOWUP = "followup" # deferred response from autonomy bridge DELIVERY = "delivery" # outbound webhook delivery
@dataclassclass IntentEnvelope: source: IntentSource kind: IntentKind user_id: str payload: dict[str, Any] idempotency_key: str
# Optional hints — Tier 1 uses these room_id: Optional[str] = None conversation_id: Optional[str] = None agent_hint: Optional[str] = None channel_binding: Optional[str] = None # e.g. "slack:C123" device_pin: Optional[str] = None
# Policy inputs — Tier 2 uses these urgency: float = 0.5 # 0.0 - 1.0 proactive_value: float = 0.0 # only nonzero for INSIGHT can_interrupt: bool = False domain: Optional[str] = None
# Tracing parent_envelope_id: Optional[str] = None created_at: str = field(default_factory=...) envelope_id: str = field(default_factory=...)interface IntentEnvelope { envelope_id: string source: 'user_message' | 'channel' | 'hook' | 'scheduler' | 'autonomy' | 'device' | 'proactive' | 'api' | 'internal' kind: 'message' | 'command' | 'signal' | 'insight' | 'followup' | 'delivery' user_id: string payload: Record<string, unknown> idempotency_key: string
// Optional hints room_id?: string | null agent_hint?: string | null channel_binding?: string | null device_pin?: string | null
// Policy inputs urgency: number // 0.0 - 1.0 proactive_value: number // 0.0 - 1.0 can_interrupt: boolean
// Tracing parent_envelope_id?: string | null created_at: string // ISO 8601 UTC}The envelope source lives at packages/core/src/snippbot_core/dispatch/types.py.
The four tiers
Section titled “The four tiers”Tier 1 — Deterministic resolver
Section titled “Tier 1 — Deterministic resolver”Most signals already carry their destination. The scheduler knows which room a job delivers to; a Slack webhook knows its channel binding; an @mention in a chat message names the agent. Tier 1 is pure pattern matching on envelope hints — no LLM, no I/O, just a function lookup. It handles roughly 80–90% of all traffic.
The rules:
| Hint present | → Action |
|---|---|
channel_binding="slack:C123" + kind=MESSAGE | DeliverToChannel(platform=slack, channel=C123) |
room_id set + kind=MESSAGE | DeliverToChat(room_id, agent_id=agent_hint or primary) |
device_pin set + kind=COMMAND | DeliverToDevice(device_id=device_pin) |
kind=FOLLOWUP + parent_envelope_id | Resolve parent’s destination, reuse |
kind=INSIGHT + room_id set | DeliverAsInsight(user_id, room_id) |
kind=INSIGHT + no room_id | DeliverAsInsight(user_id, room_id=None) — global tray |
kind=DELIVERY (outbound webhook) | TriggerHook via existing dispatcher.py |
| None match | Fall through to Tier 3 (after Tier 2 if applicable) |
Source: tier1_deterministic.py.
Tier 2 — Policy gate (ProactivityManager)
Section titled “Tier 2 — Policy gate (ProactivityManager)”Tier 2 runs after Tier 1 for proactive envelopes only (source IN (PROACTIVE, AUTONOMY)). For reactive envelopes (USER_MESSAGE, CHANNEL, API) it is bypassed entirely — the user asked, we answer.
For proactive envelopes, Tier 2 calls ProactivityManager.can_deliver_insight() which checks:
- Urgency threshold for the user’s current proactivity level (Silent / Cautious / Balanced / Active / Eager)
- Quiet hours with timezone awareness
- Daily cap for delivered insights
- Interrupt permission for
can_interrupt=Trueenvelopes - Per-domain opt-out filters
The verdict downgrades the Tier 1 action:
| Verdict | Result |
|---|---|
| Approved | Tier 1 action passes through unchanged |
| Gated by quiet hours | Replaced with ScheduleFor(when=next_window) |
| Gated by daily cap | Replaced with Suppress(reason=daily_cap_exceeded) |
| Gated by urgency | Replaced with Suppress(reason=below_threshold) |
Source: tier2_policy.py. Policy logic in proactivity.py.
Tier 3 — LLM classifier
Section titled “Tier 3 — LLM classifier”Tier 3 only runs when Tier 1 can’t resolve and Tier 2 hasn’t suppressed. This is the “intelligent” tier — currently relevant for:
kind=INSIGHTenvelopes the generator didn’t bind to a specific roomkind=COMMANDenvelopes from API/CLI without a device pin or channel hintkind=MESSAGEenvelopes fromAPI/INTERNALwithoutroom_idoragent_hint
The classifier uses get_default_internal_model() — the same model setting that powers the thinking gate and strategic-insight generator. Output is a JSON destination verdict (chat / channel / device / insight tray / push / defer / suppress) with confidence and reason.
Operational guardrails:
- 3-second timeout per call; fall back to Tier 4 on timeout
- Per-user daily budget (default 50 calls) — see Operator controls
- Content-hash cache (10-minute TTL) — retries don’t double-classify
- Confidence floor — verdicts below 0.3 fall through to Tier 4
Source: tier3_classifier.py.
Tier 4 — Fallback
Section titled “Tier 4 — Fallback”Tier 4 fires when Tier 3 times out, returns malformed JSON, exceeds budget, or returns a low-confidence verdict. The fallback policy is:
urgency >= 0.7→ScheduleFor(now + 1h)(defer a high-urgency-but-unrouted signal)- otherwise →
DeliverAsInsight(user_id, room_id=None)with low priority (global tray, no notification)
Source: tier4_fallback.py.
Producers
Section titled “Producers”| Source enum | Where it lives | Kind(s) emitted | Notes |
|---|---|---|---|
USER_MESSAGE | api/chat.py | MESSAGE | Fire-and-forget shadow dispatch; SSE response is not blocked |
CHANNEL | channel_adapter/app.py | MESSAGE | Idempotency key includes message_id for webhook redelivery |
HOOK | hooks/engine.py | SIGNAL, MESSAGE | Only hooks with config.user_visible=true emit envelopes |
SCHEDULER | scheduler/delivery.py | COMMAND, FOLLOWUP, DELIVERY | One envelope per delivery method (chat_room, announce, webhook, push) |
AUTONOMY | autonomy/bridge.py | FOLLOWUP | Deferred chat followups from the thinking daemon |
DEVICE | device/router.py | SIGNAL, COMMAND | Status updates from paired devices |
PROACTIVE | insight_generator.py | INSIGHT | Tier 2 gate is the entire point — every insight is gated |
API | Various API routes | COMMAND, MESSAGE | CLI calls and MCP invocations |
INTERNAL | One subsystem → another | Any | Reserved for explicit subsystem-to-subsystem signals |
Executors
Section titled “Executors”DispatchAction.kind | Bridges to |
|---|---|
deliver_to_chat | TurnOrchestrator via the internal chat path |
deliver_to_channel | channel_adapter/dispatch.py dispatch_to_agent |
deliver_to_device | device/router.py DeviceToolRouter.route_tool_call |
deliver_as_insight | InsightQueue.enqueue + SSE broadcast |
deliver_as_push | Push notification service (APNs / FCM / Web Push) |
schedule_for | autonomy/bridge.py schedule_followup |
trigger_hook | hooks/engine.py HookEngine.dispatch |
suppress | Telemetry only — counter increment, no side effect |
Source: executors.py.
Each executor is a thin adapter (~20 lines) that translates a DispatchAction.target dict into the subsystem’s signature. Failures in one executor never block siblings — they catch, log, and emit dispatcher.execution_failed on the Event Bus.
Decision log
Section titled “Decision log”Every dispatch result is written as one JSON line to:
~/.snippbot/dispatch/dispatch-YYYY-MM-DD.jsonlFiles rotate daily, 30-day retention by default (configurable in dispatcher settings).
Sample entry
Section titled “Sample entry”{ "envelope": { "envelope_id": "env_a8f2c19b3d4e", "source": "autonomy", "kind": "followup", "user_id": "default", "room_id": "conv_xyz", "urgency": 0.65, "proactive_value": 0.0, "idempotency_key": "autonomy:conv_xyz:1716148400", "created_at": "2026-05-19T14:20:00Z" }, "result": { "tier_used": "tier_1", "actions": [ { "kind": "deliver_to_chat", "target": {"room_id": "conv_xyz", "agent_id": "primary"}, "reason": "tier1:followup" } ], "classifier_called": false, "classifier_latency_ms": null, "suppressed": false, "suppress_reason": null, "deduped": false, "decided_at": "2026-05-19T14:20:00.231Z", "dispatch_latency_ms": 1.4 }, "extra": { "shadow_mismatch": false }}Useful queries
Section titled “Useful queries”Tail and pretty-print:
tail -f ~/.snippbot/dispatch/dispatch-$(date -u +%Y-%m-%d).jsonl | jq .Tier distribution over the last hour:
tail -n 10000 ~/.snippbot/dispatch/dispatch-$(date -u +%Y-%m-%d).jsonl \ | jq -r '.result.tier_used' \ | sort | uniq -cShadow-mode mismatches:
jq 'select(.extra.shadow_mismatch == true) | { envelope_id: .envelope.envelope_id, source: .envelope.source, reason: .extra.reason, action: .result.actions[0].kind }' \ ~/.snippbot/dispatch/dispatch-*.jsonlThe snippbot dispatcher CLI wraps these patterns — see snippbot dispatcher.
Operator controls
Section titled “Operator controls”snippbot dispatcher tail # Live-tail recent decisionssnippbot dispatcher mismatches # Shadow-mode divergences over last 24hsnippbot dispatcher stats # Per-source / per-tier countssnippbot dispatcher soak # PASS/FAIL verdict vs per-source soak budgetssnippbot dispatcher inject --tier 2 # Fire a synthetic envelope at a chosen tiersnippbot dispatcher health # Live snapshot from the daemon's REST endpointFull reference: snippbot dispatcher CLI.
REST endpoints
Section titled “REST endpoints”All admin-gated; single-user mode treats the bootstrap account as admin.
| Method | Path | Purpose |
|---|---|---|
GET | /api/dispatcher/decisions | Recent decisions with filters (source, tier, suppressed) |
GET | /api/dispatcher/health | Live tier-distribution + classifier budget snapshot |
GET | /api/dispatcher/settings | Current settings (Tier 3 toggle, budgets, route flags) |
PUT | /api/dispatcher/settings | Bulk update settings |
POST | /api/dispatcher/inject | Operator-only synthetic envelope injection |
Admin UI
Section titled “Admin UI”/admin/dispatcher— live decisions table with filters, mismatch highlighting, per-decision detail panel. See PLAN_DISPATCHER_ADMIN_UI./settings?section=dispatcher— Tier 3 enable, daily budget, per-source shadow mode, log retention. See PLAN_DISPATCHER_SETTINGS_PAGE.
Environment variables
Section titled “Environment variables”Per-source shadow / enforce flags — useful for staged producer migration. Each variable accepts off | shadow | enforce.
| Variable | Controls |
|---|---|
DISPATCHER_ROUTE_USER_MESSAGE | API chat ingestion |
DISPATCHER_ROUTE_CHANNEL | Inbound platform webhooks |
DISPATCHER_ROUTE_SCHEDULER | Scheduler delivery (chat/announce/webhook/push) |
DISPATCHER_ROUTE_HOOKS | User-visible hook outputs |
DISPATCHER_ROUTE_AUTONOMY | Deferred followups from thinking daemon |
DISPATCHER_ROUTE_PROACTIVE | InsightGenerator output |
DISPATCHER_ROUTE_DEVICE | Device-originated events |
DISPATCHER_ROUTE_API | Internal API/MCP calls |
SNIPPBOT_TIER3_CLASSIFIER | Global Tier 3 enable/disable (on / off) |
Env vars override settings-store values, so they remain the deploy-time escape hatch.
How it routes proactive insights
Section titled “How it routes proactive insights”Proactive intelligence is the most visible end-to-end use of the dispatcher. Walk-through:
InsightGenerator.generate_candidates() │ ▼ emits one IntentEnvelope per candidateIntentEnvelope( source = PROACTIVE, kind = INSIGHT, urgency = candidate.urgency, # 0.0 - 1.0 proactive_value = candidate.value, can_interrupt = candidate.interrupt, payload = { insight_id, title, content, ... }) │ ▼Tier 1: kind=INSIGHT → DeliverAsInsight(user_id, room_id?) │ ▼Tier 2: ProactivityManager.can_deliver_insight(...) │ ├─ approved ──► DeliverAsInsight passes through │ │ │ ▼ │ InsightQueue.enqueue + SSE broadcast │ │ │ ▼ │ Insight tray + (optional) push notification │ ├─ quiet hours ──► ScheduleFor(next_window) │ └─ daily cap ──► Suppress(reason=daily_cap_exceeded) │ ▼ Logged only — never reaches the userThe dispatcher’s policy gate is what makes the proactivity slider in settings actually act on every insight. Without it, the slider would only affect generation cadence — generated insights would always reach the tray.
See also: Proactivity concept, PLAN_INSIGHT_GENERATOR_REFACTOR.
Privacy & retention
Section titled “Privacy & retention”- Decision log payload — envelope
payloadmay include message previews. The log writer omits known-sensitive keys: schedulersigned_secret(HMAC), webhook deliverybody(potential PII), and any field flagged by the producer as private. - Retention — JSONL files rotate daily; default 30-day retention. Configurable in dispatcher settings (
log.retention_days, range 7–365). - CLI viewing —
snippbot dispatcher tailprints message previews to stdout. In shared terminals or screen-shared sessions, expect to see envelope payloads scroll by. Run over SSH or in a private window when handling sensitive workflows.
Related
Section titled “Related”- Overview — system architecture and component map
- Execution Flow — task lifecycle (Tool Dispatcher vs Intent Dispatcher disambiguation)
- Events — Event Bus vs Intent Dispatcher
- Packages —
dispatch/module responsibilities - Proactivity concept — user-facing proactivity controls
- snippbot dispatcher CLI — operator inspection commands
- Implementation plan: PLAN_UNIFIED_DISPATCHER
- Producer migration: PLAN_DISPATCHER_PRODUCER_MIGRATION
- Admin UI: PLAN_DISPATCHER_ADMIN_UI
- Settings page: PLAN_DISPATCHER_SETTINGS_PAGE