---
name: cya-app-builder
description: Build, locally test, and submit a cya.live platform app — a synchronized multiplayer web app that runs in a sandboxed iframe inside a live Cya room. Use when asked to create or change a Cya app, work with @cya/app-sdk, cya.app.json manifests, the cya dev harness, or the Cya developer submission/review flow.
---

# Build a cya.live app

Cya apps are synchronized multiplayer web apps that run inside live rooms
on [cya.live](https://cya.live): one page of HTML/TS, bundled by Vite,
served from Cya's storage into a sandboxed iframe every participant sees.
The platform gives you synchronized state (documents, signals, counters),
server-owned roles with consent flows, a server clock, and storage — all
through one SDK call. You never run a server.

This skill walks an AI agent (or a human) through the full loop:
**scaffold → local test → manifest checks → build → submit → review
status** — headlessly, with no Cya infrastructure on the developer's
machine.

**Using this skill:**
- **Claude Code:** save this file as `~/.claude/skills/cya-app-builder/SKILL.md` (or `.claude/skills/cya-app-builder/SKILL.md` in a project).
- **ChatGPT / custom GPTs:** paste it into custom instructions, or attach it as knowledge and instruct the model to follow it.
- **Any other agent:** include this document in context; it is self-contained.

Docs: <https://cya.live/developers> · Submit portal: <https://cya.live/developer>

## Ground rules for the agent

1. **The server is the safety boundary.** Your manifest is validated and
   normalized server-side at submit, capabilities are deny-by-default, and
   every sync write is re-checked against the reviewed policy in the live
   room. You cannot grant yourself anything by editing client code — a
   spectator writing a host-only doc gets `E_POLICY` from the server, in
   dev and in production. Don't fight this; declare what you need in
   `cya.app.json`.
2. **Never ask for, handle, or store the human's password.** The only
   credential you may use is a session token the human copies themselves
   from <https://cya.live/developer> → **Agent / CLI access**, supplied to
   you as the `CYA_TOKEN` environment variable. Treat it like a password:
   environment variable only — never commit it, never print it, never put
   it in a URL. It expires (~1 hour): if calls start failing with
   auth errors, ask the human for a fresh one.
3. **Confirm before you submit.** Always run `npm run submit -- --dry-run`
   first and show the human the file list; submit for real only after they
   confirm. Submission is not destructive (it lands in a human review
   queue), but it is outward-facing — and the first account to submit an
   `appId` owns that id permanently, so let the human bless the name.
4. **Poll politely.** Review is a human process (hours–days, not seconds).
   Check `npm run status` when the human asks — never in a loop.
5. **Respect the sandbox.** Production apps run in an opaque-origin iframe:
   no `localStorage`/cookies/IndexedDB (they throw), no external network
   (CSP pins to Cya origins), relative asset URLs only, versions immutable
   once submitted. The local harness is more permissive — the
   Troubleshooting table lists what differs.

## The platform, compressed

- **Where your app runs:** an iframe in a room's main window, one instance
  per event, sandboxed to a unique opaque origin. Bundles are uploaded at
  submit and served from `https://apps.cya.live/<appId>/<version>/…` —
  which is why builds must use **relative** asset paths (`vite base: './'`).
- **The one call:** `const app = await connectCyaApp()` — performs the CAB
  (Cya App Bridge) postMessage handshake with the host page, receives
  context + a short-lived session token (auto-refreshed), connects to
  sync, applies the state snapshot. Then `app.ready()` swaps the host's
  loader for your UI. The host may `pause`/`resume`/`teardown` you at any
  time (`app.on(...)`).
- **Sync vs async (know which is which):** your app code runs in the
  browser; there is no server side to write. Reads are SYNCHRONOUS
  against a local replica — `doc.value`, `doc.rev`, `counter.value`,
  `app.roles.self/members/has()`, `app.context`, `app.clock.now()` never
  touch the network. Writes AWAIT a server round trip (~50–200 ms; ~0 in
  the local harness) — `doc.set()`, `counter.incr()`, `signal.send()`,
  `app.roles.assign/revoke/invite/leave`, the storage methods
  (upload/get/putJSON/getJSON/list/delete/refreshUsage; `storage.quota`
  is a sync cached getter) — are ordered
  server-side, and reject with `CyaAppError`. Your own write does NOT
  locally echo: the `change` event fires when the ordered publication
  returns, so drive UI from events and it's correct for everyone. After
  a reconnect or your own role change the replica is replaced wholesale —
  subscribe `app.sync.on('reset', fn)` and re-read (the React hooks
  handle this).
- **Identity & authority:** `app.context.self` is who this participant is
  (`platformRole: 'host' | 'stage' | 'audience'`).
  `app.roles.has(ROLE_HOST)` answers "is this the moderator" (sync;
  import the `ROLE_HOST` constant from `@cya/app-sdk` instead of the raw
  string, and re-check on `app.roles.on(...)` events if promotion
  mid-session matters). The host can always write every doc of the app
  instance (arbiter override); other write access comes from your
  manifest's role grants.
- **Sync primitives** (declared in the manifest, enforced server-side):

  | Primitive | API | Semantics |
  | --- | --- | --- |
  | Document | `app.sync.doc('game')` → `.value`, `.rev`, `.set(patch, {ifRev, ifAbsent})`, `.on('change')` | JSON doc, dot-path patches (`null` deletes), per-doc revision, last-writer-wins by server order; CAS via `ifRev` (doc rev) or `ifAbsent` (key) for locks/turns. ≤64 KiB/doc. |
  | Per-participant doc | declare `"family": "per-participant"`; `doc('answers.self')` is yours, `doc('answers.self', uid)` reads another's (if your role may) | Hidden-answer pattern: owner writes, listed roles read. |
  | Signal | `app.sync.signal('buzz').send(data)` / `.on(fn)` | Fire-and-forget ordered event, not stored, rate-limited by manifest (`"5/10s"`). |
  | Counter | `app.sync.counter('votes').incr(1)` / `.value` / `.on('change')` | Atomic server counter; optional `per_user_max`. |

- **Roles:** custom roles live in the manifest (`max`, `assignable_by`,
  `requires_consent`, `grants`). `app.roles.assign/revoke/invite/leave`;
  consented promotions render a host-chrome prompt the app cannot forge.
  Role changes arrive as events on the same ordered stream as state.
- **Clock:** `app.clock.now()` is a server-clock estimate (typically
  within a few tens of ms; depends on connection quality). Shared
  deadlines are absolute times in a doc (`endsAt`), never durations.
- **Watch-together:** a doc with `"class": "playback"` +
  `app.sync.playback()` gives you `play/pause/seekTo/position()` and
  `driftAction()` for convergence.
- **Storage** (`app.storage`, only if the manifest declares scopes):
  Durable assets + JSON KV with per-user quotas — not available in the
  local harness; test on the platform.
- **Errors are structured:** every rejection is `CyaAppError {code,
  message}` — `E_POLICY` (not allowed by manifest), `E_REV_CONFLICT`
  (CAS lost; re-read and retry), `E_KEY_EXISTS` (ifAbsent lock lost),
  `E_RATE_LIMIT`, `E_ROLE_STALE`, `E_CONSENT_REQUIRED`, `E_AUTH`.
- **React?** `@cya/app-sdk/react` ships hooks (React 18+ is an optional
  peer dependency): `useCyaApp()` (connect once, StrictMode-safe),
  `useSyncDoc(app, name)`, `useSyncCounter(app, name)`, `useRoles(app)`,
  `useSignal(app, name, fn)` — subscriptions AND the resync case wired
  for you. The scaffold below is vanilla TS (smallest possible); swap
  `src/app.ts` for a React entry if that's your stack — examples at
  <https://cya.live/developers#react>. Full TypeScript types ship in the
  package (`.d.ts` — your editor picks them up automatically).

## Step 1 — Scaffold

Create this project (a complete, working app — "Tap Together": a shared
tap counter with host-run rounds and a rate-limited cheer). Rename
`hello-cya` pieces to your app id everywhere: **`app.id` in
`cya.app.json`, the `appId` in `dev/host.ts`**, and the name fields.
App ids are permanent, lowercase `a-z0-9-`, 3–64 chars.

```
my-app/
├── cya.app.json          # THE manifest — identity + declared capabilities
├── index.html            # your app's page (the bundle entry)
├── src/app.ts            # your app code
├── vite.config.ts        # relative base + the local platform stand-in
├── tsconfig.json
├── package.json
├── .gitignore
├── dev/
│   ├── host.html         # local multi-pane host page
│   └── host.ts
├── scripts/
│   ├── check.mjs         # the platform's automated manifest checks
│   ├── submit.mjs        # headless submit (register → upload → confirm)
│   └── status.mjs        # poll review status
└── test/
    ├── check.test.ts     # manifest passes checks + normalizes as expected
    └── submit.test.ts    # the 4-call submit protocol, pinned with fakes
```

Then `npm install` (the SDK installs from a tarball URL — no npm registry
involved) and you're running.

```json path=package.json
{
  "name": "@cya/sample-hello-cya",
  "version": "0.1.0",
  "private": true,
  "description": "Tap Together — the minimal Cya app and the scaffold behind the 'Build with an LLM' skill: one doc, one counter, one signal, a zero-infra local dev harness, and headless check/submit/status scripts.",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "check": "node scripts/check.mjs",
    "submit": "node scripts/submit.mjs",
    "status": "node scripts/status.mjs",
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@cya/app-sdk": "https://cya.live/developers/sdk/cya-app-sdk-0.1.0.tgz"
  },
  "devDependencies": {
    "typescript": "^5.4.5",
    "vite": "^5.4.19",
    "vitest": "^2.1.9"
  }
}
```

```json path=cya.app.json
{
  "app": {
    "id": "hello-cya",
    "name": "Tap Together",
    "version": "0.1.0",
    "api_version": "2026-07"
  },
  "targets": { "web": { "entry": "index.html" } },
  "slots": { "occupies": ["main"] },
  "sync": {
    "docs": {
      "game": { "write": ["host"], "read": "all" }
    },
    "signals": {
      "cheer": { "send": "all", "rate": "5/10s" }
    },
    "counters": {
      "taps": { "incr": "all" }
    }
  }
}
```

```ts path=vite.config.ts
import { defineConfig } from 'vite';
import { cyaAppDevServer } from '@cya/app-sdk/dev';

export default defineConfig({
  // Relative base is REQUIRED: apps-host serves your bundle under
  // /<version>/ (e.g. /0.1.0/index.html), so a root-absolute '/assets/…'
  // 404s and the app never boots. Version-agnostic — the same build works
  // under any prefix.
  base: './',
  // The local platform stand-in: reads cya.app.json, enforces its policy
  // (same normalization the registry runs at submit), serves token mint +
  // sync HTTP + the sync WebSocket. No Cya account needed until submit.
  plugins: [cyaAppDevServer()],
  // The SDK is consumed as TypeScript source — let vite transform it.
  optimizeDeps: { exclude: ['@cya/app-sdk'] },
  server: { port: 5320, fs: { allow: ['..', '../../cya-app-sdk'] } },
  build: { target: 'es2022' },
  esbuild: { target: 'es2022' },
});
```

```json path=tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "types": ["vite/client"]
  },
  "include": ["src", "dev"]
}
```

```text path=.gitignore
node_modules/
dist/
```

```html path=index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tap Together</title>
    <style>
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1010; color: #e6e9ef; font: 15px system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
      #count { font-size: 64px; font-weight: 800; letter-spacing: -0.02em; }
      #goal { color: #8b8b95; font-size: 13px; }
      #tap { font-size: 22px; padding: 18px 42px; border-radius: 999px; border: 0; background: #d0de55; color: #161818; font-weight: 700; cursor: pointer; }
      #tap:active { transform: scale(0.96); }
      #cheer { background: none; border: 1px solid #2a2a30; color: #e6e9ef; border-radius: 999px; padding: 8px 18px; cursor: pointer; }
      #hostbar { display: flex; gap: 8px; align-items: center; }
      #hostbar[hidden] { display: none; }
      #hostbar input { width: 90px; padding: 6px 8px; background: #16161a; color: #e6e9ef; border: 1px solid #2a2a30; border-radius: 8px; }
      #hostbar button { padding: 6px 14px; border-radius: 8px; border: 0; background: #4f46e5; color: #fff; cursor: pointer; }
      #status { position: fixed; bottom: 8px; left: 0; right: 0; text-align: center; color: #8b8b95; font: 12px ui-monospace, monospace; }
      .party { animation: party 600ms ease-out; }
      @keyframes party { 0% { transform: scale(1); } 30% { transform: scale(1.25) rotate(-3deg); } 100% { transform: scale(1); } }
    </style>
  </head>
  <body>
    <div id="count">0</div>
    <div id="goal">connecting…</div>
    <button id="tap">TAP</button>
    <button id="cheer">🎉 cheer</button>
    <div id="hostbar" hidden>
      <input id="goalInput" type="number" min="1" value="20" />
      <button id="newRound">New round</button>
    </div>
    <div id="status"></div>
    <script type="module" src="/src/app.ts"></script>
  </body>
</html>
```

```ts path=src/app.ts
/*
 * Tap Together — the minimal Cya app: one shared counter everyone taps,
 * a host-owned doc holding the round + goal, and a rate-limited cheer
 * signal. Demonstrates the three sync primitives, host gating, and a
 * compare-and-set write — in under 100 lines.
 *
 * Everything goes through the public SDK; there is no dev/prod fork in
 * app code. Server-side policy (cya.app.json) is the authority: a
 * non-host writing `game` gets E_POLICY no matter what this code does.
 */

import { connectCyaApp, CyaAppError, ROLE_HOST } from '@cya/app-sdk';

const app = await connectCyaApp();

const countEl = document.getElementById('count') as HTMLDivElement;
const goalEl = document.getElementById('goal') as HTMLDivElement;
const tapBtn = document.getElementById('tap') as HTMLButtonElement;
const cheerBtn = document.getElementById('cheer') as HTMLButtonElement;
const hostbar = document.getElementById('hostbar') as HTMLDivElement;
const goalInput = document.getElementById('goalInput') as HTMLInputElement;
const newRoundBtn = document.getElementById('newRound') as HTMLButtonElement;
const statusEl = document.getElementById('status') as HTMLDivElement;

// Synchronous read of the local replica; re-check on app.roles.on(...)
// events if promotion mid-session matters to you (it doesn't here — the
// host pane is the host from the start).
const amHost = app.roles.has(ROLE_HOST);
hostbar.hidden = !amHost;

const game = app.sync.doc('game');
const taps = app.sync.counter('taps');
const cheer = app.sync.signal('cheer');

// Counters only ever increase — a "round" is the doc remembering the
// counter's value at round start; progress is the delta since then.
const progress = () => {
  const baseline = (game.value?.baseline as number | undefined) ?? 0;
  return Math.max(0, taps.value - baseline);
};

const render = () => {
  const goal = (game.value?.goal as number | undefined) ?? 0;
  const round = (game.value?.round as number | undefined) ?? 0;
  const p = progress();
  countEl.textContent = String(p);
  if (!round) {
    goalEl.textContent = amHost ? 'start a round below' : 'waiting for the host to start a round…';
  } else if (goal && p >= goal) {
    goalEl.textContent = `round ${round}: GOAL! ${p}/${goal} 🏁`;
  } else {
    goalEl.textContent = `round ${round}: ${p}/${goal || '∞'} taps`;
  }
};

game.on('change', render);
taps.on('change', render);

tapBtn.onclick = () => {
  void taps.incr(1).catch((e: CyaAppError) => flash(`tap rejected: ${e.code}`));
};

cheerBtn.onclick = () => {
  void cheer.send({ at: app.clock.now() }).catch((e: CyaAppError) => {
    // Signals are rate-limited by the manifest (5/10s) — show it honestly.
    flash(e.code === 'E_RATE_LIMIT' ? 'easy there — cheer limit reached' : `cheer failed: ${e.code}`);
  });
};

cheer.on(({ by }) => {
  countEl.classList.remove('party');
  void countEl.offsetWidth; // restart the animation
  countEl.classList.add('party');
  flash(`${by} cheered 🎉`);
});

if (amHost) {
  newRoundBtn.onclick = async () => {
    const goal = Math.max(1, Number(goalInput.value) || 20);
    const round = ((game.value?.round as number | undefined) ?? 0) + 1;
    try {
      // Compare-and-set: if another moderator started a round in the same
      // instant, re-read and retry rather than clobbering their write.
      await game.set({ round, goal, baseline: taps.value }, { ifRev: game.rev });
    } catch (e) {
      if ((e as CyaAppError).code === 'E_REV_CONFLICT') flash('someone else just started a round');
      else flash(`round failed: ${(e as CyaAppError).code}`);
    }
  };
}

let flashTimer: ReturnType<typeof setTimeout> | undefined;
function flash(text: string) {
  statusEl.textContent = text;
  clearTimeout(flashTimer);
  flashTimer = setTimeout(() => (statusEl.textContent = ''), 2000);
}

app.on('pause', () => flash('paused by host'));
app.on('teardown', () => flash('goodbye'));

render();
app.ready();
```

```html path=dev/host.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Cya dev host — Tap Together</title>
  </head>
  <body>
    <script type="module" src="/dev/host.ts"></script>
  </body>
</html>
```

```ts path=dev/host.ts
/*
 * Local dev host: side-by-side panes of the app under different
 * identities, all talking to the in-memory platform stand-in from
 * vite.config.ts. Open http://localhost:5320/dev/host.html
 */

import { mountDevHost } from '@cya/app-sdk/dev-host';

mountDevHost({
  appId: 'hello-cya',
  panes: [
    { userId: 'u_host', displayName: 'Host', roles: ['host'] },
    { userId: 'u_alice', displayName: 'Alice' },
    // add more identities to stress multi-viewer behavior:
    // { userId: 'u_bob', displayName: 'Bob' },
  ],
});
```

```js path=scripts/check.mjs
/*
 * Pre-submit gate: the platform's full automated manifest checks
 * (JSON-Schema + cross-reference lint, via @cya/app-sdk/dev) plus an
 * entry-exists check. These are STRICTER than
 * the submit endpoint's own validation (a sanity check + policy
 * normalization) and are what reviewers expect to hold — fix everything
 * here before submitting.
 *
 *   node scripts/check.mjs
 */

import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { checkManifest } from '@cya/app-sdk/dev';

const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const manifestPath = join(appDir, 'cya.app.json');

const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const result = checkManifest(manifest);
const errors = [...result.errors];

for (const [target, def] of Object.entries(manifest.targets ?? {})) {
  if (def?.entry && !existsSync(join(appDir, def.entry))) {
    errors.push({
      rule: 'entry-missing',
      path: `/targets/${target}/entry`,
      message: `${def.entry} does not exist (relative to the app root)`,
    });
  }
}

if (errors.length) {
  console.error(`✗ ${manifestPath}`);
  for (const e of errors) console.error(`  - [${e.rule}] ${e.path}: ${e.message}`);
  process.exit(1);
}
console.log(`✓ ${manifest.app.id}@${manifest.app.version} — manifest passes the platform's automated checks`);
```

```js path=scripts/submit.mjs
/*
 * Headless submit to the Cya app registry — the same four calls the
 * developer portal makes:
 *
 *   1. POST /apps/v1/developer/apps           register app + version (manifest
 *                                             is normalized & validated server-side)
 *   2. POST /apps/v1/developer/bundle         presigned upload URL per file
 *   3. PUT  each file to its presigned URL
 *   4. POST /apps/v1/developer/bundle/confirm server verifies the entry exists
 *
 * The submission lands in `submitted` and waits for a human Cya reviewer.
 * Server-side validation is the safety boundary — this script only drives
 * the public developer API with credentials the developer supplies.
 *
 * Environment:
 *   CYA_TOKEN  (required)  your session token — copy it from
 *                          cya.live/developer → "Agent / CLI access".
 *                          Treat it like a password; it expires (~1 h).
 *   CYA_API    (optional)  default https://sapi.cya.live
 *   CYA_UA     (optional)  ONLY for legacy (non-Firebase) sessions: the
 *                          exact User-Agent of the browser that minted the
 *                          token. Legacy tokens only decode under that same
 *                          User-Agent — a mismatched UA reads as 10001
 *                          "token expired", not an auth error. Firebase
 *                          tokens don't care.
 *
 * Usage:
 *   npm run build && node scripts/submit.mjs --dry-run   # show the plan
 *   npm run build && node scripts/submit.mjs             # really submit
 */

import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

// apps-host serves the bundle with these content types; a wrong type on
// the entry HTML makes the browser download it instead of rendering.
const CONTENT_TYPES = {
  html: 'text/html; charset=utf-8',
  js: 'text/javascript; charset=utf-8',
  mjs: 'text/javascript; charset=utf-8',
  css: 'text/css; charset=utf-8',
  json: 'application/json; charset=utf-8',
  map: 'application/json; charset=utf-8',
  svg: 'image/svg+xml',
  png: 'image/png',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  gif: 'image/gif',
  webp: 'image/webp',
  ico: 'image/x-icon',
  woff: 'font/woff',
  woff2: 'font/woff2',
  ttf: 'font/ttf',
  wasm: 'application/wasm',
  txt: 'text/plain; charset=utf-8',
};

export function contentTypeFor(path) {
  const ext = String(path).split('.').pop().toLowerCase();
  return CONTENT_TYPES[ext] ?? 'application/octet-stream';
}

/** The API requires the literal `Bearer ` scheme; accept a token pasted either way. */
export function authHeader(token) {
  const raw = String(token ?? '').trim();
  return /^Bearer /i.test(raw) ? raw : `Bearer ${raw}`;
}

/** Legacy cya tokens only decode under a browser-like User-Agent (Mozilla/
 *  prefix). Honest about what it is; override with CYA_UA when your
 *  session is legacy. */
export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (cya-app-skill)';

/** Root-absolute src/href in the entry HTML 404s under the /<version>/
 *  serving prefix — the app would never boot. Same lint as `cya pack`. */
export function lintEntryHtml(html) {
  return /(?:src|href)=["']\//.test(html)
    ? ['entry HTML references root-absolute assets (src="/…" or href="/…") — build with vite base "./"']
    : [];
}

/** The uploadable bundle: everything vite emitted into dist/, staged at the
 *  bundle root, plus cya.app.json — the exact layout `cya pack` produces. */
export function collectBundle(appDir) {
  const manifest = JSON.parse(readFileSync(join(appDir, 'cya.app.json'), 'utf8'));
  const dist = join(appDir, 'dist');
  if (!existsSync(dist)) {
    throw new Error(`no dist/ at ${dist} — run \`npm run build\` first`);
  }
  const files = [];
  const walk = (dir) => {
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      const full = join(dir, entry.name);
      if (entry.isDirectory()) walk(full);
      else files.push(relative(dist, full));
    }
  };
  walk(dist);
  files.sort();

  const entry = manifest.targets?.web?.entry ?? 'index.html';
  if (!files.includes(entry)) {
    throw new Error(`entry ${entry} missing from dist/ — check your build`);
  }
  const lint = lintEntryHtml(readFileSync(join(dist, entry), 'utf8'));
  if (lint.length) throw new Error(lint.join('; '));

  return {
    manifest,
    files: [...files, 'cya.app.json'],
    readBytes: (rel) =>
      rel === 'cya.app.json' ? readFileSync(join(appDir, rel)) : readFileSync(join(dist, rel)),
  };
}

/**
 * Pure orchestration (unit-tested without a network): register → presign →
 * PUT every file → confirm. Confirm only runs after every PUT because the
 * server verifies the uploaded entry. A re-submit of an already-uploaded version
 * skips straight to review (needsBundle=false).
 */
export async function submitBundle({ manifest, files, readBytes }, { http, putBytes }) {
  const appId = manifest.app.id;
  const version = manifest.app.version;
  const entry = manifest.targets?.web?.entry ?? 'index.html';

  const reg = await http('/apps/v1/developer/apps', {
    appId,
    version,
    entry,
    manifest,
    name: manifest.app.name ?? appId,
    developerName: manifest.app.developer ?? '',
    tagline: manifest.app.tagline ?? '',
    description: manifest.app.description ?? '',
    category: manifest.app.category ?? 'other',
  });

  if (reg.needsBundle) {
    const descs = files.map((path) => ({ path, contentType: contentTypeFor(path) }));
    const { uploads } = await http('/apps/v1/developer/bundle', { appId, version, files: descs });
    for (const up of uploads) {
      await putBytes(up.putUrl, readBytes(up.path), contentTypeFor(up.path));
    }
    await http('/apps/v1/developer/bundle/confirm', { appId, version });
  }

  return { appId, version, uploaded: Boolean(reg.needsBundle) };
}

async function main() {
  const dryRun = process.argv.includes('--dry-run');
  const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
  const bundle = collectBundle(appDir);

  const api = (process.env.CYA_API ?? 'https://sapi.cya.live').replace(/\/$/, '');
  console.log(`app     ${bundle.manifest.app.id}@${bundle.manifest.app.version}`);
  console.log(`api     ${api}`);
  console.log(`bundle  ${bundle.files.length} files:`);
  for (const f of bundle.files) console.log(`        ${f} (${contentTypeFor(f)})`);

  if (dryRun) {
    console.log('\n--dry-run: nothing sent. Re-run without --dry-run to submit for review.');
    return;
  }

  const token = process.env.CYA_TOKEN;
  if (!token) {
    console.error('CYA_TOKEN is required — copy it from cya.live/developer → "Agent / CLI access".');
    process.exit(1);
  }
  const userAgent = process.env.CYA_UA ?? DEFAULT_USER_AGENT;

  // Unwrap the cya response envelope ({code,msg,data}); anything but
  // code 200 is a hard failure with the server's message.
  const http = async (path, body) => {
    const res = await fetch(`${api}${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: authHeader(token),
        'User-Agent': userAgent,
      },
      body: JSON.stringify(body),
    });
    const json = await res.json().catch(() => ({}));
    if (!res.ok || json.code !== 200) {
      throw new Error(`${path} → ${res.status} ${json.msg ?? res.statusText}`);
    }
    return json.data ?? {};
  };
  const putBytes = async (url, bytes, contentType) => {
    const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: bytes });
    if (!res.ok) throw new Error(`bundle PUT → ${res.status} ${res.statusText}`);
  };

  const { appId, version, uploaded } = await submitBundle(bundle, { http, putBytes });
  console.log(`\n✓ submitted ${appId}@${version}${uploaded ? ' (bundle uploaded)' : ' (bundle already present)'}`);
  console.log('  status: node scripts/status.mjs — approval is a human review on Cya’s side.');
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((e) => {
    console.error(`✗ ${e.message}`);
    process.exit(1);
  });
}
```

```js path=scripts/status.mjs
/*
 * Poll review status for this app: GET /apps/v1/developer/apps and print
 * the row for cya.app.json's app.id. Reviews are human — poll politely
 * (once when you check in, not in a loop).
 *
 *   node scripts/status.mjs           # human-readable
 *   node scripts/status.mjs --json    # machine-readable (for agents)
 *
 * Env: CYA_TOKEN (required), CYA_API, CYA_UA — same meaning as submit.mjs.
 */

import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { authHeader, DEFAULT_USER_AGENT } from './submit.mjs';

const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const manifest = JSON.parse(readFileSync(join(appDir, 'cya.app.json'), 'utf8'));

const api = (process.env.CYA_API ?? 'https://sapi.cya.live').replace(/\/$/, '');
const token = process.env.CYA_TOKEN;
if (!token) {
  console.error('CYA_TOKEN is required — copy it from cya.live/developer → "Agent / CLI access".');
  process.exit(1);
}

const res = await fetch(`${api}/apps/v1/developer/apps`, {
  headers: {
    Authorization: authHeader(token),
    'User-Agent': process.env.CYA_UA ?? DEFAULT_USER_AGENT,
  },
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.code !== 200) {
  console.error(`✗ /apps/v1/developer/apps → ${res.status} ${json.msg ?? res.statusText}`);
  process.exit(1);
}

const app = (json.data?.apps ?? []).find((a) => a.appId === manifest.app.id);
if (!app) {
  console.log(`no submission found for ${manifest.app.id} on this account`);
  process.exit(0);
}

if (process.argv.includes('--json')) {
  console.log(JSON.stringify(app, null, 2));
} else {
  console.log(`${app.appId} — ${app.status}${app.currentVersion ? ` (live: ${app.currentVersion})` : ''}`);
  if (app.reviewNote) console.log(`review note: ${app.reviewNote}`);
  for (const v of app.versions ?? []) {
    const checks = v.checkResults?.length ? ` checks: ${v.checkResults.map((c) => c.rule).join(', ')}` : '';
    console.log(`  ${v.version}  ${v.status}  bundle:${v.bundleUploaded ? 'yes' : 'no'}${checks}`);
  }
}
```

```ts path=test/check.test.ts
import { readFileSync, existsSync } from 'node:fs';

import { describe, expect, it } from 'vitest';

// @ts-expect-error plain-mjs harness module
import { checkManifest, normalizeManifest } from '@cya/app-sdk/dev';

/* The shipped manifest must pass the exact checks review runs. */

const manifest = JSON.parse(
  readFileSync(new URL('../cya.app.json', import.meta.url), 'utf8')
);

describe('cya.app.json', () => {
  it('passes the review pipeline automated checks', () => {
    const res = checkManifest(manifest);
    expect(res.errors).toEqual([]);
    expect(res.ok).toBe(true);
  });

  it('normalizes to the registry policy shape (what the room enforces)', () => {
    const res = normalizeManifest(manifest);
    expect(res.ok).toBe(true);
    expect(res.manifest.policy).toEqual({
      docs: { game: { write: ['host'], read: 'all' } },
      signals: { cheer: { send: 'all', rate: { count: 5, windowMs: 10_000 } } },
      counters: { taps: { incr: 'all' } },
    });
  });

  it('declares an entry that exists in the app', () => {
    expect(manifest.targets.web.entry).toBe('index.html');
    expect(existsSync(new URL('../index.html', import.meta.url))).toBe(true);
  });
});
```

```ts path=test/submit.test.ts
import { describe, expect, it } from 'vitest';

// @ts-expect-error plain-mjs script under test
import {
  authHeader,
  collectBundle,
  contentTypeFor,
  DEFAULT_USER_AGENT,
  lintEntryHtml,
  submitBundle,
} from '../scripts/submit.mjs';

/*
 * The submit protocol contract — the ordering the platform expects:
 * register → presign → PUT every file → confirm, with confirm strictly
 * last (the server verifies the uploaded entry exists).
 */

const MANIFEST = {
  app: { id: 'hello-cya', name: 'Tap Together', version: '0.1.0', api_version: '2026-07' },
  targets: { web: { entry: 'index.html' } },
  slots: { occupies: ['main'] },
};

function fakes({ needsBundle = true } = {}) {
  const calls: Array<[string, unknown]> = [];
  const puts: Array<{ url: string; bytes: string; contentType: string }> = [];
  const http = async (path: string, body: Record<string, unknown>) => {
    calls.push([path, body]);
    if (path === '/apps/v1/developer/apps') return { appId: body.appId, version: body.version, needsBundle };
    if (path === '/apps/v1/developer/bundle') {
      const files = body.files as Array<{ path: string }>;
      return { uploads: files.map((f) => ({ path: f.path, key: `k/${f.path}`, putUrl: `https://r2/${f.path}` })) };
    }
    if (path === '/apps/v1/developer/bundle/confirm') return { confirmed: true };
    throw new Error(`unexpected ${path}`);
  };
  const putBytes = async (url: string, bytes: Uint8Array, contentType: string) => {
    puts.push({ url, bytes: String(bytes), contentType });
  };
  return { calls, puts, http, putBytes };
}

const BUNDLE = {
  manifest: MANIFEST,
  files: ['assets/app.js', 'index.html', 'cya.app.json'],
  readBytes: (rel: string) => `<${rel}>`,
};

describe('submitBundle', () => {
  it('runs register → presign → PUT each file → confirm, in that order', async () => {
    const f = fakes();
    const res = await submitBundle(BUNDLE, { http: f.http, putBytes: f.putBytes });
    expect(res).toEqual({ appId: 'hello-cya', version: '0.1.0', uploaded: true });

    expect(f.calls.map(([p]) => p)).toEqual([
      '/apps/v1/developer/apps',
      '/apps/v1/developer/bundle',
      '/apps/v1/developer/bundle/confirm',
    ]);
    // every PUT happened between presign and confirm
    expect(f.puts.map((p) => p.url)).toEqual([
      'https://r2/assets/app.js',
      'https://r2/index.html',
      'https://r2/cya.app.json',
    ]);
  });

  it('registers with the full manifest and listing fields', async () => {
    const f = fakes();
    await submitBundle(BUNDLE, { http: f.http, putBytes: f.putBytes });
    const [, body] = f.calls[0] as [string, Record<string, unknown>];
    expect(body).toMatchObject({
      appId: 'hello-cya',
      version: '0.1.0',
      entry: 'index.html',
      manifest: MANIFEST,
      name: 'Tap Together',
      category: 'other',
    });
  });

  it('declares a content type per file and PUTs with the same one', async () => {
    const f = fakes();
    await submitBundle(BUNDLE, { http: f.http, putBytes: f.putBytes });
    const [, presign] = f.calls[1] as [string, { files: Array<{ path: string; contentType: string }> }];
    expect(presign.files).toEqual([
      { path: 'assets/app.js', contentType: 'text/javascript; charset=utf-8' },
      { path: 'index.html', contentType: 'text/html; charset=utf-8' },
      { path: 'cya.app.json', contentType: 'application/json; charset=utf-8' },
    ]);
    expect(f.puts.map((p) => p.contentType)).toEqual([
      'text/javascript; charset=utf-8',
      'text/html; charset=utf-8',
      'application/json; charset=utf-8',
    ]);
  });

  it('skips upload when the version already has its bundle', async () => {
    const f = fakes({ needsBundle: false });
    const res = await submitBundle(BUNDLE, { http: f.http, putBytes: f.putBytes });
    expect(res.uploaded).toBe(false);
    expect(f.calls.map(([p]) => p)).toEqual(['/apps/v1/developer/apps']);
    expect(f.puts).toEqual([]);
  });

  it('propagates server rejections', async () => {
    const http = async () => {
      throw new Error('/apps/v1/developer/apps → 400 invalid manifest: sync.docs.game.write …');
    };
    await expect(submitBundle(BUNDLE, { http, putBytes: async () => {} })).rejects.toThrow(
      /invalid manifest/
    );
  });
});

describe('helpers', () => {
  it('authHeader accepts a bare token or a pasted "Bearer …" value', () => {
    expect(authHeader('abc')).toBe('Bearer abc');
    expect(authHeader('Bearer abc')).toBe('Bearer abc');
    expect(authHeader('  bearer abc ')).toBe('bearer abc');
  });

  it('the default User-Agent keeps the Mozilla/ prefix legacy tokens require', () => {
    expect(DEFAULT_USER_AGENT.startsWith('Mozilla/')).toBe(true);
  });

  it('contentTypeFor falls back to octet-stream', () => {
    expect(contentTypeFor('a.weird')).toBe('application/octet-stream');
    expect(contentTypeFor('font.woff2')).toBe('font/woff2');
  });

  it('lintEntryHtml rejects root-absolute assets and allows relative ones', () => {
    expect(lintEntryHtml('<script src="/assets/app.js"></script>')).toHaveLength(1);
    expect(lintEntryHtml('<link href="/style.css">')).toHaveLength(1);
    expect(lintEntryHtml('<script src="./assets/app.js"></script>')).toEqual([]);
    expect(lintEntryHtml('<script src="assets/app.js"></script>')).toEqual([]);
  });

  it('collectBundle demands a build first', () => {
    expect(() => collectBundle(new URL('./no-such-dir/', import.meta.url).pathname)).toThrow();
  });
});
```

## Step 2 — Local test (no Cya account needed)

```bash
npm install
npm run dev
# open http://localhost:5320/dev/host.html
```

You get side-by-side panes — a host and viewers, each a real instance of
your app with its own identity and session token, synchronized through an
in-memory version of the platform's policy engine. Verify at minimum:

- both panes reach **ready** (the pane header says so);
- a host action replicates to the viewer pane, and the viewer's actions
  replicate back;
- a viewer attempting a host-only write is rejected (watch for `E_POLICY`
  in the app's own error surface — the harness enforces your manifest);
- reload one pane mid-game: it must catch up from the snapshot alone
  (never assume in-order attendance from the start).

The harness enforces your `cya.app.json` through the same normalization
the registry applies at submit, so "works locally, dead in review" policy
drift can't happen. Restart `npm run dev` after editing the manifest.
`npm test` runs the submit-protocol contract tests plus a manifest test —
when you change `cya.app.json`, update the expected policy in
`test/check.test.ts` to match (the test shows you exactly what the
platform will enforce). `npm run typecheck` keeps the TS honest.

**Harness fidelity limits** (differences from a real room): dev iframes
are not opaque-origin (`localStorage` works locally but **throws in
production** — don't use it); `app.storage` endpoints are not emulated;
there is no replay ring (gaps always resync via snapshot); the clock
offset is 0. Anything touching those must be verified on the platform
after approval.

## Step 3 — Manifest checks

```bash
npm run check
```

Runs the platform's full automated checks (JSON-Schema + cross-reference
lint: role references, family ownership, grant targets, reserved role
names `host owner self all player spectator`, relative entries) plus an
entry-exists check. These are stricter than the submit endpoint's own
validation (an id/version sanity check plus the policy normalization), so
a clean run here clears submit-time validation — and it's the bar
reviewers hold your manifest to.

Manifest quick reference (`additionalProperties: false` — unknown keys are
errors): `app` (id, name, semver `version`, `api_version: "2026-07"`),
`targets.web.entry`, `slots.occupies: ["main"]`, optional `sync`
(docs/signals/counters as above), optional `roles[]`
(`{name, max, assignable_by, requires_consent, grants}`), optional
`capabilities` (`participants_read`, `media: ["stage_read"|"mic_prompt"]`,
`network_access` — note network_access is not yet honored by the runtime
CSP), optional `storage` (`scopes`, `budget_mb` per scope, `max_object_mb`,
`content_types`). Grants take the forms `sync:write:<doc>`,
`storage:write:shared|self`, `media:mic_prompt|stage_read`.

## Step 4 — Build and dry-run

```bash
npm run build
npm run submit -- --dry-run
```

The dry run prints the exact bundle (every `dist/` file plus
`cya.app.json`, each with its content type) and the target API, sending
nothing. **Show this to the human and get their go-ahead.** Remember: the
first account to submit an `appId` owns it permanently, and each submitted
`version` is immutable — bump `app.version` for every new upload.

## Step 5 — Submit

The human logs in at <https://cya.live/developer>, opens **Agent / CLI
access**, and copies the environment snippet (their session token). Then:

```bash
CYA_TOKEN="…" npm run submit
```

The submission registers the app + version (the server normalizes and
validates the manifest — errors come back as
`invalid manifest: <rule>: <message>`), uploads the bundle via presigned
URLs, confirms, and lands in the **submitted** state for human review.

Auth notes: the token is a normal logged-in session token sent as
`Authorization: Bearer …`. Firebase-era sessions (the default) expire
after ~1 hour — ask the human for a fresh copy if you see auth failures.
If the human's account is a legacy session, the copied snippet also
includes `CYA_UA` — pass it through exactly (legacy tokens only work with
the minting browser's exact User-Agent; without it the API answers
`10001 your token has expired`, which is a header mismatch, not a stale
token).

## Step 6 — Review status

```bash
CYA_TOKEN="…" npm run status          # or --json for machines
```

States: `submitted → approved | rejected` (a `reviewNote` explains
rejections; `suspended` means the platform pulled a live app). Review is
human — hours to days. Once approved, the app appears in the Apps Center;
creators install it and launch it in their rooms. To update: bump
`app.version`, repeat build → dry-run → submit.

## Extending past the starter

- **Custom roles + consent** (game shows, seats, judges): declare
  `roles[]`, gate docs/signals on them, promote with
  `app.roles.invite(userId, 'contestant')` — the platform renders the
  consent prompt; your app can't forge acceptance.
- **Hidden per-player state**: a `family: "per-participant"` doc
  (`answers.self` pattern) with `write: ["owner"]`, `read: ["host"]`.
- **First-buzz-wins**: `game.set({ 'lock.owner': app.context.self.userId }, { ifAbsent: 'lock.owner' })`
  — the server serializes; exactly one write wins, everyone converges.
- **Watch-together**: `"class": "playback"` doc + `app.sync.playback()`.
- **Deadlines**: write absolute `endsAt` (from `app.clock.now()`) into a
  doc; every client renders its own countdown.

## Troubleshooting

| Symptom | Cause → fix |
| --- | --- |
| `10001 your token has expired` on submit | Token really expired (~1 h — get a fresh one) or a legacy token presented with the wrong User-Agent (set `CYA_UA` from the portal snippet). |
| `10027 required user token` | Malformed Authorization header — the scripts normalize `Bearer` for you; check `CYA_TOKEN` isn't empty. |
| `invalid manifest: …` (30016) at submit | The server's normalization rejected the manifest; `npm run check` reproduces every rule locally. |
| App approved but blank in the room | Root-absolute asset paths — the bundle serves under `/<version>/`. `vite base: './'`; the dry-run lints this. |
| `E_POLICY` on a write that works for the host | Your manifest doesn't grant that doc/signal/counter to the caller's role — declare it and resubmit rather than working around it. |
| `E_REV_CONFLICT` | You lost a CAS race: re-read `doc.value`/`doc.rev` and retry or surface it. |
| Works locally, storage/localStorage dead in prod | Production iframes are opaque-origin: document storage throws, `app.storage` needs manifest scopes and isn't in the harness. |
| `forbidden` on submit | The `appId` belongs to another account — pick a new id. |
| Submit succeeds but nothing to review | You re-submitted an unchanged version whose bundle already existed — bump `app.version`. |

## What the agent cannot do (the human's part)

1. Create the cya.live account and log in (browser).
2. Copy the **Agent / CLI access** snippet (the `CYA_TOKEN`, and `CYA_UA`
   for legacy sessions) and hand it to the agent — refreshed when it
   expires.
3. Approve the dry-run before the real submit.
4. Wait for Cya's human review; install the approved app into their room
   and run it live with real participants (storage and stage/media
   capabilities can only be exercised there).
