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
npm i -g @cya/cli
cya app init my-app # scaffold with @cya/app-sdk wired up
cd my-app && npm install
cya app dev # local host simulatorOne 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:
cya check # manifest + scope lint (same checks as review)
cya pack # produce the uploadable bundle
# then: cya publish, or upload via the developer portal UIPrefer the UI? The developer portal mirrors the CLI: add your listing, icon, and screenshots, paste your manifest, pick your built dist/ folder, and submit. Build with a relative base (vite base: './') — bundles are served under a versioned path.
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 | AppContext — self (userId, displayName, roles, platformRole), event.eventId, appId, instanceId, slot. |
| app.auth.token() | Current session token (auto-refreshed ~5 min); attach as Authorization: Bearer for direct HTTP calls. |
| app.on('pause' | 'resume' | 'teardown', fn) | Host lifecycle events. Free your timers on pause; teardown is final. |
| app.clock.now() | Server clock (ms). Use for countdowns and sync 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) | A named JSON document for this app instance (per-user docs via doc(name, ofUserId)). |
| doc.value / doc.rev | Current value and revision. |
| doc.set(patch, { ifRev }) | Dot-path patch with optional compare-and-set. Rejected CAS → re-read and retry. |
| doc.on('change', fn) | Fires for local and remote changes with { value, rev }. |
const game = app.sync.doc("game");
try {
await game.set({ "players.u42.score": 10 }, { ifRev: game.rev });
} catch {
// someone wrote first — doc.value is already fresh, retry your patch
}Signals — fire-and-forget events
| app.sync.signal(name).send(data) | Broadcast a transient event (buzz, emoji, ping). Not persisted. |
| signal.on(fn) | Receive { by, data, seq } in order. |
Counters — server-tallied numbers
| app.sync.counter(name).incr(by) | Atomic increment (votes, scores) — no read-modify-write races. |
| counter.value / counter.on('change', fn) | Live tally. |
Playback — synced media clock
| app.sync.playback(name) | Host-driven play/pause/seek state machine on the server clock. |
| playback.play(fromMs) / pause() / seekTo(ms) | Grant-holder controls (manifest write policy); viewers follow. |
| playback.position() | Derived position on the server clock — no per-tick sync traffic. |
| driftAction(actualMs, targetMs) | 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 | Your app roles + epoch. |
| app.roles.members | userId → roles for the instance. |
| app.roles.has(role) | True for your app roles; 'host' also honors the platform host. |
| app.roles.invite(userId, role) | Consent flow — the user accepts in Cya's own UI before the grant lands. |
| app.roles.assign(userId, role) | Direct grant, no prompt (where your manifest policy allows). |
| app.roles.revoke(userId, role) / leave(role) | Remove a grant; epochs bump so stale writers are fenced. |
| app.roles.on('assigned' | 'revoked', fn) | Live membership changes. |
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 }) | Two-phase upload (presigned PUT + confirm). Returns the stored { key, bytes }. |
| app.storage.get(key) | Fetch an asset as a Blob (time-limited signed reads). |
| app.storage.putJSON(key, value) / getJSON(key) | KV for small JSON (≤64 KB per entry); optional { scope }, default 'user'. |
| app.storage.list({ scope, prefix }) | Enumerate confirmed objects. |
| app.storage.delete(key) | Remove an object and release its bytes. |
| app.storage.refreshUsage() | Fetch bytes used vs quota from the server. |
| app.storage.quota / on('quota', fn) | Cached usage snapshot + quota events as you approach limits (80/95/100%). |
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.
{
"appId": "my-poker-app",
"name": "Poker Night",
"apiVersion": "1",
"entry": "index.html",
"policy": {
"docs": { "game": {}, "hand": { "perUser": true } },
"roles": { "player": { "max": 6 } },
"signals": ["buzz"],
"counters": ["pot"]
},
"storage": { "scopes": ["room:shared", "room:self"] }
}| appId | Permanent slug; names your sandbox origin and storage folders. |
| apiVersion | Platform API line (currently "1"). |
| entry | Bundle entry file, served at /<version>/<entry>. |
| policy.docs | Named documents (+ perUser / role visibility options). |
| policy.roles | Roles your app may grant, with member caps. |
| policy.signals / policy.counters | Names you may send / tally. |
| storage.scopes | Storage scopes (deny-by-default). |
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.