Packages: gateway/, queue/, router/. Entrypoint: gated/.
Tables owned: routes, registered_groups, router_state, sessions, session_log, system_messages.
The gateway polls store.NewMessages every 2 seconds using a timestamp cursor persisted in router_state. On each tick it also calls store.ActiveWebJIDs to pick up web-channel messages that arrived since the last agent run. The cursor advances after each batch is dispatched.
Messages pass through impulseGate before dispatch. The gate assigns weights by verb: social verbs (reactions, typing) weight 0; all others weight 100. A JID is held until its accumulated weight reaches the threshold or a 5-minute maximum hold elapses. This batches rapid-fire messages into a single agent invocation.
Every inbound message flows through four ordered layers. Each layer owns its namespace and does not consult the next:
| Layer | Implementation | Function |
|---|---|---|
| 1. Sticky | Code (in-memory) | Absorbs bare @name/#topic tokens as routing state updates |
| 2. Command | Code (gatewayCommands table) | Matches slash-prefixed first token (e.g. /new, /ping) |
| 3. Prefix | Code (no DB) | Inspects content for inline @name/#name tokens, navigates to child folder or synthesizes topic session |
| 4. Routing | Data (routes table) | Walks rows in seq order, returns first matching target |
Commands never touch routes. Prefixes never touch routes. Only the routing layer reads the routes table.
CREATE TABLE routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seq INTEGER NOT NULL DEFAULT 0,
match TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL,
impulse_config TEXT
);
CREATE INDEX idx_routes_seq ON routes(seq);
No jid column. No type column. One table for the whole instance.
match is a space-separated list of key=glob pairs. All pairs must match for the row to fire. Empty match = wildcard (matches everything).
| Key | Resolves to |
|---|---|
platform | Platform name (e.g. telegram, discord) |
room | Post-colon portion of JID (e.g. -5075870332) |
chat_jid | Full JID (e.g. telegram:-5075870332) |
sender | Sender name or ID |
verb | Message verb (defaults to message) |
Globs use Go path.Match semantics: *, ?, [abc]. No regex. Case-sensitive.
| seq | match | target |
|---|---|---|
| 0 | room=-5075870332 | krons/content |
| 10 | platform=telegram verb=mention | rhias/mentions |
| 20 | verb=follow | krons/notifs |
| 30 | platform=bluesky | bsky/feed |
| 99 | | default/firehose |
| Target | Routed to |
|---|---|
krons/content | Folder (agent container) |
folder:krons/content | Same (folder: prefix optional) |
daemon:onbod | HTTP POST to registered daemon (future) |
builtin:ping | In-gateway handler (future) |
Gateway commands are intercepted before agent dispatch and are not forwarded to the container. Registered in gateway/commands.go as a single table.
| Command | Action |
|---|---|
/new | Reset session for the current group/topic |
/new #topic | Reset session for a named topic only |
/ping | Reply with pong (liveness check) |
/chatid | Reply with the current JID |
/stop | Evict session cursor, stop active container for group |
/status | Route to dashd for status reply |
/approve | HTTP POST to onbod (onboarding only) |
/reject | HTTP POST to onbod (onboarding only) |
Before route resolution, the gateway inspects message content for prefix forms:
@name — routes to the named group (matched against registered_groups.folder)#topic — routes to the topic session within the current groupThese override normal routing. The prefix is stripped from the message before forwarding to the agent.
All messages (inbound, outbound, delegation, escalation, topic routing) flow through PutMessage to the messages table. No separate outbound path.
Previously (v0.24.2): three disjoint paths:
PutMessageStoreOutboundEnqueueTask closures (in-memory only)Now (v0.25.0): single path via PutMessage. Delegation is durable by construction (prompt in SQLite before queue sees it).
Package: queue/. Each group has its own serialized queue (GroupQueue). A global cap limits total concurrent container invocations (default 5).
SendMessage pipes the new message directly to its stdin rather than queuing a new invocation.Container stdout is scanned for the delimiter pair:
---ARIZUKO_OUTPUT_START---
{"status":"ok","result":"...","newSessionId":"...","error":""}
---ARIZUKO_OUTPUT_END---
Everything outside the delimiters is discarded. The gateway strips <internal> XML tags from result before sending to the channel adapter. <think> blocks are also stripped.
The status field controls session advancement: on error with no output the cursor rolls back (messages retry on next poll); on error with output the cursor advances (partial work preserved).
Sessions are stored in store.sessions keyed by (group_folder, topic). The session ID is the Claude Code session identifier returned in container output. On /new or /new #topic, the session row is deleted; the next container invocation starts a fresh session.
Session history is written to session_log with start time, end time, result, and error. On container error with no output, the session is evicted so the next invocation retries from the same message cursor position.
The gate accumulates messages per JID. When the total weight of pending messages for a JID exceeds the configured threshold (default 100), the batch is released for processing. Messages held longer than 5 minutes are flushed regardless of weight. This prevents a burst of low-weight events (e.g. reactions) from triggering a container invocation each.
Web messages use web:<folder> JIDs. The gateway discovers active web JIDs via store.ActiveWebJIDs(since) on each poll tick. processWebTopics splits messages by topic and runs one agent per topic in parallel, subject to the global queue cap.