# 🤖 Member bots — autonomous, scheduled agents members make by asking

*"Make me a bot that sends me a news summary every morning from NYTimes, RTÉ
and the Guardian."* This document describes how that works: the design, the
trust model, and where each piece lives. It is the **Option B** build of
issue #432 (charter blobs, no new E2EE identity), constructed on top of the
member-apps machinery (#397) it deliberately mirrors.

## Apps and bots are two axes, not competitors

| | **Member apps** (docs/member-apps.md) | **Member bots** (this) |
|---|---|---|
| What it is | an interactive **surface** you open | an autonomous **agent** that messages you |
| Runs | only while you have it open | on a schedule, unattended |
| Network | none (sandbox CSP `connect-src 'none'`) | fetches feeds/pages via the sidecar |
| Execution | sandboxed iframe, on-device | the bot sidecar (holds keys + the model) |
| Example | habit tracker, poll, whiteboard | morning digest, weekly round-up |

Rule of thumb: **apps are surfaces you open; bots are agents that message
you.** When something could be either, ask "does it need to act when nobody's
looking?" — yes → bot.

## What a member bot IS: a charter

A member bot is not a new roster member. It is a **charter** — a small JSON
document (persona + schedule + task + where output goes) that the member owns:

```json
{
  "v": 1,
  "name": "morning-news", "title": "Morning News", "glyph": "📰",
  "schedule": { "at": "08:00", "tz": "Europe/Dublin" },
  "task": {
    "prompt": "Fetch today's headlines from each source; write a short digest…",
    "tools": ["fetch"],
    "fetch": { "allow": ["https://rss.nytimes.com/…", "https://www.rte.ie/feeds/…"] },
    "post": { "card": true }
  },
  "thread": "<the bot's dedicated thread>",
  "owner": "<member uid>", "by": "Paul", "builtBy": "Eleven"
}
```

The charter is encrypted under the **instance key** and stored in the server's
`member_bots` table (`memberbots.go`) exactly like a member app's
`{source, manifest}` blob: the server learns `(random id, author, owner,
size, times)` and nothing else — not the bot's name, schedule, sources, or
prompt. Ownership follows the member-app pattern verbatim: an **agent** may
set `owner_id` to the member it built for; overwrite is author-or-owner;
delete is author-or-owner-or-admin (moderation without readability).

## Who runs it: the charter host

One identity in the existing sidecar — **Eleven** when present, elected in
`botcmd.go` — is the **charter host** (`Runtime.Charters`). It:

- decrypts and validates every stored charter (`internal/bot/charter.go`;
  fail closed per charter — a corrupt row never kills the rest);
- keeps one **alarm** pending per live charter, using the server's existing
  scheduled-message queue as the clock (`internal/bot/chartersched.go`): a
  `bot.tick` payload encrypted under the host's **self-thread** key,
  delivered by the ordinary sweep (`miniapps.go`). No human thread carries
  tick traffic, nobody else can ring the alarms, alarms survive restarts
  (the queue is server-side), and reconcile (on connect + a slow ticker)
  heals lost or stale ones;
- on each tick, re-reads the charter (edits/deletes take effect immediately),
  re-arms the next occurrence *first* (a crash can't silence the bot), then
  runs the task.

## What a run is: the task engine

`internal/bot/task.go` — a bounded agentic loop: system prompt from the
charter, a closed tool set, execute tool calls, post the final text.

- **fetch** (`internal/bot/fetch.go`): HTTPS GET only, against the charter's
  URL-prefix allowlist, same private-address blocking as the unfurler,
  redirects re-checked against the allowlist, bodies capped. A task with no
  allowlist has no network.
- **card** (`internal/bot/card.go`): the model may compose one card — a small
  declarative document (heading / text / item / list / kv / divider /
  one-level section / https-link chips) that renders natively on web
  (`web/static/cards.js`) and iOS/Mac (`LChatCore/Card.swift` + `CardView`),
  pinned three ways by `test/event-vectors/cards.json`. Malformed cards
  bounce back as tool_results the model can fix; unknown cards fail closed at
  render and the text (the mandatory plain fallback) shows instead.
- **Budgets**: `max_tool_calls` (default 6, cap 16), fetch bodies truncated
  for context, one card per run.
- **Failure backoff**: a run that errors, comes back empty, or reaches *none*
  of its sources (every fetch failed — the all-403 case, where the model
  writes an apology that would otherwise post as a normal edition every
  occurrence) is a **failed edition**. The first failure of an outage posts
  the model's explanation once, with a keep-trying note; further consecutive
  failures post nothing, and interval schedules stretch the next attempt
  (2×, 4×, 8×… the interval, capped at a day — daily bots keep their day).
  A successful run clears the state and editions resume by themselves. The
  counter is in-memory (`chartersched.go`); a sidecar restart forgives,
  costing at most one extra notice.
- **Injection story is containment, not detection**: fetched content arrives
  fenced in tool_result blocks and is framed as data; the model can only call
  the tools the charter names; output can only go where the charter's thread
  already points. Worst case is a weird summary in the bot's own thread.

The result posts into the charter's **dedicated thread** — a titled group of
exactly (owner, host) created at birth — under the charter's **persona name**
("Morning News"), so the bot reads as its own presence without being a
keypair. The plaintext trust note is the same as any AI bot today: the
sidecar process holds thread plaintext while it works; the *server* still
never holds a usable key.

**That thread is conversational.** The owner talking there is always talking
to the host — no `@` needed (`triggered`, `runtime.go`, gated on the owner's
uid so the bot's own persona-named editions never draw a reply) — and the
brain answers with the charter as per-thread system context: the real
`bot_id`, persona, schedule, sources, and standing instructions
(`charterHomeNote`, `brain.go`), plus the `create_bot`/`remove_bot` tools. So
"change it to The Journal", "make it daily at 8", and "remove this bot" all
work right in the bot's own chat, not just in the DM where it was born.
Renaming the bot renames its chat too (`renameCharterThread`) — but only when
the persona actually changed, so a group name the member set by hand survives
an unrelated schedule or source tweak.

## How one is born: the builder, and the dry run as the gate

`internal/bot/botbuild.go`, mirroring `appbuild.go`: the brain's `create_bot`
tool (offered only on the charter host) is an **executor** — the sidecar runs
the setup on its own goroutine, reporting progress on one action line.

1. Validate the request (slug/title/glyph/schedule/prompt).
2. **Feed discovery** (`internal/bot/discover.go`): the member names
   publications ("rte.ie", "the Guardian") — never URLs. The builder fetches
   each site the member named and reads the feed URLs out of its own HTML
   (`<link rel="alternate" type="…rss/atom…">`). A site with no advertised
   feed falls back to the page itself; the dry run judges.
3. **The dry run is the gate**: the task is executed once, immediately. If it
   fails, comes back empty, or can't read any of its sources, *nothing is
   created* and the member sees why — never a bot that fails silently at 8am,
   and never one born apologising.
4. On success: charter stored (owner = requester), dedicated thread created,
   first alarm armed, and the dry run's output delivered as the bot's **first
   edition** — proof, not promise.

Changing a bot is conversational: ask in the bot's own chat (the host has the
charter and its id as context there), or ask the builder in the DM where it
was born (`create_bot` with the `bot_id` from its earlier reply). Ownership is
checked against the requester in the sidecar, where plaintext lives, either
way. `remove_bot` retires it: the charter is deleted, alarms cancelled, and
the bot's thread — the member's history — stays.

## What the server gained (deliberately little)

One table + three routes (`memberbots.go`), mirroring member apps. Everything
else rides surfaces the server already had: the schedule queue, self/group
threads, encrypted thread names, `POST /api/messages`. The tick, the charter,
the digest, the card — all ciphertext.

## Testing

- `internal/bot`: charter/schedule validation + occurrence maths, fetch SSRF
  + allowlist, feed discovery, card normalization (vector-pinned).
- Root: `bot_botbuild_test.go` drives the whole pipeline against the real
  router with a scripted model — create_bot → discovery → dry run (with a
  thinking block, pinning the echo-thinking-intact fix) → stored charter →
  dedicated thread → persona-named first edition with card → armed alarm;
  and the autonomous loop — tick delivered → task run → post → re-arm.
  `memberbots_test.go` pins the ownership gates.
- Web: `test/cards.test.mjs` (+ `test/event-vectors/cards.json`); native:
  `CardVectorTests.swift` reads the same vectors.

## Try it locally (two terminals + a browser)

```sh
# 1 — a local server, seeded and signed in (persistent across restarts):
tools/lchat-dev-up.mjs            # open the printed sign-in link

# 2 — the sidecar; its one identity becomes the charter host (the 1m floor
#     lets you watch a bot fire — production's floor is 15m):
ANTHROPIC_API_KEY=sk-ant-… LCHAT_BOT_MIN_EVERY=1m tools/lchat-dev-bot.mjs

# 3 — in the browser: ＋ New chat → Helpers → Claude, then ask:
#   "make me a bot that sends me a news summary every 2 minutes
#    from rte.ie and theguardian.com"
```

Watch: the status line advances (finding the feeds → running a first edition
→ opening its chat), a new **chat named after the bot** appears with the first
edition (a card over plain text), and further editions arrive on schedule.
Say "every 2 minutes" to watch it fire; "every morning at 8, Irish time" is
the real thing. Change or retire it by asking in the same DM. If the first ask
answers "I don't hold this space's shared key yet", ask again a few seconds
later — the bot nudges your open tab to grant it.

## Deferred (deliberately)

- **The drafting-table panel** (preview beside the chat before creation) —
  the dry-run gate delivers the same safety; the panel is UI polish on top
  (prototype `ae2143b`/`37dbce0` is the reference).
- **Integrations** (bots using connected services as tools, read/write-scoped
  grants, confirm chips) — designed in `docs/integrations.md` on the
  `integrations` prototype branch; stacks on this cleanly.
- **Governance dials**: write rate limits; an admin oversight panel. (The
  interval floor is handled: 15 minutes in production, lowered only via
  `LCHAT_BOT_MIN_EVERY` on tests/demo canaries.)
- A client surface for listing/managing your bots (today: ask in the bot's
  own chat, or ask the builder).
