eleven / ocho Public
π€ 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).
{
"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).everysets the poll interval (Go duration, default5m). 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β posttextin the thread, with{name}replaced by the sender's name. Deterministic, no model call β perdocs/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. NeedsANTHROPIC_API_KEY(the bot then badgesai); 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.