Migrate an App to the package contract
Move an App saved as module, ui, and styles strings onto the package contract, keep its data and URL, and know what changes for its code.
Apps are packages now: a package.json, a server.js, and optionally a ui.js, a styles.css, and the files they import, saved as one files map. An App saved before packages, as module, ui, and styles strings with an exported manifest, keeps running as it is and still accepts those fields on update. Nothing forces a migration. Migrate when you want npm dependencies, more than one file, or the same contract the docs describe.
A migration is one save: the App’s whole tree as files with migrate_contract: true. The App’s id, URL, data, shares, Routines, Webhooks, secrets, and History stay. Its description comes from package.json from then on, and its connector approvals stay only for the tools package.json declares; see the map below. The App keeps serving its current revision while the package builds, and only a published build switches it over.
Before you start
Read the current source. Over MCP, get_app_source({ app_id }) returns source.module, source.ui, source.styles, and the revision you will send back as expected_revision. Over HTTP, GET /app/<id>/source with the user’s chrm_user_* bearer returns the same fields and an ETag to send as If-Match. From the CLI, charming apps source <id> --out <dir> writes module.js, ui.js, and styles.css to a directory. Keep that copy: an App whose manifest.$schema is not a dated app-manifest URL has no History revision it can return to after migrating, so the copy is its only way back.
Saves on the package contract need a signed-in user’s token and an idempotency key. An App-scoped chrm_app_* token from an anonymous create cannot migrate; pair the user’s account first.
Map the old shape onto files
| Before | After |
|---|---|
manifest.id |
package.json → name. Keep the same value: it is the App’s id and other Apps may import it. |
manifest.meta.name, manifest.meta.icon |
package.json → charming.name, charming.icon |
| The App’s description | package.json → description, up to 500 characters. A package sets the App’s description from this field alone, so leaving it out clears the description that list_apps and search match. |
manifest.$schema |
Dropped. package.json may carry "$schema": "https://charm.ing/schema/app-package/2026-10-25.json"; it is optional. |
manifest.capabilities.imports: ['charming:storage/[email protected]'] |
import { kv } from 'charming:storage/[email protected]' at the top of server.js. Each capability becomes an import of the module that provides it. |
manifest.permissions.browser["img-src"], ["frame-src"], ["connect-src"] |
package.json → charming.permissions.browser["img-src"], ["frame-src"], ["connect-src"] |
manifest.permissions.server.fetch |
package.json → charming.permissions.server.fetch |
env.connectors.call(service, tool, args) |
charming.permissions.server.connectors in package.json plus import { connectors } from 'charming:[email protected]'. Declare every service and tool the old module calls: the migration deletes approvals and owner sharing for any the declaration leaves out, people must approve them again, and a History restore does not bring them back. |
env.storage |
kv from charming:storage/[email protected], same get, put, delete, list |
env.assets |
assets from charming:storage/[email protected], same methods |
env.log |
log from charming:logging/[email protected] |
plain fetch with charming:network/[email protected] declared |
import { fetch } from 'charming:network/[email protected]' |
env.fetch with charming:secrets/[email protected] declared |
import { fetch } from 'charming:secrets/[email protected]'; {{secret:NAME}} placeholders work the same way |
env.apps['<id>'].fetch(request) |
import { app } from 'charming:app/<id>@1.0' then app.fetch(request) |
env.user |
Unchanged: read it from the handler’s { env } argument. |
export const routes = [...] |
Unchanged, in server.js. Route shapes, callers, annotations, and schemas are the same. |
export default { fetch } |
Removed. A request that matches no route gets Charming’s 404; move fallback logic into a route. A server.js with a default export is refused as invalid_module. |
ui (an inline classic script) |
ui.js, an ES module. Replace const api = window.charming.api('<id>') with import { api, onStateChange } from 'charming:ui/[email protected]'; window.charming.onStateChange becomes the imported onStateChange. Everything else on window.charming (user, login, withUser, images, assets, openLink, viewer) is unchanged. |
inlined IIFE library in ui, or a pinned CDN <script> |
"dependencies": { "<package>": "<range>" } in package.json and import ... from '<package>'; the build bundles it. The browser bundle makes no CDN request. |
styles |
styles.css, unchanged. |
kv, assets, log, and the imported fetch carry the same semantics and limits as the bindings they replace. Call them inside a route handler, not at module initialization.
Two shapes have no package equivalent. charming:browser/* permission imports that the old capabilities.imports carried for camera, microphone, geolocation, clipboard reading, screen capture, and MIDI need no import at all: Charming finds those calls in ui.js at publish time. device-motion, ambient-light, and storage stay as side-effect imports in ui.js, for example import 'charming:browser/[email protected]'.
A worked migration
Before, as module and ui strings:
// module
export const manifest = {
$schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
id: 'todo',
meta: { name: 'Todo' },
capabilities: { imports: ['charming:storage/[email protected]'] },
};
export const routes = [
{
op: 'list',
method: 'GET',
annotations: { readOnlyHint: true },
handler: async (_input, { env }) => (await env.storage.get('todos')) ?? [],
},
];
// ui
const api = window.charming.api('todo');
const root = document.getElementById('app');
async function render() {
root.textContent = JSON.stringify(await api.list());
}
render();
window.charming.onStateChange(render);
After, as three files:
{
"name": "todo",
"version": "1.0.0",
"description": "A todo list.",
"type": "module",
"dependencies": {},
"charming": { "name": "Todo" }
}
// server.js
import { kv } from 'charming:storage/[email protected]';
export const routes = [
{
op: 'list',
method: 'GET',
annotations: { readOnlyHint: true },
handler: async () => (await kv.get('todos')) ?? [],
},
];
// ui.js
import { api, onStateChange } from 'charming:ui/[email protected]';
const root = document.getElementById('app');
async function render() {
root.textContent = JSON.stringify(await api.list());
}
render();
onStateChange(render);
Save the migration
Every path sends the whole tree, migrate_contract: true, the desired-source revision, and a fresh idempotency key.
Over MCP:
update_app({
app_id: "<uuid>",
files: { "package.json": "...", "server.js": "...", "ui.js": "..." },
migrate_contract: true,
expected_revision: <revision from get_app_source>,
idempotency_key: "todo-migrate-001"
})
Over HTTP, with the ETag from GET /app/<id>/source:
jq -n --rawfile p package.json --rawfile s server.js --rawfile u ui.js \
'{files: {"package.json": $p, "server.js": $s, "ui.js": $u}, migrate_contract: true}' \
| curl -sS -i -X PUT https://charm.ing/app/<id> \
-H "Authorization: Bearer $TOKEN" -H 'If-Match: "<etag>"' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: todo-migrate-001' -d @-
From the CLI, after charming auth login:
charming apps update <id> --files ./todo --migrate-contract --dry-run
charming apps update <id> --files ./todo --migrate-contract
The save returns an accepted build (HTTP 202 with buildId and statusUrl; the MCP result carries buildId). Poll get_app_build({ build_id }) or GET /api/v1/app-builds/<buildId> until state is published. The App keeps serving the old revision until then. A failed build names the error kind and, when available, the file, line, and column; the App is unchanged, so fix the file and save again with a fresh key.
A save without migrate_contract: true on an App that is still on the earlier contract is refused with contract_migration_required. A save with module on an App that has already migrated is refused with invalid_request over MCP and app_build_unsupported over HTTP: once migrated, every save is files or path edits.
After the migration
get_app_sourcereturns the tree assource.files;source.module,source.ui, andsource.stylesmirrorserver.js,ui.js, andstyles.css.- Exact edits name a
path(server.js,lib/text.js) instead of a bucket. - History lists the migration as a new revision. Restoring a revision from before it is an explicit recovery action: Charming validates that source under its own contract and publishes it as a fresh revision, so the App goes back to the earlier contract. Only revisions on a dated
app-manifestcontract can be restored this way; an App on an undated manifest has none, so for it the migration is one-way apart from re-creating it from the copy you kept. Source saves never roll a contract back on their own. - Copies of the App, including Template copies, carry the package tree.
charming devdoes not run package Apps yet; test through the published URL andcharming apps call.
Errors on the way
| Error kind | Meaning | What to do |
|---|---|---|
contract_migration_required |
The App is on the earlier contract and the save did not set migrate_contract: true. |
Resend with migrate_contract: true and the whole tree. |
invalid_manifest_schema |
package.json is malformed or carries a refused key. |
Fix the named key. |
manifest_id_immutable (409) |
package.json → name differs from the App’s id. |
Set name back to the old manifest.id; the id never changes. Change the title with charming.name, or the URL with rename_app. |
invalid_module, invalid_ui |
server.js or ui.js failed admission: an undeclared package, a relative import to a file the tree lacks, a manifest or default export, a charming: import for the wrong target. |
The error names the file, line, and column. |
invalid_files |
A file no entry imports, a lockfile, tsconfig.json, or a bad path. |
Remove the file or import it. |
revision_mismatch |
The App changed since you read it. | Read source again and resend with the new revision. |
unauthorized, bearer_required |
The credential is anonymous, App-scoped, a render token, or a browser session. | Use the user’s chrm_user_* token. |
app_build_unavailable |
Charming cannot build packages right now. | Retry later; the App keeps serving. |