eleven / messenger Public

AI opt-in: onboarding asks honestly, and saying yes provisions an Eleven sidecar (#877)

* server+cloud+web: the AI opt-in provisions an Eleven sidecar (#858)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: the opt-in signal keys on EXPLICIT consent, not the implicit default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ad322ec743d12973385f1f2f262332086cee1460 parent 01cadd2
19 files changed, +673 −184
  • activator.go +12 −3
  • attach_bot_test.go +227 −0
  • bootstrap.go +1 −1
  • bot.go +96 −71
  • changelog/2026-07-14-ai-opt-in.md +1 −0
  • cloud.go +9 −0
  • cloud_usage.go +50 −0
  • controld.go +43 −0
  • db.go +11 −0
  • docs/ai-provisioning.md +4 −2
  • docs/cloud-provisioner.md +10 −2
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +36 −35
  • main.go +15 −0
  • test/flow.test.mjs +28 −18
  • test/quickform.test.mjs +8 −4
  • usage.go +46 −0
  • web/static/apps/quickform.js +15 −5
  • web/static/apps/setup.flow.js +49 −39
  • web/static/shell.js +12 −4
diff --git a/activator.go b/activator.go
index ced1a4b..ad5ba1e 100644
--- a/activator.go
+++ b/activator.go
@@ -824,9 +824,18 @@ func (a *activatorSvc) sweepOnce() {
// deliberately: an exec is quick, and the lock is what makes a restart
// impossible to race with a stop (sidecar fields only change in the
// awake state, and every stop flips the state under this same lock
- // first). Only what the wake meant to spawn is managed — a bot env
- // file appearing mid-flight (add-bot on an awake host) stays systemd's
- // until the next wake, so the two never double-run one sidecar.
+ // first).
+ //
+ // A bot env staged MID-FLIGHT (attach-bot on an awake child — the AI
+ // opt-in, #858) is ADOPTED here rather than waiting for the next
+ // wake, so opting in takes effect in seconds. Safe against
+ // double-running: attach-bot never enables the systemd unit for a
+ // hibernating host, and only hibernating hosts are activator
+ // children — the staged sidecar has exactly one possible runner.
+ if !e.sidecarWanted && a.backend.hasSidecar(e.host) {
+ e.sidecarWanted = true
+ log.Printf("activator: sidecar staged for awake %s — adopting (AI opt-in)", e.host)
+ }
if e.sidecarWanted && (e.sidecar == nil || e.sidecar.exited()) && !now.Before(e.sidecarRetryAt) {
if e.sidecar != nil && now.Sub(e.sidecarStarted) >= sidecarHealthyRun {
e.sidecarBackoff = 0
diff --git a/attach_bot_test.go b/attach_bot_test.go
new file mode 100644
index 0000000..3a508c1
--- /dev/null
+++ b/attach_bot_test.go
@@ -0,0 +1,227 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// swapForBotTests points attachBot's seams at test doubles: invites mint
+// against a real httptest instance, chown is a no-op (no service user on a
+// dev machine), and systemctl records instead of running.
+func swapForBotTests(t *testing.T, instanceURL string) *[]string {
+ t.Helper()
+ oldData, oldMint, oldChown, oldSysctl := instanceDataDir, botMintBase, chownToService, systemctl
+ t.Cleanup(func() {
+ instanceDataDir, botMintBase, chownToService, systemctl = oldData, oldMint, oldChown, oldSysctl
+ })
+ instanceDataDir = t.TempDir()
+ botMintBase = func(string) string { return instanceURL }
+ chownToService = func(string) {}
+ var calls []string
+ systemctl = func(args ...string) error {
+ calls = append(calls, strings.Join(args, " "))
+ return nil
+ }
+ return &calls
+}
+
+// attachBot end to end against a real instance: invites minted (Eleven-only
+// for the provisioner path), env staged with the right session vars, the unit
+// started — and a second attach is a pure no-op (idempotence is what lets the
+// opt-in signal fire on every flip).
+func TestAttachBotStagesElevenOnly(t *testing.T) {
+ ts, s := newTestServer(t)
+ calls := swapForBotTests(t, ts.URL)
+ host := "space.11ch.at"
+ if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil {
+ t.Fatal(err)
+ }
+
+ msg, err := attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@")
+ if err != nil {
+ t.Fatalf("attachBot: %v", err)
+ }
+ if !strings.Contains(msg, "started lchat-bot@"+host) {
+ t.Fatalf("msg = %q", msg)
+ }
+ env, err := readEnvFile(botEnvPath(host))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if env["LCHAT_ELEVEN_INVITE"] == "" || env["LCHAT_ELEVEN_SESSION"] == "" {
+ t.Fatalf("Eleven identity missing from env: %v", env)
+ }
+ if env["LCHAT_INVITE"] != "" || env["LCHAT_SESSION"] != "" {
+ t.Fatalf("provisioner path must stage Eleven only, got %v", env)
+ }
+ if env["ANTHROPIC_API_KEY"] != "sk-test" || env["LCHAT_BASE"] != "https://"+host {
+ t.Fatalf("env: %v", env)
+ }
+ if len(*calls) != 1 || (*calls)[0] != "enable --now lchat-bot@"+host {
+ t.Fatalf("systemctl calls: %v", *calls)
+ }
+ // The minted invite is real: it resolves on the instance under its
+ // derived id (the sidecar redeems it at first boot — #806).
+ if n := countAgentInvites(t, s); n != 1 {
+ t.Fatalf("agent invites on the instance = %d, want 1", n)
+ }
+
+ // Idempotent: attach again → no new invite, no unit action, "already".
+ msg, err = attachBot(host, false, []botIdentitySpec{elevenIdentity}, "sk-test", "", "lchat-bot@")
+ if err != nil || msg != "already staged" {
+ t.Fatalf("re-attach: %q %v", msg, err)
+ }
+ if len(*calls) != 1 {
+ t.Fatalf("re-attach touched systemd: %v", *calls)
+ }
+ if n := countAgentInvites(t, s); n != 1 {
+ t.Fatalf("re-attach minted again: %d invites", n)
+ }
+}
+
+// A hibernating host stages the env and leaves systemd alone — the activator
+// co-spawns (at wake, or the sweep adopts an awake child within seconds).
+func TestAttachBotHibernatingSkipsUnit(t *testing.T) {
+ ts, _ := newTestServer(t)
+ calls := swapForBotTests(t, ts.URL)
+ host := "sleepy.11ch.at"
+ if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "admintok"}); err != nil {
+ t.Fatal(err)
+ }
+ msg, err := attachBot(host, true, []botIdentitySpec{elevenIdentity}, "", "", "lchat-bot@")
+ if err != nil {
+ t.Fatalf("attachBot: %v", err)
+ }
+ if !strings.Contains(msg, "activator co-spawns") {
+ t.Fatalf("msg = %q", msg)
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("hibernating attach must not touch systemd: %v", *calls)
+ }
+ if _, err := os.Stat(botEnvPath(host)); err != nil {
+ t.Fatalf("bot env not staged: %v", err)
+ }
+}
+
+// countAgentInvites counts the bot identities a mint created — the agent flag
+// lives on the pre-seeded user row, not the invite row.
+func countAgentInvites(t *testing.T, s *Server) int {
+ t.Helper()
+ var n int
+ if err := s.db.sql.QueryRow(`SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 1`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+// The cloud's opt-in endpoint: meter-token auth resolves the space, controld
+// gets the attach call, a stranger is refused.
+func TestAIOptInHandler(t *testing.T) {
+ db := openTestCloudDB(t)
+ id := insertSpaceRow(t, db, "space.11ch.at", 1000)
+ if err := db.setMeterTokenHash(id, hashToken("meter-tok")); err != nil {
+ t.Fatal(err)
+ }
+ var attached []string
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /instances/{host}/bot", func(w http.ResponseWriter, r *http.Request) {
+ attached = append(attached, r.PathValue("host"))
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": "started"})
+ })
+ sock := fakeControl(t, mux.ServeHTTP)
+ s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"}
+
+ call := func(token string) int {
+ req := httptest.NewRequest("POST", "/api/ai-optin", nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ s.aiOptInHandler(rec, req)
+ return rec.Code
+ }
+ if code := call("meter-tok"); code != http.StatusOK {
+ t.Fatalf("opt-in: %d", code)
+ }
+ if len(attached) != 1 || attached[0] != "space.11ch.at" {
+ t.Fatalf("controld attach calls: %v", attached)
+ }
+ if code := call("wrong"); code != http.StatusForbidden {
+ t.Fatal("stranger must be refused")
+ }
+ if len(attached) != 1 {
+ t.Fatalf("refused call still attached: %v", attached)
+ }
+}
+
+func TestMeterSibling(t *testing.T) {
+ if got := meterSibling("https://cloud.example/api/usage", "ai-optin"); got != "https://cloud.example/api/ai-optin" {
+ t.Fatalf("meterSibling = %q", got)
+ }
+}
+
+// Flipping AI on in the admin settings asks the provisioner for the sidecar;
+// re-sending true (no transition) and flipping off stay silent.
+func TestSetSettingsAIFlipSignals(t *testing.T) {
+ ts, s := newTestServer(t)
+ signals := make(chan struct{}, 8)
+ cloud := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/ai-optin" {
+ signals <- struct{}{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ }))
+ defer cloud.Close()
+ s.meterURL, s.meterToken = cloud.URL+"/api/usage", "meter-tok"
+
+ set := func(on bool) {
+ t.Helper()
+ raw, _ := json.Marshal(map[string]any{"ai_enabled": on})
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/settings", strings.NewReader(string(raw)))
+ req.Header.Set("Authorization", "Bearer admintok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("settings: %d", resp.StatusCode)
+ }
+ }
+ waitSignal := func(want bool) {
+ t.Helper()
+ select {
+ case <-signals:
+ if !want {
+ t.Fatal("unexpected opt-in signal")
+ }
+ case <-time.After(2 * time.Second):
+ if want {
+ t.Fatal("no opt-in signal arrived")
+ }
+ }
+ }
+ // THE core case: a fresh space's FIRST explicit "yes" signals, even
+ // though the implicit default already read as on (the reviewer-caught
+ // bug — a was/now comparison against the default swallowed it).
+ set(true)
+ waitSignal(true)
+ // Re-sending true is not new consent; turning off never signals.
+ set(true)
+ set(false)
+ select {
+ case <-signals:
+ t.Fatal("non-consent writes must not signal")
+ case <-time.After(300 * time.Millisecond):
+ }
+ // Off → on is consent again (the re-enable path re-asks the provisioner).
+ set(true)
+ waitSignal(true)
+}
diff --git a/bootstrap.go b/bootstrap.go
index 9540dbb..a14e6e6 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -87,7 +87,7 @@ func ensureServiceUser() {
// chownToService recursively gives a path to the service user (best-effort exec of
// chown -R, so it also fixes ownership of any data already present).
-func chownToService(path string) {
+var chownToService = func(path string) {
out, err := exec.Command("chown", "-R", serviceUser+":"+serviceUser, path).CombinedOutput()
if err != nil {
die("bootstrap: chown %s: %v: %s", path, err, strings.TrimSpace(string(out)))
diff --git a/bot.go b/bot.go
index f0eac24..03e3b0d 100644
--- a/bot.go
+++ b/bot.go
@@ -25,13 +25,98 @@ import (
func botEnvPath(host string) string { return filepath.Join(instanceDataDir, host+"-bot.env") }
func botSessionDir(host string) string { return filepath.Join(instanceDataDir, host+"-bot") }
+// botIdentitySpec is one identity a sidecar hosts: which env vars carry its
+// invite + session path, and the catalog metadata its agent invite is minted
+// with. Both stock identities 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.)
+type botIdentitySpec struct {
+ inviteVar, sessionVar, sessionFile, label, desc string
+}
+
+var claudeIdentity = botIdentitySpec{
+ "LCHAT_INVITE", "LCHAT_SESSION", "session.json",
+ "Claude", "General assistant — DM me or @mention me in a group.",
+}
+var elevenIdentity = botIdentitySpec{
+ "LCHAT_ELEVEN_INVITE", "LCHAT_ELEVEN_SESSION", "eleven-session.json",
+ "Eleven", "Your guide to the app — I welcome new members.",
+}
+
+// botMintBase resolves the URL invites are minted against — a var so tests
+// can point a fake host at an httptest instance.
+var botMintBase = func(host string) string { return "https://" + host }
+
+// attachBot stands a sidecar up for an instance: mint one agent invite per
+// identity via the instance's admin API (pure Go since #856), write the
+// sidecar's EnvironmentFile, hand ownership to the service user, and start
+// lchat-bot@<host> — unless the host hibernates, where the activator co-spawns
+// the sidecar instead (starting the always-on unit would double-run it; on an
+// already-awake child the activator's sweep adopts the staged env within
+// seconds). Idempotent by env-file existence: a host with a staged bot env is
+// left exactly as it is. Shared by `lchat add-bot` and controld's attach-bot
+// endpoint (#858 — the AI opt-in's provisioner).
+func attachBot(host string, hibernating bool, identities []botIdentitySpec, apiKey, model, unitPrefix string) (string, error) {
+ envPath := botEnvPath(host)
+ if _, err := os.Stat(envPath); err == nil {
+ return "already staged", nil
+ }
+ env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
+ if err != nil {
+ return "", fmt.Errorf("read instance env: %w", err)
+ }
+ admin := env["LCHAT_ADMIN_TOKEN"]
+ if admin == "" {
+ return "", fmt.Errorf("no LCHAT_ADMIN_TOKEN for %s", host)
+ }
+
+ base := botMintBase(host)
+ sessionDir := botSessionDir(host)
+ botEnv := map[string]string{"LCHAT_BASE": "https://" + host}
+ if apiKey != "" {
+ botEnv["ANTHROPIC_API_KEY"] = apiKey
+ }
+ if model != "" {
+ botEnv["LCHAT_MODEL"] = model
+ }
+ for _, id := range identities {
+ t, err := mintAgentInvite(base, admin, id.label, id.desc, model)
+ if err != nil {
+ return "", fmt.Errorf("mint %s invite: %w", id.label, err)
+ }
+ botEnv[id.inviteVar] = t
+ botEnv[id.sessionVar] = filepath.Join(sessionDir, id.sessionFile)
+ }
+
+ // Persistent per-instance session dir + the sidecar's EnvironmentFile (0600 —
+ // it holds the invites and the API key).
+ if err := os.MkdirAll(sessionDir, 0o750); err != nil {
+ return "", fmt.Errorf("mkdir %s: %w", sessionDir, err)
+ }
+ if err := writeEnvFile(envPath, botEnv); err != nil {
+ return "", fmt.Errorf("write bot env: %w", err)
+ }
+ // Provisioning runs as root, so MkdirAll/writeEnvFile leave these root-owned.
+ // But the bot unit runs as serviceUser (hardened units) and must WRITE its
+ // session files into sessionDir — otherwise the sidecar can't persist, and
+ // the first boot crash-loops after burning its one-time invites. Hand both
+ // to the service user.
+ chownToService(sessionDir)
+ chownToService(envPath)
+
+ unit := unitPrefix + host
+ if hibernating {
+ return "staged (hibernating host: the activator co-spawns the sidecar)", nil
+ }
+ if err := systemctl("enable", "--now", unit); err != nil {
+ return "", fmt.Errorf("start %s: %v (invites minted; env written to %s)", unit, err, envPath)
+ }
+ return "started " + unit, nil
+}
+
// runAddBot: `lchat add-bot <host>` — provision the sidecar (Claude + Eleven)
-// for an instance. It mints an agent invite per identity via the instance's
-// admin API (pure Go since #856: internal/e2ee derives the invite id/claim
-// from the secret with the same HKDF walk the sidecar's Provision runs, so
-// the crypto can't diverge), writes the sidecar's EnvironmentFile, and starts
-// lchat-bot@<host>. The instance must be up first (Eleven baselines the
-// roster on its first tick). See docs/instance-router.md.
+// for an instance, via attachBot above. The instance must be up first (Eleven
+// baselines the roster on its first tick). See docs/instance-router.md.
func runAddBot(args []string) {
fs := flag.NewFlagSet("add-bot", flag.ExitOnError)
registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
@@ -60,76 +145,16 @@ func runAddBot(args []string) {
if rt == nil || rt.Kind != "instance" {
die("add-bot: %q is not a messenger instance (run add-instance first)", host)
}
- env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
- if err != nil {
- die("add-bot: read instance env: %v", err)
- }
- admin := env["LCHAT_ADMIN_TOKEN"]
- if admin == "" {
- die("add-bot: no LCHAT_ADMIN_TOKEN for %s", host)
- }
-
- base := "https://" + host
- sessionDir := botSessionDir(host)
-
- // 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."},
- }
- botEnv := map[string]string{
- "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
- }
- if *model != "" {
- botEnv["LCHAT_MODEL"] = *model
- }
- for _, id := range identities {
- t, err := mintAgentInvite(base, admin, id.label, id.desc, *model)
- if err != nil {
- die("add-bot: mint %s invite: %v", id.label, err)
- }
- botEnv[id.envVar] = t
- }
- // Persistent per-instance session dir + the sidecar's EnvironmentFile (0600 —
- // it holds the invites and the API key).
- if err := os.MkdirAll(sessionDir, 0o750); err != nil {
- die("add-bot: mkdir %s: %v", sessionDir, err)
- }
- envPath := botEnvPath(host)
- if err := writeEnvFile(envPath, botEnv); err != nil {
- die("add-bot: write bot env: %v", err)
+ msg, err := attachBot(host, rt.Hibernate, []botIdentitySpec{claudeIdentity, elevenIdentity}, apiKey, *model, *botUnit)
+ if err != nil {
+ die("add-bot: %v", err)
}
- // add-bot runs as root, so MkdirAll/writeEnvFile leave these root-owned. But the
- // bot unit runs as serviceUser (hardened units) and must WRITE its session files
- // into sessionDir — otherwise the sidecar can't persist, and the first boot
- // crash-loops after burning its one-time invites. Hand both to the service user.
- chownToService(sessionDir)
- chownToService(envPath)
-
unit := *botUnit + host
- if rt.Hibernate {
- // An activator-managed host co-spawns its sidecar with each wake
- // (activator.go) — starting the always-on unit here would double-run
- // it. Staging the env file is enough: the sidecar joins on the host's
- // next wake and provisions itself from the invites then.
- fmt.Printf("added bot sidecar for %s (hibernating host: the activator co-spawns it on the next wake — no %s unit)\n", host, unit)
- } else if err := systemctl("enable", "--now", unit); err != nil {
- die("add-bot: start %s: %v (invites minted; env written to %s)", unit, err, envPath)
- } else {
- fmt.Printf("added bot sidecar for %s (%s)\n", host, unit)
- }
+ fmt.Printf("added bot sidecar for %s — %s\n", host, msg)
if apiKey == "" {
fmt.Printf(" note: no ANTHROPIC_API_KEY in env — bots are deterministic-only (Eleven welcomes +\n"+
- " slash commands); add ANTHROPIC_API_KEY to %s and restart %s for AI replies\n", envPath, unit)
+ " slash commands); add ANTHROPIC_API_KEY to %s and restart %s for AI replies\n", botEnvPath(host), unit)
}
fmt.Printf(" logs: journalctl -u %s -f\n", unit)
}
diff --git a/changelog/2026-07-14-ai-opt-in.md b/changelog/2026-07-14-ai-opt-in.md
new file mode 100644
index 0000000..41a0672
--- /dev/null
+++ b/changelog/2026-07-14-ai-opt-in.md
@@ -0,0 +1 @@
+🤖 New spaces now choose AI for themselves. During setup, Eleven asks plainly whether to bring AI in — with an honest note first: Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to, which is exactly what lets it help. Say yes and Eleven joins your space moments later; say no and nothing AI ever runs there. You can change your mind anytime in Admin — switching AI on later brings Eleven in the same way.
diff --git a/cloud.go b/cloud.go
index 5726718..9e75bb8 100644
--- a/cloud.go
+++ b/cloud.go
@@ -110,6 +110,9 @@ func (s *cloudServer) handler() http.Handler {
// Usage ingest (#857): meter-token-gated, not caller-gated — an instance
// can only ever report as itself.
mux.HandleFunc("POST /api/usage", s.usageHandler)
+ // AI opt-in (#858): same meter-token gate — an instance whose admin
+ // switched AI on asks for its sidecar; idempotent all the way down.
+ mux.HandleFunc("POST /api/ai-optin", s.aiOptInHandler)
// Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
// push relay's admin API).
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
@@ -244,6 +247,12 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
}
log.Printf("cloud: created %s (id %d, caller %.8s…)", host, id, callerID)
s.pushQuota(id, host, entFree, "") // insurance cap for a fresh free space (no-op unless caps configured)
+ if s.usageURL != "" {
+ // Meter token from first boot (#858): the usage pipe AND the AI
+ // opt-in channel — a fresh space's onboarding opt-in must not
+ // wait for the sweep to mint its credential.
+ s.pushMeterToken(id, host)
+ }
writeJSON(w, http.StatusCreated, map[string]any{
"host": host,
"url": body["url"],
diff --git a/cloud_usage.go b/cloud_usage.go
index 07d1d3d..351ee0e 100644
--- a/cloud_usage.go
+++ b/cloud_usage.go
@@ -53,6 +53,56 @@ func (s *cloudServer) usageHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": map[string]any{"state": "ok"}})
}
+// aiOptInHandler provisions a space's bot sidecar when its admin switches AI
+// on (#858, docs/ai-provisioning.md phase 2). Auth is the space's meter token
+// — the instance's one cloud-facing credential — so a space can only ever ask
+// for itself. Idempotent end to end (controld answers ok for an
+// already-staged sidecar), so the instance signals on every flip-to-on.
+func (s *cloudServer) aiOptInHandler(w http.ResponseWriter, r *http.Request) {
+ spaceID, ok := s.db.spaceByMeterToken(bearerTokenHeader(r))
+ if !ok {
+ writeJSON(w, http.StatusForbidden, map[string]any{"error": "bad meter token"})
+ return
+ }
+ m, err := s.db.spaceBillingByID(spaceID)
+ if err != nil || m.Host == "" {
+ writeJSON(w, http.StatusNotFound, map[string]any{"error": "unknown space"})
+ return
+ }
+ detail, err := s.controlAttachBot(m.Host)
+ if err != nil {
+ log.Printf("cloud: ai-optin %s: %v", m.Host, err)
+ writeJSON(w, http.StatusBadGateway, map[string]any{"error": "provisioning failed"})
+ return
+ }
+ log.Printf("cloud: ai-optin %s — %s", m.Host, detail)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": detail})
+}
+
+// controlAttachBot asks controld to stage one instance's sidecar.
+func (s *cloudServer) controlAttachBot(host string) (string, error) {
+ req, err := http.NewRequest("POST", "http://control/instances/"+host+"/bot", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("control attach-bot %s: HTTP %d", host, resp.StatusCode)
+ }
+ var out struct {
+ Detail string `json:"detail"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.Detail, nil
+}
+
// ---- meter-token mint + push (sweep-driven) --------------------------------------
// pushMeterToken mints a fresh meter token for a space and hands it to the
diff --git a/controld.go b/controld.go
index 99630e9..9dc71df 100644
--- a/controld.go
+++ b/controld.go
@@ -107,6 +107,7 @@ func (c *controld) handler() http.Handler {
mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler))
mux.HandleFunc("POST /instances/{host}/meter", c.auth(c.meterHandler))
+ mux.HandleFunc("POST /instances/{host}/bot", c.auth(c.botHandler))
mux.HandleFunc("POST /instances/{host}/replication", c.auth(c.replicationHandler))
mux.HandleFunc("POST /instances/{host}/materialize", c.auth(c.materializeHandler))
return mux
@@ -324,6 +325,48 @@ func (c *controld) meterHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": restarted})
}
+// botHandler attaches a bot sidecar to one instance — the provisioner's half
+// of the AI opt-in (#858, docs/ai-provisioning.md phase 2). Cloud spaces get
+// **Eleven only** (the product voice; admins add further agents through the
+// admin console, which the managed sidecar picks up). Idempotent: a host
+// whose bot env is already staged answers ok without touching anything, so
+// re-opt-ins and admin toggle flips are safe to signal every time.
+//
+// The model key comes from controld's own environment (ANTHROPIC_API_KEY) —
+// the sidecar holds it, never the instance (CLAUDE.md's bot invariant). With
+// no key configured the sidecar still attaches deterministic-only and the
+// response says so, so the operator gap is visible rather than silent.
+func (c *controld) botHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ hibernating := false
+ if c.reg != nil {
+ rt, err := c.reg.get(host)
+ if err != nil || rt == nil || rt.Kind != "instance" {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ hibernating = rt.Hibernate
+ }
+ apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
+ msg, err := attachBot(host, hibernating, []botIdentitySpec{elevenIdentity}, apiKey, "", envOr("LCHAT_BOT_UNIT", "lchat-bot@"))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // No instance env file: a registered-but-never-materialized host.
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ log.Printf("controld: attach-bot %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "attach_failed", err.Error())
+ return
+ }
+ log.Printf("controld: attach-bot %s — %s (ai=%v)", host, msg, apiKey != "")
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "detail": msg, "ai": apiKey != ""})
+}
+
// statsHandler reports the reaper's guards for a space — setup_complete, member
// count, and has_content (any message or thread). Read-only against the live
// instance DB. validHost guards the DB path against traversal before it's joined
diff --git a/db.go b/db.go
index 95cf953..78a5257 100644
--- a/db.go
+++ b/db.go
@@ -135,6 +135,17 @@ func (db *DB) aiEnabled() bool {
v, ok, _ := db.getConfig("ai_enabled")
return !ok || v == "1"
}
+
+// aiEnabledExplicitly distinguishes "on because nothing was ever written"
+// (the compat default above) from "an admin explicitly said yes". The AI
+// opt-in signal (#858) keys on the latter: a fresh space's first "Bring
+// Eleven in" must count as consent even though the implicit default already
+// read true — and the default-on reading itself is harmless there, because
+// no sidecar exists until that explicit consent asks for one.
+func (db *DB) aiEnabledExplicitly() bool {
+ v, ok, _ := db.getConfig("ai_enabled")
+ return ok && v == "1"
+}
func (db *DB) setAIEnabled(on bool) error { return db.setConfig("ai_enabled", boolStr(on)) }
func (db *DB) botsEnabled() bool {
diff --git a/docs/ai-provisioning.md b/docs/ai-provisioning.md
index 287a23e..e0300dc 100644
--- a/docs/ai-provisioning.md
+++ b/docs/ai-provisioning.md
@@ -1,7 +1,9 @@
# 🤖 Managed AI on provisioned spaces — design
-Status: **design agreed 2026-07-14** (decisions below are Paul's); nothing in
-this document is built unless a phase says so. This is the plan for making the
+Status: **design agreed 2026-07-14** (decisions below are Paul's). Built so
+far: phase 0 (#806 fix + Go invite mint, PRs #864/#865), phase 1 (the usage
+pipe, PR #867), phase 2 (attach-bot + the onboarding opt-in, #858). Phases
+3–5 are unbuilt. This is the plan for making the
onboarding "enable AI" answer *do* something on self-serve spaces: provision a
sidecar, meter what it spends, bill it honestly, and eventually move the whole
bot trust plane off the serving box.
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 35c6e1a..cf8150b 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -557,6 +557,14 @@ curl -sX POST https://cloud.<domain>/api/admin/tokens \
- **App UI** — "+ → Sign in (home.elevenmessenger.com) → new space", and the
server list syncing/sorting through the existing encrypted blob.
-- **Bot sidecars on provisioned spaces** — once the Go bot runtime
- (`lchat bot`) lands; controld grows an attach-bot step.
+- ~~**Bot sidecars on provisioned spaces**~~ — SHIPPED (#858): controld's
+ `POST /instances/{host}/bot` stages an **Eleven-only** sidecar (idempotent;
+ the model key comes from controld's own env, never the instance's). The
+ trigger is the admin's AI opt-in: setup's dedicated `ai-optin` step (or any
+ later flip of the AI toggle) makes the instance signal
+ `POST /api/ai-optin` on the cloud over its meter token, and the cloud calls
+ controld. Hibernating hosts stage the env only — the activator co-spawns at
+ wake, and its sweep adopts an already-awake child within seconds. Meter
+ tokens are now also minted eagerly at create, so a fresh space can opt in
+ during onboarding without waiting for the sweep.
- **Multi-box placement** — a cloud front choosing among several router boxes.
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 69a5247..be120b9 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -322,7 +322,7 @@
"skipIf": [
"done"
],
- "skipTo": "keys",
+ "skipTo": "ai-optin",
"entries": [
{
"if": "done.extras-apps",
@@ -330,156 +330,157 @@
"label": "chose this space's extras",
"value": "chosen"
},
- "goto": "keys"
+ "goto": "ai-optin"
}
],
"bubbles": [
{
- "text": "Nearly there. Your space comes with a few extras:\n\n• AI — ask Eleven things in plain language and it helps out.\n• Bots — little helpers members can create for reminders and jobs.\n• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\nEverything essential works with any of these off, and you can change your mind anytime in Admin."
+ "text": "Nearly there. Your space comes with a couple of extras:\n\n• Bots — little helpers members can create for reminders and jobs.\n• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\nEverything essential works with these off, and you can change your mind anytime in Admin."
}
],
"chips": [
{
- "label": "Keep everything on",
+ "label": "Keep both on",
"spent": "Kept",
"record": {
- "label": "kept all the extras on",
+ "label": "kept bots and apps on",
"value": "all-on"
},
- "goto": "keys"
+ "goto": "ai-optin"
},
{
"label": "Let me choose",
- "goto": "extras-ai"
+ "goto": "extras-bots"
}
]
},
{
- "id": "extras-ai",
+ "id": "extras-bots",
"skipIf": [
"done"
],
"bubbles": [
{
- "text": "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it — you just won't have the natural-language help."
+ "text": "Bots — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own."
}
],
"chips": [
{
- "label": "Enable AI",
+ "label": "Enable bots",
"effect": {
"verb": "setFeature",
"args": [
- "ai",
+ "bots",
true
]
},
"record": {
- "label": "enabled AI",
+ "label": "enabled bots",
"value": "on"
},
- "goto": "extras-bots"
+ "goto": "extras-apps"
},
{
- "label": "No AI here",
+ "label": "No bots here",
"spent": "Off",
"effect": {
"verb": "setFeature",
"args": [
- "ai",
+ "bots",
false
]
},
"record": {
- "label": "switched AI off",
+ "label": "switched bots off",
"value": "off"
},
- "goto": "extras-bots"
+ "goto": "extras-apps"
}
]
},
{
- "id": "extras-bots",
+ "id": "extras-apps",
"skipIf": [
"done"
],
"bubbles": [
{
- "text": "Bots next — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own."
+ "text": "And apps — the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own."
}
],
"chips": [
{
- "label": "Enable bots",
+ "label": "Enable apps",
"effect": {
"verb": "setFeature",
"args": [
- "bots",
+ "apps",
true
]
},
"record": {
- "label": "enabled bots",
+ "label": "enabled apps",
"value": "on"
},
- "goto": "extras-apps"
+ "goto": "ai-optin"
},
{
- "label": "No bots here",
+ "label": "No apps here",
"spent": "Off",
"effect": {
"verb": "setFeature",
"args": [
- "bots",
+ "apps",
false
]
},
"record": {
- "label": "switched bots off",
+ "label": "switched apps off",
"value": "off"
},
- "goto": "extras-apps"
+ "goto": "ai-optin"
}
]
},
{
- "id": "extras-apps",
+ "id": "ai-optin",
"skipIf": [
"done"
],
"bubbles": [
{
- "text": "And apps — the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own."
+ "text": "Last choice — AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\nAn honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin."
}
],
"chips": [
{
- "label": "Enable apps",
+ "label": "Bring Eleven in",
+ "spent": "On",
"effect": {
"verb": "setFeature",
"args": [
- "apps",
+ "ai",
true
]
},
"record": {
- "label": "enabled apps",
+ "label": "invited Eleven's AI in",
"value": "on"
},
"goto": "keys"
},
{
- "label": "No apps here",
+ "label": "Not now",
"spent": "Off",
"effect": {
"verb": "setFeature",
"args": [
- "apps",
+ "ai",
false
]
},
"record": {
- "label": "switched apps off",
+ "label": "kept AI off",
"value": "off"
},
"goto": "keys"
diff --git a/main.go b/main.go
index c20c7b4..b27cb72 100644
--- a/main.go
+++ b/main.go
@@ -4365,10 +4365,25 @@ func (s *Server) setSettings(w http.ResponseWriter, r *http.Request) {
}
}
if body.AIEnabled != nil {
+ // EXPLICIT consent is what signals, not the implicit default:
+ // aiEnabled() reads true on a fresh space (absent key, the compat
+ // default), so a plain was/now comparison would swallow the very
+ // first "Bring Eleven in" — the feature's core case. Explicitly-on
+ // before the write is the one state that stays silent.
+ wasExplicitlyOn := s.db.aiEnabledExplicitly()
if err := s.db.setAIEnabled(*body.AIEnabled); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // An explicit enable asks the provisioner to make sure this space's
+ // sidecar exists (#858) — from ANY admin surface (setup flow, admin
+ // console, Admin app), so the toggle and the provisioning can't
+ // drift. Async and best-effort: idempotent end to end, no-op on
+ // self-hosted instances (no meter env), and a lost signal is
+ // re-sent by the next flip.
+ if *body.AIEnabled && !wasExplicitlyOn {
+ go s.signalAIOptIn()
+ }
}
if body.BotsEnabled != nil {
if err := s.db.setBotsEnabled(*body.BotsEnabled); err != nil {
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index ba88d85..326b6d9 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -189,13 +189,18 @@ await test("setup: the full first-run transcript, happy path with detours", asyn
await h.tap("Keep this photo");
assert.deepEqual(h.records[3], { step: "photo", label: "set the space photo", value: "Our logo" });
- // Invites → extras → keys: the one-tap common path records and moves on.
+ // Invites → extras → the AI opt-in → keys: the one-tap common path records
+ // and moves on, and AI is always its own explicit question.
await h.tap("Only me");
assert.ok(h.verbLog.find((v) => v[0] === "setUserInvites" && v[1] === false));
- assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text)));
- await h.tap("Keep everything on");
+ assert.ok(h.transcript.find((t) => /comes with a couple of extras/.test(t.text)));
+ await h.tap("Keep both on");
assert.ok(h.records.find((r) => r.step === "extras" && r.value === "all-on"));
assert.equal(h.verbLog.some((v) => v[0] === "setFeature"), false, "keeping defaults writes nothing");
+ assert.ok(h.transcript.find((t) => /read the chats it's invited to/.test(t.text)), "the AI question carries the honest note");
+ await h.tap("Bring Eleven in");
+ assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", true], "opting in writes the gate explicitly");
+ assert.ok(h.records.find((r) => r.step === "ai-optin" && r.value === "on"));
assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text)));
await h.tap("Sign in");
assert.ok(h.verbLog.find((v) => v[0] === "savePasskey"));
@@ -246,7 +251,9 @@ await test("setup: resumed run — skips, Keep chip, skip-everything path", asyn
await h.tap("Skip for now"); // photo — must disarm the waiter
assert.equal(h.cancelled, 1, "photo skip cancels the pending answer");
await h.tap("Members can invite people");
- await h.tap("Keep everything on");
+ await h.tap("Keep both on");
+ await h.tap("Not now"); // the AI opt-in — declining writes an explicit off
+ assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]);
// Keys: decline twice through the plain-words confirm.
await h.tap("Not now");
@@ -266,7 +273,7 @@ await test("setup: already enrolled — keys short-circuits with the once-only a
const h = harness({
myName: "Paul",
enrolled: true,
- done: { name: true, group: true, email: true, photo: true, invites: true, extras: true },
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true },
});
runFlow(SETUP_FLOW, h.env, h.io);
await tick();
@@ -276,7 +283,7 @@ await test("setup: already enrolled — keys short-circuits with the once-only a
assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"));
// Second entry with keys done: no duplicate record, straight to finish.
- const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, keys: true } });
+ const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true, keys: true } });
runFlow(SETUP_FLOW, h2.env, h2.io);
await tick();
// No duplicate keys record — only finish stamping its completion marker.
@@ -284,30 +291,32 @@ await test("setup: already enrolled — keys short-circuits with the once-only a
assert.equal(h2.transcript.some((t) => /key is safe. ✅/.test(t.text)), false);
});
-await test("setup: extras — 'Let me choose' walks the trio and writes each gate", async () => {
+await test("setup: extras — 'Let me choose' walks the pair, then the AI opt-in", async () => {
const h = harness({
myName: "Paul",
done: { name: true, group: true, email: true, photo: true, invites: true },
});
runFlow(SETUP_FLOW, h.env, h.io);
await tick();
- assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text)));
+ assert.ok(h.transcript.find((t) => /comes with a couple of extras/.test(t.text)));
await h.tap("Let me choose");
- assert.ok(h.transcript.find((t) => /AI first/.test(t.text)));
- await h.tap("No AI here");
- assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]);
+ assert.ok(h.transcript.find((t) => /Bots — helpers/.test(t.text)));
await h.tap("Enable bots");
assert.deepEqual(h.verbLog.at(-1), ["setFeature", "bots", true]);
await h.tap("No apps here");
assert.deepEqual(h.verbLog.at(-1), ["setFeature", "apps", false]);
- assert.ok(h.records.find((r) => r.step === "extras-ai" && r.value === "off"));
+ // The AI opt-in follows the granular pair too — never skippable by path.
+ assert.ok(h.transcript.find((t) => /read the chats it's invited to/.test(t.text)));
+ await h.tap("Not now");
+ assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]);
assert.ok(h.records.find((r) => r.step === "extras-apps" && r.value === "off"));
- // The trio lands on the keys pitch like the one-tap path.
+ assert.ok(h.records.find((r) => r.step === "ai-optin" && r.value === "off"));
+ // The path lands on the keys pitch like the one-tap path.
assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text)));
// Resume after the granular path: the extras entry closes the step out and
- // the trio never replays.
+ // neither the pair nor the AI question replays.
const h2 = harness({
myName: "Paul",
enrolled: true,
@@ -315,7 +324,7 @@ await test("setup: extras — 'Let me choose' walks the trio and writes each gat
});
runFlow(SETUP_FLOW, h2.env, h2.io);
await tick();
- assert.equal(h2.transcript.some((t) => /AI first/.test(t.text)), false, "granular steps don't replay");
+ assert.equal(h2.transcript.some((t) => /read the chats it's invited to/.test(t.text)), false, "answered steps don't replay");
assert.ok(h2.records.find((r) => r.step === "extras" && r.value === "chosen"), "the entry closes extras");
assert.ok(h2.records.find((r) => r.step === "keys"), "resume reaches keys");
});
@@ -324,7 +333,7 @@ await test("setup: from-home key step + settle auto-detects the enrolment return
const h = harness({
myName: "Paul",
fromHome: true, // arrived from the Eleven sign-in page → the keys-home variant
- done: { name: true, group: true, email: true, photo: true, invites: true, extras: true },
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true },
});
runFlow(SETUP_FLOW, h.env, h.io);
await tick();
@@ -365,7 +374,8 @@ await test("setup: native creator — greeting shows, name pre-set, passkey neve
await h.tap("Skip for now"); // email; photo auto-skips under native
assert.equal(h.transcript.some((t) => t.extra.key === "q-photo"), false);
await h.tap("Members can invite people");
- await h.tap("Keep everything on");
+ await h.tap("Keep both on");
+ await h.tap("Not now"); // the AI opt-in runs under native too
// Keys: never pitched — recorded as covered by the Eleven account.
assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false);
@@ -625,7 +635,7 @@ await test("welcome: enrolment confirmed while on access-waiting settles through
await test("setup: enrolment confirmed while on keys-waiting settles through", async () => {
const h = harness({
myName: "Paul",
- done: { name: true, group: true, email: true, photo: true, invites: true, extras: true },
+ done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, "ai-optin": true },
});
runFlow(SETUP_FLOW, h.env, h.io);
await tick();
diff --git a/test/quickform.test.mjs b/test/quickform.test.mjs
index e32a53b..a0fdd49 100644
--- a/test/quickform.test.mjs
+++ b/test/quickform.test.mjs
@@ -39,6 +39,9 @@ await test("setup plan, everything on: no setFeature writes, the one-tap extras
{ verb: "setGroupName", args: ["Team Awesome"] },
{ verb: "setContactEmail", args: ["paul@example.com"] },
{ verb: "setUserInvites", args: [true] },
+ // AI is its own explicit opt-in: ALWAYS written, even in the
+ // everything-on plan (bots/apps stay write-nothing defaults).
+ { verb: "setFeature", args: ["ai", true] },
]);
assert.deepEqual(p.records, [
{ step: "name", label: "noted: you're Paul", value: "Paul" },
@@ -46,7 +49,8 @@ await test("setup plan, everything on: no setFeature writes, the one-tap extras
{ step: "email", label: "noted a contact email", value: "paul@example.com" },
{ step: "photo", label: "skipped the space photo", value: "skipped" },
{ step: "invites", label: "decided members can invite people", value: "members" },
- { step: "extras", label: "kept all the extras on", value: "all-on" },
+ { step: "extras", label: "kept bots and apps on", value: "all-on" },
+ { step: "ai-optin", label: "invited Eleven's AI in", value: "on" },
]);
});
@@ -62,18 +66,18 @@ await test("setup plan, granular extras + skipped email + admin-only invites", (
});
assert.equal(p.verbs.some((v) => v.verb === "setContactEmail"), false, "an empty email writes nothing");
assert.deepEqual(p.verbs.filter((v) => v.verb === "setFeature"), [
- { verb: "setFeature", args: ["ai", false] },
{ verb: "setFeature", args: ["bots", true] },
{ verb: "setFeature", args: ["apps", false] },
+ { verb: "setFeature", args: ["ai", false] },
]);
assert.deepEqual(p.verbs.find((v) => v.verb === "setUserInvites"), { verb: "setUserInvites", args: [false] });
const by = Object.fromEntries(p.records.map((r) => [r.step, r]));
assert.deepEqual(by.email, { step: "email", label: "skipped the contact email", value: "skipped" });
assert.deepEqual(by.invites, { step: "invites", label: "decided only the admin invites people", value: "admin only" });
- assert.deepEqual(by["extras-ai"], { step: "extras-ai", label: "switched AI off", value: "off" });
assert.deepEqual(by["extras-bots"], { step: "extras-bots", label: "enabled bots", value: "on" });
assert.deepEqual(by["extras-apps"], { step: "extras-apps", label: "switched apps off", value: "off" });
- assert.equal(by.extras, undefined, "the one-tap record only when everything stays on");
+ assert.deepEqual(by["ai-optin"], { step: "ai-optin", label: "kept AI off", value: "off" });
+ assert.equal(by.extras, undefined, "the one-tap record only when the pair stays on");
});
// The crucial invariant behind the fast-fill: every planned record names a
diff --git a/usage.go b/usage.go
index 6436919..49bddd6 100644
--- a/usage.go
+++ b/usage.go
@@ -18,7 +18,10 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "log"
"net/http"
+ "net/url"
+ "path"
"regexp"
"time"
)
@@ -115,6 +118,49 @@ func (s *Server) postBotUsage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "budget": budget})
}
+// meterSibling swaps the last path segment of the meter URL for a sibling
+// endpoint — the meter env carries one URL (…/api/usage) and the opt-in
+// signal (…/api/ai-optin) rides the same credential and base.
+func meterSibling(meterURL, name string) string {
+ u, err := url.Parse(meterURL)
+ if err != nil {
+ return ""
+ }
+ u.Path = path.Join(path.Dir(u.Path), name)
+ return u.String()
+}
+
+// signalAIOptIn tells the provisioner this space wants its bot sidecar
+// (#858). Fire-and-forget from setSettings: the attach is idempotent, a
+// failure only means the admin flips the toggle again (or asks), and a
+// self-hosted instance (no meter env) has no provisioner to ask.
+func (s *Server) signalAIOptIn() {
+ if s.meterURL == "" || s.meterToken == "" {
+ return
+ }
+ target := meterSibling(s.meterURL, "ai-optin")
+ if target == "" {
+ return
+ }
+ req, err := http.NewRequest("POST", target, nil)
+ if err != nil {
+ return
+ }
+ req.Header.Set("Authorization", "Bearer "+s.meterToken)
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ log.Printf("ai-optin: provisioner unreachable: %v", err)
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ log.Printf("ai-optin: provisioner answered HTTP %d", resp.StatusCode)
+ return
+ }
+ log.Printf("ai-optin: sidecar requested from the provisioner")
+}
+
// relayUsage forwards a validated report to the cloud ledger and returns the
// budget object from its response.
func relayUsage(meterURL, meterToken, reportID string, rows []botUsageRow) (map[string]any, error) {
diff --git a/web/static/apps/quickform.js b/web/static/apps/quickform.js
index e208122..baf70ad 100644
--- a/web/static/apps/quickform.js
+++ b/web/static/apps/quickform.js
@@ -52,16 +52,15 @@ export function planQuickForm(kind, values) {
: { step: "invites", label: "decided only the admin invites people", value: "admin only" },
);
- // The extras trio mirrors the conversation's two paths: everything on is the
- // one-tap record (defaults are already on server-side — writing nothing is
- // the point), anything off walks the granular records, writing each gate.
+ // Bots + apps mirror the conversation's two paths: both on is the one-tap
+ // record (defaults are already on server-side — writing nothing is the
+ // point), anything off walks the granular records, writing each gate.
const extras = [
- ["ai", v.ai !== false, "AI"],
["bots", v.bots !== false, "bots"],
["apps", v.apps !== false, "apps"],
];
if (extras.every(([, on]) => on)) {
- records.push({ step: "extras", label: "kept all the extras on", value: "all-on" });
+ records.push({ step: "extras", label: "kept bots and apps on", value: "all-on" });
} else {
for (const [key, on, noun] of extras) {
verbs.push({ verb: "setFeature", args: [key, on] });
@@ -72,5 +71,16 @@ export function planQuickForm(kind, values) {
});
}
}
+ // AI is its own explicit opt-IN (docs/ai-provisioning.md): ALWAYS written —
+ // unticked means off, matching the conversation's dedicated step — and
+ // never bundled into the everything-on shortcut, because saying yes
+ // provisions a real helper on a cloud space.
+ const ai = v.ai === true;
+ verbs.push({ verb: "setFeature", args: ["ai", ai] });
+ records.push({
+ step: "ai-optin",
+ label: ai ? "invited Eleven's AI in" : "kept AI off",
+ value: ai ? "on" : "off",
+ });
return { name, verbs, records };
}
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index 8469811..3804b67 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -205,70 +205,48 @@ export default {
],
},
{
- // The extras trio (AI / bots / apps — the #712 gates) as ONE question
- // with a one-tap common path; "Let me choose" opens the granular three.
- // Defaults are already on server-side, so keeping everything on writes
- // nothing — the granular chips write the setting either way.
+ // Bots + apps (two of the #712 gates) as ONE question with a one-tap
+ // common path; "Let me choose" opens the granular pair. Defaults are
+ // already on server-side, so keeping both on writes nothing — the
+ // granular chips write the setting either way. AI is deliberately NOT
+ // in this bundle: it gets its own explicit opt-in step below
+ // (docs/ai-provisioning.md — saying yes provisions a real helper, so
+ // it's never a side effect of "keep everything on").
id: "extras",
skipIf: ["done"],
- skipTo: "keys", // past the granular trio, which only "Let me choose" enters
+ skipTo: "ai-optin", // past the granular pair, which only "Let me choose" enters
entries: [
// A resume that already finished the granular path: close this out.
{
if: "done.extras-apps",
recordIfNew: { label: "chose this space's extras", value: "chosen" },
- goto: "keys",
+ goto: "ai-optin",
},
],
bubbles: [
{
text:
- "Nearly there. Your space comes with a few extras:\n\n" +
- "• AI — ask Eleven things in plain language and it helps out.\n" +
+ "Nearly there. Your space comes with a couple of extras:\n\n" +
"• Bots — little helpers members can create for reminders and jobs.\n" +
"• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\n" +
- "Everything essential works with any of these off, and you can change your mind anytime in Admin.",
+ "Everything essential works with these off, and you can change your mind anytime in Admin.",
},
],
chips: [
{
- label: "Keep everything on",
+ label: "Keep both on",
spent: "Kept",
- record: { label: "kept all the extras on", value: "all-on" },
- goto: "keys",
- },
- { label: "Let me choose", goto: "extras-ai" },
- ],
- },
- {
- id: "extras-ai",
- skipIf: ["done"],
- bubbles: [
- {
- text: "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it — you just won't have the natural-language help.",
- },
- ],
- chips: [
- {
- label: "Enable AI",
- effect: { verb: "setFeature", args: ["ai", true] },
- record: { label: "enabled AI", value: "on" },
- goto: "extras-bots",
- },
- {
- label: "No AI here",
- spent: "Off",
- effect: { verb: "setFeature", args: ["ai", false] },
- record: { label: "switched AI off", value: "off" },
- goto: "extras-bots",
+ record: { label: "kept bots and apps on", value: "all-on" },
+ goto: "ai-optin",
},
+ { label: "Let me choose", goto: "extras-bots" },
],
},
{
id: "extras-bots",
skipIf: ["done"],
bubbles: [
- { text: "Bots next — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." },
+ { text: "Bots — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." },
],
chips: [
{
@@ -297,13 +275,45 @@ export default {
label: "Enable apps",
effect: { verb: "setFeature", args: ["apps", true] },
record: { label: "enabled apps", value: "on" },
- goto: "keys",
+ goto: "ai-optin",
},
{
label: "No apps here",
spent: "Off",
effect: { verb: "setFeature", args: ["apps", false] },
record: { label: "switched apps off", value: "off" },
+ goto: "ai-optin",
+ },
+ ],
+ },
+ {
+ // AI is opt-in with the honest note (docs/ai-provisioning.md): saying
+ // yes flips ai_enabled on, and on a cloud space the server asks the
+ // provisioner for the sidecar — Eleven joins moments later. Declining
+ // writes an explicit off. Either way the choice is the admin's, never
+ // a default's.
+ id: "ai-optin",
+ skipIf: ["done"],
+ bubbles: [
+ {
+ text:
+ "Last choice — AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\n" +
+ "An honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin.",
+ },
+ ],
+ chips: [
+ {
+ label: "Bring Eleven in",
+ spent: "On",
+ effect: { verb: "setFeature", args: ["ai", true] },
+ record: { label: "invited Eleven's AI in", value: "on" },
+ goto: "keys",
+ },
+ {
+ label: "Not now",
+ spent: "Off",
+ effect: { verb: "setFeature", args: ["ai", false] },
+ record: { label: "kept AI off", value: "off" },
goto: "keys",
},
],
diff --git a/web/static/shell.js b/web/static/shell.js
index 14f881c..510f2ae 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -6720,13 +6720,21 @@ async function openSetupQuickForm(threadId) {
gh.className = "qf-label";
gh.textContent = "Extras";
extras.appendChild(gh);
+ const bots = quickFormToggle(extras, {
+ id: "qf-bots",
+ label: "Bots",
+ desc: "Everything essential works with these off — change your mind anytime in Admin.",
+ });
+ const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" });
+ // AI is opt-IN — unticked by default, with the honest note (mirrors the
+ // conversation's dedicated ai-optin step; docs/ai-provisioning.md).
const ai = quickFormToggle(extras, {
id: "qf-ai",
- label: "AI",
- desc: "Everything essential works with any of these off — change your mind anytime in Admin.",
+ label: "AI — bring Eleven in",
+ desc:
+ "Eleven's AI runs on Eleven Messenger's machines and can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in.",
+ checked: false,
});
- const bots = quickFormToggle(extras, { id: "qf-bots", label: "Bots" });
- const apps = quickFormToggle(extras, { id: "qf-apps", label: "Apps" });
sheet.card.appendChild(extras);
sheet.finish();
sheet.submit.onclick = async () => {