Build an App as a package
Send an App as package.json plus files, declare npm dependencies the npm way, and follow its build through publication.
An App is a package: a package.json, a server entry, an optional browser entry, an optional stylesheet, and any other files those entries import. Saves return a build operation while Charming resolves dependencies and bundles the code in the background. The contract is dated 2026-10-25 and frozen: an App saved under it keeps working as written.
Send files to create or update an App. Anonymous, per-app, and render credentials cannot save and receive unauthorized; a browser session that tries to save receives bearer_required. In both cases sign in for a chrm_user_* bearer token. Collaborators need current App write access. An App saved before packages, as module, ui, and styles strings, still runs; Migrate an App to the package contract moves it over.
The package
A package App is a map from path to text. Paths are relative, hold letters, digits, ., _, and -, and never pass through node_modules. The tree holds at most 64 files.
| Path | Role |
|---|---|
package.json |
The App’s manifest, in npm’s shape. Required. |
server.js |
Server entry. Exports routes. Required. |
ui.js |
Browser entry, an ES module rendered into #app. Optional. |
styles.css |
Stylesheet injected beside ui.js. Optional, and the only CSS file. |
other .js, .mjs, .json |
Files either entry imports with ./ or ../ paths. |
package.json follows npm:
{
"$schema": "https://charm.ing/schema/app-package/2026-10-25.json",
"name": "esm-notes",
"version": "1.0.0",
"description": "Keeps short notes. Save a note, read it back.",
"type": "module",
"dependencies": { "zod": "^4.5.0" },
"charming": {
"name": "ESM Notes",
"icon": { "emoji": "📝", "bg": "#f4e9d8" },
"permissions": { "server": { "fetch": ["https://api.example.com"] } }
}
}
name is the App’s id: lowercase letters, digits, and hyphens, up to 64 characters. version is an exact semver version such as 1.0.0 and defaults to 0.0.1; a range or a short form such as 1.0 fails with invalid_manifest_schema. description is the App’s description, up to 500 characters. type, when present, is module. dependencies is one map of public npm package names to semver ranges, shared by the server and the browser; the build installs it once and bundles whatever each entry imports. devDependencies is ignored, so a local type-checking setup can keep its own tools. $schema is optional.
Charming facts sit under charming. name is the display name and defaults to the package name. icon is an emoji on a hex background. permissions carries the origins and connector tools the App’s code may reach: browser.img-src, browser.frame-src, and browser.connect-src as exact origins, server.fetch as exact HTTPS origins, and server.connectors as a map from service slug to Composio tool slugs. Permissions are parameters of a capability, not the capability itself; the capability comes from the import.
Keys the build cannot honor the way npm does are refused with the reason: overrides, resolutions, bundleDependencies, bundledDependencies, peerDependencies, peerDependenciesMeta, optionalDependencies, imports, exports, main, browser, module, and workspaces. Other keys such as license, private, scripts, and engines are ignored. A refused or malformed package.json surfaces as invalid_manifest_schema; a server.js that exports a manifest or a default export is refused as invalid_module. Any authored lockfile (package-lock.json, npm-shrinkwrap.json, yarn.lock, bun.lock, pnpm-lock.yaml), tsconfig.json, jsconfig.json, and a nested package.json are refused as invalid_files; the lock Charming retains is the lock.
Entries and imports
server.js exports a literal routes array. It exports no manifest: package.json describes the App, and the build writes the manifest the host reads. Import what the code uses:
import { z } from 'zod';
import { kv } from 'charming:storage/[email protected]';
import { trimmed } from './lib/text.js';
export const routes = [
{
op: 'save',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string' } },
additionalProperties: false,
},
annotations: { readOnlyHint: false },
handler: async (input) => {
const text = trimmed(z.string().parse(input.text));
await kv.put('text', text);
return { text: await kv.get('text') };
},
},
];
ui.js is an ES module that imports the same packages and the browser host module:
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);
Capabilities come from imports, not from a declaration. Charming walks every import from both entries before accepting a save, through the App’s own files, so charming:storage/[email protected] imported by ./lib/save.js counts the same as one imported by server.js. The supported platform modules are:
| Target | Exact module specifier | Named exports |
|---|---|---|
| Server | charming:storage/[email protected] |
kv |
| Server | charming:storage/[email protected] |
assets |
| Server | charming:logging/[email protected] |
log |
| Server | charming:network/[email protected] |
fetch |
| Server | charming:secrets/[email protected] |
fetch |
| Server | charming:[email protected] |
connectors |
| Server | charming:app/<package-name>@1.0 |
app |
| Browser | charming:ui/[email protected] |
api, onStateChange |
Camera, microphone, location, screen capture, clipboard reading, and MIDI need no import when the App’s own code makes the call: Charming finds those calls when the App is published and asks the viewer on the web. A call made inside a dependency is not found, so declare the matching import for it. Other browser permissions use side-effect imports such as import 'charming:browser/[email protected]'. The exact supported names are device-motion, ambient-light, and storage, each under charming:browser/<name>@1.0; the older microphone, camera, geolocation, clipboard-read, display-capture, and midi names are still accepted. They export no functions. 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 dependencies. The host checks the target, version, and exported names. Further platform interfaces are not enabled by inventing a specifier.
Route context arguments, env, route annotations, and ctx.waitUntil remain available. Imported kv follows the current request’s App and user scope and read-only restrictions. Call host functions inside a route handler or its supported request lifetime, not at module initialization.
assets provides get, put, delete, list, and asynchronous url; use await assets.url(key). A sibling App reached through charming:app/<name>@1.0 can call further siblings up to eight levels deep; the ninth call returns HTTP 403 with capability_denied, and the host tracks that depth across every App. log provides info, warn, and error. Direct fetch keeps the exact HTTPS origins in charming.permissions.server.fetch; sealed-secret fetch uses the existing secret configuration and host performer without exposing secret values to App code. A sibling import uses that App’s package name, for example import { app } from 'charming:app/[email protected]', and 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. A request chain may make at most eight nested sibling calls.
Checks before and during a build
Before accepting a save, Charming reads package.json, the route metadata, and every import reachable from both entries 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. A bare import must name a package in dependencies. A relative import must name a .js, .mjs, or .json file the tree holds; package.json itself is not importable. A missing declaration, a missing file, or a broken entry is rejected with invalid_module or invalid_ui and the file, line, and column. A tree the App cannot hold is rejected with invalid_files and the path: a missing package.json or server.js, a lockfile, a path outside the tree, a file no entry imports, or more than 64 files. Declaring an unused package is allowed.
URL imports, computed dynamic imports, absolute paths, CommonJS loaders, and direct access to private host modules are unsupported in App files. Compatible CommonJS npm packages can be bundled through ESM imports. Top-level await is supported for pure initialization. TypeScript files are not accepted in this release.
The build resolves public npm registry packages with install scripts disabled. It retains exact versions and archive integrity 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 App’s own files request 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 and browser pair before publication. The browser runs one bundled inline ES module that makes no CDN request. 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 in one map | 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 the App’s dependencies and the compiler’s, including transitive instances. Exceeding the bound fails the build with package_limit; the last active App stays available.
Save over HTTP
Send a genuine user credential, a fresh Idempotency-Key of 8–128 visible ASCII characters, and the tree:
jq -n --rawfile p package.json --rawfile s server.js --rawfile t lib/text.js --rawfile u ui.js \
'{files: {"package.json": $p, "server.js": $s, "lib/text.js": $t, "ui.js": $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. Until then its sourceEtag is the digest of the accepted input; once an App exists, sourceEtag and the status ETag carry the revision this build saved, which the next save can send as If-Match. They don’t change after a later save; read the current ETag from GET /app/<id>/source.
| 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 files |
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. Other callers receive not_found.
Ordinary HTTP and MCP package submissions require a user bearer credential. The browser History and copy flows accept verified session cookies.
For an existing App, read desired source with GET /app/<id>/source, then send its ETag as If-Match on PUT /app/<id> with the whole replacement tree, or on PATCH /app/<id>/source with edits that each name a path. Keep package.json’s name. Each deliberate save needs a new idempotency key. A save replaces the whole tree, so a file missing from files is removed.
A POST /app whose package name an App of the same owner already uses also needs its desired-source If-Match. To move an App saved before packages, additionally send migrate_contract: true with its whole tree as files; see Migrate an App to the package contract. Source submissions do not roll back contracts. The existing App and its data remain available while migration builds.
Save through MCP
Use files and idempotency_key on 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 the whole tree as files or exact edits that name a path.
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. After published, call get_app({ app_id: result.appId }) to open the App.
get_app_source returns the tree as source.files. source.module, source.ui, and source.styles mirror server.js, ui.js, and styles.css, so paged reads by bucket keep working.
Save from the CLI
charming apps create --files <dir> and charming apps update <id> --files <dir> send a directory as the package tree and accept the background build; add --migrate-contract to the update to move an App saved before packages. charming apps source <id> --out <dir> writes a package App back as its files. The CLI guide lists what the packer skips.
Desired source and active output
For an App already on the package contract, 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.
Source-only edits can reuse compatible retained exact dependency locks. Changing the normalized dependencies or the build policy requires a new resolution. lockState, optional lockDigest, and optional resolvedDependencies show whether resolution has completed and the exact 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 and message and retryability; the file, line, and column appear when available. An execution failure can also include error.details.execution: not_started proves the operation did not start; may_have_run means it may have executed. These reported execution failures have retryable: false; check the operation’s result before submitting a new save.
| Limit | Policy |
|---|---|
Every file except styles.css, together |
64 KiB; over it, the save fails with payload_too_large |
styles.css |
256 KiB; over it, the save fails with styles_too_large, carrying size_bytes and cap_bytes |
| Files in the tree | 64 |
| Characters in a file path | 128 |
| 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; app_build_unavailable (HTTP 503) means Charming cannot build packages right now, so retry later; deterministic source or dependency failures require a corrected save. Current credential, access, 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 files, server output, browser output, and compatible dependency locks together. Live App data stays in place. The signed-in author needs current manage access and the current desired revision. 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. Browser actions show a progress page that checks the existing status resource and opens the App only after publication. JSON clients can inspect the returned statusUrl or use get_app_build; only published means the restore completed.
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. New package copies require a signed-in author; ordinary Template access still applies.
Selecting a retained validated revision from before the App’s migration 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. 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, whether that is the package contract or the existing contract of a retained validated revision. version_history_unavailable means the stored contract has no supported source recovery path. version_limit_exceeded (429) means the owner’s plan History window does not reach back to that revision, so the response carries plan, max and upgradeUrl. The window is checked first, so a revision that is both out of reach and unrestorable answers version_limit_exceeded.