Skip to content
View as .md

When bots reply

When a bot replies without being asked. Three values: mention, ambient, always.

The thing to know first: it is not a property of the agent. There is no trigger_mode column on agents. It is a property of (scope, agent) — the same bot can be always in one thread and mention in another, and that is the point.

Where it lives

Three layers, merged on read:

LayerStored as
appDEFAULT_CONFIG.dispatch.triggerMode = 'ambient' in packages/core/schemas/config.ts — code, not a row
housea configs row, scope_kind = 'house'
threada configs row, scope_kind = 'thread'

A configs row is (scope_kind, scope_id, content.patch), one row per scope. Inside the patch the mode can sit in two places:

{ "dispatch": { "triggerMode": "ambient" } } // everyone in this scope
{ "dispatch": { "perAgent": { "<agent-uuid>": { "triggerMode": "always" } } } } // one agent here

perAgent is keyed by agent uuid. An entry on a human is inert — humans don’t dispatch — so writing one is harmless, not an error.

How it resolves

resolveConfig (packages/core/configs.ts) walks app → house → thread, one SELECT for the ancestor scope ids, then deep-merges the patches over DEFAULT_CONFIG. resolveAgentDispatch(config, agentId) overlays that agent’s perAgent leaf on top.

Both return ResolvedConfig/ResolvedDispatch, not Config. A Config is a patch with optional fields; a resolved value carries every key supplied by DEFAULT_CONFIG.

Places that read it:

  • getThreadAgents in packages/core/threads.ts — the mode shown in the thread’s agent list
  • packages/core/dispatch/participants-line.ts — the participant line a bot sees in its own context
  • loadThreadDirectorContext in apps/backstage/src/thread-director-context.ts — the bots offered to the director
  • packages/core/thread-director/thread-director-rules.ts — free choices, paid candidates, the ambient freshness rule, and the turn limit
  • apps/www and arbe agent view, which read the same resolved shape over HTTP

Changing what an unset mode means is one line: DEFAULT_CONFIG. Dispatch code names 'mention' in exactly one place, LEGACY_SIGNAL_TRIGGER_MODE — it decodes signal entries written before arbe-1910 recorded a mode, and must stay pinned to what the default was then rather than follow the live one.

What each mode does

ModeWhen the bot can speak
mentionit has an open @handle mention, from a human or a bot
ambient (default)it has not spoken since the latest human chat message and no free rule has settled the pass; while the thread’s current participants are one human and this one bot, it answers every human message, picked free
alwaysit replies to every message it owes — a conversation entry it did not author that no decision has covered yet, including one posted while it was replying, but not a human message that @mentions only other bots or those bots’ answers to it — picked free before any paid model, until botTurnLimit rests it

Eligibility is not the final choice. Exactly one open mention is a free pick; otherwise the thread director runs its ordered free rules, then asks one small model to choose one eligible bot or nobody. Turn limits, the settled-conversation rule, and arbe thread pause/resume are the director’s and are described there.

Who writes it

arbe agent edit <id> --trigger ambient # house scope (active house unless --house)
arbe agent edit <id> --trigger always --thread <ref> # this thread only
arbe agent edit <id> --trigger default # drop the override at that scope
arbe agent edit <id> --bot-turn-limit 5 --thread <ref> # per-agent turn limit, 0–10; 0 is unlimited; `default` unsets
arbe agent view <id> [--thread <ref>] # effective mode + which layer set it
arbe thread pause <id> # set this thread's default to mention-only
arbe thread resume <id> # unset that override; use the house default
arbe config set thread <id> --patch '{"dispatch":{"perAgent":{"<uuid>":{"triggerMode":"always"}}}}'

A one-on-one thread (arbe send lyra "hi") stores nothing: the one-on-one answer is the director reading the live roster, so it ends by itself when someone joins. always is only ever set on purpose. These code paths write a mode outside arbe agent edit and config:

  • createThread (packages/core/threads.ts) seeds always for a driving bot on a subagent or workflow-run thread, where turn-end is only well-defined because the bound bot always replies. Onboarding seeds always on its interview thread.
  • The create_bot tool (packages/core/dispatch/bot-tools.ts) writes a house-scope patch when the caller asks for a non-default trigger.
  • Team install (packages/core/install-team.ts) maps each manifest agent’s trigger_mode into one house-scope perAgent patch, written once at the end of the install.

Traps

A thread has two things called config and they are different. The threads.config column is read for model only (packages/core/dispatch/reply.ts) and never enters the config chain; the thread’s configs row is what dispatch resolves. The ambient-dispatch canary writes {"dispatch":{"triggerMode":"ambient"}} into the column (packages/core/workflows.ts) expecting a thread override. Every canary thread in prod holds exactly one entry — the opening message, no bot reply. It looks inert.

Code: packages/core/schemas/config.ts (DEFAULT_CONFIG, DispatchSection), packages/core/configs.ts (resolveConfig, resolveAgentDispatch, traceAgentDispatchField).
See thread director for who speaks once several bots are eligible, dispatch for the turn itself, and multi-chat for how one shared thread becomes one bot’s view of it.
Also configs and agents.