eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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). 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.