routing
Routing decides which folder runs an inbound message. When a message arrives, routd reads a table of rules. Each rule says “if this message looks like X, run it in folder Y”, and the first match wins.
One table maps messages to folders
Every message from Telegram, Discord, Slack, email or the web widget goes into the same messages table of routd. routd then asks one question: which folder runs this message? The answer comes from the routes table, with one row per rule, in order:
seq match target
--- ----- ------
-10 chat_jid=telegram:111/user/12345 atlas/legal
0 platform=telegram atlas/content
0 platform=discord room=dm/* atlas/dm
0 platform=slack verb=mention atlas/mentions
0 chat_jid=slack:T1234/channel/C5678 solo/chat
0 chat_jid=hook:acme/eng/github acme/eng#observe
0 platform=discord atlas/{sender}
9999 (empty) atlas # catchall
A lower seq goes first. The empty match at seq=9999 matches every message, so it is the fallback.
Every test in a rule must pass
The match column is a list of key=value tests, separated by spaces, and all of them must pass. For example, platform=discord room=dm/* matches a Discord message in a DM and nothing else. The value is a glob that stops at /. For this reason, discord:dm/* covers every DM but never matches a guild channel such as discord:123/456. reference / jid has the full glob grammar.
A rule can use five keys:
platform- The part before the colon, for example
telegram. room- The part after
platform:. chat_jid- The whole chat address.
sender- The address of the sender.
verb- The action, for example
postorlike.
If a rule leaves a key out, that field can have any value.
The verb key has no fixed list of values. Each adapter sends its own words, and routd adds a few. Telegram, Discord and Slack send mention when someone writes @bot. A reaction arrives as like or dislike. A rule for a verb that never arrives matches nothing and reports no error. ROUTING.md has the table of verbs for each adapter.
One rule gives each user a folder
If the target contains {sender}, routd replaces it with a sanitized sender ID. The rule platform=discord with the target atlas/{sender} gives every Discord user a child folder, such as atlas/dc-alice and atlas/dc-bob, with no route per person.
A reply to the bot goes back to the folder that sent it
When the bot replies in a chat, routd records the folder that produced the reply in the routed_to field of the message row. If a user then replies to that bot message, routd follows the chain back to that folder. The folder must be a child that the current folder can route to. So a thread stays in the folder that started it.
Users can pin a chat to a folder or a topic
A user can pin a chat to a folder with a message that contains only @atlas/social. routd stores the pin on the chats row, and every later message in that chat goes to atlas/social. A message that contains only @ clears the pin. #support pins the topic in the same way, and a bare # clears it.
If the folder after @ does not exist, routd gives the message to the agent unchanged, with no confirmation and no error. A message that starts with @ has many other uses, so routd does not consume it on a miss. Examples are a mention like @everyone, a pointer to another instance like @sloth, or a word in a language that uses @. The agent decides if the text is a typo for a child folder, a real mention or plain text. For a typo, it calls delegate_group.
Agents pass work along the folder tree. delegate_group sends a prompt to a folder that the grant of the caller covers, usually a child, and escalate_group sends one up to the parent. The child turn runs in a guest of its own, as a turn, end to end shows. Both tools refuse a call with a depth argument of 1 or more, and no code passes a depth on, so grants and not depth bound a chain of hand-offs. observe_group only reads.
At the start of a message, @name something delegates that one message to the child folder <current>/name. #topic something runs that one message under the topic. Neither prefix changes the pinned state.
routd resolves the folder in three checks
routd picks the folder with three checks, in this order:
- Direct address. A web chat or a hook token names its folder, and a JID with no
:is a folder name. The route table never sees these messages. - The route table. The first matching row by
seqwins. If the chat and topic pair is engaged, the engaged folder wins over the folder of the rule for amessageormentionfrom a person. An#observerule then starts a turn anyway. Every other verb follows its rule. - Engagement. Engagement can catch a
messageormentionthat no rule matched. A thread without its own engagement window uses the window of its root message, unless someone disengaged that thread.
One chat can hold several unread rows at once. routd reads the route table once for the batch and picks the newest row that starts a turn on its own. A mention therefore starts a turn even when a newer row of the batch matches only #observe. The turn sees the other rows as observed context.
After routd has a folder, a second pass runs inside it. The pass checks the pinned @ and #, the slash commands, an inline @child or #topic prefix, and then a delegation target. The delegation target is the folder of a replied-to bot message, the pinned group or the route table, in that order. A delegation goes only to a child that the current folder can route to. Any step in the pass can consume the message before a turn starts. A turn, end to end shows the whole path from the adapter to the reply.
A route change applies to the next message
The agent edits the table with the MCP tools add_route, delete_route, list_routes and set_routes. The operator dashboard dashd shows the same table in a browser. A caller with an operator REST token can call /v1/routes directly. One handler serves all of these, so the agent and a REST caller see the same rows. A change applies to the next inbound message, with no restart.
A target tail sets the mode or the topic
A target can end with #.... That tail tells routd what to do after the message arrives in the folder. Three tails are reserved: #observe, #engaged and #announce. Every other tail is a topic name for the routed message.
| target | effect |
|---|---|
folder | Stores the message under folder and starts a turn on the main topic. This is the default. |
folder#observe | Stores the message under folder and starts no turn. A later turn sees the message in the history, with the full ACL of the folder. If the chat and topic pair is engaged, routd ignores this mode for a message or mention from a person and starts a turn. Other verbs stay observed. See engagement. |
folder#engaged | Starts a turn while the chat and topic pair is engaged, for any verb, and stores the message under folder as observed otherwise. It never addresses the agent. Use it to let CI results continue an open conversation. |
folder#announce | Starts a turn as usual and marks a concrete channel as a release-note destination. On startup, routd queues the release notice for each configured destination. With no #announce route, it queues no notice. Remove the tail to stop the posts. |
Combine a tail with verb=mention and seq to answer only mentions. One row starts a turn on mentions, and a catch-all row observes everything else. An example for Discord, where 1100223344 is the ID of one guild:
seq match target
10 platform=discord room=1100223344/* main
20 platform=discord verb=mention main
30 platform=discord main#observe
Row 10 starts a turn on every message in guild 1100223344. Row 20 starts a turn on a mention anywhere else on Discord. Row 30 stores the rest in main as context and does not start the agent.
ROUTING.md has the full rules
[ROUTING.md] has the full rules: the reply chain, authorization across folders, and complete traces. jid shows the address strings in the match column. topics shows how #topic isolates a thread inside one folder.