Charming design guide: read before you write the UI
When Charming MCP tools are available, read any https://charm.ing/docs/... reference below through read_docs, passing the portion after /docs/ as path. For example, call read_docs({ path: "technical-reference/external-files-and-csp.md" }). Follow next_offset with the same path until it is null. You do not need browser access.
The ui field is one JavaScript program. Charming provides <div id="app"></div>, a Tailwind v3-compatible runtime, and a default theme. Your code fills #app.
Sandbox
- Use inline UI instead of
alert,confirm, orprompt. In a chat they show Charming’s dialog andconfirmreturns a Promise; on the web they are the browser’s own, so the same code behaves differently. - Prevent native form submission and handle it in JavaScript.
- Do not add external scripts or CDN imports to an app that must work in chat. On the web only, scripts from cdnjs, unpkg, and jsDelivr load when
uiwrites the full URL with an exact package version, such as[email protected]; chat embeds block them. Such a script runs withwindow.charmingauthority, so also setintegrity. - Call the app backend through
window.charming. - Store lasting data through backend operations that use
env.storage. Do not use browser storage for data that must work in chat clients or across devices.
Read https://charm.ing/docs/technical-reference/external-files-and-csp.md before using an external file, origin, worker, WebAssembly module, or model. Read https://charm.ing/docs/guides/javascript-libraries.md before using a JavaScript library. Read https://charm.ing/docs/llms-full.txt before using browser capabilities, network access, or secrets.
Layout
- Fill the viewport. Do not wrap the whole app in a narrow
max-w-*container. - Limit line length only for long text.
- Make the main action clear on first load.
- Use a fitting layout and palette. Do not default every app to a heading, input, button, and empty list.
- Avoid empty settings pages, generic “My App” headings, and fake account fields.
- Reserve a 64px square at the bottom-right. Charming places its widget button there, outside your app frame, so do not put the app’s only action, status, or scroll affordance under it.
Do not add a Charming badge, app switcher, Share, App settings, account navigation, or agent handoff control inside the app. The widget owns those actions. Your UI owns the app’s task controls.
Use the vanilla DOM and Tailwind classes unless the task needs a small library. If you need one, follow https://charm.ing/docs/guides/javascript-libraries.md to review its code, pin its exact version and integrity digest, and inline the reviewed UMD or IIFE bytes. Inlined code receives the same window.charming access as the rest of the UI.
Backend calls
window.charming.api(id) returns the operation value. It throws CharmingOperationError when an operation fails.
const api = window.charming.api("todo"); // pass manifest.id, not the UUID
const todos = await api.list();
await api.add({ text: "..." });
Credentials attach automatically. Do not manage tokens in the UI.
Access
Use window.charming.viewer.can(op) to hide or disable actions a read-only viewer cannot run, but read window.charming.user first. An anonymous visitor on an app open to signed-in editors gets can(op) === false for every write. Disabling the button there is what stops them ever reaching the sign-in.
const { viewer, user, login } = window.charming;
if (!viewer.can("addTodo") && (user !== null || !login.available)) {
addButton.disabled = true;
}
Disable the control only for a caller who is signed in and still refused, or where no sign-in can run. Otherwise leave it live and route the click through withUser.
Treat viewer.can(op) as a UI hint, not an auth check. The server enforces access. Catch forbidden and forbidden_write errors and show a read-only state — except when charming.user is null, where a forbidden means “sign in”, not “read only”.
Use window.charming.user for optional display data. Never use it to authorize an action. Read https://charm.ing/docs/guides/gate-an-action-behind-sign-in.md for the mid-app sign-in pattern and https://charm.ing/docs/technical-reference/authentication.md for roles and auth-error recovery.
When an action needs an account, wrap it in window.charming.withUser(action) instead of asking the visitor to sign in first. It runs the action, signs them in only if the action turns out to need it, and runs it again with the payload the closure captured, so what they already typed is what gets saved. Wrap the call, not the whole click handler: withUser re-runs your closure verbatim, so id generation, optimistic DOM updates, and analytics inside it happen twice. Never reload the page to pick up a sign-in.
try {
const saved = await charming.withUser(() => api.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()
}
withUser returns null when the visitor cancels; say so and leave their work alone. Have the wrapped op return something other than null, or a save that succeeded reads as a cancel. It retries around two error kinds: sign_in_required, the server’s 401, and forbidden, which the runtime’s viewer gate raises before any request goes out. An anonymous visitor hits forbidden first.
A blocked popup is not a cancel: catch CharmingLoginError with reason: "popup-blocked" and show a sign-in button instead, since only a click on that button can open the popup at that point.
Check charming.login.available before you offer a gated action in a chat-host embed. It is false there, so withUser opens no popup and rethrows the original refusal. Render a link to charming.login.canonicalUrl instead. Read https://charm.ing/docs/guides/gate-an-action-behind-sign-in.md for the full pattern.