---
title: Browser runtime API
description: Call backend operations and use live updates, identity, agent messages, files, images, links, and owner controls from browser code.
sidebar:
  label: Browser runtime API
  order: 4
---

Charming injects `window.charming` before an app's `ui` program runs. Use it from the app's browser code without an import. Charming attaches runtime credentials to its requests, so app code must not read, store, or send a token.

Assign the runtime to a local name when calling several methods:

```js
const charming = window.charming;
```

## Backend operations

### `api(manifestId)`

```ts
api(manifestId: string): Record<string, (input?: unknown) => Promise<unknown>>
```

Returns the app operations declared by the target manifest. Pass `manifest.id`, such as `todo`, not the app UUID shown in its URL.

```js
const api = charming.api('todo');
const todos = await api.list();
const created = await api.add({ text: 'Book a dentist appointment' });
```

For `GET` routes, Charming sends string-valued input fields as query parameters. Other methods receive the input as a JSON request body. If input is missing, a body route receives `{}`.

The promise resolves to the operation value, not the `{ ok, value }` wire envelope. A failed operation throws `CharmingOperationError`. For an app with declared routes, only declared operation names exist on the returned object.

Use the current app's manifest ID unless its contract explicitly gives the UI access to another app.

### `app(manifestId)`

```ts
app(manifestId: string): Record<string, (input?: unknown) => Promise<unknown>>
```

Compatibility API for older apps. It accepts any operation name, sends a `POST`, and returns the raw JSON response, including its `{ ok, value }` or `{ ok: false, error }` envelope. New app code should use `api()`.

## Live state

### `onStateChange(callback)`

```ts
type StateChangeEvent = {
  kind: 'state-changed';
  op: string | null;
  source: 'agent' | 'reconnect-resync';
  ts: number;
  result: unknown;
};

onStateChange(callback: (event: StateChangeEvent) => void): () => void
```

Runs the callback after a successful MCP app operation through `query_app` or `mutate_app`, a scheduled operation, or a successful mutating operation over the browser or HTTP API. The method opens the live connection when the first listener subscribes and returns an unsubscribe function.

For `source: 'agent'`, `op` names the operation. `result` matches the unwrapped value from `api(manifestId)[op]()` when the JSON response body is no larger than 32 KiB (32 kibibytes); otherwise it is `null`. For `source: 'reconnect-resync'`, both `op` and `result` are `null`; refetch the current state because events may have been missed.

```js
const stop = charming.onStateChange(async (event) => {
  if (event.source === 'reconnect-resync') {
    render(await api.getState());
    return;
  }

  if (event.op === 'increment') render(event.result);
});

// Call stop() when the view no longer needs updates.
```

Update only the DOM nodes that changed. Replacing the whole app with `innerHTML` can discard typed text, focus, and selection.

## Access and identity

### `viewer`

```ts
viewer: {
  readonly role: 'owner' | 'collaborator' | 'viewer' | 'anon';
  can(op: string): boolean;
}
```

`viewer.role` describes the caller's runtime role. `viewer.can(op)` returns whether the UI should offer an operation. It returns `false` for a read-only viewer calling a mutating or unknown operation.

A `false` answer has two causes that need opposite UI. When `charming.user` is `null` and `login.available` is `true`, nobody has been refused — nobody has been named yet, and an anonymous visitor on an app open to signed-in editors reads `false` for every write. Keep that control live and route its click through [`withUser`](#login), which signs the visitor in and runs the action again. Hide it only when the caller is signed in and their role still says no, or when no sign-in can run on this surface.

```js
saveButton.hidden = !charming.viewer.can('save') && charming.user !== null;
```

This is a user-interface signal, not access control. The server checks every operation even if app code ignores or changes the result.

### `user`

```ts
user: {
  id: string;
  handle?: string;
  name?: string;
  image?: string;
} | null
```

Contains the signed-in caller's public identity, or `null` for an anonymous caller or a render-token-only embed. It never includes email. Use it for display and attribution, not authorization.

### `login`

```ts
login(): Promise<{
  id: string;
  handle?: string;
  name?: string;
  image?: string;
} | null>

login.available: boolean
login.unavailableReason?: 'embedded-host'

withUser<T>(action: () => T | Promise<T>): Promise<T | null>
login.canonicalUrl: string
```

Starts Charming sign-in in a controlled popup and resolves to the caller's public identity. It resolves to `null` if the caller closes the popup or the request times out.

By the time `login()` resolves, `charming.user` and `viewer.role` already report the new caller and gated calls already work. Never reload the app to pick up a sign-in — a reload throws away the in-memory state the visitor was in the middle of, which is usually the very thing they signed in to save.

Check `login.available` before showing an in-app sign-in action. In a chat embed where sign-in cannot run, `login()` asks the host to open `login.canonicalUrl`, then throws `CharmingLoginError` with `reason: 'embedded-host'` and the same `canonicalUrl`.

```js
if (charming.login.available) {
  signInButton.addEventListener('click', async () => {
    const user = await charming.login();
    if (user) renderUser(user);
  });
} else {
  signInButton.addEventListener('click', () => {
    charming.openLink(charming.login.canonicalUrl);
  });
}
```

A browser can also refuse the popup. `login()` then rejects at once with `CharmingLoginError` and `reason: 'popup-blocked'` — not a cancel, which resolves `null`. Opening the popup straight from the button's own click is what stops a browser blocking it.

### `withUser`

```ts
withUser<T>(action: () => T | Promise<T>): Promise<T | null>
```

Runs `action`; if it fails only for want of a signed-in caller, signs the caller in and runs it again. The payload lives in the closure you wrote, so the retry saves what the visitor already entered rather than an empty form. Wrap the gated call, not the whole click handler — the closure runs twice, so id generation, optimistic DOM updates, and analytics inside it all repeat.

It retries around two refusals: `sign_in_required` (401), the server's answer to a caller with no proven identity on a claimed app, and `forbidden`, which the runtime's own viewer gate raises before any request leaves the page. An anonymous visitor meets `forbidden` first.

It resolves with the action's value, `null` when the visitor closes the sign-in window, and rethrows anything signing in would not fix. Give the wrapped op a non-null return, or a save that worked reports itself as a cancel. Where `login.available` is `false` it opens no popup and rethrows the original refusal, so check that flag before offering the gated action at all.

`withUser` calls `login()` only after the first attempt has already failed, so its popup sits outside the click that started it — Safari and Firefox block a popup opened there, and any browser blocks one the visitor disabled by hand. It then rejects with the same `popup-blocked` error. Catch it and show a sign-in button of your own; its click, not `withUser`'s, is what opens the popup.

```js
try {
  const saved = await charming.withUser(() => charming.api('app').saveScore({ score }));
  if (saved === null) status.textContent = 'Not saved. Sign in when you are ready.';
} catch (err) {
  if (err.name !== 'CharmingLoginError' || err.reason !== 'popup-blocked') throw err;
  signInButton.hidden = false; // its own click handler calls charming.login()
}
```

Full walkthrough: https://charm.ing/docs/guides/gate-an-action-behind-sign-in.md.

## Agent host connection

These members work when the app runs inside a connected MCP host. Direct browser visits remain usable, but they have no agent thread to receive messages or context.

### `isConnected`

```ts
readonly isConnected: boolean
```

`true` after a wrapping host completes the connection handshake. It stays `false` when the app runs as a top-level page.

### `onConnectionChange(callback)`

```ts
onConnectionChange(callback: (connected: boolean) => void): () => void
```

Runs the callback when the host connection changes and returns an unsubscribe function. It does not call a new listener with the current value, so read `isConnected` when setting initial UI state.

### `sendFollowUp(text)`

```ts
sendFollowUp(text: string): void
```

Adds a user message to the connected agent thread. Calls made while disconnected are dropped. Charming also drops calls made within 500 milliseconds of the previous message and calls over 10 messages in a rolling minute.

Use this for an explicit user action that should start an agent turn:

```js
askAgentButton.addEventListener('click', () => {
  charming.sendFollowUp('Plan the next three tasks from this project.');
});
```

### `updateContext(patch)`

```ts
updateContext(patch: Record<string, unknown>): void
```

Silently gives the connected host structured state for its next model turn. Updates are shallow-merged by top-level key, with the latest value winning, and are sent after a 200 millisecond debounce. Empty patches and calls made while disconnected are dropped.

```js
charming.updateContext({
  selectedProject: { id: project.id, name: project.name },
  visibleTaskCount: visibleTasks.length,
});
```

Do not write the reserved top-level key `buildyRuntimeHints`.

### `recordAction(name, params)`

```ts
recordAction(name: string, params: Record<string, unknown>): void
```

Records a user action in the context sent to the connected host. Each entry contains `{ name, params, at }`, where `at` is the current millisecond timestamp. Charming keeps the 50 most recent actions.

```js
charming.recordAction('completed-task', { taskId: task.id, title: task.title });
```

Use `recordAction()` when the action itself matters. Use `updateContext()` when the latest state matters.

## External links

### `openLink(url)`

```ts
openLink(url: string): void
```

For a string with an HTTP or HTTPS prefix, asks the host to open the URL. On a top-level page, Charming opens a new browser tab with opener access disabled. Empty strings and other schemes do nothing.

Use it instead of `window.open()` or an anchor with `target="_blank"`, which chat sandboxes can block.

## Uploaded files

### `assets.getUrl(key)`

```ts
assets.getUrl(key: string): string
```

Returns a URL for a file stored by this app. Use it in an image, link, or `fetch()` call when the host allows the app URL.

### `assets.load(key)`

```ts
assets.load(key: string): Promise<string>
```

Loads an uploaded image as a `data:` URL. Use `load()` when the app may run in a chat embed because the returned URL avoids host restrictions on app URLs; use `getUrl()` when the app only runs standalone.

### `assets.upload(file, options?)`

```ts
assets.upload(
  file: File | Blob,
  options?: { key?: string },
): Promise<{ key: string; url: string }>
```

Uploads a browser `File` or `Blob`. The key defaults to `File.name`, or `upload.bin` for an unnamed `Blob`.

### `assets.list()`

```ts
assets.list(): Promise<Array<{
  key: string;
  contentType: string;
  sizeBytes: number;
}>>
```

Lists the app's uploaded files.

### `assets.delete(key)`

```ts
assets.delete(key: string): Promise<void>
```

Deletes one uploaded file. Reading and listing require app read access. Uploading and deleting require app run access, so a read-only viewer cannot use them.

See [Data storage](/docs/capabilities/data-storage) for the capability import, backend file API, limits, and deletion rules.

## External images

### `images.proxy(url)`

```ts
images.proxy(url: string): string
```

Returns a Charming image-proxy URL. The remote origin must appear in `manifest.permissions.browser['img-src']`. The proxy checks the allowlist, redirects, public network destination, and response type when the browser fetches the returned URL.

### `images.load(url)`

```ts
images.load(url: string): Promise<string>
```

Loads an allowed remote image and returns a `data:` URL. Use `load()` when the app may run in a chat embed because the returned URL avoids host restrictions on proxy URLs; use `proxy()` when the app only runs standalone.

See [External images](/docs/capabilities/external-images) for the manifest setup and host behavior.

## Owner controls

These methods require the app owner and a Charming browser shell that can use the owner's first-party session. They are not available when no parent mediator exists, such as a bare top-level render or an MCP render without owner-action mediation.

### `remixable.set(value)`

```ts
remixable.set(remixable: boolean): Promise<{
  remixable: boolean;
  public_url: string | null;
}>
```

Turns template copying on or off. See [Templates](/docs/capabilities/templates) for the user-facing behavior.

### `icon.set(icon)` and `icon.clear()`

```ts
icon.set(icon: {
  emoji: string;
  bg: string;
}): Promise<{ icon: { emoji: string; bg: string } }>

icon.clear(): Promise<{ icon: null }>
```

Sets or clears the app icon. Charming uses only the first grapheme in `emoji` and silently drops any others. `bg` accepts `#rgb`, `#rrggbb`, or `#rrggbbaa`.

An unavailable owner action rejects with `Error('owner_action_unavailable')`; a request with no reply rejects with `Error('owner_action_timeout')`. A server refusal rejects with an object containing `status`, `kind`, and `message`.

## Errors

### `CharmingOperationError`

`api()` throws this error when an operation returns `{ ok: false }`.

```ts
interface CharmingOperationError extends Error {
  name: 'CharmingOperationError';
  op: string;
  kind: string;
  details?: unknown;
  traceId?: string;
}
```

Branch on `kind` when the UI can handle a known failure. Keep a fallback because the server can add kinds.

### `CharmingBridgeError`

Transport failures from operation calls, manifest resolution, and browser uploads can throw:

```ts
interface CharmingBridgeError extends Error {
  name: 'CharmingBridgeError';
  url: string;
  status: number;
  statusText: string;
  responseType: string;
  contentType: string;
  bodyPreview: string;
  causeMessage: string;
  isAuthError?: boolean;
  kind?: 'token_revoked' | 'token_expired' | 'auth_failed';
}
```

`bodyPreview` contains at most the first 240 response characters. Auth failures set `isAuthError` and one of the listed `kind` values.

### Other method errors

`login()` can throw `CharmingLoginError`. File and image helpers can throw plain `Error` objects with messages such as `asset_load_failed` or `image_proxy_failed`. Do not branch on those messages as a stable error contract. Owner controls use the rejection shapes described above.

Do not catch every error and replace it with empty state. Handle failures the UI can resolve and let unknown failures reach Charming's recovery UI.

## TypeScript types

The source type is `CharmingClient`, exported from `@buildy/runtime/client`. `Window.charming` is optional in the global TypeScript declaration because code can be checked outside a hosted app, but Charming defines it before hosted app UI runs.

Underscore-prefixed members such as `_self` and `_routes` belong to the bridge and Charming chrome. They are not part of the app-author API.

## Related

- [Apps that work with their agent](/docs/guides/agent-connected-apps)
- [Charming design guide](/docs/prompts/design-an-app)
- [Authentication](/docs/technical-reference/authentication)
- [Privacy and sharing](/docs/capabilities/privacy-and-sharing)

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.
