Engagement
The four-way inbound decision, the observe context buffer, the suppressor ladder, and the peer-bot loop guard
src/channels/router.ts decides, for every admitted inbound message, whether the agent wakes up and replies. The decision logic lives in src/channels/engagement.ts (decideEngagement); the router wires it to the prompt queue, the context buffer, and the inbound broadcast. This page documents the runtime concept — the operator-facing config knobs (trigger, stickiness, alias) are in the channel-adapters reference and the typeclaw-config skill.
The four-way decision
Every inbound that survives the channel.respond permission gate is tagged with one of four decisions. The router publishes the tag on a channel-inbound broadcast event (publishInbound, router.ts); typeclaw inspect renders it as a colored [...] prefix (src/inspect/render.ts).
| Decision | Meaning | Inspect color |
|---|---|---|
engage | Reply — message enters promptQueue, the drain loop prompts | green |
observe | Don't reply, but remember — message enters contextBuffer | dim |
denied | Blocked by the channel.respond permission gate | red |
claim | A claim-… role-claim code; handled before engagement | magenta |
denied and claim are decided in route() before engagement runs. decideEngagement itself only ever returns the narrower EngagementDecision = 'engage' | 'observe' — that is the union to extend if you add a wake-up reason. The wider four-way InboundDecision (src/inspect/types.ts) is the broadcast/protocol surface; both unions are currently spelled inline in engagement.ts, inspect/types.ts, shared/protocol.ts, router.ts, and server/index.ts. Adding a fifth decision means touching all five.
The engage ladder
decideEngagement is a priority ladder. The first matching rule wins; falling off the bottom yields observe.
-
Explicit triggers (config
trigger):dm,mention(platform<@id>),replyto a bot message. Any one engages. -
Sticky credit: if
stickinessis on and the author holds an unexpired credit in theStickyLedger, consume it andengage— for the full window (default 15 min). Credits are granted to the authors a bot reply targets (grantStickyForReplyTargets), so a back-and-forth stays engaged without re-mentioning. The credit is one-shot (consumed even when it engages), so a fresh reply must re-grant it; a stale credit can't resurrect a later turn. This gate is deliberately content-blind: it answers "am I mid-conversation with this author?", not "does this message need a reply?" — a boolean over membership can't tell"where did you send it?"(reply) from"lol ok"(chatter). Selectivity for plain chatter in a busy room is the model's job, applied via the group-chat nudge below, not the gate's. (This intentionally supersedes the earlier wholesale group-suppression of sticky, which dropped genuine follow-ups outright.)Multi-human target pre-check (
effectiveHumans > 1only): in a busy group the conversational target shifts every message. Before sticky consumes,decideEngagementruns the same structural "addressed to someone else" test the fallback suppressors use (mentionsOthers,replyToOtherMessageId && !botInThread,textTargetsAnyPeerBot, factored intotargetsSomeoneElse). If the credited author's current message is structurally aimed at a third party —@bob what do you think?, a reply to another human, or a peer bot by name — returnobservewithout consuming the credit, so the credit survives for that author's next untargeted follow-up. A plain follow-up (no suppressor set) falls through and engages exactly as before. This stays inside the content-blind rule: it adds no semantic text interpretation, only reuses adapter-classified structural booleans and reorders them ahead of the credit. It is gated on!matchesAnyAlias(text, selfAliases)so a message that also names us by alias still engages on rule #3 (the multi-bot토토아 라라아 둘 다 봐case). Solo-human channels (effectiveHumans <= 1) are excluded — there the sticky-over-mentionsOthersbehavior is intentional and tested. -
Alias match: the message text contains the agent's name or a configured
aliasin plain text (case-insensitive substring, no word boundaries).basename(agentDir)is an implicit alias. Not suppressed bymentionsOthers— addressing two bots by name in one message engages both, each on its own alias. -
Suppressors (see below) — if any fires, return
observeeven though the solo-human fallback would otherwise engage. -
Solo-human fallback: if the channel currently holds at most one distinct human participant and the author isn't a bot, engage. Reverts to strict the moment a second human posts. This makes a one-human dev channel work without an
@mentionon every line. -
Default:
observe.
Clearing stickiness (channel_disengage)
Sticky credit normally drains on its own — consumed on the next inbound, expired on TTL (default 15 min), or lost on a container restart. There is no other natural exit, and because every reply re-grants a fresh credit (grantStickyForReplyTargets), a bot in a busy multi-bot room can stay engaged turn after turn even after a participant tells it to stop.
channel_disengage is the runtime escape hatch. It calls ChannelRouter.clearSticky(key), which drops every credit for the channel key via StickyLedger.clear and returns the bot to strict rules #1/#3 engagement (mention / reply / dm / alias) for that conversation. It sends no message, is scoped to the one channel key, and is in-memory only — a later reply re-grants normally, so it is a "drop in, then resume" action, not a permanent mute.
The same-turn re-grant is the subtle part. The agent's natural pattern is to ack and disengage ("ok, backing off"), but that ack is itself a channel_reply whose success path would re-grant the credit clearSticky just dropped. To keep disengage binding, clearSticky stamps live.disengagedTurn = turnSeq, and send() skips grantStickyForReplyTargets while disengagedTurn === turnSeq. The suppression is scoped to that one turn (matched by turnSeq, reset at turn start), so the next turn re-grants as usual. This mirrors the skip_response send-lock shape: a turn-local flag that makes "I'm stepping back" survive a same-turn reply.
Suppressors
The suppressors exist to keep the bot out of conversations clearly aimed elsewhere, before the permissive solo-human fallback can fire. Each is populated by the adapter classifiers and flips the fallback off for one message without changing channel state. Explicit triggers (1–3 above) are unaffected — they engage even when the message also tags a third party.
mentionsOthers— the message tags at least one other user and none of the mentions resolve to us.replyToOtherMessageId !== null && !botInThread— the message is a reply to a message we didn't author, and we haven't sent into this thread yet.botInThread(router:live.successfulChannelSends > 0or a bot-authored buffered message) is the escape hatch: Slack'sparent_user_idis the thread root author, so a thread a human starts by @-mentioning the bot would otherwise drop every follow-up reply once the bot is in it.textTargetsAnyPeerBot— the message's text contains a known peer bot's observed display name. Each bot configures only its own aliases, so peer names come fromparticipants[](set once a peer has spoken); a never-seen peer's first addressing slips through, then is caught forever.
Peer-bot philosophy (do not relitigate)
Peer bots remain reachable through the same triggers as humans (mention / reply / dm / sticky). They are not downgraded to mention-only, and there is no peerBotTriggers knob. Bot-to-bot conversation is a first-class use case here. What peer bots do not get is the solo-human fallback — that courtesy is for humans who don't want to type @bot in their own channel. Letting peer bots ride the fallback produced multi-turn bot-to-bot introductions after a single human "얘들아". The fix was to close the fallback for bots, not to firewall bots behind explicit mentions. The full rationale is pinned in the comment block above the suppressors in engagement.ts.
Membership and the human count
The solo-human fallback counts humans. Persisted participants[] (speakers seen in the last ~7 days) is the baseline; resolveEffectiveHumans (engagement.ts) reconciles it with a MembershipCount from the adapter's member API (src/channels/membership.ts):
- A fresh, complete read (
!truncated && now - fetchedAt < MEMBERSHIP_FRESHNESS_MS, 60s) is ground truth — it sees lurkers and prunes leavers. - A truncated or stale read is a quieting hint only:
max(persistedHumans, membership.humans)avoids under-counting active humans.
The router invalidates and warms the membership cache when a previously-unseen author posts, so the next turn sees fresh data while the current turn still gets a fast answer.
observe: watch but remember
observe is not a dead end. When the router gets observe (route()):
- Broadcast + log.
publishInbound(event, 'observe')emits the inbound event; the router logsobserved id=…so an unanswered mention is diagnosable from logs alone (the bracketed shape mirrorsprompting batch=for log scraping). - Buffer.
observe(live, event)pushes the message intolive.contextBuffer(anObservedInbound), capped atCONTEXT_BUFFER_SIZE(20) by trimming the oldest.
The buffer is consumed by four readers:
- Next engaged turn.
drain()splices the entire buffer alongside the prompt batch intocomposeTurnPrompt, which renders it under## Recent context (not addressed to you, for awareness only)— distinct from## Current message(s) (addressed to you). So when the bot finally engages, it catches up on everything it silently watched. - Attachment resolution.
resolveInboundAttachment/listInboundAttachmentIdswalkpromptQueueandcontextBuffer, newest-first, so an observed photo is still referenceable byattachment_id. - Quote anchoring. A tool send refreshes its quote candidate against
contextBufferto detect channel chatter that landed between the inbound and the reply (refreshQuoteCandidate). See channel-adapters for the user-visible blockquote behavior. botInThread.hasBotParticipatedscans the buffer for a bot-authored message to decide thereplyToOtherMessageIdsuppressor.
Cold-start prefetch seeds the same buffer (source: 'prefetch'), and may exceed the cap on the one-shot seed; subsequent observe() calls trim back to 20.
Known asymmetry
The contextBuffer only flushes inside drain(), which only runs on an engage. A channel the bot perpetually observes (a busy multi-human room it's never addressed in) trims by count only, never by age. There is no staleness gate on flush, so the first engage after a long observe-only stretch can inject up-to-20 stale messages as "recent context." Prefetch is bounded by recency; runtime observation is bounded only by count. Whether this matters is a product call — noted here so a future maintainer doesn't assume the buffer is time-bounded.
Peer-bot loop guard
Distinct from the per-session tool loop guard (src/agent/loop-guard.ts, byte-identical tool calls). The channel-level guard (updateLoopGuard, router.ts) counts engaged peer-bot turns since the last human:
- Any human inbound resets the counters and clears
loopGuardActive. - A peer-bot engage increments
consecutiveEngagedPeerBotTurnsand pushes onto a slidingPEER_BOT_TURNS_WINDOW_MS(60s) window. - Tripping
MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN(5) orMAX_PEER_BOT_TURNS_IN_WINDOW(5 in 60s) setsloopGuardActive.
When active, composeTurnPrompt prepends a fenced **[SYSTEM MESSAGE — not from a human]** block telling the model it may reply NO_REPLY to stay silent. The notice lives in the user-turn suffix (not the system prompt) so it stays cache-neutral, and clears automatically once a human posts.
Group-chat nudge
A second, milder runtime notice rides the same fenced **[SYSTEM MESSAGE — not from a human]** convention. On any engaged turn in a multi-human group (live.multiHumanGroup, set in route() from the same membership + participants the engagement decision used), composeTurnPrompt appends a block telling the model it is woken on every message from someone it recently talked with, and should reply only when addressed or directly continuing its own last exchange — otherwise NO_REPLY / skip_response, preferring silence when unsure. It is suppressed when the loop guard already fired (one silence notice per turn), and like the loop guard it is a cache-neutral user-turn suffix.
This nudge is load-bearing, not a backstop. Because sticky now engages every group follow-up (rule #2 is content-blind), the nudge is the only thing that keeps the bot from answering chatter in a busy room — the gate gets the bot into the turn; the model decides whether to speak. Removing the nudge would reintroduce group spam. isMultiHumanGroup is the single definition shared by route() (to set live.multiHumanGroup) and the nudge gate, so they never disagree.
Rules of thumb
decideEngagementis pure. Inputs in,engage/observeout. No I/O, no side effects — the router owns buffering, broadcasting, and membership warming.- The fix for over/under-engagement is
trigger+alias, not new gates. Thetriggerset already applies symmetrically to humans and bots. Don't add bot-only suppression knobs. Keep the engagement gate content-blind — it decides whether the bot is in a conversation, not whether a given message deserves a reply. The latter is a semantic call the boolean can't make; push it to the model via the group-chat nudge. Gating sticky off in groups wholesale to silence chatter was tried and reverted: it couldn't tell a real follow-up from banter and dropped both. The multi-human sticky pre-check is not that reverted change — it does not silence chatter (the nudge still owns that), it only steps aside when an adapter-classified structural signal says the message is addressed elsewhere, and it preserves the credit so the next plain follow-up still engages. Reusing existing structural booleans is content-blind; inferring intent from text would not be. observeis logged and broadcast on every hit. An "unanswered mention" is always diagnosable fromtypeclaw inspector the container logs — never silent.- Observed messages re-enter the next turn. Treat the context buffer as "what the agent saw while quiet," not as discarded traffic.
- Two loop guards, two layers. Tool-call loops are per-session in
src/agent/; peer-bot engage loops are per-channel in the router. Don't conflate them.