# Build an app that belongs on a screen

> Canonical HTML: https://screenrig.ai/docs/sdk

Use the injected browser SDK for playlist control, application events, diagnostics, and persistent key/value state.

## Watch it built

Claude Code writes a lobby check-in kiosk against `window.screenrig`: `waitUntilActive()`, `ready()`, `kv.get()` and `emitConfirmed()`. A visitor's tap reaches the agent, and it answers on the welcome wall.

- [Build a kiosk app with the SDK, 3:07](https://screenrig.ai/videos/sdk-kiosk-app) — chapters and transcript
- [The film (MP4, 720p)](https://screenrig.ai/static-resources/videos/films/sdk-kiosk-app-720-9f35b7f6.mp4)

## The runtime is already in the app

`app pack` and `app upload` take an already-built static directory with a root
`index.html`. The packer injects `_screenrig/runtime.js` and adds its script tag
to that document. The running application receives `window.screenrig` with no
build step and no npm dependency: there is nothing to install, nothing to
import, and nothing to configure.

Each immutable application release runs on its own isolated origin under
`apps.screenrig.ai`. The application id outlives the release, so key/value data
written by one release is still there for the next one.

## The `window.screenrig` surface

Three read-only properties, nine methods, and a `kv` namespace of five more.
Read `readyState` first and check `capabilities` before calling a method that a
given activation may not have.

Each method answers a different question, so start from what you want to do:

| To do this | Call |
| --- | --- |
| Wait until the Player activates this page | `screenrig.waitUntilActive()` |
| Say the application is up and on screen | `screenrig.ready()` |
| End this application's turn on an application-advance page | `screenrig.nextPage()` |
| Tell the agent that something happened | `screenrig.emit(code)` |
| Know the backend recorded that event | `screenrig.emitConfirmed(code)` |
| Leave a diagnostic for the Player and runtime to collect | `screenrig.log(...)` |
| Read live application state | `screenrig.kv.get()`, `screenrig.kv.list()` |
| Write live application state | `screenrig.kv.set()`, `screenrig.kv.delete()` |
| Update the moment live application state changes | `screenrig.kv.onChange()` |
| React to a size or capability change | `screenrig.onResize()`, `screenrig.onCapabilities()` |
| Tear down listeners when the application is done with the runtime | `screenrig.destroy()` |

### `screenrig.readyState`

Whether the injected runtime is attached. Every document starts `inert` and
turns `active` when the Player's handshake and context arrive; a page that is
not on screen yet stays `inert`. Wait for `active` with
`screenrig.waitUntilActive()`; do not treat `inert` at load as "outside
screenRIG". On `failed`, or when `window.screenrig` is absent, render your own
content and skip the surface below, so the same build also runs outside
screenRIG. While `inert`, `ready()`, `log()`, `emit()`, `emitConfirmed()`,
`nextPage()` and the `kv` reads and writes throw or reject with code `inert`;
`kv.onChange` can be registered at any time.

- Type: `"inert" | "active" | "failed"`

### `screenrig.context`

Where this document is running.

| Field | Type | Notes |
| --- | --- | --- |
| `application_id` | string | The `app_` id from `app upload`. The key/value store belongs to it. |
| `release_id` | string | The immutable release serving this origin. |
| `primitive_id` | string | The primitive this document fills on the current page. |
| `viewport` | `{ width, height }` | The surface the Player is rendering into. |
| `primitive` | `{ width, height }` | The rect this document occupies inside that viewport. |
| `capabilities` | object | The same booleans as `screenrig.capabilities`. |
| `screen_id` | string, optional | Present when the runtime knows which screen is playing. |

`context` is description, not request material. Nothing in it is meant to be
signed, forwarded, or sent back to screenRIG; the methods below carry whatever
the runtime needs.

### `screenrig.capabilities`

Three booleans that say which gated methods will do anything for this
activation. The keys are dotted, so read them with bracket notation.

| Flag | Gates |
| --- | --- |
| `page.advance` | `screenrig.nextPage()` |
| `kv.read` | `screenrig.kv.get()`, `screenrig.kv.list()` and `screenrig.kv.onChange()` |
| `kv.write` | `screenrig.kv.set()` and `screenrig.kv.delete()` |

```javascript
if (window.screenrig.capabilities["kv.write"]) {
  saveButton.hidden = false;
}
```

Subscribe with `screenrig.onCapabilities` instead of reading the flags once at
startup.

- Type: object of booleans

### `screenrig.waitUntilActive(timeoutMs)`

**Call it first, before any other method**, and render your UI while it waits.
It resolves when the Player has activated this page, or at once when the
runtime is already `active`, and it does not declare the application rendered.
It rejects with `activation_timeout` after `timeoutMs` (default 10000, at most
300000); a page that is waiting its turn can simply wait again.

```javascript
const sr = window.screenrig;
async function start() {
  render(); // the UI works with or without screenRIG
  if (!sr) return;
  for (;;) {
    try { await sr.waitUntilActive(60000); break; }
    catch (err) { if (err.code !== "activation_timeout") return; }
  }
  await sr.ready();
  // From here: emit, emitConfirmed, log, kv, nextPage.
}
start();
```

Check `readyState === "active"` before an SDK call from an event handler, timer
or `catch`, and queue input that arrives earlier. A `log()` from a handler that
runs while `inert` throws too, which can end startup before any event is sent.

- Returns: `Promise<void>`

### `screenrig.ready()`

**Call it after `waitUntilActive()` resolves and before you depend on `context`,
`kv`, or `nextPage`**, once the application has what it needs to be on
screen. Called while `inert`, it throws.

```javascript
await window.screenrig.waitUntilActive();
await window.screenrig.ready();
```

- Returns: `Promise<void>`

### `screenrig.nextPage()`

**Call it only on an application-advance page where this primitive is the
controller**, when the application has finished its turn. It is one-shot: an
activation gets one advance.

```javascript
window.screenrig.nextPage();
```

The method is for a page whose `advance.mode` is `application`, where this
primitive is the controller:

```json
{
  "advance": { "mode": "application", "max_ms": 60000 },
  "primitives": [
    {
      "id": "app",
      "primitive": "application",
      "release_id": "rel_01EXAMPLERELEASE000000000",
      "rect": { "x": 0, "y": 0, "width": 1920, "height": 1080 },
      "layer": 0,
      "content_fit": "fill",
      "controller": true
    }
  ]
}
```

Exactly one primitive on that page carries `controller: true`, and it must be an
application primitive. `max_ms` on the page advance object bounds the turn.

- Requires: `capabilities["page.advance"]`
- Returns: `Promise<void>`

### `screenrig.emit(code)`

**Use it when the agent should see that something happened**: a page finished a
step, a kiosk form was submitted, the application proved it ran. This is a named
application event, and it lands in the account event log where the agent watches
it with the bundled CLI's
[`events list`](https://screenrig.ai/docs/cli#cmd-events-list) or
[`events follow`](https://screenrig.ai/docs/cli#cmd-events-follow). One code,
no message body.

`log` is not a substitute. A diagnostic is not an application event, and an
agent reading the event log will not see it.

```javascript
window.screenrig.emit("menu.rendered");
```

| Parameter | Type | Notes |
| --- | --- | --- |
| `code` | string | Sanitized to `[A-Za-z0-9._-]`, at most 64 characters. |

- Returns: `void`

### `screenrig.emitConfirmed(code)`

**Use it when the application must know the event landed**: a kiosk order was placed, a form was sent, a visit finished. It sends the same named application event as `emit`, and its promise resolves only once the Player confirms that the backend accepted the event.

It rejects with the backend's problem when the event is refused, and with `ack_timeout` after 10 seconds without a reply. Do not retry automatically after a timeout: the backend may already have accepted the event. A receipt proves the event is in the project event log, not that anything has acted on it yet.

Receipts work on every Player that runs applications.

```javascript
await window.screenrig.emitConfirmed("order.placed");
```

| Parameter | Type | Notes |
| --- | --- | --- |
| `code` | string | Sanitized to `[A-Za-z0-9._-]`, at most 64 characters. |

- Returns: `Promise<void>`

### `screenrig.log({ level, code, message })`

**Use it for diagnostics**: the `debug`, `info`, `warn`, and `error` text the
Player and runtime collect while the application is on screen. The injected
runtime logs through this method itself. Logging is capped:
32 lines per client, and at most 2048 bytes of message each. Log what you would
want while debugging, not one line per frame.

Do not reach for `log` when you mean `emit`. Agents do not treat log lines as
application events, so anything the agent needs to act on belongs in an `emit`
code.

```javascript
window.screenrig.log({
  level: "warn",
  code: "menu.stale",
  message: "Falling back to the cached menu."
});
```

| Field | Type | Notes |
| --- | --- | --- |
| `level` | string | `debug`, `info`, `warn`, or `error`. |
| `code` | string | Sanitized to `[A-Za-z0-9._-]`, at most 64 characters. |
| `message` | string | At most 2048 bytes. |

- Returns: `void`

### `screenrig.onResize(handler)`

**Subscribe when the layout can change under you** instead of reading `context`
once. The handler receives `{ viewport, primitive }`, the same two objects
`context` carries, and the call returns the function that ends the
subscription.

```javascript
const stopResize = window.screenrig.onResize(function (size) {
  render(size.viewport, size.primitive);
});

stopResize();
```

- Returns: unsubscribe function

### `screenrig.onCapabilities(handler)`

**Subscribe when the granted capabilities can change** instead of deciding once
at startup. Read `screenrig.capabilities` inside the handler for the current
booleans and enable or hide the affected controls; the call returns the function
that ends the subscription.

```javascript
const stopCapabilities = window.screenrig.onCapabilities(function () {
  saveButton.hidden = !window.screenrig.capabilities["kv.write"];
});
```

- Returns: unsubscribe function

### `screenrig.destroy()`

**Tear down listeners when the application is done with the runtime.** An
application that runs for its whole turn and lets the Player replace the
document does not need to call it.

- Returns: `void`

### `screenrig.kv`

The application key/value store, for live application state that outlives one
activation. Five methods of its own: `get`, `list`, `set`, `delete`, and
`onChange`. The key/value store section below documents all five.

- Type: namespace

## The key/value store

Use the store for live application state: the menu the application renders, the
copy an operator changed this morning, a counter the last activation left
behind. Read it with `kv.get` and `kv.list`, write it with `kv.set` and
`kv.delete`, and hear about every change with `kv.onChange`.

The store belongs to the application id in `context.application_id`, not to a
release, so an entry survives the next `app upload`. Values are bytes, so text,
JSON, and small binaries all round-trip. Reads need `kv.read` and writes need
`kv.write`; check the flag before you call.

The store and its change push run on every Player that runs applications: the
Player web app (PWA), Android, Amazon Signage Stick, macOS, Windows, Linux and
Raspberry Pi and BrightSign. Apple TV does not
run applications; see
[primitive support by platform](https://screenrig.ai/docs/players#primitive-support).

### `screenrig.kv.get(key)`

**Read live application state.** Await the promise.

```javascript
const entry = await window.screenrig.kv.get("menu");
```

- Requires: `capabilities["kv.read"]`
- Returns: promise

### `screenrig.kv.list()`

**Read live application state** when you do not know the keys yet: this lists
the keys the application has stored. Await the promise.

```javascript
const keys = await window.screenrig.kv.list();
```

- Requires: `capabilities["kv.read"]`
- Returns: promise

### `screenrig.kv.set(key, value, options)`

**Write live application state.** `value` is a string or a `Uint8Array`;
`options` is optional.

```javascript
await window.screenrig.kv.set("menu", JSON.stringify(menu), {
  contentType: "application/json"
});

await window.screenrig.kv.set("badge.png", pngBytes, {
  contentType: "image/png"
});
```

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `key` | string | yes | See the key rules below. |
| `value` | string or `Uint8Array` | yes | The bytes to store. |
| `options.contentType` | string | no | Defaults to `text/plain` for a string and `application/octet-stream` for a `Uint8Array`. |
| `options.expectedRevision` | non-negative integer | no | Write only if the entry is still at this revision. |
| `options.idempotencyKey` | string | no | Retry the same write without applying it twice. |

- Requires: `capabilities["kv.write"]`
- Returns: promise

### `screenrig.kv.delete(key, expectedRevision, options)`

**Write live application state** by removing an entry. `expectedRevision` is optional: omit it to delete the current entry, or supply it to reject concurrent changes.

```javascript
await window.screenrig.kv.delete("menu");
```

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `key` | string | yes | See the key rules below. |
| `expectedRevision` | non-negative integer | no | The revision the entry was at when you read it. |
| `options.idempotencyKey` | string | no | Retry the same delete without applying it twice. |

- Requires: `capabilities["kv.write"]`
- Returns: promise

### `screenrig.kv.onChange(handler)`

**Update the moment live application state changes** instead of polling for it.
The handler runs whenever any key of this application changes, whoever wrote
it: the agent with `kv set`, an outside system through a key/value webhook (see
Push data from another system below), or this application on any screen. It
receives which key changed, not the value, so read the new value with `kv.get`.
The call returns the function that ends the subscription.

```javascript
const sr = window.screenrig;

async function showMenu() {
  render(await sr.kv.get("menu"));
}

const stopChanges = sr.kv.onChange(function (change) {
  if (change.key === "menu" && !change.deleted) showMenu();
});

await sr.waitUntilActive();
await sr.ready();
await showMenu();
```

Subscribe before the first read, so a change that lands while you read is not
missed; a handler registered before activation starts once the page is active.
Call `stopChanges()` to end the subscription.

A change reaches every screen whose playlist places this application within
seconds. Several quick writes to one key can arrive as one change carrying the
newest revision, so treat a change as "read this key again", not as a count of
writes. A change is a signal, not a history: read what you need when the
application starts, then follow changes.

| Field | Type | Notes |
| --- | --- | --- |
| `key` | string | The key that changed. |
| `revision` | integer, optional | The entry's new revision. Absent when `deleted` is `true`. |
| `deleted` | boolean | `true` when the key was deleted. |

- Requires: `capabilities["kv.read"]`; without it the handler never runs
- Returns: unsubscribe function

### Keys

A key is 1 to 256 UTF-8 bytes. The limit is bytes, not characters, so a key with
multi-byte characters reaches it sooner than its length suggests. A key must not
begin with `_screenrig/`; that prefix belongs to the injected runtime.

### Revisions and idempotency

Every entry carries a revision. Optionally pass `expectedRevision` to `screenrig.kv.set` or `screenrig.kv.delete` to write only if the entry is still at the revision you read. A stale revision means someone else wrote first:
read the entry again, reapply your change, and retry.

`idempotencyKey` covers the other half. A write that may be retried after a lost
response can carry the same key on every attempt, and the store applies it once.

### The same store from the CLI

The agent can reach these entries from outside the browser with the bundled
CLI's [`kv` commands](https://screenrig.ai/docs/cli#kv) against the `app_` id
from `app upload`. That is the operator's view of the store. It is not a
substitute for the five methods above: an application changes its own data
through `screenrig.kv`, in the browser, while it is on screen. A change the
agent writes reaches the running application through `kv.onChange`.

<a id="kv-webhooks"></a>

### Push data from another system

A key/value webhook is a URL that writes one key of this application. Hand it
to anything that can send an HTTP POST, such as Zapier, Make, a point of sale,
a spreadsheet script, a cron job or `curl`, and each post replaces that key's
value. Every screen running the application hears about it through
`kv.onChange` within seconds, with no new release and no agent in the loop.

The agent creates one for a key with the bundled CLI and hands you its URL and
secret:

```console
screenrig kv webhook create menu --app-id app_01EXAMPLEAPP00000000000 --description "Point of sale menu feed"
```

The answer carries the webhook's `url` and its `secret` (`srkw_…`). The secret
is shown only when the webhook is created and when its secret is rotated, so
enter it in the sending system right away. The URL is not secret; the secret
is.

The sender posts the new value as the raw request body, with the secret in the
`Authorization` header:

```console
curl https://api.screenrig.ai/api/applications/app_01EXAMPLEAPP00000000000/kv-webhooks/kvw_01EXAMPLEWEBHOOK00000/write \
  -H "Authorization: Bearer $KV_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  --data '{"soup":"lentil","price":"6.50"}'
```

A sender that only speaks HTTP Basic authentication uses the webhook id as the
username and the secret as the password:

```console
curl https://api.screenrig.ai/api/applications/app_01EXAMPLEAPP00000000000/kv-webhooks/kvw_01EXAMPLEWEBHOOK00000/write \
  --user "kvw_01EXAMPLEWEBHOOK00000:$KV_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  --data '{"soup":"lentil","price":"6.50"}'
```

- The body, at most 1 MiB, becomes the value, and `Content-Type` its content
  type (`application/octet-stream` when the header is absent). The value
  reaches the browser application, so never post a credential as one.
- Each post is a key/value write like `kv set`: it replaces the value, raises
  the revision, is one billed API request, and is metered and counted toward
  storage like any other write.
- The answer is the key's new metadata (`key`, `revision`, `bytes`, `sha256`,
  `content_type` and `updated_at`), never its value.
- Add an `Idempotency-Key` header (8 to 200 characters) to retry a post
  safely: within 24 hours the same key applies once, and the same key with
  another body or content type is refused with `idempotency_mismatch`. Without
  one, every post is a new write.
- One webhook takes up to 120 posts a minute; past that the answer is `429`
  with `Retry-After`.
- A missing or wrong secret, or an unknown webhook, answers `401`. A secret in
  the URL or query string is never accepted.
- An application has up to 20 webhooks. `kv webhook rotate-secret` issues a
  new secret and the old one stops at once; `kv webhook delete` stops the URL
  at once and leaves the key's value in place.

[Key/value webhook commands](https://screenrig.ai/docs/cli#kv-webhooks)

## Build and place the app

- [Pack and upload applications](https://screenrig.ai/docs/cli#applications)
- [Create the playlist page](https://screenrig.ai/docs/cli#playlists)
- [Key/value commands](https://screenrig.ai/docs/cli#kv)
- [Key/value webhooks](https://screenrig.ai/docs/cli#kv-webhooks)
- [Read application events](https://screenrig.ai/docs/cli#events)
