eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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

{
  "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 β€” createbot β†’ 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. `memberbotstest.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)

# 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).