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:
┌─ 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.
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:
# 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 syncOne call. The SDK handles the handshake, token refresh, and reconnection:
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:
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 → reviewPrefer 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.
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
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.
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.
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.context | Sync — 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.
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.rev | Sync — 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. |
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. |
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.self | Sync — your app roles + epoch, kept current by the replica. |
| app.roles.members | Sync — 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.
Storage
R2-backed assets and a small JSON KV, scoped per app and per user with server-enforced quotas. Declare the scopes you need:
| user | Private to one user, follows them across rooms (profiles, save-games). |
| room:self | One participant's data within one room (their answer sheet). |
| room:shared | Shared 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%). |
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.
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.
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:
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.
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.
{
"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.id | Permanent slug; names your sandbox origin and storage folders. First submitter owns it. |
| app.version | Semver — every submission is a new, immutable version. |
| app.api_version | Dated platform API line (currently "2026-07"). |
| targets.web.entry | Bundle entry file, served at /<version>/<entry>. Relative paths only. |
| sync.docs | Named documents with write/read principals; "family": "per-participant" makes one per user. |
| sync.signals / sync.counters | Names you may send / tally, with rate limits and per-user caps. |
| roles | Roles your app may grant: capacity, who assigns, consent, capability grants. |
| storage.scopes | Storage scopes (deny-by-default) with per-scope budgets. |
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 replay | Last 256 events per channel replayed to late joiners (plus snapshot). |
| KV entries | ≤64 KB JSON each. |
| Listing images | Icon + up to 8 screenshots, ≤5 MB each (PNG/JPEG/WebP). |
| Storage quota | Per-user quota shared across Cya (uploads reserve, confirm settles). |
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.
Scaffold with the SDK, run it locally, and submit from the portal.