package bot // The app-builder: the create_app tool that closes the loop "ask a bot for an // app in chat → the app is deployed to this instance's Apps menu". The flow // stays inside the trust model (docs/member-apps.md): // // 1. The conversational brain interviews the member and calls create_app // with a spec. The tool call is an EXECUTOR (unlike the chip tools): the // sidecar itself runs the build. // 2. A dedicated generation call writes the single-file app module against // the instance's own sandboxed authoring guide (fetched from // /api/member-app-guide at connect, so bot and server can't disagree). // 3. The source is statically validated here (imports, forbidden APIs, one // defineApp, namespaced event types) and regenerated on failure. This is // a QUALITY gate — the browser sandbox is the security boundary; a bad // app that slipped through would still be caged. // 4. { source, manifest } is encrypted under the INSTANCE KEY — the bot is // an ordinary E2EE member and unwraps its own copy — and PUT to // /api/member-apps with the bot's member token. The server stores an // opaque blob under a random id; it never sees the app's name or code. // // Updates reuse the same tool with app_id: the bot decrypts the existing app, // checks the manifest's owner (the member who asked for it) against the // requester, and refuses otherwise — the server's author-or-owner gate covers // the bot's own deploys (and its agent-for-owner extension covers apps the bot // didn't author); requester ownership is enforced here where the plaintext // lives. // // The brain's EYES on existing apps live here too: memberAppsNote lists the // requester's deployed apps (exact ids included) into the model's context, and // the read_app_source tool returns one app's current code — together they're // what turns "fix the bug in my habit tracker" from a blind rebuild into a // targeted update (the model knows the id, can read the code, and the build // call regenerates FROM that code). import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "regexp" "sort" "strings" "time" "lchat/internal/e2ee" ) const ( maxAppSourceBytes = 256 * 1024 maxAppBuildTries = 3 appBuildMaxTokens = 16384 ) var ( appNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,23}$`) appIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{5,47}$`) importRe = regexp.MustCompile(`import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']`) // nameFieldRe finds a `name: "..."` field value (lowercase id). Run over // ALL matches on stripped source, so a `name:` in a comment/string can't // throw the check off and an unrelated earlier `name:` can't shadow the // real one — we assert the requested name is present, not that the first is. nameFieldRe = regexp.MustCompile(`name\s*:\s*["']([a-z0-9-]+)["']`) fenceRe = regexp.MustCompile("(?s)```(?:js|javascript)?\\s*\n(.*?)```") // The sandbox's TYPE_TAIL rule (sandbox.js validateManifest): after the // "." prefix, only lowercase letters/digits/_/-, dot-separated. A // generated type outside this (e.g. camelCase "notes.markDone") is REJECTED // by the client, so the builder must catch it — not silently drop it. typeTailRe = regexp.MustCompile(`^[a-z0-9_-]{1,40}(\.[a-z0-9_-]{1,40})*$`) // exportRe finds any export keyword (stripped source) for the export-form // check below. exportRe = regexp.MustCompile(`(^|[^.\w$])export\b`) ) // allowedAppImports mirrors ALLOWED_IMPORTS in web/static/sandbox-frame.js — // the sandbox SDK surface (drift-pinned by TestAppImportsMatchSandbox). var allowedAppImports = []string{ "defineApp", "namespace", "addItem", "updateItem", "removeItem", "partitionByGrace", "DONE_GRACE_MS", "keyedList", } // allowedFmtImports mirrors ALLOWED_FMT in web/static/sandbox-frame.js — the // pure text helpers a member app may import from "../format.js" // (drift-pinned by TestAppImportsMatchSandbox). var allowedFmtImports = []string{ "tokenizeText", "richTokens", "blockTokens", "firstLink", "youtubeId", "replySnippet", "emojiOnlyCount", "typingSummary", "autoGroupTitle", "formatBytes", } // forbiddenApp pairs a human label with a boundary-aware regex, scanned on // comment/string-stripped source. Advisory quality gate (the sandbox blocks // all of this at runtime): an app reaching for any of these is wrong per the // guide's hard constraints, so the build loop regenerates. Boundaries matter: // a bare substring match flags legitimate identifiers (`refetch(` contains // `fetch(`, `retrieval(` contains `eval(`), burning build tries on valid code. var forbiddenApp = []struct { label string re *regexp.Regexp hint string }{ {"fetch", regexp.MustCompile(`(^|[^.\w$])fetch\s*\(`), ""}, {"XMLHttpRequest", regexp.MustCompile(`\bXMLHttpRequest\b`), ""}, {"WebSocket", regexp.MustCompile(`\bWebSocket\b`), ""}, {"EventSource", regexp.MustCompile(`\bEventSource\b`), ""}, {"sendBeacon", regexp.MustCompile(`\bsendBeacon\b`), ""}, {"localStorage", regexp.MustCompile(`\blocalStorage\b`), ""}, {"sessionStorage", regexp.MustCompile(`\bsessionStorage\b`), ""}, {"indexedDB", regexp.MustCompile(`\bindexedDB\b`), ""}, {"document.cookie", regexp.MustCompile(`\bdocument\s*\.\s*cookie\b`), ""}, {"window.parent", regexp.MustCompile(`\bwindow\s*\.\s*parent\b`), ""}, {"window.top", regexp.MustCompile(`\bwindow\s*\.\s*top\b`), ""}, {"postMessage", regexp.MustCompile(`\bpostMessage\s*\(`), ""}, {"importScripts", regexp.MustCompile(`\bimportScripts\s*\(`), ""}, {"eval", regexp.MustCompile(`(^|[^.\w$])eval\s*\(`), ""}, {"new Function", regexp.MustCompile(`\bnew\s+Function\b`), ""}, {"innerHTML", regexp.MustCompile(`\binnerHTML\b`), " (build DOM with createElement/textContent)"}, {"setInterval", regexp.MustCompile(`\bsetInterval\s*\(`), " (use ctx.interval)"}, } // manifestCommand is one slash command's METADATA (the handler lives in the // source and runs in the frame) — feeds the "/" typeahead. type manifestCommand struct { Name string `json:"name"` Desc string `json:"desc,omitempty"` Args string `json:"args,omitempty"` } // memberAppManifest is the plaintext half of a member-app blob — everything // the client proxy needs to register the app without running its code. It // rides INSIDE the encrypted blob, so none of it is server-visible. type memberAppManifest struct { V int `json:"v"` Name string `json:"name"` Title string `json:"title"` Glyph string `json:"glyph"` // Places is where copies of the app can live — the one placement // declaration (docs/space-apps.md); bot-built apps are personal, ["you"]. // Scope is its LEGACY spelling, kept so manifests written before the // places model still unmarshal (the client accepts either forever) — // never written by new builds. Places []string `json:"places,omitempty"` Scope string `json:"scope,omitempty"` Types []string `json:"types"` Commands []manifestCommand `json:"commands,omitempty"` // Chat marks a companion app (its source implements chatMessage): the thread // gets a Chat view alongside the Interface. Derived from the source. Chat bool `json:"chat,omitempty"` // DefaultCommand is the command plain (non-slash) text in the Chat view runs // (like To-do's /add). Derived from the source; must name one of Commands. DefaultCommand string `json:"defaultCommand,omitempty"` // Owner is the requesting member's user id — the update gate this file // enforces. By is their display name, for the client's "Built by …" byline. Owner string `json:"owner,omitempty"` By string `json:"by,omitempty"` // BuiltBy is the bot's own name, so the client can show "you + " — you // own it, this bot wrote it. Absent for hand-written (App Studio) apps. BuiltBy string `json:"builtBy,omitempty"` } type memberAppDoc struct { Source string `json:"source"` Manifest memberAppManifest `json:"manifest"` } // createAppTool is the tool the conversational brain may call. Only offered // when the instance served us the authoring guide (replyTools in brain.go). func createAppTool() anthTool { return anthTool{ Name: "create_app", Description: "Build a small personal mini-app for THIS member and deploy it to this chat's Apps " + "menu (or update one they already own — fixing a bug counts, whoever built it: an update " + "loads the app's current source into the build). The app is theirs alone — a private tool like a " + "tracker, list, counter, or tiny game — and runs sandboxed. Before calling, make sure you " + "understand what they want (at most one or two clarifying questions: the core thing being " + "tracked and its lifecycle). Then call with a precise spec. The build takes about a minute " + "and you will post the result to the thread when it finishes — your accompanying text should " + "just say you've started. NEVER announce an app as built or updated yourself: the ✅/✨ " + "confirmation (naming the app's new version) is posted by the build system only after the " + "deploy is verified on the server. If you did not call this tool, nothing was built, and " + "writing a confirmation-style message would be false.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "app_id": map[string]any{ "type": "string", "description": "ONLY to UPDATE an app this member already owns — pass the exact opaque id " + "(looks like \"app-1a2b3c4d\") from the list of their apps in your context, or from your earlier " + "reply. NEVER invent one, and never use the app's name here. Omit entirely to create a new app.", }, "name": map[string]any{ "type": "string", "description": "Short lowercase identifier: letters, digits, dashes; starts with a letter; max 24 chars. e.g. \"habits\"", }, "title": map[string]any{ "type": "string", "description": "Human name shown in the Apps list, max 40 chars. e.g. \"Habit Tracker\"", }, "glyph": map[string]any{ "type": "string", "description": "One emoji used as the app's avatar. e.g. \"🌱\"", }, "spec": map[string]any{ "type": "string", "description": "A precise build specification: the core entity and its lifecycle (one event " + "type per transition), what the surface shows and in what order, the interactions " + "(forms/buttons), the empty state, any time-based behaviour (schedules/notices), and — " + "if useful — slash commands for quick capture from any chat and whether the thread " + "should also read as a conversation (a companion Chat view via chatMessage).", }, "commands": map[string]any{ "type": "array", "description": "Optional slash commands the app offers (implement each in the source's `commands`). " + "Quick capture from any chat, e.g. /habit water. Omit if the app is surface-only.", "items": map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string", "description": "e.g. \"/habit\" — lowercase, leading slash"}, "desc": map[string]any{"type": "string", "description": "one-line help for the / typeahead"}, "args": map[string]any{"type": "string", "description": "argument hint, e.g. \"\""}, }, "required": []any{"name"}, }, }, }, "required": []any{"name", "title", "glyph", "spec"}, }, } } type appBuildReq struct { AppID string Name string Title string Glyph string Spec string Commands []manifestCommand } // parseAppBuildReq validates the model's tool input — same rules the client // proxy applies to a manifest, enforced before we spend a build on it. func parseAppBuildReq(input map[string]any) (appBuildReq, error) { str := func(k string) string { v, _ := input[k].(string); return strings.TrimSpace(v) } req := appBuildReq{ AppID: str("app_id"), Name: str("name"), Title: str("title"), Glyph: str("glyph"), Spec: str("spec"), } if !appNameRe.MatchString(req.Name) { return req, fmt.Errorf("app name must be lowercase letters/digits/dashes, starting with a letter (got %q)", req.Name) } if req.Title == "" || len(req.Title) > 40 { return req, fmt.Errorf("title must be 1-40 characters") } if req.Glyph == "" || len(req.Glyph) > 16 { return req, fmt.Errorf("glyph must be one emoji") } if req.Spec == "" || len(req.Spec) > 4000 { return req, fmt.Errorf("spec must be 1-4000 characters") } if req.AppID != "" && !appIDRe.MatchString(req.AppID) { return req, fmt.Errorf("bad app_id") } if raw, ok := input["commands"].([]any); ok { if len(raw) > 16 { return req, fmt.Errorf("at most 16 commands") } for _, r := range raw { c, _ := r.(map[string]any) name, _ := c["name"].(string) name = strings.TrimSpace(name) if !cmdNameRe.MatchString(name) { return req, fmt.Errorf("bad command name %q (want e.g. /habit)", name) } desc, _ := c["desc"].(string) args, _ := c["args"].(string) if len(desc) > 80 || len(args) > 40 { return req, fmt.Errorf("command %q: desc/args too long", name) } req.Commands = append(req.Commands, manifestCommand{Name: name, Desc: strings.TrimSpace(desc), Args: strings.TrimSpace(args)}) } } return req, nil } var cmdNameRe = regexp.MustCompile(`^/[a-z][a-z0-9-]{0,30}$`) // stripJS drops comments and string/template contents so token scans don't // trip on prose — the Go twin of the strip pass in tools/check-app.mjs and // web/static/sandbox-frame.js. func stripJS(code string) string { var out strings.Builder i, n := 0, len(code) for i < n { c := code[i] var c2 byte if i+1 < n { c2 = code[i+1] } if c == '/' && c2 == '/' { for i < n && code[i] != '\n' { i++ } continue } if c == '/' && c2 == '*' { i += 2 for i+1 < n && !(code[i] == '*' && code[i+1] == '/') { i++ } i += 2 continue } if c == '"' || c == '\'' || c == '`' { q := c i++ for i < n && code[i] != q { if code[i] == '\\' { i++ } i++ } i++ out.WriteByte(q) out.WriteByte(q) continue } out.WriteByte(c) i++ } return out.String() } // validateMemberAppSource checks a generated module against the guide's // mechanical rules and extracts its event types. An empty problems slice // means deployable. func validateMemberAppSource(name, src string) (types []string, problems []string) { if len(src) > maxAppSourceBytes { return nil, []string{fmt.Sprintf("source too large (%d bytes)", len(src))} } stripped := stripJS(src) if c := strings.Count(stripped, "defineApp("); c != 1 { problems = append(problems, fmt.Sprintf("must call defineApp exactly once (found %d calls)", c)) } // Imports: named bindings from "../apps.js" (SDK) or "../format.js" (pure // text helpers) only, each name in its source's allow-list. legal := 0 for _, m := range importRe.FindAllStringSubmatch(src, -1) { var allow []string switch m[2] { case "../apps.js": allow = allowedAppImports case "../format.js": allow = allowedFmtImports default: problems = append(problems, fmt.Sprintf("import from %q — only \"../apps.js\" and \"../format.js\" are allowed", m[2])) continue } legal++ for _, part := range strings.Split(m[1], ",") { bare := strings.TrimSpace(strings.Split(strings.TrimSpace(part), " as ")[0]) if bare == "" { continue } ok := false for _, a := range allow { if bare == a { ok = true break } } if !ok { problems = append(problems, fmt.Sprintf("%q is not importable from %q (allowed: %s)", bare, m[2], strings.Join(allow, ", "))) } } } // Any import the legal pattern didn't consume (side-effect imports, // dynamic import(), namespace imports) is a violation. if extra := len(regexp.MustCompile(`(^|[^.\w$])import\b`).FindAllString(stripped, -1)) - legal; extra > 0 { problems = append(problems, `imports must be exactly: import { … } from "../apps.js" (or "../format.js")`) } // Exports: mirror what the sandbox transform (sandbox-frame.js // transformModuleSource) accepts — `export default`, `export `, and a // bare `export { … }` list alone on its line. Anything else throws in the // frame ("unsupported export form") and deploys a broken app, so reject it // here where the build loop can fix it. exportSurvivors := stripped exportSurvivors = regexp.MustCompile(`\bexport\s+default\s+`).ReplaceAllString(exportSurvivors, " ") exportSurvivors = regexp.MustCompile(`\bexport\s+(?:const|let|var|function|class|async)\b`).ReplaceAllString(exportSurvivors, " ") exportSurvivors = regexp.MustCompile(`(?m)^\s*export\s*\{[^}]*\}\s*;?\s*$`).ReplaceAllString(exportSurvivors, " ") if exportRe.MatchString(exportSurvivors) { problems = append(problems, `unsupported export form — use "export const app = defineApp({…})" (or export default)`) } for _, f := range forbiddenApp { if f.re.MatchString(stripped) { problems = append(problems, fmt.Sprintf("forbidden API %q%s", f.label, f.hint)) } } // The app must register under the requested name. Assert the requested name // is PRESENT among the name: fields rather than that the FIRST one matches — // so an unrelated earlier `name:` (a config object, a UI label) can't // false-reject. Run on raw source: the value is a string literal, which // stripJS would blank. (A false accept — the name buried in a string — is // caught authoritatively by the sandbox, which compares defineApp's real // name to the manifest.) nameOK := false for _, m := range nameFieldRe.FindAllStringSubmatch(src, -1) { if m[1] == name { nameOK = true break } } if !nameOK { problems = append(problems, fmt.Sprintf("defineApp's name field must be %q", name)) } // Event types: every quoted ".…" literal. Match a PERMISSIVE tail // (including uppercase) so a camelCase type the sandbox would reject is // caught and flagged here, not silently dropped from the manifest (which // would leave that event dead end-to-end). A superset of valid types is // harmless (extras route nowhere); a subset breaks folding. typeRe := regexp.MustCompile(`["'](` + regexp.QuoteMeta(name) + `\.[A-Za-z0-9_.-]{1,48})["']`) seen := map[string]bool{} sawAnyType := false for _, m := range typeRe.FindAllStringSubmatch(src, -1) { t := m[1] if seen[t] { continue } seen[t] = true sawAnyType = true tail := t[len(name)+1:] if !typeTailRe.MatchString(tail) { problems = append(problems, fmt.Sprintf("event type %q must be lowercase letters/digits/_/- after the %q prefix", t, name+".")) continue } types = append(types, t) } sort.Strings(types) if !sawAnyType { problems = append(problems, fmt.Sprintf("no event types found — the events map keys must be quoted strings prefixed %q", name+".")) } if len(types) > 64 { problems = append(problems, "too many event types (max 64)") } return types, problems } // sourceHasChatMessage reports whether the app source implements chatMessage // (companion opt-in) — scanned on stripped source so a mention in a comment or // string doesn't count. func sourceHasChatMessage(src string) bool { return regexp.MustCompile(`\bchatMessage\s*[:(]`).MatchString(stripJS(src)) } // commandDefinedInSource reports whether the source registers a handler for the // given "/name" command (a `"/name":` key in the commands object). Checked on // raw source since the key is a string literal. func commandDefinedInSource(src, name string) bool { return regexp.MustCompile(`["']` + regexp.QuoteMeta(name) + `["']\s*:`).MatchString(src) } // sourceDefaultCommand extracts the `defaultCommand: "/name"` value from the // source ("" if none). Read on raw source since the value is a string literal. var defaultCmdRe = regexp.MustCompile(`\bdefaultCommand\s*:\s*["'](/[a-z][a-z0-9-]{0,30})["']`) func sourceDefaultCommand(src string) string { if m := defaultCmdRe.FindStringSubmatch(src); m != nil { return m[1] } return "" } // extractCodeBlock pulls the module out of the model's reply: the largest // fenced code block (the reply should be nothing else, but be forgiving). func extractCodeBlock(text string) string { best := "" for _, m := range fenceRe.FindAllStringSubmatch(text, -1) { if len(m[1]) > len(best) { best = m[1] } } return strings.TrimSpace(best) } // beginBuild atomically marks an app build in flight for (thread, requester). // It returns false if one is already running, so the caller declines to start a // duplicate — the fix for a member re-asking mid-build and getting two apps. func (r *Runtime) beginBuild(threadID, uid string) bool { key := threadID + "\x00" + uid r.buildMu.Lock() defer r.buildMu.Unlock() if r.building == nil { r.building = map[string]bool{} } if r.building[key] { return false } r.building[key] = true return true } // endBuild clears the in-flight marker (deferred by runAppBuild), so the next // request — including a retry after a failed build — can start. func (r *Runtime) endBuild(threadID, uid string) { r.buildMu.Lock() delete(r.building, threadID+"\x00"+uid) r.buildMu.Unlock() } // runAppBuild is the whole pipeline, run on its own goroutine so a ~minute of // generation never blocks the bot's other threads. It reports progress and its // result by updating a single status line in place. The caller must have reserved // the build slot via beginBuild; this releases it on return. func (r *Runtime) runAppBuild(tc *ThreadCtx, req appBuildReq, requester, requesterUID string) { defer r.endBuild(tc.ID, requesterUID) // One /me-style status line for the whole build: its start, progress, and // (on success) result are all the SAME line, updated in place, so the wait // never stacks bot bubbles. (actionLine is a general primitive — runtime.go.) start := time.Now() status := tc.actionLine(fmt.Sprintf("🔨 Building %s %s… (about a minute)", req.Title, req.Glyph)) // Refresh the line every 5s so it visibly moves — the bare typing dots read // as "hung", and a slow beat leaves members wondering. Each tick advances the // phase (holding the last) and shows elapsed seconds, so there's always fresh // proof of life. The same tick re-beats "typing" (its 15s expiry outlives 5s). // The heartbeat runs on its own goroutine; a done channel lets the terminal // paths halt it first, so a late tick can't clobber the final state. stop := make(chan struct{}) done := make(chan struct{}) go func() { defer close(done) tc.Typing(15) tick := time.NewTicker(5 * time.Second) defer tick.Stop() phases := []string{ "writing the code", "wiring up the interface", "checking and validating", } n := 0 for { select { case <-tick.C: tc.Typing(15) phase := phases[min(n, len(phases)-1)] n++ status.set(fmt.Sprintf("🔨 Building %s %s — %s… (%ds)", req.Title, req.Glyph, phase, int(time.Since(start).Seconds()))) case <-stop: tc.Typing(0) return } } }() stopped := false stopBeat := func() { if stopped { return } stopped = true close(stop) <-done } defer stopBeat() // settle ends the build. The status line only ever reflects work-in-progress; // once we KNOW the outcome, it stops moving and becomes a short breadcrumb // ("✅ …"/"❌ …") marking where the attempt was, and the real outcome — the // result or the failure detail — is posted as a proper chat message (a real, // notifying bubble one won't miss, not a muted /me line). settle := func(breadcrumb, message string) { stopBeat() status.set(breadcrumb) r.postReply(tc.ID, tc.key, Reply{Text: message}) } fail := func(format string, args ...any) { msg := fmt.Sprintf(format, args...) r.logf("app build %q: %s", req.Name, msg) settle(fmt.Sprintf("❌ Couldn't build %s %s.", req.Title, req.Glyph), "I couldn't finish building that app — "+msg+" Feel free to ask me to try again.") } // The instance key first: without it there is nothing to deploy under, so // fail before spending a generation call. instKey, err := r.instanceKey() if err != nil { fail(fmt.Sprintf("I don't hold this space's shared key yet (%v). It's granted automatically when a member has the app open — try again in a minute.", err)) return } // Update path: fetch + decrypt the existing app, enforce requester // ownership, pin the name (renaming would orphan its event types). // // Ownership is by the requester's uid recorded in the manifest. Note the // residual: chat payloads aren't sender-authenticated (the thread key is // symmetric — every member can encrypt one), so a determined member could // assert another's uid, exactly as they could impersonate anyone in plain // chat. That's a property of the messaging layer, not something this gate // can close; the blast radius is capped by the sandbox (a hijacked update // can only alter that app's own caged pane, never reach keys or data). What // we DO enforce firmly: an unknown requester (no uid) can neither own nor // update anything, and an app with no recorded owner can't be updated by // anyone — it must be rebuilt — so the gate never silently opens. if requesterUID == "" { fail("I couldn't tell who's asking, so I can't build or change an app safely. Try again in a moment.") return } prevSource := "" if req.AppID != "" { doc, err := r.fetchMemberApp(req.AppID, instKey) switch { case errors.Is(err, errMemberAppNotFound): // The model supplied an app_id for an app that doesn't exist — a // common slip where it invents an id (or reuses the app name) for a // NEW app. Don't fail: treat it as a create and mint a fresh id. r.logf("app build: app_id %q not found — creating a new app instead", req.AppID) req.AppID = "" case err != nil: fail(fmt.Sprintf("I couldn't load the app to update (%v).", err)) return case doc.Manifest.Owner == "" || doc.Manifest.Owner != requesterUID: fail("that app isn't one I can change for you — only the person it was built for can, by asking me themselves.") return default: prevSource = doc.Source req.Name = doc.Manifest.Name } } // A new app's name must not collide with an existing one — the client keys // apps by name (and its event-type prefix). We hold the instance key, so read // the deployed names and pick a free variant rather than colliding; the // requester never chose the name, so "habits" → "habits2" is invisible (the // title they see is unchanged). Done before generation so the event types are // prefixed with the final name from the start. if req.AppID == "" { if taken, err := r.deployedAppNames(instKey); err != nil { r.logf("app build: couldn't read names to de-dupe (%v) — using %q as-is", err, req.Name) } else { req.Name = uniqueAppName(req.Name, taken) } } source, types, err := r.generateApp(req, prevSource) if err != nil { fail(err.Error()) return } // Companion (Chat view) is derived from the source (chatMessage present), // so a manifest can't claim a view the code doesn't project. Commands are // the declared metadata; generateApp verified each has a handler. manifest := memberAppManifest{ V: 1, Name: req.Name, Title: req.Title, Glyph: req.Glyph, Places: []string{"you"}, Types: types, Commands: req.Commands, Chat: sourceHasChatMessage(source), DefaultCommand: sourceDefaultCommand(source), Owner: requesterUID, By: requester, BuiltBy: r.Session.Name, } blob, err := e2ee.EncryptJSON(instKey, memberAppDoc{Source: source, Manifest: manifest}) if err != nil { fail(fmt.Sprintf("encryption failed (%v).", err)) return } id := req.AppID verb := "updated" if id == "" { var buf [6]byte if _, err := rand.Read(buf[:]); err != nil { fail("couldn't mint an app id.") return } id = "app-" + hex.EncodeToString(buf[:]) verb = "built" } version, err := r.putMemberApp(id, blob, requesterUID) if err != nil { fail(fmt.Sprintf("the deploy was rejected (%v).", err)) return } // Double-check before telling the member anything: read the deployed list // back and confirm the app really is there at the version the PUT reported. // The success message quotes that verified version (App Studio shows the // same number), so "✨ … version N is live" is a checkable claim — never a // report written on the strength of a 200 alone. listed, err := r.deployedAppVersion(id) if err != nil { fail(fmt.Sprintf("the deploy didn't verify — I couldn't read the app back (%v).", err)) return } if listed == 0 || (version > 0 && listed < version) { fail(fmt.Sprintf("the deploy didn't verify — the server lists version %d, expected %d.", listed, version)) return } r.logf("app build %q: deployed as %s version %d (%d types, %d bytes)", req.Name, id, listed, len(types), len(source)) // The next reply's apps note must already list this deploy at its new // version — don't let a stale cache contradict the confirmation below. r.invalidateAppsCache() // New apps register live, so it's usable straight away; an update replaces // code the app already loaded, so it needs a reload. Either way we point at // the gentle Reload offer rather than implying a forced refresh. pickup := "Find it in the + New chat menu, under Apps." if verb == "updated" { pickup = "Tap Reload in the sidebar (or whenever you're ready) to pick up the changes — no rush." } settle(fmt.Sprintf("✅ %s %s %s (version %d).", req.Title, req.Glyph, verb, listed), fmt.Sprintf("✨ %s %s %s — version %d is live. %s Want changes? Just describe them here. (app id: %s)", req.Title, req.Glyph, verb, listed, pickup, id)) } // generateApp is the dedicated build call (+ regenerate-on-validation-failure // loop). Returns the validated source and its event types. func (r *Runtime) generateApp(req appBuildReq, prevSource string) (string, []string, error) { if r.ai == nil || r.appGuide == "" || r.budgetExhausted() { return "", nil, fmt.Errorf("app building isn't available right now.") } system := []anthBlock{ {Type: "text", Text: "You write single-file member apps for an end-to-end encrypted messenger, " + "following this authoring guide exactly:\n\n" + r.appGuide, CacheControl: &anthCache{Type: "ephemeral"}}, } ask := fmt.Sprintf("Build a member app.\n\nname: %s\ntitle: %s\nglyph: %s\n\nSpecification:\n%s\n\n"+ "Reply with ONLY the complete JavaScript module in a single fenced code block. No prose outside the fence.", req.Name, req.Title, req.Glyph, req.Spec) if prevSource != "" { ask = fmt.Sprintf("Update an existing member app (name: %s, title: %s, glyph: %s). Current source:\n\n```js\n%s\n```\n\n"+ "Requested changes:\n%s\n\nKeep the same app name and keep existing event types working (old events will be re-folded "+ "by the new code). Reply with ONLY the complete updated module in a single fenced code block.", req.Name, req.Title, req.Glyph, prevSource, req.Spec) } msgs := []anthMessage{{Role: "user", Content: ask}} for try := 1; ; try++ { // Stream this one: it's the long call (large max_tokens + thinking) that a // non-streamed request lets sit idle until an intermediary cuts it // ("unexpected EOF"). Streaming keeps bytes flowing so it completes. resp, err := r.ai.createStream(anthRequest{ Model: r.model, MaxTokens: appBuildMaxTokens, System: system, Thinking: adaptiveThinking(r.model), Messages: msgs, }) if err != nil { return "", nil, fmt.Errorf("the build call failed (%v).", err) } source := extractCodeBlock(resp.text()) var problems []string var types []string if source == "" { problems = []string{"reply contained no fenced code block"} } else { types, problems = validateMemberAppSource(req.Name, source) // Every declared command must have a handler in the source, or the // manifest would advertise a "/" command that does nothing. for _, c := range req.Commands { if !commandDefinedInSource(source, c.Name) { problems = append(problems, fmt.Sprintf("declared command %q has no handler — add %q to the app's `commands`", c.Name, c.Name)) } } // A defaultCommand (plain text → this command) must be one the app // declares, or the client rejects the manifest. if dc := sourceDefaultCommand(source); dc != "" { declared := false for _, c := range req.Commands { if c.Name == dc { declared = true break } } if !declared { problems = append(problems, fmt.Sprintf("defaultCommand %q must be one of the declared commands", dc)) } } } if len(problems) == 0 { return source, types, nil } r.logf("app build %q try %d: %s", req.Name, try, strings.Join(problems, "; ")) if try >= maxAppBuildTries { return "", nil, fmt.Errorf("the generated code kept violating the app rules (%s).", strings.Join(problems, "; ")) } msgs = append(msgs, anthMessage{Role: "assistant", Content: resp.text()}, anthMessage{Role: "user", Content: "That module violates the authoring rules:\n- " + strings.Join(problems, "\n- ") + "\n\nFix every problem and reply again with ONLY the complete corrected module in a single fenced code block."}) } } // instanceKey fetches and unwraps this bot's copy of the instance key (the // all-members content key member apps are sealed under). Same wrap format as // a thread key. func (r *Runtime) instanceKey() ([]byte, error) { var ik struct { Wrapped string `json:"wrapped"` } if err := r.get("/api/instance-key", &ik); err != nil { return nil, err } if ik.Wrapped == "" { return nil, fmt.Errorf("no wrapped copy for this bot") } keyB64, err := e2ee.UnwrapThreadKey(ik.Wrapped, r.Session.UserPriv) if err != nil { return nil, err } return e2ee.ImportKey(keyB64) } // fetchMemberApp loads and decrypts one deployed member app by id. func (r *Runtime) fetchMemberApp(id string, instKey []byte) (*memberAppDoc, error) { var list struct { Apps []struct { ID string `json:"id"` Blob string `json:"blob"` } `json:"apps"` } if err := r.get("/api/member-apps", &list); err != nil { return nil, err } for _, a := range list.Apps { if a.ID != id { continue } var doc memberAppDoc if err := e2ee.DecryptJSON(instKey, a.Blob, &doc); err != nil { return nil, fmt.Errorf("can't decrypt app %s", id) } return &doc, nil } return nil, fmt.Errorf("%w: %s", errMemberAppNotFound, id) } // errMemberAppNotFound is returned by fetchMemberApp when no deployed app has // the given id — so runAppBuild can distinguish "wrong id, make a new one" from // a real load error. var errMemberAppNotFound = errors.New("no app with that id") // deployedApp is one decrypted entry of the member-app list: the server-side // metadata (id, version) plus the document only instance-key holders can open. type deployedApp struct { ID string Version int Doc memberAppDoc } // fetchDeployedApps loads and decrypts the full member-app list. A blob that // doesn't open under the instance key (foreign content, corruption) is skipped // rather than failing the list — same tolerance as deployedAppNames. func (r *Runtime) fetchDeployedApps(instKey []byte) ([]deployedApp, error) { var list struct { Apps []struct { ID string `json:"id"` Version int `json:"version"` Blob string `json:"blob"` } `json:"apps"` } if err := r.get("/api/member-apps", &list); err != nil { return nil, err } out := make([]deployedApp, 0, len(list.Apps)) for _, a := range list.Apps { var doc memberAppDoc if e2ee.DecryptJSON(instKey, a.Blob, &doc) == nil { out = append(out, deployedApp{ID: a.ID, Version: a.Version, Doc: doc}) } } return out, nil } // appsNoteTTL is how long one decrypted list serves memberAppsNote — the note // rides EVERY model reply, and refetching + decrypting every deployed blob per // message would be pure waste at chat cadence. Deploys through this sidecar // invalidate immediately (runAppBuild); a deploy from elsewhere (App Studio, // another bot) shows up within the TTL. const appsNoteTTL = 30 * time.Second // cachedDeployedApps returns the deployed list, refreshing after appsNoteTTL. func (r *Runtime) cachedDeployedApps() ([]deployedApp, error) { r.appsMu.Lock() if !r.appsAt.IsZero() && time.Since(r.appsAt) < appsNoteTTL { apps := r.apps r.appsMu.Unlock() return apps, nil } r.appsMu.Unlock() instKey, err := r.instanceKey() if err != nil { return nil, err } apps, err := r.fetchDeployedApps(instKey) if err != nil { return nil, err } r.appsMu.Lock() r.apps, r.appsAt = apps, time.Now() r.appsMu.Unlock() return apps, nil } // invalidateAppsCache drops the cached list — called after this sidecar's own // deploys so the very next reply's note already shows the new version. func (r *Runtime) invalidateAppsCache() { r.appsMu.Lock() r.apps, r.appsAt = nil, time.Time{} r.appsMu.Unlock() } // memberAppsNote renders the per-reply system addendum listing the apps the // current sender owns — the discovery half of fixing an existing app. Without // it the model only ever knows an app_id that happens to appear in the last 30 // messages, so "fix my habit tracker" from a fresh conversation silently // became a blind NEW build. Empty (and silent) on any failure: the note is an // enrichment, never worth failing a reply over. func (r *Runtime) memberAppsNote(uid string) string { if uid == "" || r.appGuide == "" { return "" } apps, err := r.cachedDeployedApps() if err != nil { r.logf("memberAppsNote: %v", err) return "" } return memberAppsNoteText(apps, uid) } // memberAppsNoteText is the pure render: only apps whose manifest owner is the // sender (an app with no recorded owner is nobody's to change — same rule as // the update gate), with the EXACT ids the tools need. func memberAppsNoteText(apps []deployedApp, uid string) string { var own []deployedApp for _, a := range apps { if a.Doc.Manifest.Owner != "" && a.Doc.Manifest.Owner == uid { own = append(own, a) } } if len(own) == 0 { return "" } var b strings.Builder b.WriteString("Mini-apps the member you're replying to already owns — the complete list, with the EXACT app_id the app tools need:\n") for _, a := range own { m := a.Doc.Manifest fmt.Fprintf(&b, "- %s — %s %s (name: %s, version %d", a.ID, m.Title, m.Glyph, m.Name, a.Version) if m.BuiltBy != "" { fmt.Fprintf(&b, ", built by %s", m.BuiltBy) } b.WriteString(")\n") } b.WriteString("\nWhen they ask to fix, change, or extend one of these — whoever built it — call create_app " + "with that exact app_id: the build loads the app's current source itself, so put the precise bug or " + "change in the spec (never code). To diagnose a bug or answer how one works, call read_app_source " + "first and read the code before replying. Apps owned by other members are not yours to touch — " + "their owner must ask themselves.") return b.String() } // readAppSourceTool lets the conversational brain LOOK at a deployed app // before acting — the diagnosis half of a bug fix ("why does the done button // do nothing?"). Reading is gated exactly like updating: the requester must // own the app. Offered alongside create_app (replyTools). func readAppSourceTool() anthTool { return anthTool{ Name: "read_app_source", Description: "Read the current source code of a mini-app THIS member owns — to diagnose a bug or " + "answer a question about how the app works before you reply. Pass the exact app_id from the " + "apps list in your context (or your earlier reply); you get the app's manifest and complete " + "current source back, then you continue your reply. Call it on its own and wait for the " + "result — other tools called in the same turn are not run. You never need this just to " + "UPDATE an app: create_app loads the current source into the build automatically.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "app_id": map[string]any{ "type": "string", "description": "the app's exact opaque id, e.g. \"app-1a2b3c4d\" — never its name", }, }, "required": []any{"app_id"}, }, } } // maxReadSourceChars caps one read_app_source result's share of the model // context. Generous next to typical member apps (a few KB); a source at the // 256 KB deploy cap is truncated, not refused. const maxReadSourceChars = 48 * 1024 // runReadAppSource executes one read_app_source call: fetch, decrypt, gate on // the requester's ownership, render manifest + source as the tool result. // Errors come back as result text — the model should know a read failed and // say so, not hallucinate code it never saw. func (r *Runtime) runReadAppSource(input map[string]any, requesterUID string) string { id, _ := input["app_id"].(string) id = strings.TrimSpace(id) if !appIDRe.MatchString(id) { return "bad app_id — pass the exact opaque id from the apps list (never the app's name)." } if requesterUID == "" { return "the sender can't be verified, so app code stays closed." } instKey, err := r.instanceKey() if err != nil { return "this space's shared key isn't available right now — try again shortly." } doc, err := r.fetchMemberApp(id, instKey) if errors.Is(err, errMemberAppNotFound) { return "no deployed app has that id — check the apps list in your context." } if err != nil { return "couldn't load that app: " + err.Error() } if doc.Manifest.Owner == "" || doc.Manifest.Owner != requesterUID { return "that app belongs to another member — only its owner can ask about its code." } manifest, err := json.Marshal(doc.Manifest) if err != nil { return "couldn't render that app's manifest." } src, note := doc.Source, "" if len(src) > maxReadSourceChars { src, note = src[:maxReadSourceChars], "\n[source truncated]" } return "manifest: " + string(manifest) + "\n\nsource:\n```js\n" + src + "\n```" + note } // deployedAppNames returns the set of names of every deployed member app, read // via the instance key — so the builder can guarantee a unique name without the // server ever seeing (or storing) a name. A blob we can't decrypt is skipped. func (r *Runtime) deployedAppNames(instKey []byte) (map[string]bool, error) { apps, err := r.fetchDeployedApps(instKey) if err != nil { return nil, err } names := make(map[string]bool, len(apps)) for _, a := range apps { if a.Doc.Manifest.Name != "" { names[a.Doc.Manifest.Name] = true } } return names, nil } // uniqueAppName returns name if it's free, else the first "name2", "name3", … not // in taken, trimming the base so the numeric suffix keeps it within the 24-char // name limit. Falls back to the original after an absurd run of collisions. func uniqueAppName(name string, taken map[string]bool) string { if !taken[name] { return name } for n := 2; n < 1000; n++ { suffix := fmt.Sprintf("%d", n) base := name if len(base)+len(suffix) > 24 { base = base[:24-len(suffix)] } if cand := base + suffix; !taken[cand] { return cand } } return name } // putMemberApp deploys the encrypted blob with the bot's member token, recording // the requester as the app's owner so it's theirs to manage (App Studio // view/edit/remove) even though the bot deployed it. Returns the version the // server reports for this deploy (0 against an older server that doesn't say). func (r *Runtime) putMemberApp(id, blob, ownerID string) (int, error) { body, err := json.Marshal(map[string]string{"token": r.Session.Token, "blob": blob, "owner_id": ownerID}) if err != nil { return 0, err } req, err := http.NewRequest("PUT", r.Base+"/api/member-apps/"+id, strings.NewReader(string(body))) if err != nil { return 0, err } req.Header.Set("Content-Type", "application/json") resp, err := r.client.Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("server %d", resp.StatusCode) } var out struct { Version int `json:"version"` } // Tolerate a version-less response (older server): verification then only // requires the app to be listed at all. _ = json.NewDecoder(resp.Body).Decode(&out) return out.Version, nil } // deployedAppVersion returns the version the server currently lists for the // given app id — 0 when the id isn't deployed at all. This is the read-back // behind runAppBuild's verification: no metadata endpoint exists on purpose // (the list is the only surface), so it re-fetches the list like any client. func (r *Runtime) deployedAppVersion(id string) (int, error) { var list struct { Apps []struct { ID string `json:"id"` Version int `json:"version"` } `json:"apps"` } if err := r.get("/api/member-apps", &list); err != nil { return 0, err } for _, a := range list.Apps { if a.ID == id { return a.Version, nil } } return 0, nil }