arizuko

arizukoconcepts › 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.

Off unless you turn it on. 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.

A broken block is an error, not a default. An unknown mode, an unparseable window, a timezone that isn’t real — any of these logs a config error and the group fires nothing. It is never quietly rounded down to 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.

CheckVetoes whenTuned by (default)
RunningTurna turn is already running in this chat
Silencethe gap since the last real inbound is under the floor or over the ceilingPROACTIVE_SILENCE_MIN (90s), PROACTIVE_SILENCE_MAX (12h)
Cooldownthis chat already had a proactive turn recentlyPROACTIVE_COOLDOWN (24h)
QuietHourslocal time falls inside one of the group’s windowsthe quiet_hours list
BotQuietthe bot has spoken here recentlyPROACTIVE_BOT_QUIET (15m)
RecentActivitytoo few inbound messages in the last hourPROACTIVE_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

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.

VariableDefaultWhat it moves
PROACTIVE_ENABLEDunset — offthe master switch; unset means no scanner exists
PROACTIVE_SCAN_INTERVAL30show often the sweep runs
PROACTIVE_SILENCE_MIN90show quiet it must get before interrupting is polite
PROACTIVE_SILENCE_MAX12hhow quiet is too quiet to bother
PROACTIVE_COOLDOWN24hthe per-chat bar on a second interjection
PROACTIVE_BOT_QUIET15mhow long after its own message the bot stays out of it
PROACTIVE_RECENT_ACTIVITY_MIN3inbound 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.