Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

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_source returns the tree as source.files; source.module, source.ui, and source.styles mirror server.js, ui.js, and styles.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-manifest contract 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 dev does not run package Apps yet; test through the published URL and charming 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.

Was this page helpful?