# 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:

```jsonc
{ "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

| 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](thread-director.md) 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`).<br>
See [thread director](thread-director.md) for who speaks once several bots are eligible, [dispatch](dispatch.md) for the turn itself, and [multi-chat](multi-chat.md) for how one shared thread becomes one bot’s view of it.<br>
Also [configs](../data/configs.md) and [agents](../access/agents.md#trigger-modes).
