| 1 | # π€ Bot recipes: behaviour as data |
| 2 | |
| 3 | A **recipe** is a bot's behaviour written as data β an ordered list of |
| 4 | triggerβaction rules from a small, bounded palette β executed by the Go bot |
| 5 | runtime (`lchat bot run`, `internal/bot/recipe.go`). It's the concrete form of |
| 6 | the decision in `docs/bots.md` that the Go runtime should be *recipe-shaped |
| 7 | from day one*: the engine executes config, so declarative bots (and eventually |
| 8 | the admin console's "describe a bot" front-end) need no code and no second |
| 9 | rewrite. |
| 10 | |
| 11 | Recipes have a **sibling schema, deliberately not unified**: |
| 12 | `docs/conversation-scripts.md` β Eleven's onboarding conversations as data, |
| 13 | interpreted *client-side* per platform. Conversation scripts choreograph a UI |
| 14 | the member is looking at; recipes run headless in a sidecar. They share a |
| 15 | philosophy (flows as data, closed capability sets, fail-closed loading), not a |
| 16 | language. |
| 17 | |
| 18 | ## The palette is the capability boundary |
| 19 | |
| 20 | A recipe can only express what the schema names. That's the point: an action |
| 21 | the palette doesn't have *cannot be written down*, so a recipe is auditable by |
| 22 | reading it, and the plain-English builder planned in `docs/bots.md` |
| 23 | Β§ "Creating bots in plain English" can only ever emit things an operator could |
| 24 | have typed. Out-of-palette ideas ("delete messages", "email people") have no |
| 25 | spelling. Growth happens by adding verbs to the schema β a code change, a |
| 26 | review β never by a recipe smuggling one in. |
| 27 | |
| 28 | Loading **fails closed**: a recipe that doesn't validate stops the bot with an |
| 29 | error. It never falls back to different behaviour the operator didn't write. |
| 30 | |
| 31 | ## Schema v1 |
| 32 | |
| 33 | A JSON file next to the bot's session file β `<session>.recipe.json`, the same |
| 34 | sibling convention as a changelog rule's `.state.json`. No recipe file means the |
| 35 | **default recipe**: answer the deterministic command floor when addressed |
| 36 | (exactly the keyless Node sidecar's behaviour). |
| 37 | |
| 38 | ```json |
| 39 | { |
| 40 | "rules": [ |
| 41 | { "on": "message", "match": "any", "regex": "(?i)\\bguys\\b", |
| 42 | "action": "reply", "text": "Hey {name} β how about \"folks\"?" }, |
| 43 | { "on": "message", "action": "commands" }, |
| 44 | { "on": "changelog", "action": "announce", "every": "5m" } |
| 45 | ] |
| 46 | } |
| 47 | ``` |
| 48 | |
| 49 | Rules are evaluated **in order; the first matching rule acts** and the rest |
| 50 | are skipped. |
| 51 | |
| 52 | ### Triggers (`on`) |
| 53 | |
| 54 | - **`message`** β an incoming chat message the bot can read. Refinements: |
| 55 | - `match` β who the rule listens to. `"trigger"` (the default) fires only |
| 56 | when the bot is *addressed*: a DM, or a group @mention. `"any"` fires on |
| 57 | every message β the loud **watcher** mode (below). |
| 58 | - `keyword` β case-insensitive substring gate on the text. |
| 59 | - `regex` β RE2 gate. Both set = both must match. |
| 60 | - **`changelog`** β new entries in the instance's embedded changelog (the |
| 61 | the retired default Announcer's trigger β kept for custom deploy |
| 62 | watchers). `every` sets the poll interval (Go duration, default |
| 63 | `5m`). At most one changelog rule per recipe β one announce memory per bot. |
| 64 | |
| 65 | ### Actions (`action`) |
| 66 | |
| 67 | - **`commands`** β answer the deterministic command floor (`/help`, `/theme`, |
| 68 | `/dark`, β¦, `internal/bot/commands.go`). A non-command falls through to |
| 69 | later rules; if nothing else acts and the bot was addressed, it answers with |
| 70 | the "try /help" hint. Message rules only. |
| 71 | - **`reply`** β post `text` in the thread, with `{name}` replaced by the |
| 72 | sender's name. Deterministic, no model call β per `docs/bots.md`, a fixed |
| 73 | template is often the *best* form for a nudge. Message rules only. |
| 74 | - **`model`** β phrase the reply with Claude (the runtime's model layer, |
| 75 | `internal/bot/brain.go`): builds context from thread history, calls the |
| 76 | Messages API with the layered system prompt + chip tools, posts the result. |
| 77 | Needs `ANTHROPIC_API_KEY` (the bot then badges `ai`); with no key it falls |
| 78 | through to later rules, so a recipe reads the same with or without a brain. |
| 79 | This is the one action that sends text to a provider β hence its place in a |
| 80 | watcher matters (below). Message rules only. |
| 81 | - **`announce`** β post fresh changelog entries to every thread the bot is in |
| 82 | (the retired Announcer's full behaviour, including the silent first-run |
| 83 | baseline). |
| 84 | Changelog rules only. |
| 85 | |
| 86 | The **default recipe** (no file) is `commands` then `model`: answer a command |
| 87 | when addressed, else let the brain reply β exactly the Node sidecar with and |
| 88 | without a key. |
| 89 | |
| 90 | Planned, not in v1: a `schedule` trigger and a private-DM nudge variant. Each |
| 91 | lands as a schema addition with its own review, per the boundary above. |
| 92 | |
| 93 | ## Watchers are loud |
| 94 | |
| 95 | `match: "any"` is the **"reads every message in this group"** mode from |
| 96 | `docs/bots.md` Β§ Consent β the exception, never the default. The engine |
| 97 | surfaces it (`Recipe.Watches()`; `lchat bot run` logs *"watches every |
| 98 | message"* at startup), and the consent story rides on it: adding a watcher to |
| 99 | a group is the consent moment, and the admin console should show the flag |
| 100 | wherever the bot appears. Detection stays in the sidecar β with a |
| 101 | deterministic `reply` action, a watched message **never leaves the box at |
| 102 | all**; with a `model` action, only *matches* ever reach the provider. |
| 103 | |
| 104 | ## Execution |
| 105 | |
| 106 | `lchat bot run -base <url> a.session.json b.session.json β¦` loads each |
| 107 | session's recipe (or the default), then runs one `bot.Engine` per identity: |
| 108 | the live WebSocket runtime with the message rules folded into its Handler |
| 109 | seam, plus one poller per changelog rule. One process, many bots, zero Node. |
| 110 | |
| 111 | A changelog recipe replaces the retired `lchat bot announce -poll` for long-running |
| 112 | setups (`announce` remains for cron-style one-shots); both share the same |
| 113 | `.state.json`, so switching modes never replays entries. |
| 114 | |