Cya App Platform · API v1

Build experiences people share together.

Build multiplayer games, AI agents, collaborative workspaces, 3D worlds, interactive media, and experiences nobody has invented yet. Cya keeps every participant perfectly synchronized in real time while handling state, permissions, storage, networking, and distribution behind the scenes.

No backend required
Stop building distributed systems. No WebSockets, presence, or conflict resolution to maintain — Cya handles all of it. You just build the experience.
Perfectly synchronized
Five people or a stadium, everyone sees the same state in real time. Every interaction is ordered and instantly shared, so no one drifts out of sync.
Secure by default
Apps run in isolated sandboxes with capability-based permissions. Request only the access you need — nothing more.
Deploy with confidence
Every release is validated, reviewed, versioned, and safely distributed. Your users always run a version you approved.
Concepts

How it works

You build a normal web app. Cya runs it for everyone at once and keeps them in sync. A room is simply where that shared experience happens — a live session with a host, promoted participants, and an audience, from a few people to thousands. When the host launches your app, every participant's client mounts your bundle in a sandboxed iframe and hands it a session token over the Cya App Bridge (CAB, a typed postMessage contract). From there, the SDK is your entire backend:

the moving parts
┌─ Cya Room (host + audience) ─────────────────────────────┐
│  ┌─ your iframe: <appId>.apps.cya.live/<version>/ ──────┐  │
│  │  @cya/app-sdk                                        │  │
│  │   ├─ app.sync    documents · signals · counters      │  │
│  │   ├─ app.roles   participants · consent invites      │  │
│  │   ├─ app.storage R2 assets + JSON KV (quota'd)       │  │
│  │   └─ app.clock   server clock for drift-free timing  │  │
│  └───────────────────────────────────────────────────────┘  │
│        ▲ CAB handshake + 5-min capability tokens            │
└──────────────────────────────────────────────────────────────┘

Every write lands on one server-side instance and fans out to all participants in order, so everyone converges on the same state — late joiners included. You don't stand up servers, sockets, or a database, and assets get server-enforced storage out of the box. Building synchronized software feels like building a normal web app.

Get started

Quickstart

Grab the starter project from the Build with an LLM skill below — it embeds a complete, working scaffold (app + manifest + local dev harness + submit scripts) that you can create by hand or let your coding agent write for you. The SDK installs straight from this site:

terminal
# scaffold per the skill document, then:
npm install             # @cya/app-sdk installs from cya.live/developers/sdk/…
npm run dev             # vite + an in-memory stand-in for the platform
open http://localhost:5320/dev/host.html   # host + viewer panes, live sync

One call. The SDK handles the handshake, token refresh, and reconnection:

src/main.ts
import { connectCyaApp } from "@cya/app-sdk";

const app = await connectCyaApp();      // CAB handshake → ready
const { self, event } = app.context;
console.log(event.eventId, self.userId, self.platformRole);

// shared, synchronized state — every participant sees this
const game = app.sync.doc("game");
game.on("change", ({ value }) => render(value));
if (app.roles.has("host")) {
  await game.set({ round: 1, phase: "lobby" });
}

Validate, bundle and submit:

terminal
npm run check           # manifest schema + scope lint
npm run build           # vite build — relative base, see below
npm run submit -- --dry-run   # show exactly what would upload
CYA_TOKEN=… npm run submit    # register + upload + confirm → review

Prefer the UI? The developer portal does the same thing in the browser: add your listing, icon, and screenshots, paste your manifest, pick your built dist/ folder, and submit. Either way, build with a relative base (vite base: './') — bundles are served under a versioned path.

Agents welcome

Build with an LLM

The fastest way to ship a Cya app is to hand your coding agent the cya-app-builder skill: one self-contained document that teaches Claude Code, ChatGPT, or any capable agent the whole loop — scaffold, local multi-pane testing with zero infrastructure, manifest checks, build, headless submit, and review status.

Point your agent at it

Claude Code
mkdir -p ~/.claude/skills/cya-app-builder
curl -o ~/.claude/skills/cya-app-builder/SKILL.md https://cya.live/developers/skill.md
# then: "build me a trivia app for my show on cya"

For ChatGPT or other agents, paste the document into custom instructions (or attach it as knowledge). It embeds the full starter project, so the agent needs nothing else from you until submit time.

You keep the keys

  • The agent never sees your password. When it's time to submit, copy a short-lived token from the developer portal's Agent / CLI access card and hand it over as an environment variable.
  • Your manifest is validated and normalized server-side, and capabilities are deny-by-default — generated code can't grant itself anything the manifest (and review) didn't approve.
  • Every submission lands in human review before it can appear in the Apps Center; the skill instructs agents to show you a dry-run and get your go-ahead before submitting.
Mental model

Runtime & latency

Your app code runs in the browser — a sandboxed iframe inside the room. Node.js is only for tooling: the dev-harness Vite plugin and the check/submit/status scripts. There is no server side to write.

Reads are synchronous, writes await the server

The SDK keeps a local replica of your instance's state. Reading it never touches the network:

Sync (instant, no await)doc.value · doc.rev · counter.value · app.roles.self / members / has() · app.context · app.clock.now() · playback.position() · storage.quota
Async (server round-trip)doc.set() · counter.incr() · signal.send() · app.roles.assign / revoke / invite / leave · storage upload / get / putJSON / getJSON / list / delete / refreshUsage · playback.play / pause / seekTo

Every write is a network call — typically ~50–200 ms for the returned promise, but that's an observation, not a guarantee: budget for variance on rough connections (the local harness is ~0). Writes are ordered server-side and reject with a structured CyaAppError (E_POLICY, E_REV_CONFLICT, …) — never fire-and-forget a write you need to have landed.

Render from events, not from your own writes

Your own set() does not locally echo: the doc's change event fires when the ordered publication comes back from the server (that's what keeps every participant converged). Drive your UI from doc.on('change', fn) and it stays correct for your writes, everyone else's, and late joins alike.

Resyncs replace the replica

After a reconnect — or when your own roles change and alter what you may read — the SDK refetches a snapshot and swaps the replica wholesale. Per-doc change events don't fire for that swap; subscribe to app.sync.on('reset', fn) (the React hooks do this for you) and re-read your values there.

API reference

Lifecycle & tokens

connectCyaApp() resolves after the CAB connect → init → joined handshake with a ready CyaApp. The host may pause, resume or tear your app down at any time.

app.contextSync — AppContext: self (userId, displayName, roles, platformRole), event.eventId, appId, instanceId, slot.
app.auth.token()Async — current session token; returns from cache with no network I/O while fresh, does a network refresh when not. Attach as Authorization: Bearer for direct HTTP calls.
app.on('pause' | 'resume' | 'teardown', fn)Sync subscribe — host lifecycle events. Free your timers on pause; teardown is final.
app.clock.now()Sync — server clock estimate (ms), no network per call. Use for countdowns so late joiners agree.

Tokens are scoped to your manifest and expire in ~5 minutes; the SDK refreshes them for you. Nothing your app can do outlives the session.

API reference

Synchronized state

Four primitives, all synchronized for you. Writes are ordered server-side and every participant converges on the same state — late joiners included, via snapshot plus replay. This is the core of Cya: shared state that just works.

Documents — shared mutable state

app.sync.doc(name)Sync — a named JSON document handle (per-user docs via doc(name, ofUserId)).
doc.value / doc.revSync — current local-replica value and revision. undefined / 0 before the doc first exists.
doc.set(patch, { ifRev })Async (server round-trip) — dot-path patch with optional compare-and-set. Rejected CAS → re-read and retry.
doc.on('change', fn)Sync subscribe — fires for your writes and everyone else's, in server order, with { value, rev }.
app.sync.on('reset', fn)Sync subscribe — the replica was replaced by a snapshot resync (reconnect, own role change): re-read values.
optimistic concurrency (event-driven retry)
const game = app.sync.doc("game");
game.on("change", () => render(game.value));   // stay event-driven

async function scorePoint() {
  try {
    await game.set({ "players.u42.score": 10 }, { ifRev: game.rev });
  } catch (e) {
    if ((e as CyaAppError).code !== "E_REV_CONFLICT") throw e;
    // Someone wrote first. Don't assume doc.value is fresh yet — the
    // winning patch travels separately and lands as a 'change' (or via
    // a 'reset'). Once your replica catches up, recompute and retry.
    const off = game.on("change", () => { off(); void scorePoint(); });
  }
}

Signals — fire-and-forget events

app.sync.signal(name).send(data)Async (server round-trip) — broadcast a transient event (buzz, emoji, ping). Not persisted.
signal.on(fn)Sync subscribe — receive { by, data, seq } in order.

Counters — server-tallied numbers

app.sync.counter(name).incr(by)Async (server round-trip) — atomic increment (votes, scores), no read-modify-write races.
counter.value / counter.on('change', fn)Sync read + sync subscribe — the live local tally.

Playback — synced media clock

app.sync.playback(name)Sync — host-driven play/pause/seek state machine on the server clock.
playback.play(fromMs) / pause() / seekTo(ms)Async (server round-trip) — grant-holder controls (manifest write policy); viewers follow.
playback.position()Sync — derived position on the server clock, no per-tick sync traffic.
driftAction(actualMs, targetMs)Sync, pure — tells you to rate-nudge or hard-seek your media element to stay in sync.
API reference

Roles & consent

Promote participants into roles — contestants, co-hosts, panelists. Promotion is consent-based: invite() asks, the user accepts in Cya's own UI, and only then does the grant land (assign() grants directly where your policy allows it). Role-scoped channels carry private state, like a contestant's hidden prompt.

app.roles.selfSync — your app roles + epoch, kept current by the replica.
app.roles.membersSync — userId → roles for the instance.
app.roles.has(role)Sync, instant — true for your app roles; ROLE_HOST also honors the platform host. Snapshot of NOW: promotions/demotions land as events, so re-check in roles.on(...) (or use the React hooks).
app.roles.invite(userId, role)Async (server round-trip) — consent flow: the user accepts in Cya's own UI before the grant lands.
app.roles.assign(userId, role)Async (server round-trip) — direct grant, no prompt (where your manifest policy allows).
app.roles.revoke(userId, role) / leave(role)Async (server round-trip) — remove a grant; epochs bump so stale writers are fenced.
app.roles.on('assigned' | 'revoked', fn)Sync subscribe — live membership changes, on the same ordered stream as state.

Role names are plain strings from your manifest; the moderator role ships as a constant — import { ROLE_HOST } from "@cya/app-sdk" — so app.roles.has(ROLE_HOST) never typos. Your own promotion/demotion also changes what you may read, so the SDK resyncs after it — see Runtime & latency.

API reference

Storage

R2-backed assets and a small JSON KV, scoped per app and per user with server-enforced quotas. Declare the scopes you need:

userPrivate to one user, follows them across rooms (profiles, save-games).
room:selfOne participant's data within one room (their answer sheet).
room:sharedShared room data — the HOST's quota pays for it (uploaded photos, board state).
app.storage.upload(file, { scope, path })Async, slowest call in the SDK (two-phase: presigned PUT + confirm — expect upload time + ~2 round-trips). Returns the stored { key, bytes }.
app.storage.get(key)Async — fetch an asset as a Blob (time-limited signed reads).
app.storage.putJSON(key, value) / getJSON(key)Async — KV for small JSON (≤64 KB per entry); optional { scope }, default 'user'.
app.storage.list({ scope, prefix })Async — enumerate confirmed objects.
app.storage.delete(key)Async — remove an object and release its bytes.
app.storage.refreshUsage()Async — fetch bytes used vs quota from the server.
app.storage.quota / on('quota', fn)Sync read + sync subscribe — cached usage snapshot + quota events as you approach limits (80/95/100%).
Bindings

React

Building the UI in React? Import from @cya/app-sdk/react (React 18+ is an optional peer dependency — vanilla apps never load it). The hooks wire every subscription for you, including the resync case that plain change listeners miss.

src/App.tsx
import { ROLE_HOST } from "@cya/app-sdk";
import { useCyaApp, useRoles, useSignal, useSyncCounter, useSyncDoc } from "@cya/app-sdk/react";

export default function App() {
  const { app, error } = useCyaApp();      // connects once, StrictMode-safe
  const { value: game } = useSyncDoc(app, "game");
  const taps = useSyncCounter(app, "taps");
  const { has } = useRoles(app);
  useSignal(app, "cheer", ({ by }) => console.log(by, "cheered"));

  if (error) return <p>couldn't join: {error.message}</p>;
  if (!app) return <p>connecting…</p>;
  return (
    <main>
      <h1>{String(game?.phase ?? "lobby")} — {taps} taps</h1>
      <button onClick={() => void app.sync.counter("taps").incr(1)}>TAP</button>
      {has(ROLE_HOST) && (
        <button onClick={() => void app.sync.doc("game").set({ phase: "go" })}>
          Start
        </button>
      )}
    </main>
  );
}
useCyaApp()Connects once per page and shares the app; { app, error } — app is null while connecting.
useSyncDoc(app, name)Live { value, rev } — re-renders on every ordered change and after resyncs.
useSyncCounter(app, name)Live number.
useRoles(app)Live { self, members, has } — has(ROLE_HOST) in render stays current through promotions.
useSignal(app, name, fn)Subscribes fn for the component's lifetime; always calls your latest handler.

Remember the write model: hook values update when the server's ordered publication arrives (~50–200 ms after your write), not optimistically.

Bindings

TypeScript

The SDK is written in TypeScript and the package ships its generated .d.ts — your editor gets full types for every call with no extra install. The core surface:

generated types (excerpt)
function connectCyaApp(): Promise<CyaApp>;
const ROLE_HOST = "host";
type PlatformRole = "host" | "stage" | "audience";

class CyaApp {
  readonly context: AppContext;                       // sync
  readonly sync: SyncFacade;
  readonly roles: RolesFacade;
  readonly clock: ServerClock;                        // clock.now(): number (sync)
  storage?: StorageFacade;                            // present only with manifest scopes
  ready(): void;
  on(event: "pause" | "resume" | "teardown", fn: (payload: Record<string, unknown>) => void): () => void;
}

class SyncDocFacade {
  get value(): Record<string, unknown> | undefined;   // sync
  get rev(): number;                                  // sync
  set(patch: Record<string, unknown>, opts?: { ifRev?: number; ifAbsent?: string }): Promise<{ rev: number; seq: number }>;
  on(event: "change", fn: (change: DocChange) => void): () => void;
}

class RolesFacade {
  get self(): { roles: string[]; epoch: number };     // sync
  get members(): Record<string, string[]>;            // sync
  has(role: string): boolean;                         // sync
  assign(userId: string, role: string): Promise<{ seq: number }>;
  invite(userId: string, role: string): Promise<{ seq: number }>;
  revoke(userId: string, role: string): Promise<{ seq: number }>;
  leave(role: string): Promise<{ seq: number }>;
  on(event: "assigned" | "revoked", fn: (e: RoleEvent) => void): () => void;
}

class CyaAppError extends Error {
  readonly code: string;   // E_POLICY | E_REV_CONFLICT | E_KEY_EXISTS | E_RATE_LIMIT | …
  readonly details?: unknown;
}

In the sync, roles, and storage APIs, a returned Promise means a server round-trip (the one exception: auth.token() does no network I/O while its cached token is fresh); the getters are synchronous replica reads.

Reference

The manifest (cya.app.json)

The manifest is your capability contract. A namespace exists on the SDK only if you declare it, and the server enforces the same set on every call (E_SCOPE otherwise). Reviewers see exactly what you ask for.

cya.app.json
{
  "app": {
    "id": "my-poker-app",
    "name": "Poker Night",
    "version": "1.0.0",
    "api_version": "2026-07"
  },
  "targets": { "web": { "entry": "index.html" } },
  "slots": { "occupies": ["main"] },
  "sync": {
    "docs": {
      "game": { "write": ["host"], "read": "all" },
      "hand": { "family": "per-participant", "write": ["owner"], "read": ["host"] }
    },
    "signals": { "buzz": { "send": ["seat"], "rate": "5/10s" } },
    "counters": { "pot": { "incr": "all" } }
  },
  "roles": [
    { "name": "seat", "max": 6, "assignable_by": ["host"], "requires_consent": true,
      "grants": ["sync:write:hand"] }
  ],
  "storage": { "scopes": ["room:shared", "room:self"],
    "budget_mb": { "room:shared": 100, "room:self": 10 } }
}
app.idPermanent slug; names your sandbox origin and storage folders. First submitter owns it.
app.versionSemver — every submission is a new, immutable version.
app.api_versionDated platform API line (currently "2026-07").
targets.web.entryBundle entry file, served at /<version>/<entry>. Relative paths only.
sync.docsNamed documents with write/read principals; "family": "per-participant" makes one per user.
sync.signals / sync.countersNames you may send / tally, with rate limits and per-user caps.
rolesRoles your app may grant: capacity, who assigns, consent, capability grants.
storage.scopesStorage scopes (deny-by-default) with per-scope budgets.
Reference

Limits

Session token~5 min TTL, auto-refreshed by the SDK; capability-scoped.
Documents≤64 KiB per doc · ≤128 docs per instance · ≤1 MiB total.
Event replayLast 256 events per channel replayed to late joiners (plus snapshot).
KV entries≤64 KB JSON each.
Listing imagesIcon + up to 8 screenshots, ≤5 MB each (PNG/JPEG/WebP).
Storage quotaPer-user quota shared across Cya (uploads reserve, confirm settles).
Shipping

Review & publishing

Submission runs automated checks first (blocking):

  • manifest schema + scope lint (undeclared API usage fails)
  • bundle sanity — entry file present, no root-absolute asset paths
  • size and content-type limits

Then Cya runs your app in a staging room with synthetic participants — lifecycle, role flows, and storage. Track status in the portal submitted → in review → approved / rejected. Approved versions go live in the Apps Center; creators install once, and your app reaches every audience they bring. New versions ship through the same pipeline while your current one keeps serving.

Build something people share.

Scaffold with the SDK, run it locally, and submit from the portal.

Cya App Platform · Build synchronized, real-time apps