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
mentiona human has an open @handle mention for it
ambient (default)it has not spoken since the latest human chat message and no free rule has settled the pass
alwaysit replies to every message it owes — an unread conversation entry it did not author sits after its own last turn — picked free before any paid model, until botTurnLimit rests it

Eligibility is not the final choice. Exactly one open human 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. Bot-authored mentions create no obligation. 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"}}}}'

Three code paths write it without being asked:

  • The create_thread RPC seeds a thread-scope always for the bot when the roster at birth is exactly one human and one bot — a DM. arbe send lyra "hi" is the usual way to make one. It is a birth-time default, not a rule in dispatch: the override is visible in arbe agent view --thread, --trigger default drops it, and it stays put if someone joins later. A caller seed that already sets that bot’s mode wins.
  • createThread (packages/core/threads.ts) seeds the same always in two more cases — an agent-parented DM (legacy; nothing creates them), and a driving bot on a subagent or workflow-run thread, where turn-end is only well-defined because the bound bot always replies.
  • 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.