Skip to content
Charming Docs
Esc
navigateopen⌘Jpreview
On this page

Browser runtime API

Call backend operations and use live updates, identity, agent messages, files, images, links, and owner controls from browser code.

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:

const charming = window.charming;

Backend operations

api(manifestId)

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.

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)

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)

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.

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

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, 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.

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

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

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.

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

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.

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

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)

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)

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:

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

updateContext(patch)

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.

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

Do not write the reserved top-level key buildyRuntimeHints.

recordAction(name, params)

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.

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

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

openLink(url)

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)

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)

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?)

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()

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

Lists the app’s uploaded files.

assets.delete(key)

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 for the capability import, backend file API, limits, and deletion rules.

External images

images.proxy(url)

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)

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 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)

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

Turns template copying on or off. See Templates for the user-facing behavior.

icon.set(icon) and icon.clear()

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 }.

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:

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.

Was this page helpful?