routing

how a message lands in a folder · ← concepts

When a message arrives, the gateway walks a small 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. The gateway 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=web:acme                      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.

Values use path.Match globs: * matches any run of characters except /, ? matches one character, [abc] matches a class. That’s why discord:dm/* covers every DM but doesn’t cross into discord:guild/123/channel/456.

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 the gateway 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, the gateway records which folder produced the reply on the message row (routed_to). If the user then replies to that bot message, the gateway 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 — the gateway 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.

If the folder after @ doesn’t exist, the gateway 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 the gateway 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, get_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

The target column carries an optional # fragment that decides what the gateway does after the message lands in the folder. #observe is the only reserved fragment — it sets a mode. Every other fragment 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, the gateway 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.

Future modes (#drop, #digest, …) extend the fragment vocabulary without schema changes.

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.