# 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/charming-codex-plugin) before the HTTP fallback: run `codex plugin marketplace add https://github.com/tambo-labs/charming-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://usecharming.com/clients.txt` and follow the block matching your client. Most clients use `https://charm.ing/mcp`; ChatGPT uses `https://charm.ing/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://charm.ing/mcp, add Charming as a custom connector, and read https://usecharming.com/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 route definitions plus optional inline UI, POST it to [`https://charm.ing/app`](https://charm.ing/app), and Charming returns a public URL. Apps get key-value storage, file storage for images and other assets (`upload_asset`, `env.assets`), 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 Full reference for working across agents on the same app (credential families, what each token can touch): https://charm.ing/docs/capabilities/connect-any-agent.md. The pairing steps below are the procedure to run mid-generation. 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://charm.ing/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://charm.ing/pair). They open it, paste the code, approve. 3. Poll every `polling_interval` seconds: POST https://charm.ing/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://charm.ing/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 Distinct from owner-only `/describe`: `GET /app//agent.json` is a public, side-effect-free descriptor (manifest, operations, canonical URL) that needs no auth for a public or remixable app — a private app's owner still needs a bearer token to see the full descriptor rather than the minimal stub everyone else gets. Any agent that lands on a rendered app page (the `/app/` or `//` form) can find it via the page's `` and matching `Link:` HTTP header — no handle lookup required, since it always points at the canonical UUID form. 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 — `manifest` and `routes`: 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: [], }, }; export const routes = [ { op: "getTodos", method: "GET", handler: async (_input, { env }) => (await env.storage.get("todos")) ?? [], }, { op: "addTodo", method: "POST", handler: async (input, { env }) => { /* update storage and return a JSON-compatible value */ }, }, ]; An authored `default.fetch` is optional. When it is absent, Charming supplies a 404 fallback: requests whose `Accept` header includes `json` receive `{"ok":false,"error":{"kind":"unknown_operation","message":"404 - page not found"}}` as JSON; every other request receives the plain-text body `404 - page not found`. If you provide `default.fetch`, Charming uses it for requests that do not match a declared route. `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. `capabilities.exports`, when present, must be an array of strings — bare op names or WIT-style `':api/@'`. The same applies if you declare `export const routes` instead of (or alongside) `default.fetch`: it must be an ARRAY of route objects (`[{ op: 'list', handler: async (input, ctx) => {...} }, ...]`), never an object keyed by op name. Either shorthand silently resolves to zero discoverable ops; publish rejects it outright (`invalid_manifest_exports` / `invalid_routes_shape`) rather than shipping an app query_app/mutate_app can't discover. `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`. # API discovery requires route definitions Full API discovery is available only for operations declared in a literal `export const routes = [...]` array. Charming reads each route's `op`, `method`, `path`, `readOnly`, schemas, and other metadata to choose `query_app` versus `mutate_app` and to populate create/update responses, `get_app`, `GET /app//describe`, and the app's OpenAPI document. Legacy `export default { fetch(...) }` function definitions and `manifest.capabilities.exports` remain supported for compatibility, but they do not add method or read-only metadata. A `request.method === "GET"` guard inside a legacy function is not API-discovery metadata; a name-only legacy operation defaults to `POST` and `readOnly: false`. To expose a read through `query_app` or to a viewer, declare it as a route with `method: "GET"` or `readOnly: true`. # Route handlers and legacy fetch A route handler receives `(input, { env, ctx, request })` and returns a JSON-compatible value. The platform validates the input and output schemas, dispatches the operation, and wraps the result in the wire envelope. Legacy `export default { fetch(request, env, ctx) }` remains available for existing apps. `request` is a standard `Request`. The platform strips `/app/` before invoking it, so external `POST /app//api/foo` arrives as a request for `/api/foo`. Use this compatibility path only when maintaining a legacy app; new operations should be route definitions so their API metadata is discoverable. `env` is built per invocation 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 and survives module updates (changing your code does NOT wipe state). Each value you put is capped at 10MB (JSON-serialized); there is no separate cap on the total across keys. File storage (`env.assets`), limits, and access rules: full reference https://charm.ing/docs/capabilities/data-storage.md. # Secrets and sealed env.fetch Full reference: https://charm.ing/docs/capabilities/secrets.md (why this exists, the 8 KiB cap, and how the owner manages values from the dashboard). The build contract below is what you need mid-generation. 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 a route handler or legacy `fetch`: // wrong: env is undefined here, and stale even if it weren't const storage = env.storage; // right: take env from the handler context every call export const routes = [{ op: "getX", method: "GET", handler: (_input, { env }) => 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 `