---
title: Use npm and platform imports
description: Declare server and browser dependencies, save ESM source, and follow its build through publication.
sidebar:
  label: npm and platform imports
  order: 9
---

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:

```js
import { z } from 'zod';
import { kv } from 'charming:storage/kv@1.0';

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

```js
import { z } from 'zod';
import { api } from 'charming:ui/app@1.0';

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/kv@1.0`        | `kv`                   |
| Server `module` | `charming:storage/blob@1.0`      | `assets`               |
| Server `module` | `charming:logging/emit@1.0`      | `log`                  |
| Server `module` | `charming:network/fetch@1.0`     | `fetch`                |
| Server `module` | `charming:secrets/fetch@1.0`     | `fetch`                |
| Server `module` | `charming:app/<manifest-id>@1.0` | `app`                  |
| Browser `ui`    | `charming:ui/app@1.0`            | `api`, `onStateChange` |

Browser permissions use side-effect imports such as `import 'charming:browser/microphone@1.0'`. 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/notes@1.0'`. 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](../technical-reference/external-files-and-csp) 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:

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

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.
