Atomics
Account Plans Usage Billing
Appearance
Language
English español España español Latinoamérica

Install Atomics App State Get help Share feedback Privacy Policy Terms of Service Sign out
© 2024-2026 Keith Andre Rose

Build an app for Atomics

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.

Quickstart

  1. Write app.js: one plain script, no imports and no build step. Wrap it in a function so nothing leaks into the page.
  2. Put it in a zip, with a manifest.json beside it if you want to name the app, its permissions or its size.
  3. Open Studio, choose Import a zip, and pick the file. Studio checks it and shows you what it found.
  4. Add the app to one of your spaces. It arrives as a card, and everyone in the space sees the same card.

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"
}

The manifest

Every field is optional in a Studio zip. Studio sets the app's id, version, entry and hash itself.

FieldWhat it is
nameWhat the card and Studio call the app, 1 to 60 characters.
permissionsWhat 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.
sizeThe card's size when it is added, { width, height } in pixels, each from 120 to 2000.
glyphOne lower-case word for the card's icon, such as timer or poll.
hostsThe https:// origins the app may fetch from, with no path, such as https://api.example.com. Nothing else is reachable.
entryThe script's file name, when the zip holds more than one. Defaults to app.js.

The sandbox

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.

  • Network. 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.
  • Storage. No cookies, localStorage or IndexedDB. Keep state on the card with card.write.
  • Code. Inline script and blob: workers run; eval and new Function are refused, and so is WebAssembly in a Studio app.
  • The page. No navigation, popups, forms or nested frames. Use embeds.add to put a page or player beside the card.
  • Devices. No camera, microphone, location, clipboard, passkeys or payments.
  • Size. A Studio app is one script of at most 200 KB, in a zip of at most 2 MB.

What a person is told about all this is on What apps can reach.

Talking to the space

  • 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.
  • The host sets --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.
  • Card state is shared by everyone in the space. When two people may write at once, give each person their own key, such as vote.<member id>, and redraw on card events rather than assuming your write was the last.

Methods

MethodPermissionTakes and answers
app.infononeAnswers { id, name, version, permissions, space }, where space is "local" or "synced".
card.readcard.stateAnswers { state }: the app’s own keys on its card, as strings.
card.writecard.stateTakes { 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.listmembersAnswers { members: [{ id, me, name? }], count }. Ids are made for this app in this space; names need members.names.
reading.setreadings.writeTakes { text }: what the card says, for search and the assistant.
files.pickfiles.readTakes { kind }, such as "image". Asks the person to pick a card and answers its handle, or null.
files.readfiles.readTakes { handle }. Answers the bytes of a card the person gave the app.
files.openedfiles.readAnswers { handle, name } for the file the card was opened with, or null.
rows.pickrows.readAsks the person to pick a table or list. Answers what it is and its columns, or null.
rows.readrows.readTakes { handle }. Answers { name, kind, columns, rows }, and sends rows events while it changes.
entities.createentities.createTakes { heading?, notes: [{ text, surface?, ink? }] }. Puts notes in a row under the card.
assets.getassetsTakes { name } of a file the manifest lists. Answers its bytes; sends asset events while it downloads.
assets.keptassetsAnswers { names, bytes }: which listed files are kept on this device. Downloads nothing.
assets.forgetassetsForgets the kept files, so the next assets.get downloads again. Answers { names }.
transcribe.runtranscribeTakes { handle, language?, prompt? } of a recording. Answers { text, segments, seconds, language? }. Synced spaces only.
files.transcriptreadings.writeTakes { handle, text, note? }. Writes a transcript onto a recording; note adds it as a card too.
pictures.addpictures.writeTakes { bytes, type, name? }. Puts a picture beside the card and answers { handle, name }.
files.addfiles.writeTakes { bytes, name, type? }. Puts a file beside the card and answers { handle, name }.
files.updatefiles.writeTakes { handle, bytes } of a file the app was given or made. Writes over it and answers { handle, name }.
realtime.openrealtimeTakes { name, lane }, lane "fast" (bytes, newest wins) or "reliable" (JSON, in order). Sends realtime events from then on.
realtime.sendrealtimeTakes { name, bytes } on a fast channel or { name, data } on a reliable one.
realtime.closerealtimeTakes { name }. Nothing more is heard on it.
realtime.clockrealtimeAnswers { offset, rtt }. Space time is Date.now() + offset.
lobby.readrealtimeAnswers who is sitting at the card’s game, and sends lobby events when it changes.
lobby.seatrealtimeTakes { seat: 0 }, { seat: 1 } or { seat: null } to get up. Answers the lobby.
lobby.readyrealtimeTakes { ready: true } or false. When both seats are ready, the countdown starts.
lobby.scorerealtimeTakes { match, score: [left, right], final? }: the host’s report of the score.
achievements.awardachievementsTakes { id, title, detail?, holder?, tone?, glyph? }. Puts a badge beside the card and answers { name }.
plugs.peersplugsAnswers { peers } joined to this card by a line, and sends plugs and plug events from then on.
plugs.sendplugsTakes { values, peer? }: sends values along this card’s lines, to one card or all of them.
plugs.practiceplugsTakes { peer, op: "reset", count, seed, options? } or { peer, op: "step", moves }.
embeds.addembeds.writeTakes { url, title?, width?, height? }. Puts an https page or player from another site beside the card.
app.fetchnoneTakes { 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.

Events

EventWhat it carries
card{ state } when anyone changes the card.
members{ members, count } when people come and go.
lookThe theme changed; the CSS custom properties on <html> already have.
rowsA table or list the app was given changed.
assetDownload 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.
lobbyThe lobby changed.
plugsA line to another card was drawn or taken away.
plug{ from, values } sent by a wired card.

Permissions

Each permission is shown to the person adding the app, in these words.

PermissionWhat the person is told
card.stateKeeps its own data on its card, where everyone in the space sees it
membersCounts the people in the space
members.namesSees the names of the people in the space
files.readReads the files you give it
rows.readReads the tables and lists you give it, and follows them as they change
entities.createAdds cards to the board
readings.writeWrites text onto the cards you give it, for search and the assistant
transcribeSends recordings you give it to our transcription service, in synced spaces
assetsDownloads the large files it needs, such as a speech model, once, and keeps them on this device
pictures.writePuts a picture it has made on the board, beside its own card
commandsTakes named commands, so you and the assistant can drive this card instead of only clicking it
files.writeSaves its own files to the board, such as a project you can open again later
realtimePlays live with the people in the space: takes a seat at its game and sends its moves straight to their screens
achievementsGives badges for what you do in it, which land on the board beside its card
plugsConnects to the cards you draw a line to: shares what it shows, takes their moves, and lets them practise on it
embeds.writePuts web pages and players, such as a video you play, on the board beside its card

Server code

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)
  • Approval. Server code runs in a space only once the space’s owner approves that version. A new version is a new approval. Until then app.fetch is refused unapproved, so tell people who has to approve it.
  • Who calls. Only people signed in who can open the card. The request’s 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.
  • The space. 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.
  • Network. fetch reaches only the hosts the manifest lists, and redirects are not followed.
  • Limits. A request or answer of at most 1 MB. A response cannot set cookies, and is served with Content-Security-Policy: sandbox.
MethodPermissionTakes and answers
card.readcard.stateAnswers { state }, the same keys the frame’s card.read sees.
card.writecard.stateTakes { set }, the same write as the frame’s card.write, and answers the state after.
storage.getnoneTakes { key, user? }. Answers the value, or null.
storage.setnoneTakes { key, value, user? }. The value is anything JSON can hold, up to 64 KB.
storage.removenoneTakes { key, user? }.
storage.listnoneTakes { user?, cursor? }. Answers { keys, done, cursor }.