package bot // The bot-builder: the create_bot tool that closes the loop "ask a bot for a // BOT in chat → a standing charter runs on schedule and messages you". The // member-app builder (appbuild.go) is the template — same executor seam in // modelReply, same live status line, same instance-key blob with // agent-set ownership — but where an app build generates CODE, a bot build // assembles a CHARTER and proves it: // // 1. The conversational brain interviews the member and calls create_bot // with the persona, schedule, sources, and standing prompt. // 2. Sources are resolved to real feeds — the member names publications, // never URLs (discover.go reads the feed URL out of each site's HTML). // 3. THE DRY RUN IS THE GATE: the task is executed once, now. If it can't // produce a result, nothing is created — the member sees why instead of // a bot that fails silently at 8am. // 4. On success the charter is stored (member_bots, owner = the requester), // a dedicated titled thread (owner + host) is created, the first alarm // is armed, and the dry run's output lands in the new thread as the // bot's first edition — proof, not promise. // // Updates reuse the tool with bot_id (ownership enforced here, where the // plaintext lives, exactly like app updates); remove_bot retires a charter. import ( "crypto/rand" "encoding/hex" "fmt" "strings" "time" "lchat/internal/e2ee" ) // createBotTool is offered only by the charter host (replyTools, brain.go). func createBotTool() anthTool { return anthTool{ Name: "create_bot", Description: "Create (or update) a personal BOT for this member: an autonomous agent that runs " + "on a schedule, reads its allowed sources, and posts its result into its own chat — e.g. a " + "morning news digest. This is different from create_app (an interactive surface they open): " + "a bot acts when nobody's looking. Before calling, make sure you know: what it should do " + "(the standing task), when (time of day + their timezone — ask if unstated), and which " + "publications/sites to read (their words are fine; feeds are discovered automatically — " + "NEVER ask for an RSS/feed URL). The setup takes a minute — it runs a real first edition as " + "a test before anything is created — and you post the outcome when it's done, so your " + "accompanying text should only say you've started.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "bot_id": map[string]any{ "type": "string", "description": "ONLY to UPDATE an existing bot — the exact opaque id from your earlier reply, or " + "from your context when you're chatting in the bot's own chat (looks like \"bot-1a2b3c4d\"). " + "NEVER invent one. Omit to create.", }, "name": map[string]any{ "type": "string", "description": "Short lowercase identifier: letters, digits, dashes; starts with a letter; max 24 chars. e.g. \"morning-news\"", }, "title": map[string]any{ "type": "string", "description": "The bot's name as the member will see it — on its messages and its chat. Max 40 chars. e.g. \"Morning News\"", }, "glyph": map[string]any{ "type": "string", "description": "One emoji used as the bot's avatar. e.g. \"📰\"", }, "at": map[string]any{ "type": "string", "description": "Daily run time as HH:MM (24h) in the tz given. Use for \"every morning at 8\". Exactly one of at / every.", }, "tz": map[string]any{ "type": "string", "description": "IANA timezone for `at`, e.g. \"Europe/Dublin\". Ask the member if you don't know theirs.", }, "every": map[string]any{ "type": "string", "description": "Interval schedule as a Go duration (\"30m\", \"4h\"). Use for \"every few hours\". Exactly one of at / every.", }, "sources": map[string]any{ "type": "array", "description": "The sites to read, as the member named them — a domain or homepage URL per entry " + "(e.g. \"nytimes.com\", \"rte.ie\", \"theguardian.com\"). Feeds are discovered from each site's own HTML. Max 6.", "items": map[string]any{"type": "string"}, }, "prompt": map[string]any{ "type": "string", "description": "The standing instruction the bot runs each time, written like a good system prompt: " + "what to fetch, what to produce, tone and length. e.g. \"Fetch today's headlines from each " + "source; write a 6-10 item digest, one line each, grouped by theme, with the source in brackets.\"", }, }, "required": []any{"name", "title", "glyph", "prompt"}, }, } } // removeBotTool retires a bot the member owns. func removeBotTool() anthTool { return anthTool{ Name: "remove_bot", Description: "Retire one of this member's bots — it stops running and its charter is deleted " + "(its chat and history stay). Use the exact bot id from an earlier reply or from your " + "context in the bot's own chat. Only do this when the member clearly asked to stop/remove " + "the bot.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"bot_id": map[string]any{"type": "string"}}, "required": []any{"bot_id"}, }, } } type botBuildReq struct { BotID string Name string Title string Glyph string Schedule ScheduleSpec Sources []string Prompt string } // parseBotBuildReq validates the model's tool input before any work is spent. func parseBotBuildReq(input map[string]any) (botBuildReq, error) { str := func(k string) string { v, _ := input[k].(string); return strings.TrimSpace(v) } req := botBuildReq{ BotID: str("bot_id"), Name: str("name"), Title: str("title"), Glyph: str("glyph"), Schedule: ScheduleSpec{At: str("at"), TZ: str("tz"), Every: str("every")}, Prompt: str("prompt"), } if !appNameRe.MatchString(req.Name) { return req, fmt.Errorf("bot name must be lowercase letters/digits/dashes, starting with a letter (got %q)", req.Name) } if req.Title == "" || len(req.Title) > 40 { return req, fmt.Errorf("title must be 1-40 characters") } if req.Glyph == "" || len(req.Glyph) > 16 { return req, fmt.Errorf("glyph must be one emoji") } if req.Prompt == "" || len(req.Prompt) > 4000 { return req, fmt.Errorf("prompt must be 1-4000 characters") } if req.BotID != "" && !appIDRe.MatchString(req.BotID) { return req, fmt.Errorf("bad bot_id") } if err := req.Schedule.validate(); err != nil { return req, err } if raw, ok := input["sources"].([]any); ok { if len(raw) > 6 { return req, fmt.Errorf("at most 6 sources") } for _, s := range raw { site, _ := s.(string) site = strings.TrimSpace(site) if site == "" || len(site) > 200 { return req, fmt.Errorf("bad source %q", site) } req.Sources = append(req.Sources, site) } } return req, nil } // resolveSources turns member-named sites into a fetch allowlist: each site's // advertised feeds (up to 2 per site), or — when a site advertises none — the // homepage itself (the model can still read the HTML; the dry run judges). // caveats are plain-English exceptions for the settle message ("bbc.com // doesn't share a feed, so it reads the front page instead") — a member wants // to know what their bot reads, never a transcript of discovery errors (those // go to the log). func (r *Runtime) resolveSources(sources []string) (allow []string, caveats []string) { for _, site := range sources { feeds, err := discoverFeeds(site) if err != nil { // No advertised feed (or unreachable): let the dry run try the page // itself. Normalise to an https URL prefix. r.logf("bot build: %s: feed discovery: %v", site, err) page := site if !strings.Contains(page, "://") { page = "https://" + page } allow = append(allow, page) caveats = append(caveats, fmt.Sprintf("%s doesn't share a feed, so it reads the front page instead", hostLabel(site))) continue } if len(feeds) > 2 { feeds = feeds[:2] } allow = append(allow, feeds...) } return allow, caveats } // hostLabel is a site as a member said it — bare host, no scheme/path/www. func hostLabel(site string) string { s := strings.TrimSpace(site) if i := strings.Index(s, "://"); i >= 0 { s = s[i+3:] } if i := strings.IndexAny(s, "/?#"); i >= 0 { s = s[:i] } return strings.TrimPrefix(s, "www.") } // joinAnd joins words the way a sentence would: "a", "a and b", "a, b and c". func joinAnd(words []string) string { switch len(words) { case 0: return "" case 1: return words[0] default: return strings.Join(words[:len(words)-1], ", ") + " and " + words[len(words)-1] } } // runBotBuild is the whole pipeline, on its own goroutine (like runAppBuild): // resolve sources → dry-run the task (the gate) → store the charter → create // the dedicated thread → arm the alarm → deliver the dry run as the first // edition. Progress rides one status line updated in place. The caller must // have reserved the build slot via beginBuild; this releases it on return. func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, requesterUID string) { defer r.endBuild(tc.ID, requesterUID) start := time.Now() status := tc.actionLine(fmt.Sprintf("🛠️ Setting up %s %s… (about a minute)", req.Title, req.Glyph)) stop := make(chan struct{}) done := make(chan struct{}) phase := make(chan string, 4) go func() { defer close(done) tc.Typing(15) tick := time.NewTicker(5 * time.Second) defer tick.Stop() current := "getting ready" for { select { case p := <-phase: current = p case <-tick.C: tc.Typing(15) status.set(fmt.Sprintf("🛠️ Setting up %s %s — %s… (%ds)", req.Title, req.Glyph, current, int(time.Since(start).Seconds()))) case <-stop: tc.Typing(0) return } } }() stopped := false stopBeat := func() { if stopped { return } stopped = true close(stop) <-done } defer stopBeat() settle := func(breadcrumb, message string, actions ...map[string]any) { stopBeat() status.set(breadcrumb) r.postReply(tc.ID, tc.key, Reply{Text: message, Actions: actions}) } // Failure copy speaks the member's situation: a failed CREATE made // nothing; a failed UPDATE changed nothing — the bot they have keeps // running exactly as it was, and saying "nothing was created" there reads // like the bot is gone. The vars flip once the update path loads the // existing charter (fail is a closure, so later calls see the flip). doing := "set up" cantIntro := "I couldn't set that bot up — " nothing := "Nothing was created." fail := func(format string, args ...any) { msg := fmt.Sprintf(format, args...) r.logf("bot build %q: %s", req.Name, msg) settle(fmt.Sprintf("❌ Couldn't %s %s %s.", doing, req.Title, req.Glyph), cantIntro+msg+" Feel free to ask me to try again.") } if requesterUID == "" { fail("I couldn't tell who's asking, so I can't create a bot safely. Try again in a moment.") return } instKey, err := r.instanceKey() if err != nil { // Ask online holders to grant (an open tab wraps it on the spot), so // "try again in a minute" is literally all it takes. r.nudgeInstanceKey() fail(fmt.Sprintf("I don't hold this space's shared key yet (%v). It's granted automatically as members use the app — try again in a minute.", err)) return } selfID, selfKey, ok := r.selfThread() if !ok { fail("my scheduler isn't ready yet — try again in a minute.") return } // Update path: load the existing charter, enforce ownership, keep the slug // and the thread (renaming the slug would orphan nothing, but stability is // kinder; the thread is the member's history). var existing *Charter if req.BotID != "" { c, err := r.fetchCharter(req.BotID, instKey) if err != nil { fail(fmt.Sprintf("I couldn't load the bot to update (%v).", err)) return } switch { case c == nil: // The model supplied an id that doesn't exist — a common slip where // it invents one for a NEW bot. Treat as a create. r.logf("bot build: bot_id %q not found — creating a new bot instead", req.BotID) req.BotID = "" case c.Owner == "" || c.Owner != requesterUID: fail("that bot isn't one I can change for you — only the person it was made for can, by asking me themselves.") return default: existing = c req.Name = c.Name doing = "update" cantIntro = "I couldn't make that change — " nothing = "Your bot is unchanged and still running as before." } } if req.BotID == "" { if entries, err := r.listCharters(instKey); err == nil { req.Name = uniqueAppName(req.Name, charterNames(entries)) } } // Sources → allowlist. A bot with no sources runs promptly on model // knowledge alone (a daily poem needs no feed), so an empty list is legal. phase <- "finding the feeds" allow, caveats := r.resolveSources(req.Sources) charter := &Charter{ V: charterVersion, Name: req.Name, Title: req.Title, Glyph: req.Glyph, Schedule: req.Schedule, Task: TaskSpec{ Prompt: req.Prompt, Post: &PostSpec{Card: true}, }, Owner: requesterUID, By: requester, BuiltBy: r.Session.Name, } if len(allow) > 0 { charter.Task.Tools = []string{"fetch"} charter.Task.Fetch = &FetchSpec{Allow: allow} } if existing != nil { charter.Thread = existing.Thread } if err := validateCharter(charter); err != nil { fail(fmt.Sprintf("the charter didn't validate (%v).", err)) return } // THE DRY RUN IS THE GATE: run the task once, now. No result → no bot. // sourcesDown is checked FIRST: an all-sources-failed run that also came // back textless should name the real problem, not "empty". phase <- "running a first edition as a test" sample, err := r.execTask(charter.Title, &charter.Task) if err != nil { fail(fmt.Sprintf("the test run failed (%v). %s", err, nothing)) return } if sample.sourcesDown() { // The model reached NONE of the sources: the "edition" is an apology, // not proof. Same gate as empty — never mint a bot that's born failing. fail("the test run couldn't read any of its sources — they may be down or blocking readers right now. " + nothing) return } if strings.TrimSpace(sample.text) == "" { fail("the test run came back empty — the sources may not be readable. " + nothing) return } // The dedicated thread: the bot's own chat with its owner, titled after it. if charter.Thread == "" { phase <- "opening its chat" threadID, _, err := r.createCharterThread(requesterUID, req.Title+" "+req.Glyph) if err != nil { fail(fmt.Sprintf("I couldn't open the bot's chat (%v).", err)) return } charter.Thread = threadID } id := req.BotID verb := "is live" if id == "" { var buf [6]byte if _, err := rand.Read(buf[:]); err != nil { fail("couldn't mint a bot id.") return } id = "bot-" + hex.EncodeToString(buf[:]) } else { verb = "is updated" } if err := r.putCharter(id, charter, instKey); err != nil { fail(fmt.Sprintf("storing the bot was rejected (%v).", err)) return } // A renamed bot renames its chat — but ONLY when the persona actually // changed: a member's hand-picked group name must survive an unrelated // schedule or source tweak. if existing != nil && (existing.Title != req.Title || existing.Glyph != req.Glyph) { r.renameCharterThread(charter.Thread, req.Title+" "+req.Glyph) } // Re-arm cleanly: drop any pending alarm for this bot (an update may have // changed the schedule), then arm the next occurrence. A fresh charter also // starts with a clean slate — the dry run just proved it works, so any // backoff from its previous shape is stale. r.dropCharterAlarms(selfID, selfKey, id) r.clearCharterFails(id) r.armCharter(selfID, selfKey, id, charter.Schedule.next(time.Now())) // The store changed shape — refresh the instance's charter wake-time so a // hibernating instance is woken for the new/updated schedule // (chartersched.go reportCharterDue). r.recomputeCharterDue(instKey) // Deliver the dry run as the first edition — proof, not promise. The key // comes back from createCharterThread only on the create path; on updates // (and any refetch) read it via the threads endpoint to stay off r.keys. if key, err := r.wrappedKeyFor(charter.Thread); err == nil { r.postTaskResult(charter.Thread, key, charter.Title, &charter.Task, sample.text, sample.card) } else { r.logf("bot build %q: first-edition post: %v", req.Name, err) } // The confirmation is for a PERSON: what it does, where it lives, when the // next one lands, in plain words — no URLs (they'd drag a link preview into // the chat), no error transcripts (the log has those), and the opaque id // rides the muted breadcrumb line, where the brain can still read it back // for later updates without it cluttering the real message. Told from // where the member is standing: an ask made IN the bot's own chat isn't // pointed at "your new chat" — the fresh edition just posted right here. inHome := tc.ID == charter.Thread var sentences string switch { case inHome: sentences = fmt.Sprintf("✨ %s %s %s — that fresh edition above is the proof, and the next arrives %s.", req.Title, req.Glyph, verb, charter.Schedule.Summary()) case existing != nil: sentences = fmt.Sprintf("✨ %s %s %s — a fresh edition just landed in its **%s** chat, and the next arrives %s.", req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary()) default: sentences = fmt.Sprintf("✨ %s %s %s — its first edition is waiting in your new **%s** chat, and the next arrives %s.", req.Title, req.Glyph, verb, req.Title, charter.Schedule.Summary()) } if len(req.Sources) > 0 { labels := make([]string, len(req.Sources)) for i, s := range req.Sources { labels[i] = hostLabel(s) } sentences += " It reads " + joinAnd(labels) + "." } if len(caveats) == 1 { sentences += " One thing to know: " + caveats[0] + "." } else if len(caveats) > 1 { sentences += " Worth knowing: " + joinAnd(caveats) + "." } sentences += " Want to change it — or stop it? Just ask me here." r.logf("bot build %q: stored as %s (%d sources)", req.Name, id, len(allow)) if inHome { settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id), sentences) return } settle(fmt.Sprintf("✅ %s %s %s · %s", req.Title, req.Glyph, verb, id), sentences, map[string]any{"type": "open_thread", "thread": charter.Thread, "label": "Open " + req.Title + " " + req.Glyph}) } // runBotRemove retires a charter (the remove_bot tool): ownership checked // where the plaintext lives, alarms cleaned up, the thread left in place. func (r *Runtime) runBotRemove(tc *ThreadCtx, botID, requesterUID string) Reply { if requesterUID == "" { return Reply{Text: "I couldn't tell who's asking, so I can't remove a bot safely."} } instKey, err := r.instanceKey() if err != nil { return Reply{Text: fmt.Sprintf("I can't reach the bot store right now (%v).", err)} } c, err := r.fetchCharter(botID, instKey) if err != nil || c == nil { return Reply{Text: "I couldn't find that bot — it may already be gone."} } if c.Owner != requesterUID { return Reply{Text: "That bot isn't yours to remove — only the person it was made for can."} } if err := r.deleteCharter(botID); err != nil { return Reply{Text: fmt.Sprintf("Removing it failed (%v) — try again in a moment.", err)} } if selfID, selfKey, ok := r.selfThread(); ok { r.dropCharterAlarms(selfID, selfKey, botID) } r.clearCharterFails(botID) // The retired charter may have been the earliest — refresh the instance's // charter wake-time so it doesn't wake a hibernating space for a bot that // no longer exists. r.recomputeCharterDue(instKey) return Reply{Text: fmt.Sprintf("Done — %s %s is retired and won't run again. Its chat and everything it posted stay yours; hide the chat if you like.", c.Title, c.Glyph)} } // wrappedKeyFor unwraps a thread's key straight from the threads endpoint — // the builder goroutine's way to a key without touching r.keys (which belongs // to the frame goroutine). func (r *Runtime) wrappedKeyFor(threadID string) ([]byte, error) { var tl struct { Threads []struct { ID string `json:"id"` Wrapped string `json:"wrapped"` } `json:"threads"` } if err := r.get("/api/threads", &tl); err != nil { return nil, err } for _, t := range tl.Threads { if t.ID != threadID || t.Wrapped == "" { continue } keyB64, err := e2ee.UnwrapThreadKey(t.Wrapped, r.Session.UserPriv) if err != nil { return nil, err } return e2ee.ImportKey(keyB64) } return nil, fmt.Errorf("no key for thread %s", threadID) }