An Atomics app is a small web page that runs on a card on a shared board. It draws its own interface, keeps its state on its card, and asks the space for anything else through one global, atomics. This page is the whole contract.
app.js: one plain script, no imports and no build step. Wrap it in a function so nothing leaks into the page.manifest.json beside it if you want to name the app, its permissions or its size.A counter that everyone in the space shares:
(() => {
const button = document.createElement('button')
document.body.append(button)
const draw = (state) => (button.textContent = `Clicked ${state.count || 0} times`)
atomics.call('card.read').then(({ state }) => draw(state))
atomics.on('card', ({ state }) => draw(state))
button.onclick = async () => {
const { state } = await atomics.call('card.read')
await atomics.call('card.write', { set: { count: String(Number(state.count || 0) + 1) } })
}
})()
{
"name": "Counter",
"permissions": ["card.state"],
"size": { "width": 320, "height": 200 },
"glyph": "timer"
}
Every field is optional in a Studio zip. Studio sets the app's id, version, entry and hash itself.
| Field | What it is |
|---|---|
name | What the card and Studio call the app, 1 to 60 characters. |
permissions | What the app may ask the space for, from the list below. The person adding the app is shown each one. Ask only for what you use. |
size | The card's size when it is added, { width, height } in pixels, each from 120 to 2000. |
glyph | One lower-case word for the card's icon, such as timer or poll. |
hosts | The https:// origins the app may fetch from, with no path, such as https://api.example.com. Nothing else is reachable. |
entry | The script's file name, when the zip holds more than one. Defaults to app.js. |
The app runs in an iframe with no origin of its own and a strict content security policy. Atomics checks the app's permissions on every call it makes, not only when it opens.
fetch reaches only the hosts the manifest lists. Pictures, sound and fonts load only from data: and blob: addresses, so fetch a picture from a listed host and show it with URL.createObjectURL.localStorage or IndexedDB. Keep state on the card with card.write.blob: workers run; eval and new Function are refused, and so is WebAssembly in a Studio app.embeds.add to put a page or player beside the card.What a person is told about all this is on What apps can reach.
atomics.call(method, params) returns a promise of the answer. A method the app has no permission for is refused.atomics.on(event, listener) calls the listener each time the host sends that event.--atomics-ink, --atomics-paper, --atomics-accent, --atomics-font and color-scheme on <html>. Use them with fallbacks so the app matches the board in light and dark.vote.<member id>, and redraw on card events rather than assuming your write was the last.| Method | Permission | Takes and answers |
|---|---|---|
app.info | none | Answers { id, name, version, permissions, space }, where space is "local" or "synced". |
card.read | card.state | Answers { state }: the app’s own keys on its card, as strings. |
card.write | card.state | Takes { set: { key: "value", gone: null } }; null removes a key. Keys are a-z, 0-9, dot, dash and underscore, up to 64; values up to 4,000 characters; 32 keys a write, 512 on a card. |
members.list | members | Answers { members: [{ id, me, name? }], count }. Ids are made for this app in this space; names need members.names. |
reading.set | readings.write | Takes { text }: what the card says, for search and the assistant. |
files.pick | files.read | Takes { kind }, such as "image". Asks the person to pick a card and answers its handle, or null. |
files.read | files.read | Takes { handle }. Answers the bytes of a card the person gave the app. |
files.opened | files.read | Answers { handle, name } for the file the card was opened with, or null. |
rows.pick | rows.read | Asks the person to pick a table or list. Answers what it is and its columns, or null. |
rows.read | rows.read | Takes { handle }. Answers { name, kind, columns, rows }, and sends rows events while it changes. |
entities.create | entities.create | Takes { heading?, notes: [{ text, surface?, ink? }] }. Puts notes in a row under the card. |
assets.get | assets | Takes { name } of a file the manifest lists. Answers its bytes; sends asset events while it downloads. |
assets.kept | assets | Answers { names, bytes }: which listed files are kept on this device. Downloads nothing. |
assets.forget | assets | Forgets the kept files, so the next assets.get downloads again. Answers { names }. |
transcribe.run | transcribe | Takes { handle, language?, prompt? } of a recording. Answers { text, segments, seconds, language? }. Synced spaces only. |
files.transcript | readings.write | Takes { handle, text, note? }. Writes a transcript onto a recording; note adds it as a card too. |
pictures.add | pictures.write | Takes { bytes, type, name? }. Puts a picture beside the card and answers { handle, name }. |
files.add | files.write | Takes { bytes, name, type? }. Puts a file beside the card and answers { handle, name }. |
files.update | files.write | Takes { handle, bytes } of a file the app was given or made. Writes over it and answers { handle, name }. |
realtime.open | realtime | Takes { name, lane }, lane "fast" (bytes, newest wins) or "reliable" (JSON, in order). Sends realtime events from then on. |
realtime.send | realtime | Takes { name, bytes } on a fast channel or { name, data } on a reliable one. |
realtime.close | realtime | Takes { name }. Nothing more is heard on it. |
realtime.clock | realtime | Answers { offset, rtt }. Space time is Date.now() + offset. |
lobby.read | realtime | Answers who is sitting at the card’s game, and sends lobby events when it changes. |
lobby.seat | realtime | Takes { seat: 0 }, { seat: 1 } or { seat: null } to get up. Answers the lobby. |
lobby.ready | realtime | Takes { ready: true } or false. When both seats are ready, the countdown starts. |
lobby.score | realtime | Takes { match, score: [left, right], final? }: the host’s report of the score. |
achievements.award | achievements | Takes { id, title, detail?, holder?, tone?, glyph? }. Puts a badge beside the card and answers { name }. |
plugs.peers | plugs | Answers { peers } joined to this card by a line, and sends plugs and plug events from then on. |
plugs.send | plugs | Takes { values, peer? }: sends values along this card’s lines, to one card or all of them. |
plugs.practice | plugs | Takes { peer, op: "reset", count, seed, options? } or { peer, op: "step", moves }. |
embeds.add | embeds.write | Takes { url, title?, width?, height? }. Puts an https page or player from another site beside the card. |
app.fetch | none | Takes { path, method?, headers?, body? }. Calls the app’s own server code and answers { status, type, text } or { status, type, bytes }. Refused "unapproved" until the space’s owner approves it; synced spaces only. |
| Event | What it carries |
|---|---|
card | { state } when anyone changes the card. |
members | { members, count } when people come and go. |
look | The theme changed; the CSS custom properties on <html> already have. |
rows | A table or list the app was given changed. |
asset | Download progress for assets.get. |
realtime | { name, lane, from, seat, bytes } or { …, data } on an open channel. |
clock | { offset, rtt } whenever the room’s clock is measured. |
lobby | The lobby changed. |
plugs | A line to another card was drawn or taken away. |
plug | { from, values } sent by a wired card. |
Each permission is shown to the person adding the app, in these words.
| Permission | What the person is told |
|---|---|
card.state | Keeps its own data on its card, where everyone in the space sees it |
members | Counts the people in the space |
members.names | Sees the names of the people in the space |
files.read | Reads the files you give it |
rows.read | Reads the tables and lists you give it, and follows them as they change |
entities.create | Adds cards to the board |
readings.write | Writes text onto the cards you give it, for search and the assistant |
transcribe | Sends recordings you give it to our transcription service, in synced spaces |
assets | Downloads the large files it needs, such as a speech model, once, and keeps them on this device |
pictures.write | Puts a picture it has made on the board, beside its own card |
commands | Takes named commands, so you and the assistant can drive this card instead of only clicking it |
files.write | Saves its own files to the board, such as a project you can open again later |
realtime | Plays live with the people in the space: takes a seat at its game and sends its moves straight to their screens |
achievements | Gives badges for what you do in it, which land on the board beside its card |
plugs | Connects to the cards you draw a line to: shares what it shows, takes their moves, and lets them practise on it |
embeds.write | Puts web pages and players, such as a video you play, on the board beside its card |
An app can carry code that runs on the server, for work a frame cannot do alone: keeping data that only the app writes, per space or per person, and calling sites the frame should not call itself. Put a server.js beside app.js in the zip. It is an ES module with a default fetch handler, and it runs in an isolated worker of its own.
export default {
async fetch(request, env) {
const { data: count } = await env.SPACE.call('storage.get', { key: 'count' })
const next = (count || 0) + 1
await env.SPACE.call('storage.set', { key: 'count', value: next })
return Response.json({ count: next })
}
}
The frame calls it with app.fetch:
const { status, text } = await atomics.call('app.fetch', { path: '/count', method: 'POST' })
const { count } = JSON.parse(text)
app.fetch is refused unapproved, so tell people who has to approve it.x-firebrand-app-caller header is the id the app knows them by in this space, the same as members.list. Their cookies and credentials are never forwarded.env.SPACE.call(method, params) answers { ok: true, data } or { ok: false, problem, message }. It reaches only this card and the app’s own storage in this space. For somebody who may only watch the space, every write is refused read-only.fetch reaches only the hosts the manifest lists, and redirects are not followed.Content-Security-Policy: sandbox.| Method | Permission | Takes and answers |
|---|---|---|
card.read | card.state | Answers { state }, the same keys the frame’s card.read sees. |
card.write | card.state | Takes { set }, the same write as the frame’s card.write, and answers the state after. |
storage.get | none | Takes { key, user? }. Answers the value, or null. |
storage.set | none | Takes { key, value, user? }. The value is anything JSON can hold, up to 64 KB. |
storage.remove | none | Takes { key, user? }. |
storage.list | none | Takes { user?, cursor? }. Answers { keys, done, cursor }. |