Robutler

host.kv

host.kv is a small, fast, server-persisted JSON store. Each person using your app gets their own, and it is keyed on the content the app is open on, so the same store answers whether they opened the app on a canvas, took it fullscreen, or followed a share link.

It is the first reach for state that must survive a reload: a game's high score, a form draft, a toggle, a cursor position. For binary data or large files use host.content instead. For state several people edit together use host.collab, which is a CRDT (conflict-free replicated data type, so concurrent edits merge).

API

type KvScope = 'private' | 'shared';

interface KvNamespace {
  get(key: string, opts?: { scope?: KvScope }): Promise<unknown | null>;
  set(key: string, value: unknown, opts?: { ttl?: number; scope?: KvScope }): Promise<void>;
  incr(key: string, delta?: number, opts?: { ttl?: number; scope?: KvScope }): Promise<number | null>;
  delete(key: string, opts?: { scope?: KvScope }): Promise<void>;
  list(prefix?: string, opts?: { scope?: KvScope }): Promise<Array<{ key: string; value: unknown }>>;
  subscribe(key: string, handler: (value: unknown) => void): () => void;
}
MethodReturnsWhat it does
get(key, opts?)unknown | nullRead a value, null if absent.
set(key, value, opts?)voidWrite a JSON value, optional ttl in seconds.
incr(key, delta?, opts?)number | nullAtomic server-side value = value + delta. Shared by default.
delete(key, opts?)voidRemove a key.
list(prefix?, opts?){ key, value }[]Enumerate keys, optionally by prefix.
subscribe(key, handler)unsubscribe fnFire handler on every change to key.

Read and write

await host.ready();

await host.kv.set('theme', 'dark');
const theme = await host.kv.get('theme'); // 'dark' | null

Values are JSON, so store objects directly. No manual JSON.stringify:

await host.kv.set('draft', { title, body, savedAt: Date.now() });

TTL

set and incr take an optional { ttl } in seconds. The key expires server-side after the window. Default is persistent (no expiry).

await host.kv.set('otp', code, { ttl: 300 }); // expires in 5 minutes

Atomic counters

incr applies value = value + delta server-side, so concurrent viewers never clobber each other. The stored value must be a JSON number. delta defaults to 1. It returns the new value.

const plays = await host.kv.incr('playCount');  // +1
const score = await host.kv.incr('score', 10);  // +10

incr writes the shared store, not your private one. A counter exists to coordinate several actors: a board's next task number must never repeat, so everyone has to draw from one sequence. That is the opposite of what the value operations want, so it is the one operation whose default scope is shared. Writing it needs write access to the content the app is open on, and a read-only viewer is refused with permission.

On content nobody else can see, the default counter is yours. An app added to a canvas points at the app's own catalog row until it is saved to a project, and that row has no shared store (see Scope and sharing below). Rather than fail there, a counter you did not name a scope for falls back to your private store: still atomic, still monotonic, never repeating inside anything you can see.

If your counter must be one sequence for everybody, say so. incr resolves a bare number and does not tell you which store answered, so a defaulted call is not a promise of anything. Ask explicitly and you get a guarantee or a refusal:

try {
  const n = await host.kv.incr('issue:seq', { scope: 'shared' });   // or (key, 1, opts)
} catch (err) {
  if (err.code === 'scope_unavailable') useLocalNumbering();
}

The options object may take the delta's place, as above. If you genuinely need to read the resolved store rather than assert it, host.rpc('kv.incr', { key, delta, scope }) resolves the raw { value, scope }.

Use incr for any shared tally (play counts, vote totals, live scoreboards, an issue number). A read-modify-write with get then set is racy across viewers; incr is not. For a counter only the current person should see, pass { scope: 'private' }.

List by prefix

const entries = await host.kv.list('player:');
// → [{ key: 'player:alice', value: {...} }, { key: 'player:bob', value: {...} }]

Namespace keys with a delimiter (player:alice) so list(prefix) can scan a group cheaply. Calling list() with no prefix returns every key in your store for this content.

Live updates

subscribe fires the handler whenever a key changes, including from the same person's other open tabs. It returns an unsubscribe function. Call it on teardown.

const off = host.kv.subscribe('score', (value) => {
  scoreEl.textContent = String(value ?? 0);
});
// later, on unmount:
off();

Scope and sharing

Two stores, addressed by the same keys:

ScopeWho sees itWho may write itDefault for
privatethe current person onlythe current personget, set, delete, list
sharedeveryone who can open the contentanyone with write access to the contentincr
  • Private is per person, not per copy. Two tiles of the same app on one canvas, the fullscreen view and a share link all read one store, because the store is keyed on the person and the content rather than on the window. Two different people never see each other's values.
  • Shared is owned by the content's creator. It is the app's visible state, so anyone who can open the content can read it, and only someone with write access can change it.
  • Shared is unavailable on an app's own catalog row, which is the row every copy of the app points at before it is saved to a project. A store owned by that row would be global to every user of the app, which is almost never what "shared" means. Put genuinely global state behind a function instead, where it has an owner and a quota. An operation that did not name a scope falls back to your private store there; one that asked for shared explicitly is refused with scope_unavailable. Name the scope when it matters, rather than trying to detect which store answered.
  • Saving to a project moves the store. The store is keyed on the content the app is mounted on, so when a tile that was running on the app's catalog row binds to a project or folder, it starts reading that project's store: the keys written before the save are still on the server but are no longer reachable from that tile, and list returns only the project's keys. Write anything the person should keep across that boundary into the project's own files rather than into KV, or read the old keys once and carry them over yourself while both are in reach.
  • Neither store is a secret store. Values are readable by the person they belong to and, for the shared store, by everyone who can open the content. Do not put credentials here.
  • A signed-out visitor gets a store too. The first time an app on a share link tries to save, a lightweight visitor account is created for the person then and there, so the save lands and reloads normally for someone who has not signed up. Just looking at a shared app creates nothing. Those stores carry tighter size caps and expire after seven days, so treat them as convenience rather than durable storage and offer a sign-in path for anything that matters; if the account cannot be created (a bot check that does not pass) the write rejects with no_session and the person should be told, not left thinking it saved.
  • Keep values small. It is a KV store, not a blob store. Reach for host.content once a value is large or binary.

Errors

CodeWhen
quota_exceededthe per-value or per-store size or count cap is hit
rate_limitedmore than 100 writes a minute to one store
permissiona shared write without write access to the content
scope_unavailablea shared scope on an app's own catalog row
no_sessionno session at all, so there is no store to write
not_foundthis window has no store yet (a window still resolving what it is showing)
internalanything else

Every one of these carries the server's own message, so err.message is worth showing: a refused TTL says "TTL exceeds the plan maximum of 30 days" rather than leaving you to guess.

Reads never raise these. When there is nothing to read from, get resolves null and list resolves [], so an app boots on its defaults. Writes do raise, because an app that cannot save should say so rather than look like it did.

Handle the rejection. set, incr and delete return promises, and a write can be refused for every reason in the table above. An app that calls them without awaiting or catching turns a refusal into an unhandled rejection, which means the person sees the edit accepted on screen and gone on the next reload, with nothing said. Await the write where the result matters, and tell the person when it fails:

try {
  await host.kv.set('items', items);
} catch (err) {
  if (err.code === 'no_session') showSignInPrompt();
  else if (err.code === 'quota_exceeded') showTooBig();
  else showSaveFailed();
}

On this page