---
title: Migrate an App to the package contract
description: 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.
sidebar:
  label: Migrate to packages
  order: 10
---

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/kv@1.0']` | `import { kv } from 'charming:storage/kv@1.0'` 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:connectors@1.0'`. 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/kv@1.0`, same `get`, `put`, `delete`, `list` |
| `env.assets` | `assets` from `charming:storage/blob@1.0`, same methods |
| `env.log` | `log` from `charming:logging/emit@1.0` |
| plain `fetch` with `charming:network/fetch@1.0` declared | `import { fetch } from 'charming:network/fetch@1.0'` |
| `env.fetch` with `charming:secrets/fetch@1.0` declared | `import { fetch } from 'charming:secrets/fetch@1.0'`; `{{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/app@1.0'`; `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/storage@1.0'`.

## A worked migration

Before, as `module` and `ui` strings:

```js
// module
export const manifest = {
  $schema: 'https://charm.ing/schema/app-manifest/2026-10-16.json',
  id: 'todo',
  meta: { name: 'Todo' },
  capabilities: { imports: ['charming:storage/kv@1.0'] },
};
export const routes = [
  {
    op: 'list',
    method: 'GET',
    annotations: { readOnlyHint: true },
    handler: async (_input, { env }) => (await env.storage.get('todos')) ?? [],
  },
];
```

```js
// 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:

```json
{
  "name": "todo",
  "version": "1.0.0",
  "description": "A todo list.",
  "type": "module",
  "dependencies": {},
  "charming": { "name": "Todo" }
}
```

```js
// server.js
import { kv } from 'charming:storage/kv@1.0';

export const routes = [
  {
    op: 'list',
    method: 'GET',
    annotations: { readOnlyHint: true },
    handler: async () => (await kv.get('todos')) ?? [],
  },
];
```

```js
// ui.js
import { api, onStateChange } from 'charming:ui/app@1.0';

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:

```text
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`:

```bash
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`:

```bash
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. |

Found a bug or need a feature? [Tell us](/docs/capabilities/feedback) with `submit_feedback` or `POST /app/{id}/feedback`. Your feedback shapes what we build next.
