---
name: capyhive-runtime-sdk
description: Build a static HTML/JS app or game to publish on capyhive.app. The Runtime SDK gives every user cloud-backed localStorage and viewer identity automatically — write code as if localStorage syncs across devices.
---

# Capyhive Runtime SDK

You are building a static web app or game that ships to capyhive.app. When a visitor plays it, capyhive injects a runtime script that gives you:

1. **Cloud-backed localStorage** — `localStorage.setItem`/`getItem` persists per-user, across devices. Writes work anywhere; reads return `null` until `capyhive:ready` fires (see point 3).
2. **Viewer identity** — `window.capyhive.user` is `{ id, username, displayName, avatarUrl, isAnonymous }`.
3. **Ready event** — identity and saved state load asynchronously. Wait for `capyhive:ready` before reading.
4. **Home Screen instructions** — `capyhive.showAddToHomeScreen()` opens a playful, mobile-only popup that teaches viewers how to save the app as a one-tap shortcut. Desktop browsers intentionally no-op.

## Runtime SDK vs Agent REST API

- **Runtime SDK** means `window.capyhive` inside an uploaded app or game. Use it for viewer identity, cloud-backed `localStorage`, shared data, leaderboards, sign-in, and Home Screen instructions.
- **Agent REST API** means token-authenticated HTTP requests from an external agent, CLI, or backend tool acting for a capyhive User. Use it to publish Posts, upload Apps, reply to Comments, and manage account-level resources.
- Uploaded apps should not call the Agent REST API directly. Inside app code, use the Runtime SDK APIs below.

## Rules

- **Static files only.** No server code, no Node, no databases. HTML, CSS, JS, images, audio, fonts.
- **Wait for `capyhive:ready` before your first `localStorage` read.** Before that event fires, `getItem` returns `null` — saved state hasn't loaded yet. Once `ready` has fired (it fires once, early), reads are correct from anywhere: click handlers, timers, render loops. Writes can happen any time.
- **`index.html` must be at the root** of your zip.
- **Use relative paths for every asset.** Good: `./game.js`, `assets/sprite.png`, `sounds/song.ogg`. Bad: `/sprite.png`, `/my-slug/sprite.png` — leading-slash URLs bypass the `<base href="v{N}/">` capyhive injects, so they lose version-pinned long caching and, on local dev (`/_app/<user>/<slug>/`), escape the app's URL prefix entirely and 404. The trap is silent: `<img>`, `<audio>`/`<source>`, `<video>`, and CSS `url(/foo.webp)` references just fail without throwing. Never hardcode your app's slug into asset URLs.
- **Assume uploaded assets are CDN-served and canvas-tainting.** Files from your zip are uploaded to blob/CDN storage and may be served from a different origin even when referenced with relative paths. Drawing those images or videos to `<canvas>` is fine for rendering, but do not rely on `getImageData()`, `toBlob()`, `toDataURL()`, or other canvas readback/export APIs after drawing uploaded assets.
- **Don't set a `<base>` tag** unless you really need to. Capyhive injects one so re-uploads invalidate browser caches automatically. If your app needs its own `<base>` (typically SPA routing setups), we'll respect it, but your assets won't get the long-cache treatment.
- **Zip limits**: 50 MB compressed upload, 100 MB extracted contents, fewer than 3,000 files. Zip the build output only; don't include source trees or dependency folders.
- **localStorage limits**: 100 KB per key, 1 MB total per user.
- **No direct calls to capyhive's Agent REST API from uploaded apps.** Use the Runtime SDK instead.

## Minimal example

```html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>My game</title>
</head>
<body>
  <h1 id="welcome">Loading…</h1>
  <button id="save">Save score</button>
  <script>
    document.addEventListener('capyhive:ready', () => {
      const name = capyhive.user.isAnonymous ? 'guest' : capyhive.user.displayName;
      document.getElementById('welcome').textContent = `Hi, ${name}`;
      document.getElementById('save').onclick = () => {
        const best = Math.max(parseInt(localStorage.getItem('best') || '0'), Date.now() % 1000);
        localStorage.setItem('best', String(best));
      };
    });
  </script>
</body>
</html>
```

## Bundling (creating the zip)

**Zip the built output, not your source tree.** If the project has a build step (Vite, Webpack, Parcel, Next static export, etc.), run it first and zip the output folder (`dist/`, `build/`, `out/`). The zip must contain only what the browser needs to run: `index.html`, compiled JS/CSS, static assets. No `node_modules/`, no `.git/`, no TypeScript source, no source maps, no `package.json`.

**Pure static project (no build step):**

```bash
cd my-app           # the folder that already contains index.html
zip -r ../my-app.zip . -x '.DS_Store' '__MACOSX/*' '.git/*'
```

**Project with a build step:**

```bash
npm run build       # or: pnpm build / yarn build / vite build
cd dist             # or build/, out/ — whatever your tool emits
zip -r ../my-app.zip .
```

**If you must zip from a project root that has source code mixed in,** exclude the noise explicitly:

```bash
zip -r ../my-app.zip . \
  -x 'node_modules/*' '.git/*' '*.map' '.DS_Store' '__MACOSX/*' \
     'package*.json' 'tsconfig*.json' '*.ts' 'src/*' 'node_modules' '.next/*'
```

**Verify before uploading.** Run `unzip -l my-app.zip` and check:

1. `index.html` appears at the top level (no folder prefix like `dist/index.html`).
2. No `node_modules/`, `.git/`, or `.ts` source files in the listing.
3. The `.zip` file is under 50 MB.
4. The `unzip -l` total extracted size is 100 MB or less.
5. Fewer than 3,000 files. If the count is high, rebuild without source maps and dependency/source folders.

If `index.html` is nested (e.g., `my-app/index.html`), you zipped the wrong directory. Re-zip from inside the folder containing `index.html`.

Upload `my-app.zip` via your capyhive dashboard.

## App listing assets

Alongside your zip, prepare two optional but strongly recommended images:

- **Cover image** — the main card/player image for the app. Use PNG, JPEG, WebP, or GIF; WebP preferred. Aim for about 1600 px on the longest edge.
- **App icon** — the small badge used on app grid tiles and, when uploaded with a zip, injected as the served app favicon. Aim for about 256 px on the longest edge.

You can upload these from the capyhive dashboard. External agents can upload them through the Agent REST API:

https://capyhive.com/developers/api/skill.md#upload-images

## External agents

This Runtime SDK skill is for code that runs inside an uploaded app. If you are an external agent acting for a capyhive User with a Bearer token, use the Agent REST API skill instead:

https://capyhive.com/developers/api/skill.md

## Local development

You don't need capyhive to test locally. Add this one line to your `<head>`:

```html
<script src="https://capyhive.app/sdk/sdk.dev.js"></script>
```

You'll get a fake `capyhive.user` (id=`dev`, displayName=`Dev User`) and localStorage backed by your real browser. **Leave the script in your zip if you want** — it auto-disables on capyhive.app.

## Runtime SDK reference

```ts
window.capyhive = {
  user: {
    id: string | null;            // null for anonymous viewers
    username: string | null;
    displayName: string | null;
    avatarUrl: string | null;
    isAnonymous: boolean;
  };
  store: {
    get(key: string): string | null;
    set(key: string, value: string): void;
    remove(key: string): void;
    clear(): void;
    keys(): string[];
    usage(): { bytes: number; limit: number; keys: number };
    ready(): boolean;
  };
  // App-owner shared K/V — read by anyone, written by the app owner
  // (usually from the dashboard, not from app code). Snapshot is preloaded
  // before `capyhive:ready` so reads are synchronous.
  shared: {
    get(key: string): string | null;
    getJSON<T = unknown>(key: string): T | null;
    keys(): string[];
    refresh(): Promise<void>;
    set(key: string, value: string): Promise<{ ok: true; usage }>;
    setJSON(key: string, value: unknown): Promise<{ ok: true; usage }>;
    remove(key: string): Promise<{ ok: true; usage }>;
  };
  // Per-player high scores against app-owner-declared boards. Signed-in submit
  // only; max-best per (user, board). Read returns top N plus your own rank.
  leaderboard: {
    submit(slug: string, score: number): Promise<{
      ok: true; rank: number; score: number; updated: boolean;
    }>;
    top(slug: string, opts?: { limit?: number }): Promise<{
      entries: Array<{
        rank: number; userId: string; username: string; displayName: string;
        avatarUrl: string | null; score: number; submittedAt: string;
      }>;
      me: { rank: number; score: number; submittedAt: string } | null;
      board: { slug: string; name: string; direction: 'high' | 'low' };
    }>;
  };
  on(event: 'ready', cb: () => void): void;
  on(event: 'error', cb: (e) => void): void;
  signIn(): Promise<void>;
  // Opens a mobile-only instructional popup for saving the current app.
  showAddToHomeScreen(): void;
};
```

(Full types: https://capyhive.app/sdk/capyhive.d.ts)

## Events

- `capyhive:ready` — fired once when identity + saved state have loaded. Use `document.addEventListener` or `capyhive.on('ready', ...)`.
- `capyhive:error` — fired on init failure, repeated flush failure, or quota exceeded. `e.detail.type` is one of `init_failed | flush_failed | quota_exceeded | sync_failed`.

## Anonymous viewers

Public apps are playable without sign-in. Those viewers get `capyhive.user.isAnonymous === true`, `id === null`, and writes to localStorage are kept on-device only (mirrored to the app subdomain's real localStorage — durable across reloads). Progress is lost only if the user clears their browser storage. Build your app to degrade gracefully (e.g., gate "save high score" on `!capyhive.user.isAnonymous`, or just let anonymous users play without saving).

## Patterns

**Save settings:**
```js
// Writes can happen any time
localStorage.setItem('volume', '0.8');

// Reads must wait for ready — they return `null` before then
document.addEventListener('capyhive:ready', () => {
  const volume = localStorage.getItem('volume') ?? '1.0';
  // → '0.8' next visit, on any device
});
```

**Per-user data:**
```js
// no need to prefix with user id — capyhive scopes storage to (app, user) for you
localStorage.setItem('progress', JSON.stringify({ level: 3, coins: 50 }));
```

**App-owner content (`capyhive.shared`):**

Use this when you want content the app owner can update **without re-zipping the app** — leaderboards, daily challenges, quiz questions, news, feature flags.

```js
document.addEventListener('capyhive:ready', () => {
  // Synchronous read — snapshot is loaded before the event fires.
  const levels = capyhive.shared.getJSON('levels') || [];
  renderLevels(levels);
});
```

The app owner edits values from the dashboard ("Shared data" section on the app edit page). Anyone playing the app sees the latest values on next load (or after `capyhive.shared.refresh()`). Limits: 1 MB per key, 10 MB per app, 1,000 keys.

Don't put per-player progress here — that's what `localStorage` is for.

**React to quota:**
```js
capyhive.on('error', (e) => {
  if (e.detail.type === 'quota_exceeded') {
    alert('Storage full — please clear some data');
  }
});
```

**Leaderboards (`capyhive.leaderboard`):**

For per-player high scores. The app owner declares boards in the dashboard ("Leaderboards" section on the app edit page) — give each one a `slug` you'll use in code, a display name, a direction (high wins / low wins), and an optional max-score cap. Only signed-in viewers can submit; anonymous viewers can view via the app's detail page on capyhive.

```js
// Submit the current viewer's score. Server keeps max-best per user.
try {
  const { rank, score, updated } = await capyhive.leaderboard.submit('hard', 1234);
  // updated === false means an existing better score is kept.
  showToast(`Rank #${rank}`);
} catch (e) {
  if (e.name === 'not_signed_in') capyhive.signIn();
  else if (e.name === 'rate_limited') console.log(`retry in ${e.retryAfterMs}ms`);
  else if (e.name === 'board_not_declared') console.warn('declare the board first');
  else if (e.name === 'score_rejected') console.warn(e.reason); // 'not_integer' | 'cap_exceeded'
}

// Read top 10 (default) plus the viewer's own row if they're on the board.
const { entries, me } = await capyhive.leaderboard.top('hard');
entries.forEach(r => console.log(r.rank, r.displayName, r.score));
if (me) console.log('You are #' + me.rank);
```

Scores must be integers within the JS-safe range. There's a 5-second minimum interval between accepted submissions per user per board. Top 10 also renders automatically on the app's page on capyhive.

**Add to Home Screen prompt (`capyhive.showAddToHomeScreen()`):**

Use this when you want to teach a mobile viewer how to save your app as a one-tap shortcut. The SDK opens a playful popup with generated visual guides and steps for iPhone/iPad Safari, iPhone/iPad Chrome, Android Chrome, and other Android browsers. On desktop browsers it intentionally does nothing.

```js
document.getElementById('save-shortcut').onclick = () => {
  capyhive.showAddToHomeScreen();
};
```

The helper does not force a native browser install prompt. It teaches the user the right menu path for their mobile browser, which is more reliable across Safari, Chrome, and embedded app contexts.

## Saving progress when users sign in

Anonymous viewers can play your app and make progress, but their data only persists on their device until they sign in. To sync their progress to a real account:

```js
// Show a "Sign in to save" button only for anonymous viewers
if (capyhive.user.isAnonymous) {
  document.getElementById('save-button').textContent = 'Sign in to save';
  document.getElementById('save-button').onclick = () => {
    capyhive.signIn(); // navigates away, then returns with progress synced
  };
}
```

`capyhive.signIn()`:
- **Standalone**: redirects the page to capyhive sign-in, then returns to the standalone app signed in.
- **Inside the capyhive feed**: takes over the top-level page for sign-in, then returns to the capyhive game page so the iframe reloads signed in.
- **Embedded elsewhere**: also uses top-level sign-in and falls back to the standalone app URL.

After sign-in, the SDK automatically migrates all the user's anonymous progress to their account (anonymous values overwrite any older server values for the same key; server-only keys are preserved).
