Skip to content

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.

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:

  1. 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).
  2. Adding a new producer meant wiring it into every downstream subsystem that might receive it. Adding a new executor meant similar.
  3. 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.

┌──────────────────────────────────────────────────────────────────────────────┐
│ 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.

Every producer emits the same envelope shape. Source-specific data lives in payload; optional hints populate the deterministic resolver inputs.

from dataclasses import dataclass, field
from enum import Enum
from 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
@dataclass
class 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=...)

The envelope source lives at packages/core/src/snippbot_core/dispatch/types.py.

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=MESSAGEDeliverToChannel(platform=slack, channel=C123)
room_id set + kind=MESSAGEDeliverToChat(room_id, agent_id=agent_hint or primary)
device_pin set + kind=COMMANDDeliverToDevice(device_id=device_pin)
kind=FOLLOWUP + parent_envelope_idResolve parent’s destination, reuse
kind=INSIGHT + room_id setDeliverAsInsight(user_id, room_id)
kind=INSIGHT + no room_idDeliverAsInsight(user_id, room_id=None) — global tray
kind=DELIVERY (outbound webhook)TriggerHook via existing dispatcher.py
None matchFall 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=True envelopes
  • Per-domain opt-out filters

The verdict downgrades the Tier 1 action:

VerdictResult
ApprovedTier 1 action passes through unchanged
Gated by quiet hoursReplaced with ScheduleFor(when=next_window)
Gated by daily capReplaced with Suppress(reason=daily_cap_exceeded)
Gated by urgencyReplaced with Suppress(reason=below_threshold)

Source: tier2_policy.py. Policy logic in proactivity.py.

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=INSIGHT envelopes the generator didn’t bind to a specific room
  • kind=COMMAND envelopes from API/CLI without a device pin or channel hint
  • kind=MESSAGE envelopes from API/INTERNAL without room_id or agent_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 fires when Tier 3 times out, returns malformed JSON, exceeds budget, or returns a low-confidence verdict. The fallback policy is:

  • urgency >= 0.7ScheduleFor(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.

Source enumWhere it livesKind(s) emittedNotes
USER_MESSAGEapi/chat.pyMESSAGEFire-and-forget shadow dispatch; SSE response is not blocked
CHANNELchannel_adapter/app.pyMESSAGEIdempotency key includes message_id for webhook redelivery
HOOKhooks/engine.pySIGNAL, MESSAGEOnly hooks with config.user_visible=true emit envelopes
SCHEDULERscheduler/delivery.pyCOMMAND, FOLLOWUP, DELIVERYOne envelope per delivery method (chat_room, announce, webhook, push)
AUTONOMYautonomy/bridge.pyFOLLOWUPDeferred chat followups from the thinking daemon
DEVICEdevice/router.pySIGNAL, COMMANDStatus updates from paired devices
PROACTIVEinsight_generator.pyINSIGHTTier 2 gate is the entire point — every insight is gated
APIVarious API routesCOMMAND, MESSAGECLI calls and MCP invocations
INTERNALOne subsystem → anotherAnyReserved for explicit subsystem-to-subsystem signals
DispatchAction.kindBridges to
deliver_to_chatTurnOrchestrator via the internal chat path
deliver_to_channelchannel_adapter/dispatch.py dispatch_to_agent
deliver_to_devicedevice/router.py DeviceToolRouter.route_tool_call
deliver_as_insightInsightQueue.enqueue + SSE broadcast
deliver_as_pushPush notification service (APNs / FCM / Web Push)
schedule_forautonomy/bridge.py schedule_followup
trigger_hookhooks/engine.py HookEngine.dispatch
suppressTelemetry 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.

Every dispatch result is written as one JSON line to:

~/.snippbot/dispatch/dispatch-YYYY-MM-DD.jsonl

Files rotate daily, 30-day retention by default (configurable in dispatcher settings).

{
"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
}
}

Tail and pretty-print:

Terminal window
tail -f ~/.snippbot/dispatch/dispatch-$(date -u +%Y-%m-%d).jsonl | jq .

Tier distribution over the last hour:

Terminal window
tail -n 10000 ~/.snippbot/dispatch/dispatch-$(date -u +%Y-%m-%d).jsonl \
| jq -r '.result.tier_used' \
| sort | uniq -c

Shadow-mode mismatches:

Terminal window
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-*.jsonl

The snippbot dispatcher CLI wraps these patterns — see snippbot dispatcher.

Terminal window
snippbot dispatcher tail # Live-tail recent decisions
snippbot dispatcher mismatches # Shadow-mode divergences over last 24h
snippbot dispatcher stats # Per-source / per-tier counts
snippbot dispatcher soak # PASS/FAIL verdict vs per-source soak budgets
snippbot dispatcher inject --tier 2 # Fire a synthetic envelope at a chosen tier
snippbot dispatcher health # Live snapshot from the daemon's REST endpoint

Full reference: snippbot dispatcher CLI.

All admin-gated; single-user mode treats the bootstrap account as admin.

MethodPathPurpose
GET/api/dispatcher/decisionsRecent decisions with filters (source, tier, suppressed)
GET/api/dispatcher/healthLive tier-distribution + classifier budget snapshot
GET/api/dispatcher/settingsCurrent settings (Tier 3 toggle, budgets, route flags)
PUT/api/dispatcher/settingsBulk update settings
POST/api/dispatcher/injectOperator-only synthetic envelope injection
  • /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.

Per-source shadow / enforce flags — useful for staged producer migration. Each variable accepts off | shadow | enforce.

VariableControls
DISPATCHER_ROUTE_USER_MESSAGEAPI chat ingestion
DISPATCHER_ROUTE_CHANNELInbound platform webhooks
DISPATCHER_ROUTE_SCHEDULERScheduler delivery (chat/announce/webhook/push)
DISPATCHER_ROUTE_HOOKSUser-visible hook outputs
DISPATCHER_ROUTE_AUTONOMYDeferred followups from thinking daemon
DISPATCHER_ROUTE_PROACTIVEInsightGenerator output
DISPATCHER_ROUTE_DEVICEDevice-originated events
DISPATCHER_ROUTE_APIInternal API/MCP calls
SNIPPBOT_TIER3_CLASSIFIERGlobal Tier 3 enable/disable (on / off)

Env vars override settings-store values, so they remain the deploy-time escape hatch.

Proactive intelligence is the most visible end-to-end use of the dispatcher. Walk-through:

InsightGenerator.generate_candidates()
▼ emits one IntentEnvelope per candidate
IntentEnvelope(
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 user

The 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.

  • Decision log payload — envelope payload may include message previews. The log writer omits known-sensitive keys: scheduler signed_secret (HMAC), webhook delivery body (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 viewingsnippbot dispatcher tail prints 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.