# 🤖 Bot recipes: behaviour as data

A **recipe** is a bot's behaviour written as data — an ordered list of
trigger→action rules from a small, bounded palette — executed by the Go bot
runtime (`lchat bot run`, `internal/bot/recipe.go`). It's the concrete form of
the decision in `docs/bots.md` that the Go runtime should be *recipe-shaped
from day one*: the engine executes config, so declarative bots (and eventually
the admin console's "describe a bot" front-end) need no code and no second
rewrite.

Recipes have a **sibling schema, deliberately not unified**:
`docs/conversation-scripts.md` — Eleven's onboarding conversations as data,
interpreted *client-side* per platform. Conversation scripts choreograph a UI
the member is looking at; recipes run headless in a sidecar. They share a
philosophy (flows as data, closed capability sets, fail-closed loading), not a
language.

## The palette is the capability boundary

A recipe can only express what the schema names. That's the point: an action
the palette doesn't have *cannot be written down*, so a recipe is auditable by
reading it, and the plain-English builder planned in `docs/bots.md`
§ "Creating bots in plain English" can only ever emit things an operator could
have typed. Out-of-palette ideas ("delete messages", "email people") have no
spelling. Growth happens by adding verbs to the schema — a code change, a
review — never by a recipe smuggling one in.

Loading **fails closed**: a recipe that doesn't validate stops the bot with an
error. It never falls back to different behaviour the operator didn't write.

## Schema v1

A JSON file next to the bot's session file — `<session>.recipe.json`, the same
sibling convention as a changelog rule's `.state.json`. No recipe file means the
**default recipe**: answer the deterministic command floor when addressed
(exactly the keyless Node sidecar's behaviour).

```json
{
  "rules": [
    { "on": "message", "match": "any", "regex": "(?i)\\bguys\\b",
      "action": "reply", "text": "Hey {name} — how about \"folks\"?" },
    { "on": "message", "action": "commands" },
    { "on": "changelog", "action": "announce", "every": "5m" }
  ]
}
```

Rules are evaluated **in order; the first matching rule acts** and the rest
are skipped.

### Triggers (`on`)

- **`message`** — an incoming chat message the bot can read. Refinements:
  - `match` — who the rule listens to. `"trigger"` (the default) fires only
    when the bot is *addressed*: a DM, or a group @mention. `"any"` fires on
    every message — the loud **watcher** mode (below).
  - `keyword` — case-insensitive substring gate on the text.
  - `regex` — RE2 gate. Both set = both must match.
- **`changelog`** — new entries in the instance's embedded changelog (the
  the retired default Announcer's trigger — kept for custom deploy
  watchers). `every` sets the poll interval (Go duration, default
  `5m`). At most one changelog rule per recipe — one announce memory per bot.

### Actions (`action`)

- **`commands`** — answer the deterministic command floor (`/help`, `/theme`,
  `/dark`, …, `internal/bot/commands.go`). A non-command falls through to
  later rules; if nothing else acts and the bot was addressed, it answers with
  the "try /help" hint. Message rules only.
- **`reply`** — post `text` in the thread, with `{name}` replaced by the
  sender's name. Deterministic, no model call — per `docs/bots.md`, a fixed
  template is often the *best* form for a nudge. Message rules only.
- **`model`** — phrase the reply with Claude (the runtime's model layer,
  `internal/bot/brain.go`): builds context from thread history, calls the
  Messages API with the layered system prompt + chip tools, posts the result.
  Needs `ANTHROPIC_API_KEY` (the bot then badges `ai`); with no key it falls
  through to later rules, so a recipe reads the same with or without a brain.
  This is the one action that sends text to a provider — hence its place in a
  watcher matters (below). Message rules only.
- **`announce`** — post fresh changelog entries to every thread the bot is in
  (the retired Announcer's full behaviour, including the silent first-run
  baseline).
  Changelog rules only.

The **default recipe** (no file) is `commands` then `model`: answer a command
when addressed, else let the brain reply — exactly the Node sidecar with and
without a key.

Planned, not in v1: a `schedule` trigger and a private-DM nudge variant. Each
lands as a schema addition with its own review, per the boundary above.

## Watchers are loud

`match: "any"` is the **"reads every message in this group"** mode from
`docs/bots.md` § Consent — the exception, never the default. The engine
surfaces it (`Recipe.Watches()`; `lchat bot run` logs *"watches every
message"* at startup), and the consent story rides on it: adding a watcher to
a group is the consent moment, and the admin console should show the flag
wherever the bot appears. Detection stays in the sidecar — with a
deterministic `reply` action, a watched message **never leaves the box at
all**; with a `model` action, only *matches* ever reach the provider.

## Execution

`lchat bot run -base <url> a.session.json b.session.json …` loads each
session's recipe (or the default), then runs one `bot.Engine` per identity:
the live WebSocket runtime with the message rules folded into its Handler
seam, plus one poller per changelog rule. One process, many bots, zero Node.

A changelog recipe replaces the retired `lchat bot announce -poll` for long-running
setups (`announce` remains for cron-style one-shots); both share the same
`.state.json`, so switching modes never replays entries.
