Use npm and platform imports
Declare server and browser dependencies, save ESM source, and follow its build through publication.
Selected authenticated authors can use the ESM manifest contract at https://charm.ing/schema/app-manifest/2026-09-05.json. It supports separate npm dependencies for the server module and browser ui, plus versioned Charming imports. Saves return a build operation while Charming resolves and bundles the code in the background.
The existing manifest contract remains the default. Eligibility does not change an app’s contract automatically. Include the exact ESM $schema to create an ESM app; importing a package alone does not select it. Anonymous, per-app, and render credentials cannot submit ESM builds. An excluded author gets contract_not_enabled; collaborators need their own eligibility and current app write access.
Define the two targets
Keep manifest a literal export const object and routes a literal array. Put public npm package names and semver ranges under dependencies.server or dependencies.client. A package used in both files must appear in both maps. Omitted maps are empty. Package subpaths such as react-dom/client use the root package’s declaration, react-dom.
This server source uses npm validation and the imported key-value API:
import { z } from 'zod';
import { kv } from 'charming:storage/[email protected]';
export const manifest = {
$schema: 'https://charm.ing/schema/app-manifest/2026-09-05.json',
id: 'esm-notes',
meta: { name: 'ESM Notes' },
dependencies: {
server: { zod: '4.5.4' },
client: { zod: '4.5.4' },
},
};
export const routes = [
{
op: 'save',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string' } },
additionalProperties: false,
},
annotations: { readOnlyHint: false },
handler: async (input) => {
const text = z.string().trim().min(1).parse(input.text);
await kv.put('text', text);
return { text: await kv.get('text') };
},
},
];
Pass the browser module separately as ui; put CSS in styles:
import { z } from 'zod';
import { api } from 'charming:ui/[email protected]';
const input = document.createElement('input');
input.setAttribute('aria-label', 'Note');
const button = document.createElement('button');
button.textContent = 'Save';
const result = document.createElement('output');
button.addEventListener('click', async () => {
try {
const saved = await api.save({ text: z.string().min(1).parse(input.value) });
result.textContent = saved.text;
} catch (error) {
result.textContent = error.message;
}
});
document.getElementById('app').append(input, button, result);
The supported platform modules for this contract are:
| Target | Exact module specifier | Named exports |
|---|---|---|
Server module |
charming:storage/[email protected] |
kv |
Server module |
charming:storage/[email protected] |
assets |
Server module |
charming:logging/[email protected] |
log |
Server module |
charming:network/[email protected] |
fetch |
Server module |
charming:secrets/[email protected] |
fetch |
Server module |
charming:app/<manifest-id>@1.0 |
app |
Browser ui |
charming:ui/[email protected] |
api, onStateChange |
Browser permissions use side-effect imports such as import 'charming:browser/[email protected]'. The exact supported names are microphone, camera, geolocation, clipboard-read, display-capture, midi, device-motion, ambient-light, and storage, each under charming:browser/<name>@1.0. They export no functions. Use the existing browser API after the app’s claim, origin, browser support, user consent and ancestor permissions allow it. An import cannot override a chat host’s iframe policy. The storage marker selects the existing app origin behavior; it does not expose server KV to the browser.
Include the @1.0 suffix. Platform imports do not go in npm dependency maps or an old capabilities.imports array. The host checks the target, version, and exported names. The ESM schema rejects capabilities and display_name; set the app name in manifest.meta.name. Further platform interfaces are not enabled by inventing a specifier.
Existing route context arguments, env, route annotations, and ctx.waitUntil remain available. Imported kv follows the current request’s app/user scope and read-only restrictions. Call host functions inside a route handler or its supported request lifetime, not at module initialization.
assets provides the existing get, put, delete, list, and asynchronous url operations; use await assets.url(key). log provides info, warn, and error. Direct fetch keeps the exact HTTPS origins in manifest.permissions.server.fetch; sealed-secret fetch uses the existing secret configuration and host performer without exposing secret values to app code. Both preserve their current access rules and request lifetime.
A sibling import uses that app’s exact manifest ID and version, for example import { app } from 'charming:app/[email protected]'. Call app.fetch(new Request('http://callee/api/read')) inside a route. The host selects the current active sibling in the caller’s user or team namespace, applies its own storage and asset identity, and preserves read-only and visitor restrictions. Pending source cannot become the callee. The import does not grant cross-tenant access or invent a caller identity API. A request chain may make at most eight nested sibling calls; the ninth returns HTTP 403 with capability_denied. The host tracks this limit privately, including across legacy and ESM apps; request headers and bodies cannot reset it.
The existing contract declares the same sibling in manifest.capabilities.imports and calls env.apps['notes'].fetch(request). These declarations require a signed-in author and the exact @1.0 interface; anonymous authors and other versions are rejected. Existing-contract callees receive the request through default.fetch, while ESM callees use their declared routes.
Checks before and during a build
Before accepting a save, Charming parses the manifest, route metadata, server imports, and browser imports without executing app code. It checks static imports, re-exports, side-effect imports, and literal dynamic imports such as import('zod'), including those in unused branches. Bare package imports must match that target’s dependency map. A missing declaration or wrong target is rejected with invalid_module or invalid_ui and target/specifier/location details. Declaring an unused package is allowed.
App-root relative files, URL imports, computed dynamic imports, CommonJS loaders, and direct access to private host modules are unsupported. Use ESM import syntax in authored source. Compatible CommonJS npm packages can be bundled through those ESM imports. Top-level await is supported for pure initialization.
The build resolves public npm registry packages with install scripts disabled. It retains exact versions and archive integrity for each target before compilation. Compilation uses those retained archives without network access or a silent new resolution. Each package can load only its own allowed dependency graph; a transitive package does not gain extra Charming authority. A package may reference a Charming host only when the authored root requests that exact host for the same target, even when the package import is later removed by tree shaking.
Packages that need Node built-ins, native addons, executable install scripts, CSS imports, emitted assets, WebAssembly, workers, or runtime code loading are outside this contract. Compilation and final validation must produce a verified server/browser pair before publication. The browser runs one bundled inline ES module; it does not fetch npm or CDN scripts at runtime. CSP restrictions still apply in both standalone and chat views.
Package target selection
Server output uses the portable workerd, worker, and import export conditions, with module then main package fields. Browser output uses browser and import conditions, with browser, module, then main fields. Both replace process.env.NODE_ENV with the compile-time string production; this does not install a Node runtime or a process global.
| Package form | Verified behavior |
|---|---|
| Semver ranges and independent target maps | Resolve once to retained exact package instances |
React and react-dom/client |
Production CommonJS dependencies bundle into browser ESM; React state changes render normally |
react/package.json |
JSON package subpaths bundle into the client entry |
cowsay |
Server selects its portable module entry; client selects its browser UMD entry |
Literal import('zod') |
Bundles completely and supports top-level await |
cowsay/index.js |
Explicit Node entry fails with unsupported_node_builtin and the offending fs or path import |
Native .node addons |
Fail with unsupported_native_addon |
The total limit is 64 acquired package instances across server, client, and compiler dependencies, including transitive instances. A package declared in both target maps contributes to both graphs. Exceeding the bound fails the build with package_limit; the last active app stays available. Prefer using a dependency in the target that needs it and returning its result through the imported app API.
Save over HTTP
Send a genuine user credential and a fresh Idempotency-Key of 8–128 visible ASCII characters:
jq -n --rawfile m module.js --rawfile u ui.js \
'{module: $m, ui: $u}' \
| curl -sS -i https://charm.ing/app \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: esm-notes-create-001' -d @-
A successful admission returns HTTP 202, a durable buildId, state, statusUrl, sourceEtag, and a polling hint. Location points to statusUrl; Retry-After gives the delay before polling, normally 2 seconds. The request returns before npm resolution, compilation, or app evaluation. A new creation has no runnable app or app URL until it publishes.
| Request | Purpose |
|---|---|
GET /api/v1/app-builds/<buildId> |
Read progress or the retained terminal result |
GET /api/v1/app-builds/<buildId>?include_source=true |
Also inspect that operation’s exact accepted source |
POST /api/v1/app-builds/<buildId>/cancel |
Cancel pending work; repeat cancellation safely |
Status and cancellation require current source access, or the submitting author’s identity for a private creation. They remain available after cohort removal. Other callers receive not_found.
For an existing app, read desired source with GET /app/<id>/source, then send its ETag as If-Match on PUT /app/<id> or PATCH /app/<id>/source. Keep manifest.id and the exact ESM schema. Each deliberate save needs a new idempotency key. On ESM PUT, omitted ui, styles, or description preserves the desired value; explicit null clears it. PATCH applies exact edits to desired source. Existing-contract PUT keeps its original replacement behavior.
An ESM POST that resolves an existing same-owner manifest.id also needs its desired-source If-Match. To migrate an existing contract, additionally send migrate_contract: true and complete valid ESM source, either directly or through exact edits. Source submissions do not roll back contracts. History can explicitly restore a retained validated existing-contract revision. The existing app and its data remain available while migration builds.
Save through MCP
Use idempotency_key on ESM create_app and update_app. For an existing destination, supply expected_revision from get_app_source; migration also needs migrate_contract: true. update_app accepts full source or exact edits.
The tool returns an ordinary accepted build result promptly. Poll get_app_build({ build_id: result.buildId, include_source: true }) and use cancel_app_build({ build_id: result.buildId }) to cancel it. The result uses camelCase fields such as buildId, appId, and statusUrl. These are ordinary MCP tools; no Tasks extension or long-running MCP request is required. After published, call get_app({ app_id: result.appId }) to open the app.
Desired source and active output
For an already-ESM app, acceptance immediately creates an immutable desired source revision. Rendering and operation calls continue using the last working active revision until both build targets pass validation and activate together. Pending or failed source is available for inspection and editing; it cannot run as an app.
Creation and migration keep a private build input until publication. A failed create produces no unusable app. A same-name target is fixed at acceptance: a later collision cannot convert a create into an overwrite.
A newer accepted save supersedes pending builds for the app. Canceled, superseded, expired, unauthorized, or stale work cannot publish, even if remote work finishes later. A failed build leaves active output unchanged. Fix desired source and submit a fresh save instead of discarding the last working app.
Retries, locks, and retention
After response loss, resend the exact same body, target, revision, and idempotency key. Charming returns the same operation, including a terminal result. A changed request with that key returns idempotency_conflict; it does not create a second build. PATCH replay happens before stale-basis and edit-match checks.
Source-only edits can reuse compatible retained exact dependency locks. Changing the normalized target dependency declarations or build policy requires a new resolution. lockState, optional lockDigest, and optional resolvedDependencies show whether resolution has completed and the resolved package versions.
Status moves through queued, resolving, building, and validating, then one of published, failed, superseded, canceled, or expired. Treat only published as a successful save. Failure details include a safe error kind/message and retryability; target, specifier, and source location appear when available.
| Limit | Policy |
|---|---|
| Combined authored server/browser source | 64 KiB |
| Styles | 256 KiB |
| Compiled output per target | 2 MiB |
| Pending operations | 16 overall, 3 per author |
| Simultaneous builds | 2 overall, 1 per author |
| Queue deadline | 10 minutes |
| Attempt deadline | 10 minutes |
| Total operation lifetime | 20 minutes |
| Automatic attempts | At most 2 for transient failures, with retry delay |
| Terminal input inspection and prepared bytes | 7 days |
| Operation results, exact locks, and idempotency records | 30 days |
Status exposes queueDeadline, deadline, inspectionExpiresAt, and idempotencyExpiresAt; terminal expiry fields are null while work is pending. Active and historical completed builds remain while app revisions reference them. app_build_capacity means retry later; deterministic source or dependency failures require a corrected save. Current credential, access, cohort, source basis, deadline, and cancellation checks run again before publication.
History and copies
History restores a selected completed revision as a fresh desired revision. It keeps the exact authored source, server output, browser output, and compatible dependency locks together. Live app data stays in place. The signed-in author needs current manage access, the current desired revision, and ESM authoring eligibility. History shows the contract of the selected revision before confirmation.
Compatible retained outputs can publish immediately without npm access. If the runner or host profile changed, restore returns an accepted build and keeps the previous active app running while it prepares the selected source. A failed build leaves that desired source editable. A newer save, lost access, credential revocation, cohort removal, or cancellation prevents the old operation from publishing. Browser actions show a progress page that checks the existing status resource and opens the app only after publication. It also shows terminal failure or cancellation and offers Cancel while work is pending. JSON clients can inspect the returned statusUrl or use get_app_build; only published means the restore completed. Requesting build status with Accept: text/html selects that browser page; published pages redirect to the actual app URL.
Copy freezes the source app’s active revision, even when its desired source is newer or failed. A copy gets its own owner, empty data scope, and source provenance. Compatible copies reuse the exact retained pair; copies that need preparation remain private build inputs until publication creates the app. Pending copies have a buildId and status URL, with no app ID or app URL. Repeating a pending copy request returns the same build, and reopening an existing copy does not create another revision. New ESM copies require an eligible signed-in author; ordinary template access still applies.
The browser History and copy flows accept verified session cookies. Ordinary HTTP and MCP ESM source submissions still require a user bearer credential. Status reads and cancellation remain available with current resource access even after authoring eligibility is removed.
Selecting a retained validated existing-contract revision in History is an explicit recovery action. It validates that source under its selected contract, checks the current credential, manage access and desired basis, then publishes a fresh revision under that contract. It remains available when ESM authoring is disabled. Source text never silently changes a stored contract.
version_not_restorable means the selected revision is missing, has no completed output, or cannot pass the selected contract. version_history_unavailable means the stored contract has no supported source recovery path. Choose another retained revision or submit a corrected source edit.