Appearance
Storage
Why storage
Extension Storage is the built-in way to persist data, and it is the primary approach for building extensions without your own backend. Instead of standing up a server and a database, you keep game state, counters, and leaderboards directly on the platform and read them back from any slot. No infrastructure to run, scale, or secure.
Reach for storage whenever your extension needs to remember something between requests, slots, viewers, or tab reopens. For the rare server-side needs — calling third-party APIs, keeping secrets, trusted computation — use extension actions instead.
Every key you use is declared in your manifest under storage. In the snippets, ext is your createExtHelper() instance.
Choosing a storage type
| Type | Use it for |
|---|---|
strings | Arbitrary text or JSON-serialized state (game state, a saved selection); read, overwrite, or append. |
ints | Atomic counters updated concurrently (votes, tallies); supports optional payment-gated increments. |
top | Leaderboards — per-user totals ranked over a sliding 1d / 7d / 30d window. |
mutexes | Coordinating exclusive access to shared state across slots or viewers (e.g. one game round at a time). |
How a key is built
A storage key is the name you declare for an entry in storage. Names can include {uint64} / {string} placeholders (for example user_{uint64}_state) that you fill in at request time — a key declared as user_{uint64}_state is used at request time as a concrete key like user_42_state.
Scope
By default every value is scoped per model — it lives in the current model's room (the broadcaster you're watching). In addition, string, int, and mutex values are scoped per viewer (the "owner"), so the same key name holds a separate value for each model and viewer. Top counters are scoped per model and only the model can write them.
The key's read/write permission (owner, owner_and_model, everyone, model) controls who may read or write a value — it gates access and does not by itself change which value is addressed; the room and owner scope does that.
Using storage
Each type follows the same two steps: declare the key in your manifest.json, then call the matching requests.
Strings — text or JSON state
json
"storage": {
"strings": [
{
"name": "game_state",
"read": "everyone",
"write": "everyone"
}
]
}ts
// Values are always strings — stringify / parse objects yourself.
await ext.makeRequest('v1.storage.string.set', {
key: 'game_state',
value: JSON.stringify(state),
});
const res = await ext.makeRequest('v1.storage.string.get', {
key: 'game_state',
});
const state = res.exists ? JSON.parse(res.value) : null;
await ext.makeRequest('v1.storage.string.append', {
key: 'chat_log',
value: 'alice: hi\n',
});set and append accept an optional ttl (seconds) to auto-expire the key. See v1.storage.string.set, v1.storage.string.get, and v1.storage.string.append.
Counters (ints) — atomic tallies
json
"storage": {
"ints": [
{
"name": "total_votes",
"read": "everyone",
"write": "everyone"
}
]
}ts
await ext.makeRequest('v1.storage.int.increment', {
key: 'total_votes',
delta: '1',
});
// value is '0' if never set
const res = await ext.makeRequest('v1.storage.int.get', {
key: 'total_votes',
});
await ext.makeRequest('v1.storage.int.reset', {
key: 'total_votes',
});delta is a string and may be negative. See v1.storage.int.get, v1.storage.int.increment, and v1.storage.int.reset.
Mutexes — exclusive coordination
json
"storage": {
"mutexes": [
{
"name": "game_round",
"read": "everyone",
"write": "everyone"
}
]
}ts
const { success } = await ext.makeRequest('v1.storage.mutex.lock', {
mutexName: 'game_round',
ttl: '30',
});
if (success) {
// ...exclusive work...
await ext.makeRequest('v1.storage.mutex.unlock', {
mutexName: 'game_round',
});
}lock returns success: false if the mutex is already held; getState reports the current held state. See v1.storage.mutex.lock, v1.storage.mutex.unlock, and v1.storage.mutex.getState.
Leaderboards (top) — ranked per-user totals
Top counters are written only by the model (write: "model"); viewers can read.
json
"storage": {
"top": [
{
"name": "tips",
"read": "everyone",
"write": "model"
}
]
}ts
// Model credits a viewer:
await ext.makeRequest('v1.storage.top.increment', {
key: 'tips',
userId: '42',
amount: '25',
});
// Anyone reads the leaderboard for a sliding window:
const res = await ext.makeRequest('v1.storage.top.get', {
key: 'tips',
top: 'TOP_7D',
topN: 5,
});
res.topUsers?.forEach((u) => console.log(`${u.userId}: ${u.total}`));See v1.storage.top.get, v1.storage.top.increment, and v1.storage.top.reset.
Persist and restore state across tab close
Shared slot state is in-memory and is wiped when the tab closes — it does not restore anything by itself. To survive a reopen, the background slot hydrates the host from storage on start and persists on every change.
ts
const CHANNEL = 'game';
const KEY = 'game_state';
// 1. hydrate from storage before creating the host
const saved = await ext.makeRequest('v1.storage.string.get', { key: KEY });
const initialState = saved.exists ? JSON.parse(saved.value) : { round: 0, pot: 0 };
const host = createSlotStateHost({ channel: CHANNEL, extHelper: ext, initialState });
// 2. every change updates the slot state AND persists it
function yourUpdate(patch) {
host.setState(patch);
void ext.makeRequest('v1.storage.string.set', { key: KEY, value: JSON.stringify(host.getState()) });
}String storage is scoped per model and per viewer, so this restores each user's own state in that room. For shared room state, use a key with write: everyone or an int counter.