arizuko

arizukoconcepts › routing

routing

You've met the ant — a folder that is an agent. The next question is how an inbound message finds the right one. When a message arrives, routd walks a table of rules. Each rule says “if this message looks like X, run it in folder Y.” First match wins.

the table

Every message arriving from Telegram, Discord, Mastodon, email, or the web widget hits the same store (routd.db). routd then asks one question: which folder runs this? The answer comes from the routes table — one row per rule, evaluated in order:

seq    match                                  target
---    -----                                  ------
-10    chat_jid=telegram:user/12345           atlas/legal
0      platform=telegram                      atlas/content
0      platform=discord room=dm/*             atlas/dm
0      platform=reddit verb=post              atlas/posts
0      chat_jid=slack:acme/eng                solo/chat
0      chat_jid=hook:acme/eng/github          acme/eng#observe
0      platform=discord                       atlas/{sender}
9999   (empty)                                atlas              # catchall

Lower seq goes first. The empty match at seq=9999 matches every message, so it acts as the fallback.

how a rule matches

The match column is a space-separated list of key=value tests, all of which must pass. So platform=discord room=dm/* matches a Discord message whose chat is a DM, and nothing else. The right-hand side is a glob that stops at / — which is why discord:dm/* covers every DM yet never crosses into discord:guild/123/channel/456 (full glob grammar in reference / jid).

Five keys are available: platform (e.g. telegram), room (the part after platform:), chat_jid (the whole address), sender (who sent it), and verb (the action, e.g. post, like). Omit a key and that field is unconstrained.

per-user folders

Write {sender} in the target and routd expands it to a sanitized sender ID. One rule like platform=discord → atlas/{sender} gives every Discord user their own child folder — atlas/dc-alice, atlas/dc-bob — without one route per person.

reply chain wins

When the bot replies in a chat, routd records which folder produced the reply on the message row (routed_to). If the user then replies to that bot message, routd follows the chain back to the same folder, even if the route table says otherwise. So a thread stays in the folder that started it.

sticky and prefix overrides

A user can pin a chat to a folder by sending @atlas/social as the entire message — routd stores it on the chats row and every later message in that chat goes to atlas/social until someone sends bare @ to clear it. #support does the same for the topic; bare # clears.

A miss means “treat it as normal text.” If the folder after @ doesn’t exist, routd leaves the message alone and hands it to the agent unchanged — no confirmation, no error. Messages starting with @ have too many other uses (a mention like @everyone, a cross-instance pointer like @sloth, a sentence in a language where @ is just a word) for routd to swallow them on a miss. The agent decides whether it’s a typo for a child folder (and calls delegate_group), a real mention, or plain text.

Inline at the start of a message, @name something delegates that one message to the child folder <current>/name, and #topic something runs it under that topic — for one message only, sticky state untouched.

resolution order

Put together, every inbound message goes through these layers in order:

  1. Engagement — if the (chat, topic) is engaged, route to the engaged folder and fire a turn. All layers below are skipped.
  2. Reply chain — if the message replies to a bot message, follow routed_to.
  3. Sticky group — if the chat has one set, use it.
  4. Route table — first matching row wins; the empty catchall rule is last.

The inline @name/#topic prefix is handled separately before the route table, so it can override a default route without touching sticky state.

editing live

The agent edits the table through the MCP tools add_route, delete_route, list_routes, set_routes. The operator dashboard (dashd) shows the same table in a browser. Changes take effect on the next inbound message — no restart.

modes and topic pinning

A target may end with #.... That tail tells routd what to do after the message lands in the folder. #observe is the one reserved tail — it sets a mode. Every other tail is a topic name pinned for the routed message.

targeteffect
folderstore under folder and fire a turn on main (default)
folder#observestore under folder, do not fire a turn. The agent still sees the message in history when a later turn runs and has full folder ACL. Exception: engagement overrides all route targets — if the (chat, topic) is engaged, routd ignores this mode and fires a turn anyway. See engagement.
folder#deploystore under folder with topic #deploy and fire a turn there. Useful for piping a webhook source into a dedicated topic without operator @-prefixes.

Combine with verb=mention and seq to build a two-row mention-only pattern: a tight row triggers on mentions, a catch-all row observes everything else. Sloth Discord guild:

seq  match                                       target
10   platform=discord room=guild/sloth           main
20   platform=discord room=guild/* verb=mention  main
30   platform=discord room=guild/*               main#observe

Row 10 fires on every message in the named guild. Row 20 fires on @mentions in any other guild. Row 30 silently archives the rest into main for context without invoking the agent.

Later, new #... tails (#drop, #digest, …) can add new behavior without a new table column.

go deeper

Full rules — reply-chain mechanics, cross-folder authorization, worked end-to-end traces — live in ROUTING.md. For what the address strings on the left side actually look like, see jid. For how #topic isolates a thread inside one folder, see topics.