eleven / messenger Public

bot: retire the Announcer β€” the server announces its own changelog now (#470)

The write-only third identity is gone from the default fleet. Release notes
reach people with NO bot in the loop (works on no-sidecar / no-AI instances):
the server pushes them to admins on deploy (announce.go, PR #463/#468) and
they live in the Admin app's update stream (PR #460). Members stop seeing
"πŸš€ Just shipped" in group chats β€” updates are the admins' concern now.

- add-bot mints two invites (Claude / Eleven); no LCHAT_ANNOUNCER_* env.
- `lchat bot announce` is retired (dies with a pointer, not unknown-command).
- `lchat bot run`: a legacy session named "Announcer" is SKIPPED with a log β€”
  under the default message recipe the old write-only bot would have become a
  responder. LCHAT_ANNOUNCER_SESSION stays in the env-sourcing list so a
  pre-retirement EnvironmentFile still resolves, then skips by name.
- DefaultRecipeFor is gone (no per-name defaults left); the generic `changelog`
  recipe rule and its engine (internal/bot/announcer.go) STAY for operators
  whose custom bots explicitly opt into posting release notes to groups β€”
  docs/bot-recipes.md still documents it, tests repinned on an explicit recipe.
- agent/ (Node reference): announcer.mjs removed, agent.mjs runs the pair.
- Eleven's prompt/knowledge, docs (bots.md gains a "The Announcer is retired"
  section with the migration story), deploy scripts, .miren toml updated.

Ops note (not in this PR): live sidecars keep their Announcer identities until
redeployed; the new binary skips them, and the roster member can be removed
from the admin console whenever convenient.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0a15b1e5b14831214c900434e908233aa6d20c41 parent 8569373
23 files changed, +158 −355
  • .miren/agent.app.toml +0 −6
  • CLAUDE.md +3 −2
  • agent/README.md +9 −14
  • agent/agent.mjs +4 −12
  • agent/announcer.mjs +0 −159
  • agent/runtime.mjs +3 −3
  • bot.go +9 −9
  • bot_announcer_test.go +4 −0
  • bot_run_test.go +16 −8
  • botcmd.go +19 −59
  • changelog/2026-07-09-announcer-retired.md +5 −0
  • deploy/README.md +1 −1
  • deploy/go-router.sh +2 −2
  • deploy/miren/agent.sh +1 −2
  • docs/bot-recipes.md +6 −4
  • docs/bots.md +27 −9
  • docs/deploying-instances.md +2 −2
  • docs/instance-router.md +10 −9
  • internal/bot/announcer.go +12 −9
  • internal/bot/brain.go +1 −1
  • internal/bot/knowledge.md +8 −7
  • internal/bot/recipe.go +4 −18
  • internal/bot/recipe_test.go +12 −19
diff --git a/.miren/agent.app.toml b/.miren/agent.app.toml
index 28d27bf..cfacc1a 100644
--- a/.miren/agent.app.toml
+++ b/.miren/agent.app.toml
@@ -26,11 +26,6 @@ size_gb = 1
key = "LCHAT_SESSION"
value = "/data/session.json"
-# The deploy announcer's identity + announced-state live on the same disk (its
-# state file is derived: /data/announcer-session.json.state.json).
-[[env]]
-key = "LCHAT_ANNOUNCER_SESSION"
-value = "/data/announcer-session.json"
# Eleven (the app guide): identity + welcomed-members state, same pattern.
[[env]]
@@ -41,6 +36,5 @@ value = "/data/eleven-session.json"
# LCHAT_BASE the instance to connect to, e.g. https://test-bots.example.com
# ANTHROPIC_API_KEY billing credential β€” lives only in this app's env
# LCHAT_INVITE one-time invite #fragment; used only on first boot
-# LCHAT_ANNOUNCER_INVITE same, for the "Announcer" identity
# LCHAT_ELEVEN_INVITE same, for the "Eleven" identity
# PORT container port for the health check (defaults to 8080)
diff --git a/CLAUDE.md b/CLAUDE.md
index 776fb0c..01c846b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,7 +96,7 @@ in, so deploy is one file (no cgo).
DOM. The bot's builder is `internal/bot/appbuild.go`; authoring rules
`docs/building-member-apps.md`; design + trust model `docs/member-apps.md`.
- `push.go` β€” Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`).
-- The **bot sidecar** (Claude / Eleven / Announcer): a separate headless process
+- The **bot sidecar** (Claude / Eleven): a separate headless process
that is just another E2EE member; holds bot private keys + the
`ANTHROPIC_API_KEY`, outbound-only, runs on any machine (see `docs/bots.md`).
The default is **Go** β€” `lchat bot run`, a subcommand of the single binary
@@ -258,7 +258,8 @@ open with the note itself) carry the πŸ€– on their first line.
- **User-visible changes add a changelog entry**: a short, user-facing note in
`changelog/<date>-<slug>.md` (one file per change β€” parallel PRs never
conflict). Entries are embedded in the binary and served at `/api/changelog`;
- the deploy announcer bot posts new ones to its groups after a deploy, so write
+ the server announces new ones after a deploy β€” a push to the space's admins
+ plus the Admin app's update stream (announce.go) β€” so write
them for members, not developers. **Native Mac/iOS changes: hold the entry
until the release is minted.** The changelog announces on the next *server*
deploy, but a Mac/iOS change only reaches members once a build is actually
diff --git a/agent/README.md b/agent/README.md
index d0af4a8..d9cf2f4 100644
--- a/agent/README.md
+++ b/agent/README.md
@@ -23,7 +23,6 @@ its wire format is guaranteed to match the app.
|---|---|
| **Claude** | The plain assistant: replies to every DM; in groups only when @mentioned (the default listen mode β€” the console can widen a bot to also answer its bare name, gated by a cheap "am I being addressed?" classifier). (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) |
| **Eleven** | The app guide: same chat loop with a shipped-in persona + app reference (updated with every deploy), and it **DMs each new member a welcome** when they join. (`eleven.mjs`, `LCHAT_ELEVEN_*`.) |
-| **Announcer** | Write-only deploy notes: polls the embedded changelog (`/api/changelog`) and posts what's new to groups it's added to. Never reads messages, never calls the Claude API. (`announcer.mjs`, `LCHAT_ANNOUNCER_*`.) |
Each identity is optional β€” it runs if its session file exists or its invite is
set. Every identity is minted the same way: an agent invite from the admin
@@ -81,13 +80,13 @@ bot's private keys unlock every thread it's a member of:
same as for human members. The attacker can't even route to the keys.
- **Inside a private network with no inbound at all**, egress allowlisted to
the instance + `api.anthropic.com` (or Claude via Bedrock/Vertex private
- endpoints for zero public egress). The Announcer never calls the model API,
+ endpoints for zero public egress). A changelog-recipe bot never calls the model API,
so it needs only the one route to the instance.
See `docs/bots.md Β§ The sidecar runs anywhere` for the full threat-model
discussion.
-## Eleven & the Announcer
+## Eleven
**Eleven** needs to see members it shares no thread with (to welcome them), so
vetted bots β€” agents the admin marked *available to members* β€” read the full
@@ -96,17 +95,13 @@ silently (existing members are never welcomed retroactively). Its app knowledge
ships in `eleven.mjs`, so it improves with each deploy; console config can
still override the persona per instance.
-**The Announcer** closes the loop on deploys: release notes live in the repo
-(`changelog/<date>-<slug>.md`, one short user-facing file per change), ship
-**inside the server binary**, and are served at the token-gated
-`/api/changelog`. The announcer polls it and posts entries it hasn't seen to
-every group/DM it's been added to β€” a deploy announces itself, with no operator
-machine, GitHub, or extra service involved. Like Eleven's welcomes, it
-baselines silently on first run. It never reads or decrypts messages (it
-unwraps thread keys only to encrypt its own posts).
-
-`node eleven.mjs` and `node announcer.mjs` each run one standalone tick
-(welcome pass / changelog check) β€” handy for tests and cron-style setups.
+(An earlier third identity, the write-only **Announcer**, posted release notes
+to groups. It's retired: the server announces its own changelog now β€” a push to
+the space's admins plus the Admin app's update stream. The Go sidecar's
+`changelog` recipe rule remains for custom deploy watchers.)
+
+`node eleven.mjs` runs one standalone welcome tick β€” handy for tests and
+cron-style setups.
## Notes & limits
diff --git a/agent/agent.mjs b/agent/agent.mjs
index a88c093..c9ef57b 100644
--- a/agent/agent.mjs
+++ b/agent/agent.mjs
@@ -6,17 +6,16 @@
// (LCHAT_SESSION / LCHAT_INVITE; name via LCHAT_AGENT_NAME)
// Eleven the app guide β€” same chat loop with a shipped-in persona, plus a
// welcome DM to each new member (LCHAT_ELEVEN_SESSION / _INVITE)
-// Announcer write-only deploy notes from /api/changelog (announcer.mjs;
-// LCHAT_ANNOUNCER_SESSION / _INVITE)
//
// Each identity is optional: it runs if its session exists or its invite is set,
-// so an instance can host any subset.
+// so an instance can host any subset. (A third identity, the write-only
+// Announcer, is retired: the server announces its own changelog now β€” a push
+// to admins + the Admin app's update stream.)
import fs from "node:fs";
import http from "node:http";
import { BASE, AGENT_NAME, requireBase, loadSession } from "./config.mjs";
import { provision } from "./provision.mjs";
import { runBot } from "./runtime.mjs";
-import { startAnnouncer } from "./announcer.mjs";
import { CHIP_TOOLS } from "./actions.mjs";
import { ELEVEN_NAME, ELEVEN_PERSONA, ELEVEN_DOCS, loadElevenSession } from "./eleven.mjs";
@@ -95,14 +94,7 @@ if (elevenSession) {
// runs; re-wire it here only if the client welcome ever retires.
}
-// --- Announcer: write-only deploy notes -------------------------------------
-startAnnouncer();
-
if (!claudeSession && !elevenSession) {
log("no chat identities β€” set LCHAT_INVITE and/or LCHAT_ELEVEN_INVITE (or provide sessions)");
- // The announcer alone can still be a valid deployment; exit only when
- // nothing at all is configured.
- if (!fs.existsSync(process.env.LCHAT_ANNOUNCER_SESSION || "") && !process.env.LCHAT_ANNOUNCER_INVITE) {
- process.exit(1);
- }
+ process.exit(1);
}
diff --git a/agent/announcer.mjs b/agent/announcer.mjs
deleted file mode 100644
index aac8a19..0000000
--- a/agent/announcer.mjs
+++ /dev/null
@@ -1,159 +0,0 @@
-// The deploy announcer: a second, write-only bot identity ("Deploys") hosted by
-// the sidecar. It polls the instance's embedded changelog (/api/changelog β€”
-// release notes that ship inside the server binary) and posts entries it hasn't
-// announced yet into every group/DM it's been added to. So a deploy announces
-// itself: no operator machine, no GitHub, no extra service in the loop.
-//
-// Deliberately write-only: unlike agent.mjs it never fetches or decrypts a
-// single message β€” it unwraps thread keys only to *encrypt* what it posts, and
-// it makes no Claude API calls. "Deploys is in this group" means "it can post
-// here", not "it reads this room".
-//
-// Identity + memory live next to the main bot's session (the /data disk):
-// LCHAT_ANNOUNCER_SESSION (default: announcer-session.json beside this file)
-// LCHAT_ANNOUNCER_INVITE (first boot only: fragment of an admin agent-invite)
-// State (which entries have been announced) sits in <session>.state.json. On the
-// very first run it baselines to the current changelog without posting, so a
-// fresh announcer doesn't replay history β€” it announces what ships *after* it.
-//
-// Used two ways: agent.mjs imports startAnnouncer() (poll loop in the sidecar),
-// and `node announcer.mjs` runs exactly one tick (provision if needed, check,
-// post, exit) β€” handy for tests and cron-style setups.
-import fs from "node:fs";
-import { fileURLToPath } from "node:url";
-import { importKey, encrypt, unwrapThreadKey, deriveInvite, generateUserKeypair } from "../web/static/crypto.js";
-import { BASE, requireBase } from "./config.mjs";
-
-const log = (m) => {
- try {
- fs.writeSync(1, `announcer: ${m}\n`);
- } catch {}
-};
-
-export const ANNOUNCER_NAME = process.env.LCHAT_ANNOUNCER_NAME || "Announcer";
-const SESSION_PATH =
- process.env.LCHAT_ANNOUNCER_SESSION || fileURLToPath(new URL("./announcer-session.json", import.meta.url));
-const STATE_PATH = `${SESSION_PATH}.state.json`;
-const POLL_MS = Number(process.env.LCHAT_ANNOUNCE_POLL_MS) || 5 * 60 * 1000;
-
-const readJSON = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null);
-
-// Announce our kind once so the UI badges us BOT β€” the announcer is deterministic
-// (it never calls a model; it only decrypts thread keys to *encrypt* what it
-// posts). Best-effort; retried on a later tick if it fails.
-let kindDeclared = false;
-async function declareBot(session) {
- if (kindDeclared) return;
- try {
- const r = await fetch(`${BASE}/api/bot-config`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ token: session.token, kind: "bot" }),
- });
- kindDeclared = r.ok;
- } catch (e) {
- log(`declareBot: ${e.message}`);
- }
-}
-
-// Provision the announcer's own identity from LCHAT_ANNOUNCER_INVITE (same
-// claim flow as provision.mjs, separate session file). Returns null β€” quietly
-// disabled β€” when there's neither a session nor an invite.
-async function loadOrProvision() {
- const existing = readJSON(SESSION_PATH);
- if (existing) return existing;
- const T = process.env.LCHAT_ANNOUNCER_INVITE;
- if (!T) return null;
- const post = (path, body) =>
- fetch(`${BASE}${path}`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- }).then((r) => r.json());
- const { id, claimSecret } = await deriveInvite(T);
- // Prove knowledge of the invite secret (claim_secret = preimage of claim_hash);
- // the announcer's label was set server-side when the admin minted the invite.
- const claim = await post(`/api/key-invite/${encodeURIComponent(id)}/claim`, { claim_secret: claimSecret });
- if (!claim.ok || !claim.token) throw new Error("couldn't claim the announcer invite (used or expired?)");
- const kp = await generateUserKeypair();
- await post("/api/pubkey", { token: claim.token, public_key: kp.publicKey });
- const session = { token: claim.token, name: ANNOUNCER_NAME, userPub: kp.publicKey, userPriv: kp.privateKey };
- fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2));
- log(`provisioned "${ANNOUNCER_NAME}"`);
- return session;
-}
-
-// One poll: fetch the changelog, post anything new to every group/DM we're in,
-// remember what we've announced. Multiple new entries batch into one message.
-export async function announceTick() {
- const session = await loadOrProvision();
- if (!session) return false;
- await declareBot(session);
- const api = (path) =>
- fetch(`${BASE}${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(session.token)}`).then((r) =>
- r.json()
- );
-
- const { entries = [] } = await api("/api/changelog");
- const state = readJSON(STATE_PATH);
- if (!state) {
- // First run: baseline silently so a new announcer doesn't replay history.
- fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: entries.map((e) => e.id) }));
- log(`baselined at ${entries.length} entr${entries.length === 1 ? "y" : "ies"}`);
- return true;
- }
- const seen = new Set(state.announced || []);
- const fresh = entries.filter((e) => e.id && e.text && !seen.has(e.id));
- if (!fresh.length) return true;
-
- const text = `πŸš€ Just shipped:\n\n${fresh.map((e) => e.text).join("\n\n")}`;
- const { threads = [] } = await api("/api/threads");
- const targets = threads.filter((t) => t.wrapped && (t.kind === "group" || t.kind === "dm"));
- let ok = 0;
- for (const t of targets) {
- try {
- const key = await importKey(await unwrapThreadKey(t.wrapped, session.userPriv));
- const blob = await encrypt(key, { name: ANNOUNCER_NAME, text, ts: Date.now() });
- const r = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ thread_id: t.id, blob }),
- });
- if (!r.ok) throw new Error(`server ${r.status}`);
- ok++;
- } catch (e) {
- log(`thread ${t.id}: ${e.message}`);
- }
- }
- // Mark announced even with zero targets: entries shipped while the bot sat in
- // no groups are old news by the time someone adds it β€” don't replay them.
- fresh.forEach((e) => seen.add(e.id));
- fs.writeFileSync(STATE_PATH, JSON.stringify({ announced: [...seen] }));
- log(`announced ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"} to ${ok}/${targets.length} thread(s)`);
- return true;
-}
-
-// The sidecar's poll loop. Errors are logged and retried next tick β€” a failed
-// poll must never take the main bot down with it.
-export function startAnnouncer() {
- const tick = () =>
- announceTick()
- .then((enabled) => {
- if (!enabled) log("disabled (no session and no LCHAT_ANNOUNCER_INVITE)");
- return enabled;
- })
- .catch((e) => (log(`tick failed: ${e.message}`), true));
- tick().then((enabled) => {
- if (enabled) setInterval(() => tick(), POLL_MS);
- });
-}
-
-// CLI: run exactly one tick.
-if (import.meta.url === `file://${process.argv[1]}`) {
- requireBase();
- const enabled = await announceTick();
- if (!enabled) {
- console.error("No announcer session and no LCHAT_ANNOUNCER_INVITE set.");
- process.exit(1);
- }
-}
diff --git a/agent/runtime.mjs b/agent/runtime.mjs
index b7bd4a3..6bd07e3 100644
--- a/agent/runtime.mjs
+++ b/agent/runtime.mjs
@@ -62,7 +62,7 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
s +=
`\n\nOther helper bots are members of this server alongside you: ${others.join(", ")}. ` +
`They are legitimate, separately-run assistants (Eleven guides people around the app; ` +
- `Announcer posts release notes; Claude is a general companion). You may see their messages ` +
+ `Claude is a general companion). You may see their messages ` +
`in shared groups; you never reply to them, and there's no need to question their presence.`;
// Context is rebuilt from message TEXT only, so the model sees its own past
// chip replies as bare "tap to switch" texts and starts imitating them
@@ -103,8 +103,8 @@ export function runBot({ name, session, builtinPersona = "", builtinDocs = "", t
);
// Bots never talk to bots: a bot's mention trigger matches its bare name, and
- // bot-authored text naturally names other bots (Eleven recommends Claude, the
- // Announcer's notes mention Eleven) β€” without this, two bots in one group
+ // bot-authored text naturally names other bots (Eleven recommends Claude) β€”
+ // without this, two bots in one group
// ping-pong forever. Name-based (payloads aren't sender-signed yet), refreshed
// on every (re)connect so newly added bots are picked up within a cycle.
async function loadAgents() {
diff --git a/bot.go b/bot.go
index 9ae31cb..269ea5f 100644
--- a/bot.go
+++ b/bot.go
@@ -9,8 +9,8 @@ import (
"strings"
)
-// runAddBot: `lchat add-bot <host>` β€” provision the Node sidecar (Claude + Eleven +
-// Announcer) for an instance. It mints an agent invite per identity via the
+// runAddBot: `lchat add-bot <host>` β€” provision the Node sidecar (Claude +
+// Eleven) for an instance. It mints an agent invite per identity via the
// instance's admin API (through agent/mint-invite.mjs, which reuses
// web/static/crypto.js so the derived id/claim match what the sidecar computes),
// writes the sidecar's EnvironmentFile, and starts lchat-bot@<host>. The instance
@@ -59,18 +59,18 @@ func runAddBot(args []string) {
mintScript := filepath.Join(*agentDir, "mint-invite.mjs")
sessionDir := filepath.Join(instanceDataDir, host+"-bot")
- // One agent invite per identity. All three are catalog-available so Eleven's
- // roster baseline sees the joined members (docs/bots.md).
+ // One agent invite per identity. Both are catalog-available so Eleven's
+ // roster baseline sees the joined members (docs/bots.md). (The Announcer is
+ // retired: release notes are announced by the server itself β€” announce.go β€”
+ // and read in the Admin app's update stream.)
identities := []struct{ envVar, label, desc string }{
{"LCHAT_INVITE", "Claude", "General assistant β€” DM me or @mention me in a group."},
{"LCHAT_ELEVEN_INVITE", "Eleven", "Your guide to the app β€” I welcome new members."},
- {"LCHAT_ANNOUNCER_INVITE", "Announcer", "Posts what's new after each update."},
}
botEnv := map[string]string{
- "LCHAT_BASE": base,
- "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"),
- "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"),
- "LCHAT_ANNOUNCER_SESSION": filepath.Join(sessionDir, "announcer-session.json"),
+ "LCHAT_BASE": base,
+ "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"),
+ "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"),
}
if apiKey != "" {
botEnv["ANTHROPIC_API_KEY"] = apiKey
diff --git a/bot_announcer_test.go b/bot_announcer_test.go
index c1f34ff..a06fe73 100644
--- a/bot_announcer_test.go
+++ b/bot_announcer_test.go
@@ -1,5 +1,9 @@
package main
+// These tests exercise bot.Announcer β€” since the default "Announcer" identity's
+// retirement, the engine behind a custom recipe's `changelog` rule
+// (docs/bot-recipes.md). The identity name in here is just a label.
+
import (
"bytes"
"encoding/json"
diff --git a/bot_run_test.go b/bot_run_test.go
index 4ca324c..6b59c5a 100644
--- a/bot_run_test.go
+++ b/bot_run_test.go
@@ -444,26 +444,34 @@ func TestBotEngineModelReplyHaikuNoThinking(t *testing.T) {
}
}
-// TestBotEngineAnnouncerBadge pins the recipe-driven badge: an Announcer run
-// with the shared ANTHROPIC_API_KEY present must still badge BOT (write-only,
-// no model rule), not AI β€” the bug that would appear when the standard trio
-// runs from one `lchat bot run` with one key.
-func TestBotEngineAnnouncerBadge(t *testing.T) {
+// TestBotEngineChangelogBadge pins the recipe-driven badge: a bot running a
+// changelog-only recipe with the shared ANTHROPIC_API_KEY present must still
+// badge BOT (write-only, no model rule), not AI β€” the bug that would appear
+// when several identities run from one `lchat bot run` with one key.
+func TestBotEngineChangelogBadge(t *testing.T) {
ts, s := newTestServer(t)
owner := provisionNamed(t, ts.URL, "Owner", false)
- announcer := provisionNamed(t, ts.URL, "Announcer", true)
+ announcer := provisionNamed(t, ts.URL, "Deploys", true)
createGroupWith(t, ts.URL, s, owner, announcer)
dir := t.TempDir()
- sessionPath := dir + "/announcer.session.json"
+ sessionPath := dir + "/deploys.session.json"
if err := announcer.Save(sessionPath); err != nil {
t.Fatal(err)
}
+ recipePath := dir + "/deploys.recipe.json"
+ if err := os.WriteFile(recipePath, []byte(`{"rules":[{"on":"changelog","action":"announce"}]}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ recipe, err := bot.LoadRecipe(recipePath)
+ if err != nil {
+ t.Fatal(err)
+ }
ready := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng := &bot.Engine{Base: ts.URL, Session: announcer, SessionPath: sessionPath,
- Recipe: bot.DefaultRecipeFor("Announcer"), AIKey: "a-key-that-should-be-ignored",
+ Recipe: recipe, AIKey: "a-key-that-should-be-ignored",
Ready: func() { close(ready) }, Logf: t.Logf}
go eng.Run(ctx)
awaitReady(t, ready)
diff --git a/botcmd.go b/botcmd.go
index 39d2c50..fc3d04e 100644
--- a/botcmd.go
+++ b/botcmd.go
@@ -22,16 +22,17 @@ func runBotCmd(args []string) {
commands:
provision claim an agent invite and write the bot's session file
- announce post new changelog entries to the bot's threads (the Announcer)
run run the live sidecar: connect each identity and follow its recipe
- (command floor + model + connected-services; the standard trio zero-config)`)
+ (command floor + model + connected-services; the standard pair zero-config)`)
os.Exit(2)
}
switch args[0] {
case "provision":
runBotProvision(args[1:])
case "announce":
- runBotAnnounce(args[1:])
+ // Retired: the server announces its own changelog now (announce.go) β€”
+ // admins get a push and read the notes in the Admin app's update stream.
+ die("bot announce: retired β€” the server announces release notes itself; see docs/bots.md")
case "run":
runBotRun(args[1:])
default:
@@ -39,57 +40,6 @@ commands:
}
}
-// runBotAnnounce is the Announcer (agent/announcer.mjs in Go): one tick by
-// default (cron-friendly), a poll loop with -poll. Env names match the Node
-// sidecar's so a systemd unit can swap implementations without edits; the
-// session, state file, and invite flow are all Node-compatible too, so the Go
-// announcer takes over an existing one's identity and memory in place.
-func runBotAnnounce(args []string) {
- fs := flag.NewFlagSet("bot announce", flag.ExitOnError)
- base := fs.String("base", envOr("LCHAT_BASE", ""), "instance URL (env: LCHAT_BASE)")
- session := fs.String("session", envOr("LCHAT_ANNOUNCER_SESSION", "announcer-session.json"), "session file (env: LCHAT_ANNOUNCER_SESSION)")
- name := fs.String("name", envOr("LCHAT_ANNOUNCER_NAME", "Announcer"), "bot name (env: LCHAT_ANNOUNCER_NAME)")
- poll := fs.Duration("poll", 0, "poll interval, e.g. 5m; 0 runs exactly one tick and exits")
- _ = fs.Parse(reorderFlagsFirst(fs, args))
-
- if *base == "" {
- die("bot announce: set -base (or LCHAT_BASE) to your instance URL")
- }
- sess, err := bot.LoadSession(*session)
- if err != nil {
- die("bot announce: %v", err)
- }
- if sess == nil {
- // First boot: self-provision from the announcer's invite, like the Node
- // sidecar. Env-only for the same secrecy reason as `bot provision`.
- fragment := strings.TrimSpace(os.Getenv("LCHAT_ANNOUNCER_INVITE"))
- if fragment == "" {
- die("bot announce: no session at %s and no LCHAT_ANNOUNCER_INVITE set", *session)
- }
- if sess, err = bot.Provision(*base, fragment, *name); err != nil {
- die("bot announce: %v", err)
- }
- if err := sess.Save(*session); err != nil {
- die("bot announce: %v", err)
- }
- log.Printf("announcer: provisioned %q (session %s)", *name, *session)
- }
-
- a := &bot.Announcer{
- Base: strings.TrimRight(*base, "/"),
- Session: sess,
- StatePath: bot.StatePathFor(*session),
- Logf: log.Printf,
- }
- if *poll <= 0 {
- if err := a.Tick(); err != nil {
- die("bot announce: %v", err)
- }
- return
- }
- a.Run(context.Background(), *poll)
-}
-
// runBotRun is the live runtime (agent/runtime.mjs's core in Go): connect over
// WebSocket as one or more provisioned identities and run each one's recipe
// (docs/bot-recipes.md) — behaviour as data from a bounded trigger→action
@@ -113,9 +63,11 @@ func runBotRun(args []string) {
if len(paths) == 0 {
// No sessions on the command line: source them from the env the sidecar
// unit provides, running whichever identities are set β€” so the generic
- // lchat-bot@.service template needs no per-host args, and a full trio or an
+ // lchat-bot@.service template needs no per-host args, and a full pair or an
// Eleven-only box both work. Falls back to a lone session.json for a
- // hand-run bot with nothing set.
+ // hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so
+ // a pre-retirement EnvironmentFile still resolves; the session is then
+ // skipped by name below.)
for _, ev := range []string{"LCHAT_SESSION", "LCHAT_ELEVEN_SESSION", "LCHAT_ANNOUNCER_SESSION"} {
if p := strings.TrimSpace(os.Getenv(ev)); p != "" {
paths = append(paths, p)
@@ -133,6 +85,14 @@ func runBotRun(args []string) {
if sess == nil {
die("bot run: no session at %s β€” provision it first (lchat bot provision)", path)
}
+ if sess.Name == "Announcer" {
+ // The Announcer is retired (the server announces its own changelog β€”
+ // announce.go). Running this identity under the default message recipe
+ // would turn the old write-only bot into a responder, so skip it; the
+ // operator should remove the member from the roster when convenient.
+ log.Printf("bot run: %s: the Announcer is retired β€” skipping this identity (remove it from the roster; see docs/bots.md)", path)
+ continue
+ }
// A broken recipe stops the bot (fail closed) β€” silently falling back
// to the default would run behaviour the operator didn't write.
recipe, err := bot.LoadRecipe(bot.RecipePathFor(path))
@@ -140,9 +100,9 @@ func runBotRun(args []string) {
die("bot run: %v", err)
}
if recipe == nil {
- // No recipe file β†’ the built-in for this identity, so the standard
- // trio (Claude / Eleven / Announcer) deploys with zero config.
- recipe = bot.DefaultRecipeFor(sess.Name)
+ // No recipe file β†’ the built-in default, so the standard pair
+ // (Claude / Eleven) deploys with zero config.
+ recipe = bot.DefaultRecipe()
} else {
log.Printf("bot run: %s follows %s%s", sess.Name, bot.RecipePathFor(path),
map[bool]string{true: " (watches every message)", false: ""}[recipe.Watches()])
diff --git a/changelog/2026-07-09-announcer-retired.md b/changelog/2026-07-09-announcer-retired.md
new file mode 100644
index 0000000..e6c4230
--- /dev/null
+++ b/changelog/2026-07-09-announcer-retired.md
@@ -0,0 +1,5 @@
+πŸ€– Release notes have a new home. The Announcer bot is retired β€” instead of
+"πŸš€ Just shipped" posts appearing in your group chats, your space's admins
+now get a notification when it updates, and the full notes live in the
+Admin app's chat view. Less noise in your conversations; admins still see
+everything that changed.
diff --git a/deploy/README.md b/deploy/README.md
index 7ed1f4e..3abb5b2 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -41,7 +41,7 @@ instance (`docs/instance-router.md Β§ Bots`, Β§ Auto-updating).
ssh root@<box> 'ANTHROPIC_API_KEY=sk-ant-... /opt/lchat/lchat add-bot chat.example.com'
```
-`add-bot` mints an agent invite per identity (Claude / Eleven / Announcer) via the
+`add-bot` mints an agent invite per identity (Claude / Eleven) via the
instance's admin API, writes `/data/<host>-bot.env` (invites + `LCHAT_BASE` +
`LCHAT_*_SESSION` paths + optional `ANTHROPIC_API_KEY`), and enables
`lchat-bot@<host>` β€” which reads all of that from the env file, so there's nothing
diff --git a/deploy/go-router.sh b/deploy/go-router.sh
index 474e6a7..3459996 100755
--- a/deploy/go-router.sh
+++ b/deploy/go-router.sh
@@ -4,7 +4,7 @@
# see docs/instance-router.md and CLAUDE.local.md "Deploying"). Health-checked:
# `lchat deploy` itself verifies /api/version's build id after restarting and
# auto-rolls back on a mismatch, so a bad build never stays live. Also ensures
-# the instance has its default bot sidecar (Claude/Eleven/Announcer) running β€”
+# the instance has its default bot sidecar (Claude/Eleven) running β€”
# deterministic-only (no ANTHROPIC_API_KEY) unless you pass one in β€” so Eleven
# always welcomes new members and answers slash commands out of the box.
#
@@ -60,7 +60,7 @@ ssh "$BOX" "/opt/lchat/lchat deploy '$HOST' --version '$STAGED_VER'"
if [ -n "$SKIP_BOT" ]; then
echo "SKIP_BOT set β€” leaving the bot sidecar alone."
elif ssh "$BOX" "test -f /data/${HOST}-bot.env"; then
- echo "Bot sidecar already provisioned for $HOST β€” leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven/Announcer identities)."
+ echo "Bot sidecar already provisioned for $HOST β€” leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven identities)."
else
echo "No bot sidecar yet for $HOST β€” adding one (deterministic-only unless \$ANTHROPIC_API_KEY is set)..."
# Work around a known bug in the box's current control binary: add-bot's
diff --git a/deploy/miren/agent.sh b/deploy/miren/agent.sh
index 8dcad58..a7745bf 100755
--- a/deploy/miren/agent.sh
+++ b/deploy/miren/agent.sh
@@ -12,7 +12,7 @@
# ANTHROPIC_API_KEY is OPTIONAL: omit it for a deterministic, bot-only sidecar β€”
# Eleven still welcomes new members and answers slash commands, badges BOT (not
# AI), and declines free-form questions until a key is set. With a key it
-# "upgrades" to conversational AI. (Claude/Announcer are unaffected either way.)
+# "upgrades" to conversational AI. (Claude is unaffected either way.)
#
# LCHAT_INVITE is only needed on the FIRST deploy (the sidecar self-provisions
# and persists its session to the /data disk; later deploys ignore it).
@@ -45,7 +45,6 @@ ENVARGS=(-e "LCHAT_BASE=$LCHAT_BASE")
[ -n "${LCHAT_AGENT_NAME:-}" ] && ENVARGS+=(-e "LCHAT_AGENT_NAME=$LCHAT_AGENT_NAME")
[ -n "${LCHAT_MODEL:-}" ] && ENVARGS+=(-e "LCHAT_MODEL=$LCHAT_MODEL")
# First-boot invites for the other default identities, like LCHAT_INVITE.
-[ -n "${LCHAT_ANNOUNCER_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ANNOUNCER_INVITE=$LCHAT_ANNOUNCER_INVITE")
[ -n "${LCHAT_ELEVEN_INVITE:-}" ] && ENVARGS+=(-e "LCHAT_ELEVEN_INVITE=$LCHAT_ELEVEN_INVITE")
echo "Deploying sidecar '$APP' (cluster $CLUSTER) -> $LCHAT_BASE"
diff --git a/docs/bot-recipes.md b/docs/bot-recipes.md
index ca1b28a..cca282a 100644
--- a/docs/bot-recipes.md
+++ b/docs/bot-recipes.md
@@ -31,7 +31,7 @@ error. It never falls back to different behaviour the operator didn't write.
## Schema v1
A JSON file next to the bot's session file β€” `<session>.recipe.json`, the same
-sibling convention as the announcer's `.state.json`. No recipe file means the
+sibling convention as a changelog rule's `.state.json`. No recipe file means the
**default recipe**: answer the deterministic command floor when addressed
(exactly the keyless Node sidecar's behaviour).
@@ -58,7 +58,8 @@ are skipped.
- `keyword` β€” case-insensitive substring gate on the text.
- `regex` β€” RE2 gate. Both set = both must match.
- **`changelog`** β€” new entries in the instance's embedded changelog (the
- Announcer's trigger). `every` sets the poll interval (Go duration, default
+ the retired default Announcer's trigger β€” kept for custom deploy
+ watchers). `every` sets the poll interval (Go duration, default
`5m`). At most one changelog rule per recipe β€” one announce memory per bot.
### Actions (`action`)
@@ -78,7 +79,8 @@ are skipped.
This is the one action that sends text to a provider β€” hence its place in a
watcher matters (below). Message rules only.
- **`announce`** β€” post fresh changelog entries to every thread the bot is in
- (the full Announcer behaviour, including the silent first-run baseline).
+ (the retired Announcer's full behaviour, including the silent first-run
+ baseline).
Changelog rules only.
The **default recipe** (no file) is `commands` then `model`: answer a command
@@ -106,6 +108,6 @@ session's recipe (or the default), then runs one `bot.Engine` per identity:
the live WebSocket runtime with the message rules folded into its Handler
seam, plus one poller per changelog rule. One process, many bots, zero Node.
-Announcer-as-recipe replaces `lchat bot announce -poll` for long-running
+A changelog recipe replaces the retired `lchat bot announce -poll` for long-running
setups (`announce` remains for cron-style one-shots); both share the same
`.state.json`, so switching modes never replays entries.
diff --git a/docs/bots.md b/docs/bots.md
index 7f11a6e..eb9beac 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -69,7 +69,8 @@ host a free variable in the threat model:
the instance + `api.anthropic.com`. The bot machine's attack surface shrinks
to supply chain and the message content it processes. For a no-public-egress
deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over
- private endpoints β€” and the Announcer needs no model API at all.
+ private endpoints β€” and a write-only changelog watcher needs no model API at
+ all.
The honest framing (for consent copy too): a bot is **a member whose "device"
is a server**, and adding one to a thread extends that thread's E2E envelope to
@@ -122,12 +123,10 @@ them after any deliberate `crypto.js` change and re-run `go test
secret comes from `LCHAT_INVITE` in the env, never argv β€” and writes a session
file byte-compatible with the Node sidecar's `session.json`, so either
implementation can take over the other's identity in place. **And the
-Announcer**: `lchat bot announce` (one tick, cron-friendly; `-poll 5m` to
-loop) is the full Go port of `announcer.mjs` β€” auto-provisions from
-`LCHAT_ANNOUNCER_INVITE`, baselines silently on first run, batches new
-changelog entries into one encrypted post per thread, self-declares kind
-`bot`. Env names and the `.state.json` shape match the Node announcer, so a
-unit swap inherits identity *and* memory (no replayed entries). **And the live
+changelog engine** (which powered the Announcer until its retirement β€” see
+below): it baselines silently on first run, batches new changelog entries into
+one encrypted post per thread, and self-declares kind `bot`; today it runs only
+under an explicit recipe's `changelog` rule (docs/bot-recipes.md). **And the live
runtime**: `lchat bot run` (`internal/bot/runtime.go`) is the persona-agnostic
core of `runtime.mjs` in Go β€” WebSocket with reconnect/backoff/keepalive,
thread-key unwrap, cursor-gated idempotent delivery, catch-up after a blip,
@@ -158,8 +157,8 @@ time-boxed grant to the bot's key, the bot opens it with its own private key
(a grant sealed to a different bot won't open, so it stays silent), lists
DNSimple domains or runs the confidential OAuth exchange via the shared broker,
and seals the result back to the member. **The Go sidecar is now a full drop-in
-for the Node one**: everything the three shipped bots do β€” Announcer,
-deterministic floor, conversational Eleven/Claude, and connected-services β€”
+for the Node one**: everything the shipped bots do β€” the deterministic floor,
+conversational Eleven/Claude, connected-services, and the changelog engine β€”
runs from the single `lchat` binary with no Node. The one thing deliberately
*not* in the sidecar is Eleven's onboarding welcomer, which is moving
client-side as data (`docs/conversation-scripts.md`). `agent/` stays as the
@@ -538,3 +537,22 @@ Guardrails that keep it inside the boundary:
- Kids-instance defaults: personal bots off entirely, or on with a curated
recipe palette only.
- Per-bot model policy (cost): who may pick Opus vs Sonnet vs Haiku.
+
+## The Announcer is retired
+
+The third default identity β€” the write-only **Announcer**, which posted release
+notes into every group it was added to β€” is retired. The app now announces its
+own updates with **no bot in the loop**, so it works on instances that run no
+sidecar at all: on startup the server diffs its embedded changelog against a
+watermark and notifies the space's **admins** (a Web Push / APNs banner,
+`announce.go`), and the notes live in the **Admin app's update stream** (a
+read-only chat view). Members no longer see release notes in their groups β€”
+updates are the admins' concern.
+
+Existing deployments: the new `lchat bot run` skips a session named
+`Announcer` (running it under the default recipe would turn the old write-only
+bot into a responder), so a pre-retirement EnvironmentFile keeps working.
+Remove the Announcer member from the roster (admin console β†’ members) whenever
+convenient; its session/state files on the sidecar machine can be deleted. An
+operator who *wants* group-posted release notes can still run a custom bot
+with a `changelog` recipe rule (docs/bot-recipes.md).
diff --git a/docs/deploying-instances.md b/docs/deploying-instances.md
index a71d283..9b70ba3 100644
--- a/docs/deploying-instances.md
+++ b/docs/deploying-instances.md
@@ -114,8 +114,8 @@ failed/creating route is overwritten on retry.
## Adding bots (optional)
-Each instance can have a **sidecar** hosting the three default agents (Claude /
-Eleven / Announcer). The sidecar is Node (it reuses `web/static/crypto.js`, so
+Each instance can have a **sidecar** hosting the default agents (Claude /
+Eleven). The sidecar is Node (it reuses `web/static/crypto.js`, so
its E2E crypto can't drift from the browser's), so the box needs a one-time Node
setup β€” see `deploy/README.md Β§ Bots` for the `apt`/`rsync`/`npm ci` recipe.
Then, per instance:
diff --git a/docs/instance-router.md b/docs/instance-router.md
index 3f58c42..d8f2755 100644
--- a/docs/instance-router.md
+++ b/docs/instance-router.md
@@ -215,14 +215,15 @@ core rule). So a bot is a *member that runs on a server*, not a server feature
run wherever the operator wants the keys to live (same box, off-box, a Raspberry
Pi), since it only ever dials out.
-**One process, the whole trio.** `lchat bot run -base https://<host>
-<claude>.session.json <eleven>.session.json <announcer>.session.json` runs all
-three identities as goroutines in one process, each its own connection. The
-standard trio is **zero-config**: with no recipe file, an identity named
-`Announcer` gets the write-only changelog recipe (badges BOT), everyone else
-gets the conversational default (command floor + model; badges AI when
+**One process, the whole pair.** `lchat bot run -base https://<host>
+<claude>.session.json <eleven>.session.json` runs both
+identities as goroutines in one process, each its own connection. The standard
+pair is **zero-config**: with no recipe file an identity gets the
+conversational default (command floor + model; badges AI when
`ANTHROPIC_API_KEY` is set). A custom bot drops a `<session>.recipe.json`
-alongside its session (`docs/bot-recipes.md`) β€” behaviour as data, no code.
+alongside its session (`docs/bot-recipes.md`) β€” behaviour as data, no code. (A
+legacy session named `Announcer` is skipped with a log line β€” that identity is
+retired; the server announces its own changelog now. See docs/bots.md.)
Supervise it as **one `lchat-bot@<host>.service` per instance**. The unit execs
the *same* per-instance live symlink as the instance itself
@@ -261,8 +262,8 @@ no re-provisioning and no lost identity:
`/data/%i-bot.env`. Nothing per-host to write; `add-bot` just enables it.
2. `systemctl daemon-reload && systemctl restart lchat-bot@<host>`. The bot
reconnects, re-declares its kind, and backfills β€” members see no gap.
-3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory), so
- the Announcer replays nothing.
+3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory) for
+ any custom changelog-recipe bot, so nothing replays.
Once **no** unit on the box still `ExecStart`s Node, the Node runtime and the
`agent/node_modules` install can be removed entirely β€” the box becomes a pure
diff --git a/internal/bot/announcer.go b/internal/bot/announcer.go
index cce704d..1669645 100644
--- a/internal/bot/announcer.go
+++ b/internal/bot/announcer.go
@@ -13,19 +13,22 @@ import (
"lchat/internal/e2ee"
)
-// Announcer is the Go port of agent/announcer.mjs: the write-only deploy bot.
-// It polls the instance's embedded changelog (/api/changelog) and posts entries
-// it hasn't announced yet into every group/DM it's been added to β€” so a deploy
-// announces itself with no operator machine in the loop.
+// Announcer is the engine behind a recipe's `changelog` rule
+// (docs/bot-recipes.md): it polls the instance's embedded changelog
+// (/api/changelog) and posts entries it hasn't announced yet into every
+// group/DM the bot has been added to. It powered the retired default
+// "Announcer" identity; today the app announces its own updates server-side
+// (announce.go β€” a push to admins + the Admin app's update stream), and this
+// engine stays for operators whose custom bots explicitly opt into posting
+// release notes to groups.
//
// Deliberately write-only: it never fetches or decrypts a single message β€” it
// unwraps thread keys only to *encrypt* what it posts, and it calls no model
-// API. "The Announcer is in this group" means "it can post here", not "it
-// reads this room".
+// API. "This bot is in the group" means "it can post here", not "it reads
+// this room".
//
-// State (which entries have been announced) lives at <session>.state.json in
-// the same shape the Node announcer writes ({"announced":[ids]}), so a Go
-// announcer takes over a Node one's memory in place β€” no replayed entries.
+// State (which entries have been announced) lives at <session>.state.json
+// ({"announced":[ids]}), so a restart never replays entries.
type Announcer struct {
Base string
Session *Session
diff --git a/internal/bot/brain.go b/internal/bot/brain.go
index c348e8a..602fcef 100644
--- a/internal/bot/brain.go
+++ b/internal/bot/brain.go
@@ -110,7 +110,7 @@ func (r *Runtime) systemPrompt() string {
if len(others) > 0 {
s += "\n\nOther helper bots are members of this server alongside you: " + strings.Join(others, ", ") + ". " +
"They are legitimate, separately-run assistants (Eleven guides people around the app; " +
- "Announcer posts release notes; Claude is a general companion). You may see their messages " +
+ "Claude is a general companion). You may see their messages " +
"in shared groups; you never reply to them, and there's no need to question their presence."
}
// Context is rebuilt from message TEXT only, so the model sees its own past
diff --git a/internal/bot/knowledge.md b/internal/bot/knowledge.md
index 66dc884..ee2d4d7 100644
--- a/internal/bot/knowledge.md
+++ b/internal/bot/knowledge.md
@@ -473,7 +473,7 @@ it's open source.
---
-## 13. Helpers: the bots (Claude, Eleven, Announcer)
+## 13. Helpers: the bots (Claude and Eleven)
Bots ("helpers", or "agents") are **ordinary members driven by code**. A bot has
its own keypair and identity, holds the keys to the threads it's in, decrypts and
@@ -482,7 +482,7 @@ like a human member's browser does. Bots wear a **BOT** badge (or **AI** if
they're powered by a language model). They live under the **Helpers** section of
the βž• picker, each with a one-line description.
-The three default helpers:
+The two default helpers:
- **Claude** β€” the general assistant. In a **DM** it replies to everything; in a
**group** it replies only when **@mentioned** (an admin can widen an individual
@@ -494,11 +494,12 @@ The three default helpers:
a welcome** when they join. You can also *do* small things via one-tap buttons
(switch theme, light/dark, open the theme picker or profile) β€” and those same
actions are available as instant slash commands even when AI is off.
-- **Announcer** ("Deploys") β€” **write-only** release notes. It watches the app's
- built-in changelog and posts what's new to the groups/DMs it's been added to,
- so a new version announces itself. It **never reads or decrypts anyone's
- messages** and never calls a language model β€” it only unwraps thread keys to
- *encrypt its own* posts.
+(An earlier third helper, the **Announcer**, posted release notes into group
+chats. It's retired: the app now announces its own updates β€” **admins** get a
+notification when the space updates, and the release notes live in the **Admin
+app's chat view** (the update stream). If someone asks "what's new", admins can
+look there; older spaces may still have an Announcer member an admin hasn't
+removed yet.)
Some important, honest points to convey:
diff --git a/internal/bot/recipe.go b/internal/bot/recipe.go
index 6df0bb2..bc961e5 100644
--- a/internal/bot/recipe.go
+++ b/internal/bot/recipe.go
@@ -27,7 +27,8 @@ type Recipe struct {
// rule whose trigger matches acts, and the rest are skipped.
type Rule struct {
// On is the trigger: "message" (an incoming chat message) or "changelog"
- // (new entries in the instance's embedded changelog β€” the Announcer).
+ // (new entries in the instance's embedded changelog β€” a custom deploy
+ // watcher; the retired default Announcer used this).
On string `json:"on"`
// Message-trigger refinements. Match is who the rule listens to:
@@ -69,24 +70,9 @@ func DefaultRecipe() *Recipe {
}}
}
-// DefaultRecipeFor is the built-in recipe for a named identity when no recipe
-// file is present, so the standard trio deploys with zero config (mirroring
-// how the Node sidecar hard-wires these three). The Announcer is write-only β€”
-// it polls the changelog and never answers messages β€” so it gets an announce
-// recipe (and badges BOT, since it uses no model); everyone else gets the
-// conversational default. An explicit <session>.recipe.json always overrides.
-func DefaultRecipeFor(name string) *Recipe {
- if name == "Announcer" {
- r := &Recipe{Rules: []Rule{{On: "changelog", Action: "announce"}}}
- _ = r.validate() // populates the default poll interval
- return r
- }
- return DefaultRecipe()
-}
-
// usesModel reports whether any rule can produce a model-generated reply β€” the
-// signal for the AI badge (a bot with a key but no model rule, like the
-// Announcer or a template-only watcher, is deterministic and badges BOT).
+// signal for the AI badge (a bot with a key but no model rule, like a
+// changelog watcher or a template-only bot, is deterministic and badges BOT).
func (r *Recipe) usesModel() bool {
for _, rule := range r.Rules {
if rule.On == "message" && rule.Action == "model" {
diff --git a/internal/bot/recipe_test.go b/internal/bot/recipe_test.go
index d0d1734..a7e7974 100644
--- a/internal/bot/recipe_test.go
+++ b/internal/bot/recipe_test.go
@@ -119,32 +119,25 @@ func TestRecipeHandler(t *testing.T) {
}
}
-func TestDefaultRecipeFor(t *testing.T) {
- // The Announcer is write-only: an announce recipe, no model, badges BOT.
- ann := DefaultRecipeFor("Announcer")
- if err := ann.validate(); err != nil {
- t.Fatalf("announcer default recipe invalid: %v", err)
- }
- if ann.usesModel() {
- t.Error("announcer must not use the model")
+// The changelog trigger stays a supported recipe primitive after the default
+// Announcer's retirement: an operator can still write a custom deploy watcher.
+// A parsed announce recipe validates, gets the default poll interval, and β€”
+// using no model β€” stays deterministic (badges BOT).
+func TestChangelogRecipeStillSupported(t *testing.T) {
+ r := mustRecipe(t, `{"rules":[{"on":"changelog","action":"announce"}]}`)
+ if r.usesModel() {
+ t.Error("an announce recipe must not use the model")
}
var hasChangelog bool
- for _, r := range ann.Rules {
- if r.On == "changelog" && r.Action == "announce" {
+ for _, rule := range r.Rules {
+ if rule.On == "changelog" && rule.Action == "announce" {
hasChangelog = true
- if r.every <= 0 {
+ if rule.every <= 0 {
t.Error("announce recipe has no poll interval")
}
}
}
if !hasChangelog {
- t.Error("announcer default recipe missing the changelog rule")
- }
-
- // Everyone else gets the conversational default (uses the model).
- for _, name := range []string{"Claude", "Eleven", "Helper"} {
- if !DefaultRecipeFor(name).usesModel() {
- t.Errorf("%s default recipe should use the model", name)
- }
+ t.Error("parsed recipe missing the changelog rule")
}
}