TypeClawTypeClaw
Internals

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).

DecisionMeaningInspect color
engageReply — message enters promptQueue, the drain loop promptsgreen
observeDon't reply, but remember — message enters contextBufferdim
deniedBlocked by the channel.respond permission gatered
claimA claim-… role-claim code; handled before engagementmagenta

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.

  1. Explicit triggers (config trigger): dm, mention (platform <@id>), reply to a bot message. Any one engages.

  2. Sticky credit: if stickiness is on and the author holds an unexpired credit in the StickyLedger, consume it and engage — 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 > 1 only): in a busy group the conversational target shifts every message. Before sticky consumes, decideEngagement runs the same structural "addressed to someone else" test the fallback suppressors use (mentionsOthers, replyToOtherMessageId && !botInThread, textTargetsAnyPeerBot, factored into targetsSomeoneElse). 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 — return observe without 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-mentionsOthers behavior is intentional and tested.

  3. Alias match: the message text contains the agent's name or a configured alias in plain text (case-insensitive substring, no word boundaries). basename(agentDir) is an implicit alias. Not suppressed by mentionsOthers — addressing two bots by name in one message engages both, each on its own alias.

  4. Suppressors (see below) — if any fires, return observe even though the solo-human fallback would otherwise engage.

  5. 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 @mention on every line.

  6. 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 > 0 or a bot-authored buffered message) is the escape hatch: Slack's parent_user_id is 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 from participants[] (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()):

  1. Broadcast + log. publishInbound(event, 'observe') emits the inbound event; the router logs observed id=… so an unanswered mention is diagnosable from logs alone (the bracketed shape mirrors prompting batch= for log scraping).
  2. Buffer. observe(live, event) pushes the message into live.contextBuffer (an ObservedInbound), capped at CONTEXT_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 into composeTurnPrompt, 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 / listInboundAttachmentIds walk promptQueue and contextBuffer, newest-first, so an observed photo is still referenceable by attachment_id.
  • Quote anchoring. A tool send refreshes its quote candidate against contextBuffer to detect channel chatter that landed between the inbound and the reply (refreshQuoteCandidate). See channel-adapters for the user-visible blockquote behavior.
  • botInThread. hasBotParticipated scans the buffer for a bot-authored message to decide the replyToOtherMessageId suppressor.

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 consecutiveEngagedPeerBotTurns and pushes onto a sliding PEER_BOT_TURNS_WINDOW_MS (60s) window.
  • Tripping MAX_CONSECUTIVE_PEER_BOT_TURNS_SINCE_HUMAN (5) or MAX_PEER_BOT_TURNS_IN_WINDOW (5 in 60s) sets loopGuardActive.

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

  • decideEngagement is pure. Inputs in, engage/observe out. 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. The trigger set 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.
  • observe is logged and broadcast on every hit. An "unanswered mention" is always diagnosable from typeclaw inspect or 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.

On this page