| 1 | # π€ Member bots β autonomous, scheduled agents members make by asking |
| 2 | |
| 3 | *"Make me a bot that sends me a news summary every morning from NYTimes, RTΓ |
| 4 | and the Guardian."* This document describes how that works: the design, the |
| 5 | trust model, and where each piece lives. It is the **Option B** build of |
| 6 | issue #432 (charter blobs, no new E2EE identity), constructed on top of the |
| 7 | member-apps machinery (#397) it deliberately mirrors. |
| 8 | |
| 9 | ## Apps and bots are two axes, not competitors |
| 10 | |
| 11 | | | **Member apps** (docs/member-apps.md) | **Member bots** (this) | |
| 12 | |---|---|---| |
| 13 | | What it is | an interactive **surface** you open | an autonomous **agent** that messages you | |
| 14 | | Runs | only while you have it open | on a schedule, unattended | |
| 15 | | Network | none (sandbox CSP `connect-src 'none'`) | fetches feeds/pages via the sidecar | |
| 16 | | Execution | sandboxed iframe, on-device | the bot sidecar (holds keys + the model) | |
| 17 | | Example | habit tracker, poll, whiteboard | morning digest, weekly round-up | |
| 18 | |
| 19 | Rule of thumb: **apps are surfaces you open; bots are agents that message |
| 20 | you.** When something could be either, ask "does it need to act when nobody's |
| 21 | looking?" β yes β bot. |
| 22 | |
| 23 | ## What a member bot IS: a charter |
| 24 | |
| 25 | A member bot is not a new roster member. It is a **charter** β a small JSON |
| 26 | document (persona + schedule + task + where output goes) that the member owns: |
| 27 | |
| 28 | ```json |
| 29 | { |
| 30 | "v": 1, |
| 31 | "name": "morning-news", "title": "Morning News", "glyph": "π°", |
| 32 | "schedule": { "at": "08:00", "tz": "Europe/Dublin" }, |
| 33 | "task": { |
| 34 | "prompt": "Fetch today's headlines from each source; write a short digestβ¦", |
| 35 | "tools": ["fetch"], |
| 36 | "fetch": { "allow": ["https://rss.nytimes.com/β¦", "https://www.rte.ie/feeds/β¦"] }, |
| 37 | "post": { "card": true } |
| 38 | }, |
| 39 | "thread": "<the bot's dedicated thread>", |
| 40 | "owner": "<member uid>", "by": "Paul", "builtBy": "Eleven" |
| 41 | } |
| 42 | ``` |
| 43 | |
| 44 | The charter is encrypted under the **instance key** and stored in the server's |
| 45 | `member_bots` table (`memberbots.go`) exactly like a member app's |
| 46 | `{source, manifest}` blob: the server learns `(random id, author, owner, |
| 47 | size, times)` and nothing else β not the bot's name, schedule, sources, or |
| 48 | prompt. Ownership follows the member-app pattern verbatim: an **agent** may |
| 49 | set `owner_id` to the member it built for; overwrite is author-or-owner; |
| 50 | delete is author-or-owner-or-admin (moderation without readability). |
| 51 | |
| 52 | ## Who runs it: the charter host |
| 53 | |
| 54 | One identity in the existing sidecar β **Eleven** when present, elected in |
| 55 | `botcmd.go` β is the **charter host** (`Runtime.Charters`). It: |
| 56 | |
| 57 | - decrypts and validates every stored charter (`internal/bot/charter.go`; |
| 58 | fail closed per charter β a corrupt row never kills the rest); |
| 59 | - keeps one **alarm** pending per live charter, using the server's existing |
| 60 | scheduled-message queue as the clock (`internal/bot/chartersched.go`): a |
| 61 | `bot.tick` payload encrypted under the host's **self-thread** key, |
| 62 | delivered by the ordinary sweep (`miniapps.go`). No human thread carries |
| 63 | tick traffic, nobody else can ring the alarms, alarms survive restarts |
| 64 | (the queue is server-side), and reconcile (on connect + a slow ticker) |
| 65 | heals lost or stale ones; |
| 66 | - on each tick, re-reads the charter (edits/deletes take effect immediately), |
| 67 | re-arms the next occurrence *first* (a crash can't silence the bot), then |
| 68 | runs the task. |
| 69 | |
| 70 | ## What a run is: the task engine |
| 71 | |
| 72 | `internal/bot/task.go` β a bounded agentic loop: system prompt from the |
| 73 | charter, a closed tool set, execute tool calls, post the final text. |
| 74 | |
| 75 | - **fetch** (`internal/bot/fetch.go`): HTTPS GET only, against the charter's |
| 76 | URL-prefix allowlist, same private-address blocking as the unfurler, |
| 77 | redirects re-checked against the allowlist, bodies capped. A task with no |
| 78 | allowlist has no network. |
| 79 | - **card** (`internal/bot/card.go`): the model may compose one card β a small |
| 80 | declarative document (heading / text / item / list / kv / divider / |
| 81 | one-level section / https-link chips) that renders natively on web |
| 82 | (`web/static/cards.js`) and iOS/Mac (`LChatCore/Card.swift` + `CardView`), |
| 83 | pinned three ways by `test/event-vectors/cards.json`. Malformed cards |
| 84 | bounce back as tool_results the model can fix; unknown cards fail closed at |
| 85 | render and the text (the mandatory plain fallback) shows instead. |
| 86 | - **Budgets**: `max_tool_calls` (default 6, cap 16), fetch bodies truncated |
| 87 | for context, one card per run. |
| 88 | - **Failure backoff**: a run that errors, comes back empty, or reaches *none* |
| 89 | of its sources (every fetch failed β the all-403 case, where the model |
| 90 | writes an apology that would otherwise post as a normal edition every |
| 91 | occurrence) is a **failed edition**. The first failure of an outage posts |
| 92 | the model's explanation once, with a keep-trying note; further consecutive |
| 93 | failures post nothing, and interval schedules stretch the next attempt |
| 94 | (2Γ, 4Γ, 8Γβ¦ the interval, capped at a day β daily bots keep their day). |
| 95 | A successful run clears the state and editions resume by themselves. The |
| 96 | counter is in-memory (`chartersched.go`); a sidecar restart forgives, |
| 97 | costing at most one extra notice. |
| 98 | - **Injection story is containment, not detection**: fetched content arrives |
| 99 | fenced in tool_result blocks and is framed as data; the model can only call |
| 100 | the tools the charter names; output can only go where the charter's thread |
| 101 | already points. Worst case is a weird summary in the bot's own thread. |
| 102 | |
| 103 | The result posts into the charter's **dedicated thread** β a titled group of |
| 104 | exactly (owner, host) created at birth β under the charter's **persona name** |
| 105 | ("Morning News"), so the bot reads as its own presence without being a |
| 106 | keypair. The plaintext trust note is the same as any AI bot today: the |
| 107 | sidecar process holds thread plaintext while it works; the *server* still |
| 108 | never holds a usable key. |
| 109 | |
| 110 | **That thread is conversational.** The owner talking there is always talking |
| 111 | to the host β no `@` needed (`triggered`, `runtime.go`, gated on the owner's |
| 112 | uid so the bot's own persona-named editions never draw a reply) β and the |
| 113 | brain answers with the charter as per-thread system context: the real |
| 114 | `bot_id`, persona, schedule, sources, and standing instructions |
| 115 | (`charterHomeNote`, `brain.go`), plus the `create_bot`/`remove_bot` tools. So |
| 116 | "change it to The Journal", "make it daily at 8", and "remove this bot" all |
| 117 | work right in the bot's own chat, not just in the DM where it was born. |
| 118 | Renaming the bot renames its chat too (`renameCharterThread`) β but only when |
| 119 | the persona actually changed, so a group name the member set by hand survives |
| 120 | an unrelated schedule or source tweak. |
| 121 | |
| 122 | ## How one is born: the builder, and the dry run as the gate |
| 123 | |
| 124 | `internal/bot/botbuild.go`, mirroring `appbuild.go`: the brain's `create_bot` |
| 125 | tool (offered only on the charter host) is an **executor** β the sidecar runs |
| 126 | the setup on its own goroutine, reporting progress on one action line. |
| 127 | |
| 128 | 1. Validate the request (slug/title/glyph/schedule/prompt). |
| 129 | 2. **Feed discovery** (`internal/bot/discover.go`): the member names |
| 130 | publications ("rte.ie", "the Guardian") β never URLs. The builder fetches |
| 131 | each site the member named and reads the feed URLs out of its own HTML |
| 132 | (`<link rel="alternate" type="β¦rss/atomβ¦">`). A site with no advertised |
| 133 | feed falls back to the page itself; the dry run judges. |
| 134 | 3. **The dry run is the gate**: the task is executed once, immediately. If it |
| 135 | fails, comes back empty, or can't read any of its sources, *nothing is |
| 136 | created* and the member sees why β never a bot that fails silently at 8am, |
| 137 | and never one born apologising. |
| 138 | 4. On success: charter stored (owner = requester), dedicated thread created, |
| 139 | first alarm armed, and the dry run's output delivered as the bot's **first |
| 140 | edition** β proof, not promise. |
| 141 | |
| 142 | Changing a bot is conversational: ask in the bot's own chat (the host has the |
| 143 | charter and its id as context there), or ask the builder in the DM where it |
| 144 | was born (`create_bot` with the `bot_id` from its earlier reply). Ownership is |
| 145 | checked against the requester in the sidecar, where plaintext lives, either |
| 146 | way. `remove_bot` retires it: the charter is deleted, alarms cancelled, and |
| 147 | the bot's thread β the member's history β stays. |
| 148 | |
| 149 | ## What the server gained (deliberately little) |
| 150 | |
| 151 | One table + three routes (`memberbots.go`), mirroring member apps. Everything |
| 152 | else rides surfaces the server already had: the schedule queue, self/group |
| 153 | threads, encrypted thread names, `POST /api/messages`. The tick, the charter, |
| 154 | the digest, the card β all ciphertext. |
| 155 | |
| 156 | ## Testing |
| 157 | |
| 158 | - `internal/bot`: charter/schedule validation + occurrence maths, fetch SSRF |
| 159 | + allowlist, feed discovery, card normalization (vector-pinned). |
| 160 | - Root: `bot_botbuild_test.go` drives the whole pipeline against the real |
| 161 | router with a scripted model β create_bot β discovery β dry run (with a |
| 162 | thinking block, pinning the echo-thinking-intact fix) β stored charter β |
| 163 | dedicated thread β persona-named first edition with card β armed alarm; |
| 164 | and the autonomous loop β tick delivered β task run β post β re-arm. |
| 165 | `memberbots_test.go` pins the ownership gates. |
| 166 | - Web: `test/cards.test.mjs` (+ `test/event-vectors/cards.json`); native: |
| 167 | `CardVectorTests.swift` reads the same vectors. |
| 168 | |
| 169 | ## Try it locally (two terminals + a browser) |
| 170 | |
| 171 | ```sh |
| 172 | # 1 β a local server, seeded and signed in (persistent across restarts): |
| 173 | tools/lchat-dev-up.mjs # open the printed sign-in link |
| 174 | |
| 175 | # 2 β the sidecar; its one identity becomes the charter host (the 1m floor |
| 176 | # lets you watch a bot fire β production's floor is 15m): |
| 177 | ANTHROPIC_API_KEY=sk-ant-β¦ LCHAT_BOT_MIN_EVERY=1m tools/lchat-dev-bot.mjs |
| 178 | |
| 179 | # 3 β in the browser: οΌ New chat β Helpers β Claude, then ask: |
| 180 | # "make me a bot that sends me a news summary every 2 minutes |
| 181 | # from rte.ie and theguardian.com" |
| 182 | ``` |
| 183 | |
| 184 | Watch: the status line advances (finding the feeds β running a first edition |
| 185 | β opening its chat), a new **chat named after the bot** appears with the first |
| 186 | edition (a card over plain text), and further editions arrive on schedule. |
| 187 | Say "every 2 minutes" to watch it fire; "every morning at 8, Irish time" is |
| 188 | the real thing. Change or retire it by asking in the same DM. If the first ask |
| 189 | answers "I don't hold this space's shared key yet", ask again a few seconds |
| 190 | later β the bot nudges your open tab to grant it. |
| 191 | |
| 192 | ## Deferred (deliberately) |
| 193 | |
| 194 | - **The drafting-table panel** (preview beside the chat before creation) β |
| 195 | the dry-run gate delivers the same safety; the panel is UI polish on top |
| 196 | (prototype `ae2143b`/`37dbce0` is the reference). |
| 197 | - **Integrations** (bots using connected services as tools, read/write-scoped |
| 198 | grants, confirm chips) β designed in `docs/integrations.md` on the |
| 199 | `integrations` prototype branch; stacks on this cleanly. |
| 200 | - **Governance dials**: write rate limits; an admin oversight panel. (The |
| 201 | interval floor is handled: 15 minutes in production, lowered only via |
| 202 | `LCHAT_BOT_MIN_EVERY` on tests/demo canaries.) |
| 203 | - A client surface for listing/managing your bots (today: ask in the bot's |
| 204 | own chat, or ask the builder). |
| 205 | |