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:
| Layer | Stored as |
|---|---|
app | DEFAULT_CONFIG.dispatch.triggerMode = 'ambient' in packages/core/schemas/config.ts — code, not a row |
house | a configs row, scope_kind = 'house' |
thread | a 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 hereperAgent 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:
getThreadAgentsinpackages/core/threads.ts— the mode shown in the thread’s agent listpackages/core/dispatch/participants-line.ts— the participant line a bot sees in its own contextloadThreadDirectorContextinapps/backstage/src/thread-director-context.ts— the bots offered to the directorpackages/core/thread-director/thread-director-rules.ts— free choices, paid candidates, the ambient freshness rule, and the turn limitapps/wwwandarbe 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
| Mode | When the bot can speak |
|---|---|
mention | a 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 |
always | it 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 onlyarbe agent edit <id> --trigger default # drop the override at that scopearbe agent edit <id> --bot-turn-limit 5 --thread <ref> # per-agent turn limit, 0–10; 0 is unlimited; `default` unsetsarbe agent view <id> [--thread <ref>] # effective mode + which layer set itarbe thread pause <id> # set this thread's default to mention-onlyarbe 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_threadRPC seeds a thread-scopealwaysfor 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 inarbe agent view --thread,--trigger defaultdrops 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 samealwaysin 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_bottool (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’strigger_modeinto one house-scopeperAgentpatch, 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.