arizuko › concepts › proactive interjection
proactive interjection
Engagement decided whether to keep listening after someone spoke to the bot. This page is the other direction: the bot speaking when nobody spoke to it. A question was asked in a channel forty minutes ago, three people are in the room, nobody answered, and the agent has been sitting there the whole time with the answer. Proactive interjection is the narrow, heavily-fenced permission to say it.
PROACTIVE_ENABLED is unset in every shipped template, so no arizuko instance does this today. When it is unset routd builds no scanner at all — not an idle one, not an empty loop. You opt in twice, on purpose, per instance and per group. The rest of this page describes what happens after you do.the problem it solves
arizuko is mention-reactive by design. In a channel where the bot lurks without being addressed, nothing ever triggers a turn, so the useful intervention never happens — not because the agent decided to stay quiet, but because it was never asked to decide.
The obvious fix is a bot that chimes in whenever it has something to say. That bot gets muted within a week. So the mechanism here is built the other way round: assume silence is correct, and make firing the exception that has to clear a row of gates.
turning it on
Two switches, and both must be on.
One — the instance. Set PROACTIVE_ENABLED=true in the instance .env and restart. That starts the scanner and nothing else; every group is still silent.
# in /srv/data/arizuko_<instance>/.env
PROACTIVE_ENABLED=true
Two — the group. Add a proactive: block to the frontmatter of that group’s CLAUDE.md (<data-dir>/groups/<folder>/CLAUDE.md). Mode lurk is the only mode that ever fires.
---
proactive:
mode: lurk # silent (default) | lurk
quiet_hours: ['22:00-08:00 Europe/Prague'] # optional, repeatable
---
The block lives in a file the operator already edits, not in a database column, so there is one copy of the answer and nothing to keep in sync. routd re-reads it when the file’s modification time changes — edit and save, no restart.
Each quiet-hours entry is HH:MM-HH:MM <IANA timezone>, and a window may cross midnight (22:00-08:00 is the night, not a 14-hour day). List as many as you need.
silent, because then a typo would look exactly like a working setup you had turned off on purpose.the gates, in order
Once every loop iteration — and when at least PROACTIVE_SCAN_INTERVAL has passed since the last one — routd walks the eligible chats and runs these checks in this order. The first one that objects ends it; there is no score, nothing adds up, nothing outvotes anything.
| Check | Vetoes when | Tuned by (default) |
|---|---|---|
RunningTurn | a turn is already running in this chat | — |
Silence | the gap since the last real inbound is under the floor or over the ceiling | PROACTIVE_SILENCE_MIN (90s), PROACTIVE_SILENCE_MAX (12h) |
Cooldown | this chat already had a proactive turn recently | PROACTIVE_COOLDOWN (24h) |
QuietHours | local time falls inside one of the group’s windows | the quiet_hours list |
BotQuiet | the bot has spoken here recently | PROACTIVE_BOT_QUIET (15m) |
RecentActivity | too few inbound messages in the last hour | PROACTIVE_RECENT_ACTIVITY_MIN (3) |
Clear all six and the gate still says no by default. Something has to actively argue for speaking, and today exactly one thing can: UnansweredQuestion — the last inbound message ends in a question mark and the bot has not said anything since it arrived. New signals get added to this list as separate named checks; the list never becomes a weighted score, because a score is a thing you tune forever and can never explain to the person who got interrupted.
Both edges of Silence matter. Under the floor, the conversation is live and cutting in is rude. Over the ceiling, everyone has gone home and there is nobody to talk to. Only the band between is a moment. And “the last real inbound” means a real message from a real person — the bot’s own replies and its own proactive messages don’t reset that clock, or the feature would keep itself permanently awake.
the cooldown is not optional
Anything that fires off its own state, with no human in the loop, loops — so every chat that has had its proactive moment is barred from another for PROACTIVE_COOLDOWN, 24 hours by default. There is no per-group override and no mode that shortens it.
The cooldown is written in the same database transaction that queues the turn, before anything is dispatched. If routd dies in between, the cooldown is already set: the worst case is one interjection that never happened, never two that did.
That timestamp lives in routd.db (the chat_proactive table), not in CLAUDE.md, because it is not a setting — nobody edits it, the system just remembers.
the agent still gets a veto
Everything above decides whether this is a moment worth a turn. It does not decide that there is anything worth saying. So the turn runs, and the agent is free to emit nothing at all; that outcome is normal, not a failure, and it burns the cooldown just like a turn that spoke.
Two different things can go wrong and they need two different floors. A bot that talks too often is noise, and the team mutes it — the gates above handle that. A bot that speaks once, confidently, and is wrong costs trust that doesn’t come back — only the agent, reading the actual conversation, can catch that one.
The turn arrives carrying one extra block so the agent knows which kind of turn this is:
<proactive_reason check="UnansweredQuestion">
Last inbound "so do we roll back or not?" ends with a question and has no bot reply.
</proactive_reason>
It reads as “here is a moment; consider speaking”, not as a question addressed to the agent. Both the firing check and, on a no-fire, the vetoing check are logged with the chat and the folder — so if an interjection lands badly, you can find out exactly which gate let it through instead of guessing at a black box.
A proactive turn also leaves no trace on engagement. It does not open a window, so the next real message from a human routes exactly as it would have if the bot had stayed quiet.
what it deliberately isn’t
- Not an event-trigger engine. The gate holds a small fixed set of arizuko’s own signals. If you want the agent to react to your deploys or your alerting, run that watcher next to arizuko and expose it over MCP — then the agent reaches it as a tool, like everything else.
- Not cross-channel. One chat at a time. It will not read
#designand speak in#eng. - Not operator-scriptable. You choose
lurkorsilentand the quiet hours; you don’t write new checks. Same answer as the first bullet.
Nothing else changes. Output leaves by the ordinary path an adapter already handles, the turn runs with the permissions any turn has, and the agent uses the same send tool it always uses — there is no special proactive verb.
tuning it
Instance-wide knobs, all of them optional, all read once at startup. Full table with types and file:line in reference/env.
| Variable | Default | What it moves |
|---|---|---|
PROACTIVE_ENABLED | unset — off | the master switch; unset means no scanner exists |
PROACTIVE_SCAN_INTERVAL | 30s | how often the sweep runs |
PROACTIVE_SILENCE_MIN | 90s | how quiet it must get before interrupting is polite |
PROACTIVE_SILENCE_MAX | 12h | how quiet is too quiet to bother |
PROACTIVE_COOLDOWN | 24h | the per-chat bar on a second interjection |
PROACTIVE_BOT_QUIET | 15m | how long after its own message the bot stays out of it |
PROACTIVE_RECENT_ACTIVITY_MIN | 3 | inbound messages needed in the last hour for the room to count as busy |
A missed sweep is skipped, never made up. If a long turn holds the loop past three scan times, the next sweep runs once — it does not fire three interjections in a row to catch up.
go deeper
The full decision record, including why the gate is a fixed list rather than a score: specs/5/6. The mention-driven half of the same gate: engagement. Where mode: sits among the other things a group’s CLAUDE.md controls: personas.