<!-- GENERATED FROM apps/www/doc-templates/build-cli.md.tmpl; edit the template, then run `bun run docs:gen`. -->

# Charming starter — CLI

You build by shelling out to the `charming` CLI (`npm install -g usecharming`), not by making HTTP requests or calling MCP tools directly. This is the right path for a coding agent working in an arbitrary terminal with no MCP connection to Charming. The CLI stores credentials, prevents stale updates, and prints JSON to stdout.

The commands you need to ship and iterate a first app:

- `charming apps create <directory>` — create an app from a directory containing `module.js` (required) plus optional `ui.js` / `styles.css`; returns its `id` and public `url`.
- `charming apps update <id> <directory>` — full-bundle update from the same directory shape; storage survives.
- `charming apps source <id> --out <directory>` — write the current `module.js` / `ui.js` / `styles.css` to a directory, so you can edit and `update` from it.
- `charming apps list` / `charming apps describe <id>` — list your apps (needs `charming auth login`), or read one's public descriptor (`GET /app/<id>/agent.json`: operations and input schemas).
- `charming apps call <id> <op> --input '{...}'` — call one of an app's own operations directly, without a browser. The CLI reads the app's descriptor first and sends the operation with its declared method: a `GET` operation gets `--input` as query parameters (strings, numbers, booleans, and arrays of those), any other method gets it as a JSON body. An operation name the app doesn't declare fails before any call and lists the ones it does. A `DELETE` operation, or one the app marks destructive, requires `--yes`. `charming api request call-app-operation` dispatches the same way.
- `charming apps delete <id> --yes` / `charming apps rename <id> <name>` (rename needs `charming auth login`; an app-scoped credential from an unclaimed create can't rename).

Run any mutation with `--dry-run` first to see the request it would send without sending it.

## Connect to a signed-in account

```bash
charming auth status
```

If it reports `"authenticated": false`, run `charming auth login --no-open`. It writes one JSON line to stderr, `{"event":"authentication_required","verificationUrl":"…","userCode":"…"}`, then polls for up to 10 minutes and prints `{"authenticated":true,…}` on stdout once the user approves. Run it in the background or read stderr while it polls, so you can show the user `verificationUrl` and `userCode` before it exits. Once approved, the CLI saves the token for the `default` profile in `~/.config/charming/credentials.json` (mode `600`) and reuses it on every later command; there is no manual pairing dance to script.

`charming apps create` also works with no saved token. It stores an app-scoped credential locally, so `update` and `call` on that app keep working from the same machine without `charming auth login`. Its output carries a `pairing.user_code` and `pairing.verification_url`. If the user approves that code, or claims the app from its page, within 10 minutes, the next command on that app or `charming auth status` saves their account token and claims the app for them. A claim after those 10 minutes revokes the app credential and pairs nothing, so commands on that app fail with `token_revoked_post_claim` until you run `charming auth login`. A CLI signed in with `charming auth login` also claims an app it created without a login the next time a command uses that app. If a claim fails, the command runs on the app's own credential, writes an `app_claim_failed` event to stderr, and the next command on the app tries again. Sign in first when the user wants the app in their account from the start.

## ESM imports for selected authors

The existing contract below remains the default. Selected authenticated authors can explicitly use `https://charm.ing/schema/app-manifest/next.json` for server and browser ESM. This contract is unstable: anything in it can change at any time, including changes that stop existing apps from loading. Keep a literal exported `manifest` and `routes` array; declare public npm packages under separate `dependencies.server` and `dependencies.client` maps. The same package needs a declaration in each target that imports it. Use exact versioned platform imports: server `import { kv } from 'charming:storage/kv@1.0'` and browser `import { api, onStateChange } from 'charming:ui/app@1.0'`. These host modules are not npm dependencies, and this schema does not accept `capabilities.imports`. Set the name in `manifest.meta.name`.

ESM saves require an eligible user credential and an idempotency key: HTTP `Idempotency-Key` or MCP `idempotency_key`. Existing destinations also require the desired-source revision, through HTTP `If-Match` or MCP `expected_revision`; migration additionally requires `migrate_contract: true`. HTTP returns `202` with `buildId`, `statusUrl`, `Location`, and a pending `Retry-After` hint. MCP returns an ordinary accepted result. Poll HTTP `GET /api/v1/app-builds/<buildId>` or MCP `get_app_build({ build_id, include_source? })`. Add HTTP `?include_source=true` or MCP `include_source: true` for that operation's exact input. Cancel with HTTP `POST /api/v1/app-builds/<buildId>/cancel` or MCP `cancel_app_build({ build_id })`. Only `published` means the save is live; after MCP publication, call `get_app` to render it.

Import and manifest checks run without evaluating app code before acceptance. Bundling happens in the background. Exact compatible locks can be reused across source edits; compilation uses the retained archives without resolving again. New ESM edits advance desired source while the active app stays on the last successful build. Later saves supersede pending work. Failed creation makes no unusable app; migration leaves the existing app active. Resend the exact request/key after response loss; changing it returns `idempotency_conflict`.

The combined module/ui limit is 64 KiB. Queue and attempt deadlines are 10 and 10 minutes, within a 20-minute total lifetime and at most 2 automatic attempts. Terminal source inspection lasts 7 days; operation results, exact locks, and request keys last 30 days. Pending source cannot render or run. History restore and copy retain exact completed source/output and compatible locks. If preparation is required, inspect the accepted build until `published`; a pending copy has no App ID or usable URL. New ESM restores/copies require current access and authoring eligibility. Browser History/copy accepts verified sessions; ordinary ESM source submission requires a bearer credential.

An unavailable cohort returns `contract_not_enabled`; retain the existing contract unless the author is enabled and explicitly chooses ESM.

From the CLI, send an ESM save through the generated catalog. It needs a saved user token from `charming auth login`: raw `create-app` refuses anonymous and app-scoped credentials. Inspect `create-app` or `update-app` with `charming api describe`, pass the JSON body with `--body @build.json` and the key with `--header Idempotency-Key=<value>`, and for an existing destination pass the desired-source `If-Match` with `--header` or `--param`. Run it with `--dry-run` first; a live `create-app` also needs `--yes`, because it replaces an app with the same `manifest.id` in place. `charming apps create` and `charming apps update` use the existing contract.

## The module

This section describes the existing contract.

Author one ES module with two named exports and nothing else, in `module.js`:

```js
export const manifest = {
  $schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
  id: 'todo', // short, kebab-case
  meta: {
    name: 'Todo',
    icon: { emoji: '✅', bg: '#1d8a4e' }, // optional: single emoji + hex bg
  },
  capabilities: {
    imports: ['charming:storage/kv@1.0'], // gives you env.storage
  },
};
export const routes = [
  {
    op: 'list',
    method: 'GET',
    annotations: { readOnlyHint: true },
    outputSchema: { type: 'array' },
    handler: async (_input, { env }) => (await env.storage.get('todos')) ?? [],
  },
  {
    op: 'add',
    method: 'POST',
    inputSchema: {
      type: 'object',
      required: ['text'],
      properties: { text: { type: 'string' } },
      additionalProperties: false,
    },
    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. Its signature is `fetch(request, env, ctx)`: the second argument is the environment itself, not the `{ env }` context a route handler receives.

`manifest` is parsed statically — keep it a plain literal, no computed values. The backend capability strings are `charming:storage/kv@1.0` (gives you `env.storage`), `charming:storage/blob@1.0` (gives you `env.assets`), `charming:logging/emit@1.0` (gives you `env.log`, with `.info(msg)` / `.warn(msg)` / `.error(msg)`), and `charming:secrets/fetch@1.0` (gives you `env.fetch`, the sealed outbound fetch with secret substitution; claimed apps only). Without `charming:storage/kv@1.0`, `env.storage` is undefined and every read or write throws `storage capability not granted`. Camera, microphone, geolocation, screen capture, clipboard reading, and MIDI need no import and no claim: Charming finds the calls in the UI when the app is published and asks each viewer first, so handle `NotAllowedError`. Device motion and ambient light need `charming:browser/device-motion@1.0` or `charming:browser/ambient-light@1.0` in the same `imports` array, and a claimed app. `charming:browser/storage@1.0` unlocks web-only `localStorage`/`sessionStorage`/`IndexedDB`, which are empty inside Claude and ChatGPT, so keep anything a user expects to persist in `env.storage`. https://charm.ing/docs/guides/browser-features.md says which browser features work where. The host refuses unknown strings.

The canonical manifest is strict: use `$schema`, `id`, `meta`, `capabilities.imports`, and optional `permissions`; unknown keys are rejected. `export const routes` must be an ARRAY of route objects (`[{ op: 'list', handler: async (input, ctx) => {...} }, ...]`), never an object keyed by op name. Route `op` values must be unique.

`meta.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. Omit it to get the default.

Each route handler receives `(input, { env, ctx, request })`. `env` holds only the bindings for imports you declared:

```js
env.storage.get(key); // Promise<unknown | undefined>
env.storage.put(key, value); // Promise<void> — JSON-serializable, 10MB per value, 1GB per app
env.storage.delete(key); // Promise<boolean>
env.storage.list(); // Promise<string[]>
env.fetch(url, init); // Promise<Response> — sealed outbound fetch (charming:secrets/fetch@1.0)
env.secrets.NAME; // literal {{secret:NAME}} placeholder, never plaintext (charming:secrets/fetch@1.0)
```

These four are the only storage methods: `set`, `add`, `setItem`, and the like throw `TypeError: env.storage.<x> is not a function`. Store JSON-compatible values as they are; don't `JSON.stringify` before `put` or `JSON.parse` after `get`.

Signed-in authors can also declare `charming:app/<manifest-id>@1.0` in `manifest.capabilities.imports` and call `env.apps['<manifest-id>'].fetch(request)`. Anonymous authors and other interface versions are rejected. The host resolves the active sibling in the caller's user or team namespace and retains the callee's access and storage rules. Existing-contract callees handle these requests through `default.fetch`; ESM callees use their declared routes.

`env.user` is always present, whichever capabilities you declared — the signed-in caller's public identity as `{ id, handle?, name?, image? }`, or `null` for an anonymous caller. It lives only on `env`; read it as `ctx.env.user` when using a named context param — there is no `ctx.user`.

Storage is per-app and survives updates — pushing a new `update` does not wipe state. Return a JSON-compatible value from the handler; Charming wraps it in the conventional wire shape:

```text
{ ok: true,  value: /* anything JSON-able */ }
{ ok: false, error: { kind: "not_found" | "unknown_operation" | "operation_failed", message: "..." } }
```

A handler returns exactly the value its route's `outputSchema` declares. Charming adds the envelope, so don't wrap the return value in `{ ok, value }` or `{ value }` unless those fields belong to `outputSchema` itself.

Only WinterTC globals are available (Request, Response, URL, URLPattern, crypto, TextEncoder, structuredClone, ...). No Node APIs, no DOM. Backend `fetch` is off by default — declare `charming:network/fetch@1.0` in `capabilities.imports` and list each exact HTTPS origin in `manifest.permissions.server.fetch` (claimed apps only; private/loopback/metadata blocked). Both the capability and permission are required. Redirect destinations are checked too. See https://charm.ing/docs/llms-full.txt for the full standard-library list and limits.

**External images.** Charming's app-shell CSP locks `img-src` to same-origin + `data:` + `blob:`, so a remote `<img src="https://...">` will not render by default. Declare each exact HTTPS origin your UI needs in `manifest.permissions.browser["img-src"]: ["https://images.unsplash.com"]`. Wildcards, paths, credentials, and loopback/private/link-local origins are rejected. Two ways to render: `window.charming.images.load(url)` fetches through Charming's server-side image proxy and resolves to a `data:` URL that renders in every embed (Claude and ChatGPT inject their own outer CSP that forbids cross-origin `<img src>` — `data:` is the one source both permit); `window.charming.images.proxy(url)` returns the same-origin `/img?url=…` proxy URL, which works standalone but NOT inside either Claude's or ChatGPT's inline embed (both inject their own outer CSP that blocks the app's cross-origin URL there) — prefer `images.load(...)` whenever the app may be embedded. Both enforce the declared origins server-side; they do not bypass them. Blocked `<img>` requests and other CSP violations surface as `diag_report` events on `/app/<id>/activity`, so you can find them without waiting for a user to report a broken image.

**Secrets and sealed `env.fetch`.** For calls that need an API key, declare `charming: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** or a **query-parameter 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** opens **App settings**, then **Secrets**, at `/<owner-handle>/~/apps/<app-name>/settings/secrets`; the agent never sets or reads a value, it only needs to know the NAME. The machine HTTP API remains `/app/<id>/secrets`. Substitution reaches header values and query-parameter values only — never a parameter name, the host, the path, the fragment, or the body; `env.fetch` is HTTPS-only and honours `manifest.permissions.server.fetch` plus the public-only guard. The same import grants `env.secrets`: `env.secrets.NAME` returns the literal `{{secret:NAME}}` placeholder, never plaintext, while invalid names return `undefined`. Reads do not prove a secret exists; enumeration, JSON conversion, reflection, and mutation expose no names; string coercion returns `[charming secrets]`. Example: the owner has set `OPENROUTER_KEY` in App settings.

```js
// app backend: the owner set OPENROUTER_KEY in App settings
const res = await env.fetch('https://openrouter.ai/api/v1/chat/completions', {
  method: 'POST',
  headers: { Authorization: 'Bearer {{secret:OPENROUTER_KEY}}' },
  body: JSON.stringify(payload),
});
```

```js
// query-parameter form: the owner set EXAMPLE_KEY in App settings.
// Write the placeholder literally in the URL string like this — building it
// with URLSearchParams.set(...) or encodeURIComponent(...) percent-encodes
// it first and it will NOT resolve.
const res = await env.fetch(`https://api.example.com/data?api_key={{secret:EXAMPLE_KEY}}`);
```

## Dated manifest contract

Canonical apps use the exact schema URL `https://charm.ing/schema/app-manifest/2026-10-16.json`. Apps already on `https://charm.ing/schema/app-manifest/2026-07-31.json` keep loading; saving source that declares the newer URL moves them to it. `charming apps create` inserts the exact URL before it stores the app when the rest of the source is canonical, so you may omit `$schema` locally — but write it anyway; a stale or wrong `$schema` still fails the create.

## 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`, `annotations`, schemas, and other metadata to populate the app's `agent.json` descriptor (what `charming apps describe` prints) and its OpenAPI document.

Canonical routes default to `POST /api/<op>`, a closed empty-object input schema, `public: true`, and the MCP annotation defaults. Charming does not infer annotations from the method. To publish a discoverable read-only operation, set `annotations.readOnlyHint: true`:

```js
export const routes = [
  {
    op: 'getState',
    method: 'GET',
    annotations: { readOnlyHint: true },
    handler: async (_input, { env }) => ({ state: await env.storage.get('state') }),
  },
];
```

A `GET` route reads its input from the query string, and every value there is a string on the wire, so Charming converts each one to the type its `inputSchema` declares (`integer`, `number`, `boolean` from the literal `true` or `false`, or an `array` of those, with a repeated key becoming the array) before validation. `GET /app/<id>/api/list?limit=1` reaches a `{ type: "integer" }` field as `1`. Other methods read a JSON body, which already carries its types.

Call each operation with the method its route declares. A request that uses another method returns HTTP 405 with `error.kind: "method_not_allowed"`; the `Allow` header and `error.allowedMethods` name the declared method, so retry with it. An operation the app does not declare returns 404 `operation_not_found`.

To let people who aren't the App's members call one operation, such as a contact form, set `callers: 'anyone'` on that route, or `callers: 'signed-in'` to require a Charming account. The App must be public, the route must stay public, and every other route stays members-only. See https://charm.ing/docs/guides/let-visitors-call-an-operation.md.

## The UI

This section describes the existing contract.

The UI is a JavaScript program — not HTML, not a module. Charming injects `<div id="app"></div>`, a Tailwind@v3-compatible runtime implementation (UnoCSS), and a default theme; your code runs as an inline script and populates `#app`. Call your backend with `window.charming.api(id)`. It strips the `{ ok, value }` envelope — you get the `value` field only, never `ok`; throws `CharmingOperationError` on `{ ok: false }`:

```text
const api = window.charming.api("todo"); // pass manifest.id, not the app's UUID
const todos = await api.list(); // the value itself (the array), not an envelope
await api.add({ text: "..." }); // throws if the backend returns { ok: false }
```

`#app` starts empty, so set its `innerHTML` before attaching event listeners. Don't call the backend with `fetch()`: the UI runs in a null-origin frame where that request fails. `window.charming.user` is the signed-in viewer's public identity (`{ id, handle?, name?, image? }`), or `null` for an anonymous visitor; use it to greet or attribute, not to enforce access.

Make the outermost element fill the viewport, for example `<main class="min-h-screen">`. Don't put `max-w-*` or `container mx-auto` on the root, which leaves the whole app in a narrow column on a wide monitor. For a text-heavy view, cap the reading width on an inner wrapper: `<main class="min-h-screen"><div class="mx-auto max-w-2xl">…</div></main>`. Dashboards, boards, tables, and canvases use the full width.

To keep the view current, register `window.charming.onStateChange(() => refresh())`, where `refresh` reads the data again and updates the DOM. It fires after every write from any caller (another tab, an agent, a Routine, a Webhook) and after a reconnect, so don't poll with `setInterval`.

Read https://charm.ing/docs/prompts/design-an-app.md before you write the UI — it covers the sandbox rules and how to avoid the generic-AI-app look.

Read https://charm.ing/docs/technical-reference/external-files-and-csp.md before loading an external file. Apps can add exact remote image origins to `img-src`, exact embedded-site origins such as `https://www.youtube.com` to `permissions.browser["frame-src"]`, exact `https:` and `wss:` origins the browser code calls to `permissions.browser["connect-src"]`, and exact backend fetch origins to server egress. Frame an embedded site with its embed URL (`https://www.youtube.com/embed/VIDEO_ID`); it plays at the standalone app URL, not in chat embeds. Browser runtime package loaders such as `window.charming.deps` are unsupported. Selected authenticated authors can declare npm dependencies with the explicit ESM contract; Charming bundles them before publication. Apps cannot add arbitrary browser script, module, font, media, worker, WebAssembly, or model origins. On the web, an app can run `eval` and WebAssembly and load scripts from cdnjs, unpkg, or jsDelivr whose full URL, with an exact package version, is written in `ui`; create and update reject a CDN URL without one (`unpinned_cdn_script`). Chat embeds allow none of these. Under the existing classic-UI contract, follow https://charm.ing/docs/guides/javascript-libraries.md to fetch, review, pin, and inline one UMD or IIFE file while authoring.

## Avoiding the Charming Widget

The Charming Widget gives users access to Charming features. Keep it visible and do not cover, hide, or replace it.

The widget sits in the bottom-right corner. Keep critical controls, status messages, and scroll handles outside the 64px square in that corner.

Charming renders the widget outside the generated app frame. Its Access destination handles sharing; do not recreate those controls inside the app.

## Sharing & roles

Full browser runtime reference, including operations, live state, identity, host messages, files, images, links, and owner controls: https://charm.ing/docs/technical-reference/browser-runtime.md. Sharing behavior and roles: https://charm.ing/docs/capabilities/privacy-and-sharing.md.

Public, Template, and Listed are separate. Public gives anyone with the URL read-only access to the live hosted App Viewer on the main URL: they can read data but cannot run mutating operations or edit source, and Public does not enable Template or Listed. A confirmed custom domain uses a separate per-visitor storage policy; see https://charm.ing/docs/capabilities/custom-domains.md. Template lets people who can already read the App create an independent copy. Listed lets anyone discover and copy it without access to the live App or its data; publishing Listed requires a nonblank title and summary. See https://charm.ing/docs/capabilities/templates.md for the listing fields and actions.

`charming apps create`/`update` cover the app source. To copy an app without exporting it, run `charming api request copy-app --param id=<app-id> --body '{"include_data":true}' --dry-run`, then again without `--dry-run`; omit the body to copy without data. Sharing, Public, Template, Listed, and roles go through the generated platform API — `charming api list`, then `charming api describe <operation-id>`, then `charming api request <operation-id> --dry-run` before sending.

## Signing in from your app

To add sign-in, check `charming.login.available` before showing the action and call `charming.login()` after the user chooses it. In a chat embed, open `charming.login.canonicalUrl` instead. A browser that refuses the popup rejects with `CharmingLoginError` and `reason: 'popup-blocked'`, which is not a cancel — a cancel resolves `null`. Opening the popup straight from the button's own click is what keeps a browser from blocking it. `login()` succeeds once per page load: a second call resolves `null` even for a visitor who is already signed in, and that `null` is indistinguishable from a cancel. Call it once, keep the identity it returns, and read `charming.user` afterwards.

To reserve one action for signed-in people, wrap that call in `charming.withUser(action)` rather than asking for an account up front. It runs the action, signs the caller in if the action turns out to need one, and runs it again — the payload lives in your closure, so the retry saves what the visitor already entered. Wrap the call, not the whole click handler: the closure runs twice. Note that `charming.viewer.can(op)` is `false` for an anonymous visitor on an app open to signed-in editors, so keep that control live and let `withUser` handle it; disabling it means the visitor never clicks and never gets the account the app was about to offer. Full identity and sign-in contract: https://charm.ing/docs/technical-reference/browser-runtime.md#access-and-identity. Worked example: https://charm.ing/docs/guides/gate-an-action-behind-sign-in.md.

## Worked example

Save the first block as `module.js` and the second as `ui.js`.

```js
// module
export const manifest = {
  $schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
  id: 'todo',
  meta: { name: 'Todo' },
  capabilities: {
    imports: ['charming:storage/kv@1.0'],
  },
};
export const routes = [
  {
    op: 'list',
    method: 'GET',
    annotations: { readOnlyHint: true },
    outputSchema: { type: 'array' },
    handler: async (_input, { env }) => (await env.storage.get('todos')) ?? [],
  },
  {
    op: 'add',
    method: 'POST',
    inputSchema: {
      type: 'object',
      required: ['text'],
      properties: { text: { type: 'string' } },
      additionalProperties: false,
    },
    handler: async (input, { env }) => {
      const todos = (await env.storage.get('todos')) ?? [];
      const todo = { id: crypto.randomUUID(), text: input.text };
      todos.push(todo);
      await env.storage.put('todos', todos);
      return todo;
    },
  },
];
```

```js
// ui
const api = window.charming.api('todo'); // unwraps the value; throws on { ok: false }
const root = document.getElementById('app');
// Always escape user-entered text before putting it in innerHTML.
const esc = (s) =>
  String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
async function render() {
  let todos = [];
  try {
    todos = await api.list();
  } catch (err) {
    // backend returned { ok: false } or the call failed — render an empty state
  }
  root.innerHTML = `
    <main class="min-h-screen">
      <div class="max-w-2xl mx-auto p-6">
        <form id="f" class="flex gap-2 mb-4">
          <input id="t" class="flex-1 border rounded px-3 py-2" />
          <button class="px-4 py-2 bg-blue-600 text-white rounded">Add</button>
        </form>
        <ul>${todos.map((t) => `<li class="p-2 border-b">${esc(t.text)}</li>`).join('')}</ul>
      </div>
    </main>`;
  document.getElementById('f').addEventListener('submit', async (e) => {
    e.preventDefault();
    const t = document.getElementById('t');
    if (!t.value.trim()) return;
    try {
      await api.add({ text: t.value.trim() });
    } catch (err) {
      // surface the error if you like
    }
    t.value = '';
    render();
  });
}
render();
```

## Ship it

Follow these steps for an existing-contract app.

`--yes` on a signed-in create whose `manifest.id` already exists **replaces that app in place** — check `charming apps list` (signed in) for a collision before running it on a name you didn't just invent, and keep `manifest.id` stable and unique per app.

```bash
mkdir todo && mv module.js ui.js todo/
charming apps create ./todo --description "A todo list" --dry-run
charming apps create ./todo --description "A todo list" --yes
```

Without a saved token the create still succeeds, but **unclaimed**: the CLI stores an app-scoped credential so later commands on this app work from here without signing in, and the app itself expires in 7 days unless the user signs up and claims it. Tell the user the `url` is unclaimed and expires in 7 days. Show them `pairing.user_code` and `pairing.verification_url` too: if they approve the code within 10 minutes, your next command on this app saves their account token and claims the app, so it stays reachable from this machine.

Return the `url` from the create response to the user, then smoke-test the backend before claiming the app works:

```bash
charming apps call <app-id> list --input '{}'
```

To iterate, export the current source, edit it, and push a full update — `apps update` has no partial-edit mode (that's `patch-app-source` on the generic platform API, below, not a dedicated command):

```bash
charming apps source <app-id> --out ./todo
# edit ./todo/module.js or ./todo/ui.js
charming apps update <app-id> ./todo --dry-run
charming apps update <app-id> ./todo
```

`apps update` reads the current source and sends its ETag as `If-Match`, so a conflicting concurrent write fails loudly instead of clobbering it — there's no revision number to track by hand. A field missing locally is left as-is on the server: a directory with no `ui.js` updates the module and keeps the deployed UI.

To try an edit before pushing it, run the app locally. `charming dev` serves it at a local URL without touching the live app: the module runs against in-memory storage with the server's validation and error kinds, and the ui renders in the production app document. Reloading the page or calling an operation picks up file edits; Ctrl+C stops it.

```bash
charming dev ./todo --port 0 --save-state ./todo-state.json
```

`--state <file>` seeds storage from a JSON object of keys to values, `--save-state <file>` writes storage after each change and on exit (and, without `--state`, seeds storage from that file when it exists, so state carries over between sessions; with `--state`, the first write replaces that file), and `--user <file>` sets the signed-in user. It runs apps whose module declares a dated manifest `$schema`. The fetch policy matches production, but the module runs in the CLI's own Node process, not a sandbox: only preview code you trust.

### `error.kind` and `error.recovery`

Every JSON error the CLI prints to stderr carries `error.kind` and, when the server attaches one, `error.recovery` with machine-actionable next steps. Branch on `kind`; prefer `recovery.instructions` over generic copy when present:

| `error.kind` | `recovery.kind` | what to do |
| --- | --- | --- |
| `app_claim_failed` (a stderr event, not an error) | none | the command still ran on the app's own credential. Its `message` says why the claim failed. Run any command on the app to retry, or have the user claim it from the app's page |
| `token_revoked_post_claim` | `pair` | the app-scoped credential died when the user claimed the app — `charming auth login` and retry with the account token |
| `revision_mismatch`, `invalid_if_match`, `precondition_required` | `refetch` | retry the same `charming apps update`; it re-reads the current source and ETag on every run. If another writer changed the app, export with `charming apps source <id> --out <other-dir>` into a different directory and merge before updating, so you don't overwrite your own edits |
| `manifest_id_immutable`, `manifest_id_conflict` | `rename` | keep `manifest.id` stable; use `manifest.meta.name` / `charming apps rename` (signed in) |
| `contract_migration_required` | `migrate_contract` | full source update with a complete canonical manifest and routes array |
| `service_busy` | `retry` | the host is at capacity — wait a second and retry. The refusal can land partway through a call, so repeat a read freely, but read the result back after a write before retrying it |
| `app_busy` | `retry` | the app hit its own run ceiling and the call never started, so a retry is safe — wait before retrying and back off further each time; a loop in the app is the usual cause, and retrying will not fix it |
| `visitor_rate_limited` | `retry` | you are not a member of this app and called an operation its route opens to visitors more often than visitors may — wait `retry_after_seconds` when present and retry. `error.limit` says whether the bound is yours alone (`visitor_per_account`, `visitor_per_address`) or every visitor's together (`visitor_concurrent_runs`, `visitor_runs_per_window`). The call never started and is not a Run |
| `rate_limited`, `too_many_requests` | `retry` | sleep `retry_after_sec` then retry the same request |
| `payload_too_large`, `module_too_large`, `ui_too_large`, `edits_too_large`, `text_too_large`, `structured_data_too_large`, `asset_too_large`, `asset_count_exceeded`, `asset_quota_exceeded`, `description_too_long`, `secret_too_large` | `shrink` | trim the named `field` under `cap_bytes` and retry |
| `forbidden_write` | `fix_route_label` | do not retry — fix the app's route metadata |
| `invalid_input`, `not_found`, `forbidden`, `invalid_module`, … | (no recovery) | fail-fast — there is no self-correctable retry |

Never print tokens or `device_code` values. Show `user_code` only during login. Logging out the `default` profile, or the last profile on an origin, removes the user credential **and every app credential** for that origin — unclaimed apps that relied on those app credentials become unreachable.

### More than one account: profiles

A profile is a name and an origin, and each one holds one signed-in account. `charming auth login --profile work` saves a second account as `work` and selects it; `charming profile use NAME` changes the selection; `--profile NAME` or `CHARMING_PROFILE` picks one for a command. `charming profile list` shows names and origins without credentials, and `charming profile current` shows the profile this directory resolves to and where the choice came from. A login or logout without `--profile` acts on that profile.

- **Project file:** `.config/charming.json` (or `.charming/config.json`) holds only `{"profile": "work"}`. `charming profile use work --project` writes it. The CLI reads the nearest one up to the git root, reads none outside a git repository, and refuses one that holds a token.
- **Profile order:** `--profile`, `CHARMING_PROFILE`, the project file, the user's selected profile, then `default`.
- **Unsaved profile:** a selected profile that isn't saved on this machine fails before any request. Ask the user to run `charming auth login --profile NAME`.
- **Credential order on the production origin:** `--token`, `CHARMING_TOKEN`, `BUILDY_USER_TOKEN`, then the profile's saved token.
- **Other origins:** a profile never sends its token to another origin. `--profile NAME` with a `--base-url` for a different origin fails. A `--base-url` without `--profile` uses the one profile saved for that origin. An explicit `--token` skips profiles entirely.

## Hit a limit or want a feature? Send feedback

Charming is young and there are things it can't do yet. When you hit a bug, a limitation, or the user asks for something Charming doesn't support, send it instead of silently working around it — it's how the roadmap gets prioritized:

```bash
charming api describe submit-app-feedback
charming api request submit-app-feedback --param id=<app-id> \
  --body '{"category":"enhancement","text":"User wanted X; Charming cannot do it yet because Y."}' --dry-run
# drop --dry-run once the request above looks right, to actually send it:
charming api request submit-app-feedback --param id=<app-id> \
  --body '{"category":"enhancement","text":"User wanted X; Charming cannot do it yet because Y."}'
```

`category` is required — use `enhancement` for an unsupported-feature ask, with free-form `text` in the user's words. We really do read these.

## Everything else

`charming doctor` checks connectivity, auth, and contract freshness. `charming agent-context` prints the CLI's own contract as JSON — useful for a coding agent orienting itself before its first call. For everything beyond `apps` — routines, webhooks, templates, claiming an unclaimed app, the full route and error contract, what Charming can't do yet and how to work around it — run `charming api list` and `charming api describe <operation-id>`, or read https://charm.ing/docs/llms-full.txt.

Found a bug or need a feature? [Tell us](/docs/capabilities/feedback) with `submit_feedback` or `POST /app/{id}/feedback`. Your feedback shapes what we build next.
