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.
For the explicit ESM contract, import { api, onStateChange } from charming:ui/[email protected]. The imported api already targets this app: call api.save(...) directly. The runtime globals below describe the existing contract and are not an inventory of ESM host exports.
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, number, and boolean input fields as query parameters (an array of those repeats the key) and the server converts each value back to the type the route’s inputSchema declares (a boolean travels as the literal true or false). Nested objects and null fields are dropped. 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 any operation call that changed the app’s stored state, whoever made it: this tab, another person’s browser, an agent through mutate_app, a Routine, a webhook, or the HTTP API. An operation that writes nothing to storage or assets does not fire the callback, whatever its route declares, so a callback that refetches state cannot re-trigger itself. The method opens the live connection when the first listener subscribes and returns an unsubscribe function.
Every write-driven event has source: 'agent', including writes from a browser. op names the operation that wrote, and 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. The event does not list which keys changed. For source: 'reconnect-resync', both op and result are null: the connection dropped and came back, and events may have been missed.
Refetch the app’s read operation on every event. That one rule covers every writer, every operation, and every reconnect:
const api = charming.api('tasks');
async function refresh() {
render(await api.getState());
}
void refresh();
const stop = charming.onStateChange(() => refresh());
// Call stop() when the view no longer needs updates.
Do not poll. When the stream drops or the server refuses it, Charming keeps reconnecting with backoff and sends a reconnect-resync event once it is back, so a timer that refetches state only adds load and catches nothing the callback misses. To skip the refetch, have each mutating operation return the app’s full state; render(event.result) then works for every event except reconnect-resync and an oversized result, which still need refresh().
A page whose credential has expired, such as a chat render link older than 24 hours or a signed-out session, keeps retrying the stream but receives no events until it is reloaded. Its operation calls fail with the same credential, so a timer would not keep it current either.
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.
login() succeeds once per page load. The sign-in it opens is single use, so a second call in the same load resolves null even for a visitor who is signed in — there is no way to tell that null from a cancel. Call it once, keep the identity it hands you, and read charming.user afterwards.
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.
External links
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.
Device access
openDeviceAccess()
openDeviceAccess(): Promise<boolean>
Opens the Charming widget on its Device access tab, where the viewer reviews and resets the camera, microphone, location, and other devices they allowed or blocked for this app. Call it from a click or key handler, such as a “Manage camera” button. It only opens the tab: it grants nothing and resets nothing. Resolves true when the tab opened. It resolves false when the app hasn’t asked for a device yet in this browser, when the call didn’t follow a user action, or in a chat host, which has no widget.
Uploaded files
assets.getUrl(key)
assets.getUrl(key: string): string
Returns a time-limited signed URL for a file stored by this app. Store the asset key, not the URL. Use the result in an image, link, or fetch() call when the host allows the app URL. At the standalone app URL the URL is on the app’s own origin, so an uploaded .js or .mjs file also works with import(), a <script src>, and new Worker(). See External files and CSP for URL lifetime, uploaded scripts, and host behavior.
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. JavaScript is refused with 401, because the page’s credentials cannot edit the app; upload scripts with upload_asset instead.
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. See External files and CSP for file-type render support and browser policy.
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.
When the proxy cannot retrieve an external image, the promise rejects. An external-resource failure carries error.imageFailure with sourceOrigin, category, and an optional upstreamStatus. Categories are access_denied, not_found, rate_limited, temporarily_unavailable, and unavailable. Access and policy failures may omit this metadata. Keep handling a generic rejection when it is absent; do not branch on plain error-message strings.
Charming shows a dismissible status notice outside the app content for a reported external image failure, even if app code catches the rejection. It describes the failed request and suggests another image source or a permitted upload. A successful load of the same URL clears that failure and shows the latest remaining failure, if any. The notice retains at most ten distinct failed URLs per page; Dismiss clears its history. Loading a different URL does not clear it: the runtime cannot tell an alternate source from another image in the app. Your app still owns its image fallback and source link.
See External images for the manifest setup and host behavior. See External files and CSP for the directive map and unsupported resource types.
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.
An executor_error may include details: { execution, operationId, reservationId? }. execution: "not_started" means the authority permanently ruled out execution of that operation. "may_have_run" means the operation may have executed; inspect its state before trying again. If admission failed before the authority returned an identity, details contains only execution: "may_have_run"; no operation or reservation ID is invented. Missing details, a timeout, or a lost connection do not prove nonexecution. These fields do not trigger an automatic retry. An invalid_module found after remote evaluation also carries execution: "may_have_run" and its operation identity, while retaining the validation message needed to fix the source.
CharmingBridgeError
Transport failures from operation calls, manifest resolution, and browser uploads can throw:
interface CharmingBridgeError extends Error {
name: 'CharmingBridgeError';
url: string;
method: 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.
responseType: 'network' means no server answered: the connection dropped, the host was unreachable, or the browser refused the response. Treat it apart from a 4xx or 5xx, which is a decision about the request. The runtime already repeats a GET or HEAD that fails this way, twice, before the error reaches your code, so a call that arrives with responseType: 'network' has a connection that stayed down. A write is never repeated, because a browser cannot tell a request that never left the device from one whose answer was lost. Read method to tell which call failed, and retry a write yourself only where running it twice is safe. Where an app is served with a relay base, a read its API host refuses is re-issued there once, and every later call on that page goes to the relay directly, writes included, which is one send rather than a repeat.
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.