arizuko › concepts › engagement
engagement
Engagement decides whether the bot keeps listening after a mention. After the bot replies in a chat and topic pair, that pair is “engaged” for a time window. Later inbound messages in the pair start the agent without another mention.
A chat and topic pair is engaged or idle
A pair of a jid and a topic is either engaged or idle. Four transitions exist:
- Idle to engaged
- A mention, a reply to a bot message or an explicit
engage()call. The bot replies, and the timer starts. - Engaged to engaged
- Every message from a person that arrives before the deadline starts a turn, with no new mention. A reaction, an edit or a CI event does not.
- Engaged to idle
- The window expires, or the agent calls
disengage()when it has finished. - Idle to idle
- After a disengage, the next inbound message needs a new mention.
The column stores the deadline itself, not the time of the last message, so a read is a plain comparison with the clock. Each bot reply moves the deadline out again. A long exchange never times out in the middle, but 30 quiet minutes end it. Your own messages do not extend the window, only the replies of the bot. ENGAGEMENT_TTL sets the window, and the default is 30m.
ENGAGEMENT_TTL=0 stops the automatic opening and renewal of windows. Explicit mentions and direct replies to the bot still work. An open window keeps its deadline until it expires or the bot calls disengage.
routd delivers a message or mention to the engaged folder and starts a turn whatever the route table says. This includes #observe targets, trigger routes to another folder, and no matching route at all. It holds on every channel, GitHub included. Every other row follows its route: a reaction, an edit, a join or a GitHub check_run is stored as observed on an #observe route. The table applies to all rows again when the window expires or someone calls disengage().A route can extend the window to CI
A route with the target fragment #engaged fires a turn while the pair is engaged and stores the row as observed otherwise. It matches any verb and never addresses the agent. This row lets CI results continue an open pull request conversation:
arizuko route -i demo add 'chat_jid=github:o/r/pr/* verb=check_run' team/reviewer#engaged --seq 50
Write the row above the #observe fallback. A reply in the turn that the row starts renews the window, so CI that keeps arriving while the agent keeps replying holds the conversation open. The default routes carry no #engaged row.
A reply in a Slack channel opens a thread
The reply of the bot to a mention in a channel opens a Slack thread on the message of the user. The engagement window then attaches to that thread. Later replies in the thread engage the bot automatically and stay in the thread, so the main channel stays clear.
The root of the thread controls automatic mention promotion. In a thread that the bot started, any message can address the bot until it disengages. A thread that a person started gives no such permission when the bot joins it. Explicit mentions and direct replies to bot messages still address the bot.
Broadcasts, such as scheduled tasks, release announcements and system events, have no parent message to thread under. They appear at the top level.
A correction can run in a side topic
Sometimes the user corrects the last reply of the bot, for example “no, that is wrong” or “rephrase”, instead of continuing. The agent can then fork the topic to <current>#fix and run the correction there. The main thread stays clean, and the correction happens in a child thread on the incorrect reply. When the correction is complete, the agent posts one corrected answer to the parent and calls disengage() on the fix topic.
The fork is a convention. The agent decides when to fork, and routd does not enforce it.
Messaging guidance matches the channel
The core messaging skill gives the agent formatting and length guidance for the channel. routd includes a <surface> tag for the Slack assistant pane.
Two MCP tools control the window
engage(jid, topic)- Opens the window explicitly, for example before a scheduled autonomous turn.
disengage(jid, topic)- Clears the window, so the next inbound message needs a new mention.
Engagement can open a turn for context without addressing the agent. Such a turn may remain silent. An addressed request, or a turn that posts progress, owes an explicit delivery; terminal prose never becomes a chat reply.
jid is required, so the agent names the chat it means. topic defaults to the main timeline. Two folders can call the tools: the folder that holds the live window, and a folder that the chat routes to. The own chat of the turn counts as such a chat. engage can also claim a conversation that no folder holds, but it cannot take over the live window of another folder. role:member carries disengage with no approval. engage needs its own grant.
A mention requests a task. After a one-time task, the agent calls disengage and delivers its answer, and the answer does not open the conversation again. An explicit stop also blocks automatic mentions from old thread replies. A new mention or a direct reply can request another task.
Renewal has three limits, so that a stale or automatic reply cannot keep a window open alone:
- A scheduled turn never renews the window, because a cron job that answers into a chat is not a conversation.
- A
disengageat or after the start of a turn blocks the renewal by that turn. So a reply already in progress cannot undo the stop. - An MCP
sendorreplythat gets no message id from the platform renews nothing, because the next reply has no message to thread under.
The dashboard lists every open window
The dashboard page /dash/engagement/ lists every conversation that the bot stays in, with its group and the time left in the window. Only an operator can open the page, because it covers the whole instance and not one folder.
Each row has a disengage button, after a confirmation. The window ends at once, and the bot does not speak there again until someone names it. The button sends the same POST as the example below and does not edit the database. So it passes the same folder check and writes the same audit entry. Every early end records who did it: the dashboard, the API or the disengage() call of the agent.
For a script, or for one known conversation, call routd directly:
# one conversation: which folder holds it, and until when
curl -H "Authorization: Bearer $TOKEN" \
'https://<host>/v1/engagement?jid=slack:T01/channel/C02&topic='
# every window that is live right now, soonest to expire last
curl -H "Authorization: Bearer $TOKEN" https://<host>/v1/engagement
# end one early: any ttl_seconds of zero or less clears the window
curl -X POST -H "Authorization: Bearer $TOKEN" \
-d '{"jid":"slack:T01/channel/C02","topic":"","folder":"sales","ttl_seconds":0}' \
https://<host>/v1/engagement
The read for one conversation also returns last_reply_id, the message that the next reply threads under. That id outlives the window, so an idle conversation still reports it while engaged_until is empty.
A token sees only the windows that its folder owns. A token with no folder, which is a root or service token, sees every window on the instance. Ending a window follows the same rule: a live window belongs to the folder that claimed it, the POST checks that folder, and a different folder in the body does not widen the check.
openapi.json. They are written by hand and do not come from the resource registry like the cold-tier tables. Engagement is live conversation state, not operator configuration. It shares its rows with the agent tools engage and disengage, and a registry entry would add a second path to the same rows. /dash/engagement/ reads and writes through the same three endpoints, not the table.Three pages show how engagement connects
[ROUTING.md] shows where engagement sits in route resolution and how a mention is promoted. topics explains the fork that the correction pattern uses. routing modes lists the route modes, #engaged among them.