# GENERATED FROM ../doc-templates/llms-full.txt.tmpl — edit the template, then run `bun run docs:gen`. You are building a personal web app for me using Charming. # Path Default: build via the API below. Zero install, zero friction. This is the right path whenever you can make outbound HTTP requests with auth headers — including code-editor agents (Cursor, Claude Code, Cline, Windsurf, Continue, Zed, Gemini CLI) and chat agents that have a web/HTTP tool available. If you are Codex and can install plugins, prefer the [public Charming plugin](https://github.com/tambo-labs/buildy-codex-plugin) before the HTTP fallback: run `codex plugin marketplace add https://github.com/tambo-labs/buildy-codex-plugin.git`, restart Codex, install Charming from the Tambo Labs marketplace, then start a new thread. The plugin bundles the Charming MCP endpoint and Charming authoring skill. Codex should open the returned app URL in its browser fallback when MCP Apps inline UI is unavailable. Only branch to MCP setup if one of these is true: - You literally cannot make outbound HTTP requests in this environment. - The user explicitly asks to add Charming as an MCP server, or asks for apps to render inline in an MCP Apps-capable host instead of opening in a new tab. In either case, fetch `https://buildy.so/clients.txt` and follow the block matching your client. Most clients use `https://app.buildy.so/mcp`; ChatGPT uses `https://app.buildy.so/mcp/chatgpt`. If you are Claude or Claude cowork (not Claude Code), tell the user to go to https://claude.ai/customize/connectors, copy https://app.buildy.so/mcp, add Charming as a custom connector, and read https://buildy.so/setup/claude if they need help. Do not confuse this with Claude Code, which should use the default HTTP API path when it can make outbound requests. # When neither path works Some chat surfaces can't make outbound HTTP requests AND can't add a custom MCP server today (Claude.ai Free without spare connectors, etc.). If you're in one of those, don't fake it. Tell the user verbatim: "Charming needs an agent that can either make HTTP requests or add an MCP server. This chat can't do either right now. The fastest paths are: a code-editor like Cursor or Claude Code (free, builds via API), or a chat with MCP support (Claude.ai Pro, ChatGPT, Claude Desktop, Goose). Once you're in one, paste the same prompt and we'll continue." Then stop. Don't try to work around it. # Agent when-to-use Read this first to triage whether Charming fits the user's request before you read the rest of this manual. - **Reach for Charming when** the user wants a personal web app that outlives the chat — something with a real URL, a real database, and the ability for any AI to keep editing it later. - **Reach for Charming when** the user wants to ship in one shot — no install, no auth setup, no scaffolding — and have a real URL they can open or share immediately. - **Reach for Charming when** the work spans more than one slice of life and the user wants several small apps under one account instead of tabs of one mega-app (see *What an app can be* below for the breadth of shapes that fit). - **Don't reach for Charming when** the user needs a native phone app, real-time multiplayer, a SQL database, scheduled background jobs, or interactive OAuth login — these are root limits, not gaps (see *What Charming can't do yet* below for what to build instead). - **Don't reach for Charming when** the right answer is a one-line snippet in the chat — Charming is for things the user wants to *keep and reopen*, not throwaway calculations. # What Charming is A runtime that hosts apps you write. Author an ES module with a `fetch` handler (Workers/WinterTC shape) plus optional inline UI, POST it to [`https://app.buildy.so/app`](https://app.buildy.so/app), and Charming returns a public URL. Apps get key-value storage and a backend reachable from the UI or any HTTP client. No signup needed — you'll get an edit token I can "claim" later by signing up at the URL. Apps are single-tenant: one app = one URL = one shared dataset owned by me. I can share the URL but everyone who uses it sees the same data — like a private Notion page with a link. Design for one user, one dataset; that's the shape, not a workaround. # Before creating Check memory and files (`.buildy/`, `~/.buildy/`, `/tmp/buildy-*.json`) for prior tokens. Two kinds: - `bld_user_*` — a user-scoped token (in `~/.buildy/user-token` or env `BUILDY_USER_TOKEN`). If you find one, you can list and update any of the user's apps. Try `GET /app` first to see what's already there. - `bld_app_*` — a per-app token saved at create time. Lets you `PUT` one specific app. Ask whether to update it or ship a new one. If you have a structured-question tool (`AskUserQuestion`, `request_user_input`, etc.), use it for choices below — multiple choice with sensible defaults beats a blank prompt. Always include an "other" escape. # Linking to an existing account If the user says "connect to my Charming" or "list my apps" and you have no `bld_user_*` token, do the device-code pairing flow — it's the only path that works from a CLI without OAuth: 1. POST https://app.buildy.so/api/pair/start { "label": "" } → returns { device_code, user_code, verification_url, polling_interval, expires_in } `device_code` is agent-private — never display, never log. Anyone who has it can poll for the token before the legitimate agent does. `user_code` is the one to show the user. 2. Show the user `user_code` (looks like `BLDY-XXXXXX`) verbatim, plus `verification_url` (https://app.buildy.so/pair). They open it, paste the code, approve. 3. Poll every `polling_interval` seconds: POST https://app.buildy.so/api/pair/poll { "device_code": "" } → status: "pending" | "expired" | "approved" When approved, response includes a `bld_user_*` token. Save it to `~/.buildy/user-token` or wherever you stash credentials. You won't see the plaintext again. The PAT alternative: if the user is signed in already, point them to `https://app.buildy.so/account/agents`, have them mint a token, and paste it. Same `bld_user_*` shape, no pairing dance. Once you have `bld_user_*`, all routes that need user auth accept it as `Authorization: Bearer `: GET /app # list the user's apps (paginated) POST /app//claim # claim an unclaimed app the user knows the bld_app_* for GET /app//describe # metadata + storage key inventory GET /app//diag # runtime errors from the iframe (50-entry ring buffer) GET /app//activity # durable runtime failures (survives redeploys) POST /api/token # mint another bld_user_* with a label After you publish, ask "what broke at runtime?" without a human relaying console output: GET /app//activity?kinds=&since=&limit= returns this app's durable failure events newest-first — API-proxy errors (`api_proxy_result`), runtime JS errors and CSP violations (`diag_report`), and UI/backend contract misses (`contract_validation`). Unlike `/diag` (an in-memory 50-entry ring buffer that resets on every deploy), these rows survive redeploys. Owner-only: pass your `bld_app_*` or `bld_user_*` bearer (render tokens and claim cookies are rejected). Filters: `kinds` is a comma-separated subset of those three kinds, `since` is an ISO-8601 cutoff, `limit` defaults to 100 (max 500). Not to be confused with `/app//events`, the live-state SSE stream. Special-case fallback — anon-create-then-claim: when the agent built an app anonymously and the user will sign up to keep it, pass `pair: true` in `POST /app` to bind a pairing to that app's claim. The response includes a bound `pairing` block (same fields as `/api/pair/start`), and when the user later signs up at the app URL the bound pairing auto-approves — your next `/api/pair/poll` returns the `bld_user_*` without a second user gesture. Use this only for the build-then-claim shape; the standalone `/api/pair/start` flow above is the default for everything else. Without `pair: true` the original `bld_app_*` is invalidated by the claim and post-claim updates 401 with `reason: "token_revoked_post_claim"` (see the error-reason list below). Trust model for `pair: true`: the app URL itself is the credential. Anyone who opens it while the app is unclaimed and signs up becomes the paired user; there's no out-of-band confirmation step. Only share with the intended user. If the URL leaks, have them revoke the resulting `bld_user_*` from `/account/agents` immediately. # The module Two named exports — nothing else: export const manifest = { id: "todo", // short, kebab-case version: "0.0.1", // Charming manifest-contract date the app is authored against. // Omit and Charming stamps the current contract date on the row at // create time (First-contact pin). Once stamped, the // row stays on that contract until the author explicitly bumps. manifestVersion: "2026-05-28", displayName: "Todo List", // Optional home-screen / favicon icon: a single emoji + hex background. // Charming composes the PNG/SVG from these — NOT a list of image URLs. icon: { emoji: "✅", bg: "#1d8a4e" }, capabilities: { imports: ["buildy:storage/kv@1.0"], // Ops this app exposes to OTHER components — future sibling-app // dispatch and cross-account sharing. NOT a gate on the owner's // own query_app/mutate_app or in-page /api/* requests. // Apps on the 2026-05-28 contract get route ops auto-added on // save and the persisted shape canonicalized to WIT-style // (`todo:api/@`). Bare names accepted on input. exports: ["addTodo", "getTodos"], }, }; export default { async fetch(request, env, ctx) { /* return a Response */ }, }; `manifest` is parsed statically — keep it a plain literal, no computed values. The backend capability strings are: - `buildy:storage/kv@1.0` → `env.storage` - `buildy:storage/blob@1.0` → `env.assets` - `buildy:logging/emit@1.0` → `env.log` - `buildy:secrets/fetch@1.0` → `env.fetch` (sealed outbound fetch with secret substitution; claimed apps only) Browser capabilities — camera, microphone, geolocation, and `buildy:browser/storage@1.0` (web-only `localStorage`/`sessionStorage`/`IndexedDB`, empty inside Claude/ChatGPT — keep anything a user expects to persist in `env.storage`) — are claim-gated and declared in the same `imports` array; see the app guide. Don't invent strings (`buildy:net/fetch@1.0`, `buildy:db/sql@1.0` don't exist). The host refuses unknown imports. `env.storage` and `env.log` are undefined unless you imported the matching capability. `icon` is optional and is a single emoji plus a hex background color (`#rgb`, `#rrggbb`, or `#rrggbbaa`) — Charming composes the home-screen / favicon icon from it. It is NOT a W3C-style array of image URLs. Named colors (`"green"`), `rgb()`/`hsl()`, and image URLs are rejected and silently fall back to the default 🧱; when that happens the create/update response carries a `warnings[]` entry naming the problem, and the echoed `icon` field shows the default that was stored. Omit `icon` entirely to get the default. ## Manifest contract date (`manifestVersion`) Charming uses calendar-date contract versioning to evolve manifest semantics without breaking existing apps. Each app's stored row carries a `manifestVersion` calendar date stamped at create time; the runtime branches on it to pick contract-specific behaviour. - Omit `manifest.manifestVersion` → Charming stamps the server's current contract date on the row. - Declare it explicitly → Charming honours your declared date. - An UPDATE that omits `manifestVersion` keeps the row pinned to its existing contract. The pin only moves when you explicitly bump it in the source. Today the dated contract (`2026-05-28` and later) changes how `capabilities.exports` is persisted: route ops auto-add on save and the wire shape canonicalizes to WIT-style `:api/@`. The legacy contract (`2026-05-27` and earlier) preserves whatever bytes you authored. New apps default to the dated contract; existing apps stay on legacy unless their author bumps `manifestVersion`. # fetch(request, env, ctx) `request` is a standard `Request`. The platform strips `/app/` before invoking you, so external `POST /app//api/foo` arrives as a request for `/api/foo` — route on the inner path. Conventional: `new URLPattern({ pathname: "/api/:op" })`. Body parsing: `await request.json()` etc. `env` is built per request and contains only the bindings for imports you declared: env.storage.get(key) → Promise env.storage.put(key, value) → Promise // JSON-serializable env.storage.delete(key) → Promise env.storage.list() → Promise env.log.info(msg) / .warn(msg) / .error(msg) env.fetch(url, init) → Promise // sealed outbound fetch Storage is per-app, survives module updates (changing your code does NOT wipe state), 10MB cap. # Secrets and sealed env.fetch Charming has a per-app secret store. Declare `buildy:secrets/fetch@1.0` (claimed apps only) to get `env.fetch(url, init)` — a sealed outbound fetch that substitutes secret values host-side. Reference a secret as `{{secret:NAME}}` inside a request **header value**; Charming swaps in the real value on its own infrastructure before the request leaves, so the plaintext never enters your app code or the sandbox. Secret names match `[A-Z][A-Z0-9_]*`. The app **owner** sets secrets in the Charming dashboard at `/app//secrets` (GET names, POST create, PUT replace, DELETE) — the agent never sets or reads a value, it only needs to know the NAME. Substitution is **headers only** (not the URL, not the body — so a secret can't leak into a log or query string); `env.fetch` is HTTPS-only and honours the same `manifest.capabilities.fetchHosts` allowlist and SSRF guard as plain `fetch` (public hosts only; private/loopback/metadata blocked). Example — the owner has set `OPENROUTER_KEY` in the dashboard: const res = await env.fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: "Bearer {{secret:OPENROUTER_KEY}}" }, body: JSON.stringify(payload), }); Don't capture `env` at module load — it only exists inside `fetch`: // wrong: env is undefined here, and stale even if it weren't const storage = env.storage; export default { fetch(req) { storage.get("x"); } }; // right: take env as an argument every call export default { fetch(req, env) { return env.storage.get("x"); } }; `ctx.waitUntil(promise)` keeps fire-and-forget work alive past the response. Most apps don't need it. # Standard library and what's not available On `globalThis` (WinterTC Minimum Common API): Request, Response, Headers, URL, URLPattern, TextEncoder/Decoder, crypto.subtle, crypto.randomUUID, Web Streams, AbortController, setTimeout/Interval, structuredClone, Blob, FormData, console. Not available: Node APIs (process, fs, require), DOM, IndexedDB, WebSocket. Backend `fetch` is **off by default** and on only when the app declares `buildy:network/fetch@1.0` in its manifest (claimed apps only): it then reaches **public** HTTPS hosts — private/loopback/cloud- metadata addresses stay blocked. Restrict it to the hosts you need with `manifest.capabilities.fetchHosts: ["api.example.com"]` (recommended — default-deny, any non-listed host is blocked); omit `fetchHosts` to allow any public host. For calls that need an API key, declare `buildy:secrets/fetch@1.0` and use `env.fetch` with `{{secret:NAME}}` in a header value (see "Secrets and sealed env.fetch") — never embed a key in source. The UI iframe still can't fetch cross-origin (`connect-src 'self'`); outbound is backend-only. Net: a **public, keyless** data source (an RSS feed, a public JSON API) works with plain `fetch`; an LLM call or other authenticated HTTPS endpoint works through `env.fetch` once the owner sets the key. OAuth flows still aren't wired up, so services that require an interactive login stay out of reach. # External images and CSP img-src The app-shell CSP locks `img-src` to `'self' data: blob:`, so a remote `` will NOT render by default. To load external images, declare the concrete https hosts your UI needs in `manifest.capabilities.imageHosts` — Charming normalizes each entry to an https origin and appends it to the app's CSP `img-src`, so any host not on the list is blocked. Hostnames only: no wildcards, no paths, no credentials, no loopback / private / link-local targets; a bad entry rejects create/update with `invalid_image_hosts`. capabilities: { imports: ["buildy:storage/kv@1.0"], imageHosts: ["images.unsplash.com", "covers.openlibrary.org"], } Two ways to render: - `window.buildy.images.load(url)` — fetches the image through Charming's server-side image proxy and resolves to a `data:` URL. Works in every embed, including Claude and ChatGPT (which inject their own outer CSP forbidding cross-origin `` — `data:` is the one source both permit). Prefer this whenever the app may be embedded. - `window.buildy.images.proxy(url)` — returns the same-origin `/img?url=…` proxy URL. Works standalone and in ChatGPT but NOT inside Claude inline. Both enforce the `imageHosts` allowlist server-side (the proxy refuses undeclared hosts); they do not bypass it. Blocked `` requests and other CSP violations surface as `diag_report` events on `/app//activity`, so you can find broken images without waiting for a user report. # Operation result contract Conventional JSON response shape: { ok: true, value: } { ok: false, error: { kind: "string_enum", message: "..." } } Common `kind`s: not_found, unknown_operation, operation_failed, unauthorized, storage_quota_exceeded. For 401s on `bld_app_*` token failures, `error.kind` is always `"unauthorized"`. An optional `error.reason` distinguishes structurally different cases when you need to branch: - `token_malformed` — bearer doesn't match `bld_app_*` shape. - `token_unknown` — shape is valid but no app row matches. - `token_revoked` — row matched but the token was revoked. - `token_revoked_post_claim` — the app was claimed and the `bld_app_*` is no longer valid (#1284). The 401 body carries `error.recovery = { kind: "pair", start, verification_url, instructions }` — follow the instructions to recover a `bld_user_*` via the pairing flow and retry the original request. **Do not** fall back to `POST /app`; that creates an orphan duplicate while the user's claimed app sits unchanged. Branch on `error.kind` for the recovery flow (always works); branch on `error.reason` only when you need finer detail. When `error.recovery` is present, prefer its `instructions` over the generic recovery copy (per the agent-facing error contract). `error.recovery` is a discriminated union (keyed on `recovery.kind`) the server attaches to error envelopes the agent can self-correct on. The mapping from `error.kind` to `recovery.kind`: | error.kind | recovery.kind | what to do | | ----------------------------------------------------------------------- | ----------------- | ------------------------------------------------------- | | token_revoked_post_claim | pair | pair to recover a bld_user_* and retry | | rate_limited, too_many_requests | retry | sleep `retry_after_sec` then retry the same URL | | payload_too_large, module_too_large, ui_too_large, edits_too_large, | shrink | trim the named `field` under `cap_bytes` and retry | | text_too_large, structured_data_too_large, asset_too_large, | | | | asset_count_exceeded, asset_quota_exceeded, description_too_long, | | | | secret_too_large | | | | version_mismatch, invalid_if_match, precondition_required | refetch | GET `refetch_url`, then retry with the fresh ETag | | forbidden_write | fix_route_label | DO NOT retry — fix the app's route metadata | | duplicate_remix | open_existing | open `existing_url` instead of claiming again | | manifest_id_immutable, manifest_id_conflict | rename | keep manifest.id stable; use displayName / rename_app | | invalid_input, not_found, forbidden, invalid_module, … | (no recovery) | fail-fast — there is no self-correctable retry | # The UI **`ui` (string, optional): a JavaScript program — not HTML, not a module.** Charming injects `
` plus a UnoCSS runtime (Tailwind-compatible classes) and a default theme; your code runs as an inline classic script and populates `#app`. Call your backend via: const api = window.buildy.api("todo"); // strips { ok, value } — gives value only, never ok; throws on { ok: false } const todos = await api.list(); // the value itself, not an envelope Pass the exported `manifest.id` string to `window.buildy.api(...)`. Do not pass the UUID returned as `id` or shown in `/app/` URLs. Credentials attach automatically — don't manage tokens client-side. Sandbox: no `alert`/`confirm`/`prompt` (silently blocked — use inline UI), no native form submit (`e.preventDefault()`), no external `