eleven / messenger Public

bot: renaming a member bot renames its chat (#814)

Paul renamed his bot conversationally: the charter updated (Eleven
confirmed, future editions post under the new persona) but the bot's
dedicated thread kept its birth name β€” the update path never touched it,
so the sidebar and header still said "RTE News Hourly".

The update path now re-titles the thread (renameCharterThread: the same
encrypted name_blob shape the web client's "Edit group" writes, via
POST /api/threads/{id}/name) β€” but ONLY when the persona actually
changed, so a group name the member set by hand survives an unrelated
schedule or source tweak. Best-effort: the charter is the truth and is
already stored; a failed rename costs a stale label.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 28f5e04b55e2f26f4c9cca66b1a51bbe90845813 parent 830adc2
5 files changed, +190 −0
  • bot_charterrename_test.go +151 −0
  • changelog/2026-07-13-bot-rename-renames-chat.md +4 −0
  • docs/member-bots.md +3 −0
  • internal/bot/botbuild.go +7 −0
  • internal/bot/runtime.go +25 −0
diff --git a/bot_charterrename_test.go b/bot_charterrename_test.go
new file mode 100644
index 0000000..8bda68e
--- /dev/null
+++ b/bot_charterrename_test.go
@@ -0,0 +1,151 @@
+package main
+
+// End-to-end test of renaming a member bot conversationally (internal/bot:
+// botbuild.go + runtime.go renameCharterThread): a create_bot update with a
+// new title must rename the bot's dedicated thread (it was titled after the
+// bot at birth) β€” and an update that DOESN'T touch the persona must leave the
+// thread name alone, so a member's hand-picked group name survives a schedule
+// tweak.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "lchat/internal/bot"
+ "lchat/internal/e2ee"
+)
+
+func TestCharterUpdateRenamesThread(t *testing.T) {
+ t.Setenv("LCHAT_BOT_ALLOW_PRIVATE_FETCH", "1")
+
+ site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/feed.xml" {
+ w.Write([]byte(`<rss><item><title>All quiet</title></item></rss>`))
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head></html>`))
+ }))
+ defer site.Close()
+
+ farSlot := time.Now().UTC().Add(12 * time.Hour).Format("15:04")
+ farSlot2 := time.Now().UTC().Add(13 * time.Hour).Format("15:04")
+ dryRun := [][]map[string]any{
+ {{"type": "tool_use", "id": "tu_1", "name": "fetch", "input": map[string]any{"url": site.URL + "/feed.xml"}}},
+ {{"type": "text", "text": "All quiet today."}},
+ }
+ fakeAI := scriptedAnthropic(t,
+ // Update 1: a RENAME (new title + glyph, same schedule).
+ []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{
+ "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "πŸ“°",
+ "at": farSlot, "sources": []any{site.URL}, "prompt": "Summarise the feed.",
+ }}},
+ dryRun[0], dryRun[1],
+ // Update 2: schedule only β€” title/glyph unchanged.
+ []map[string]any{{"type": "tool_use", "name": "create_bot", "input": map[string]any{
+ "bot_id": "bot-home01", "name": "home-news", "title": "Journal Daily", "glyph": "πŸ“°",
+ "at": farSlot2, "sources": []any{site.URL}, "prompt": "Summarise the feed.",
+ }}},
+ dryRun[0], dryRun[1],
+ )
+ t.Setenv("ANTHROPIC_BASE_URL", fakeAI.URL)
+
+ ts, s := newTestServer(t)
+ owner := provisionNamed(t, ts.URL, "Owner", false)
+ eleven := provisionNamed(t, ts.URL, "Eleven", true)
+ ownerUID, _ := s.db.deviceTokenUser(owner.Token)
+ instKey := grantInstanceKey(t, ts.URL, owner, eleven, s)
+ threadID, threadKey := createThreadKind(t, ts.URL, s, "group", owner, eleven)
+
+ charter := map[string]any{
+ "v": 1, "name": "home-news", "title": "Home News", "glyph": "🏠",
+ "schedule": map[string]any{"at": farSlot},
+ "task": map[string]any{"prompt": "Write the household news."},
+ "thread": threadID, "owner": ownerUID,
+ }
+ blob, err := e2ee.EncryptJSON(instKey, charter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{"token": eleven.Token, "blob": blob, "owner_id": ownerUID})
+ req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/member-bots/bot-home01", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusOK {
+ t.Fatalf("seed charter: %v %d", err, resp.StatusCode)
+ }
+
+ dir := t.TempDir()
+ sessionPath := dir + "/eleven.session.json"
+ if err := eleven.Save(sessionPath); err != nil {
+ t.Fatal(err)
+ }
+ ready := make(chan struct{})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ eng := &bot.Engine{Base: ts.URL, Session: eleven, SessionPath: sessionPath,
+ Recipe: bot.DefaultRecipe(), AIKey: "test-key", Charters: true,
+ Ready: func() { close(ready) }, Logf: t.Logf}
+ go eng.Run(ctx)
+ awaitReady(t, ready)
+
+ threadName := func() string {
+ var nameBlob string
+ if err := s.db.sql.QueryRow(`SELECT name_blob FROM threads WHERE id = ?`, threadID).Scan(&nameBlob); err != nil {
+ t.Fatalf("thread name_blob: %v", err)
+ }
+ if nameBlob == "" {
+ return ""
+ }
+ var n struct {
+ Name string `json:"name"`
+ }
+ if err := e2ee.DecryptJSON(threadKey, nameBlob, &n); err != nil {
+ return nameBlob // opaque (the hand-set marker below)
+ }
+ return n.Name
+ }
+
+ // Rename: the bot's chat follows the bot's new persona.
+ postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Rename it to Journal Daily please.")
+ waitFor(t, "the rename update to settle", func() bool {
+ for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") {
+ if strings.Contains(rp.Text, "is updated") {
+ return true
+ }
+ }
+ return false
+ })
+ if got := threadName(); got != "Journal Daily πŸ“°" {
+ t.Fatalf("thread name after rename = %q, want %q", got, "Journal Daily πŸ“°")
+ }
+
+ // A schedule-only update must NOT touch the thread name β€” a member's own
+ // group rename survives.
+ if err := s.db.setThreadName(threadID, "HAND-SET-OPAQUE-BLOB"); err != nil {
+ t.Fatal(err)
+ }
+ // Let the first build's goroutine release its one-build-at-a-time slot
+ // (endBuild is deferred, a beat after the settle message lands).
+ time.Sleep(200 * time.Millisecond)
+ postAsMember(t, ts.URL, threadID, threadKey, owner, ownerUID, "Make it an hour later.")
+ // The second settle is recognisable by the NEW slot in its schedule
+ // summary ("…the next arrives daily at <farSlot2> UTC") β€” both settles
+ // say "is updated", so the first line can't stand in for it.
+ waitFor(t, "the schedule update to settle", func() bool {
+ for _, rp := range botReplies(t, s, threadID, threadKey, "Eleven") {
+ if strings.Contains(rp.Text, farSlot2) {
+ return true
+ }
+ }
+ return false
+ })
+ if got := threadName(); got != "HAND-SET-OPAQUE-BLOB" {
+ t.Fatalf("schedule-only update touched the thread name: %q", got)
+ }
+}
diff --git a/changelog/2026-07-13-bot-rename-renames-chat.md b/changelog/2026-07-13-bot-rename-renames-chat.md
new file mode 100644
index 0000000..4b9207c
--- /dev/null
+++ b/changelog/2026-07-13-bot-rename-renames-chat.md
@@ -0,0 +1,4 @@
+πŸ€– Renaming a bot now renames its chat too. Ask in the bot's chat β€” "rename
+it to Journal Daily" β€” and both the bot and the chat it lives in take the new
+name. A name you set on the group yourself stays put unless you actually
+rename the bot.
diff --git a/docs/member-bots.md b/docs/member-bots.md
index b54144d..15d2e3e 100644
--- a/docs/member-bots.md
+++ b/docs/member-bots.md
@@ -115,6 +115,9 @@ brain answers with the charter as per-thread system context: the real
(`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
diff --git a/internal/bot/botbuild.go b/internal/bot/botbuild.go
index 0d2cf0e..042a397 100644
--- a/internal/bot/botbuild.go
+++ b/internal/bot/botbuild.go
@@ -408,6 +408,13 @@ func (r *Runtime) runBotBuild(tc *ThreadCtx, req botBuildReq, requester, request
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
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 0e60ad8..2621b51 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -986,6 +986,31 @@ func (r *Runtime) createCharterThread(ownerUID, title string) (string, []byte, e
return out.ThreadID, key, nil
}
+// renameCharterThread re-titles a member bot's dedicated thread β€” a renamed
+// bot renames its chat, since the thread was titled after the bot at birth
+// and members read the bot's identity off that name. Same name_blob shape the
+// web client's "Edit group" writes, encrypted under the thread key; the key
+// comes off the threads endpoint (wrappedKeyFor) so the builder goroutine
+// stays off r.keys. Best-effort: the charter (the truth) is already stored,
+// so a failed rename costs a stale label, never a wrong bot.
+func (r *Runtime) renameCharterThread(threadID, title string) {
+ key, err := r.wrappedKeyFor(threadID)
+ if err != nil {
+ r.logf("bot rename %s: %v", threadID, err)
+ return
+ }
+ nameBlob, err := e2ee.EncryptJSON(key, map[string]any{"name": title})
+ if err != nil {
+ r.logf("bot rename %s: %v", threadID, err)
+ return
+ }
+ if err := r.post("/api/threads/"+url.PathEscape(threadID)+"/name", map[string]any{
+ "token": r.Session.Token, "name_blob": nameBlob,
+ }, nil); err != nil {
+ r.logf("bot rename %s: %v", threadID, err)
+ }
+}
+
func (r *Runtime) post(path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {