# How to make an app for noback

You are going to write a web app that runs on **noback**, a platform where apps
run in the person's browser, inside a sandboxed iframe. noback checks network
isolation before running them (see compatibility at the end). The platform takes
care of saving the data, syncing it across devices and sharing it with other
people. You only write the interface and the logic.

## Publish from your AI, with nothing to set up

Connect the MCP client to `https://apps.noback.page/mcp` (Streamable HTTP)
**without Authorization**. No account or token needed: read `get_sdk`, write the
HTML, validate with `validate_app {html}` and publish with `create_app {html, name?}`.
`get_sdk` returns `{"markdown": ...}` with the same content as
`https://apps.noback.page/sdk.md` (the Spanish original of this file). Without
MCP, the person can also paste or drop the HTML on `https://noback.app`.

The app gets an id `a-xxxxxx`, expires after **7 days** and has the limits of
anonymous mode (512 KiB of HTML, 10 publishes per hour per IP and no
`app.net.fetch`). Use `app.db`: its data persists and syncs all the same.

When you publish, **always give the person both links**, in simple words:

- `openUrl`: "Open and share your app". Everyone who opens this link sees and
  edits the same data.
- `claimUrl`: "To save it to your account before it expires". It is private:
  it carries the edit key and lets someone keep the app, so it goes only to the
  person who asked for it, never to others. When opened, noback asks to sign in
  or create an account with a passkey or Google; the app moves to my apps and
  stops expiring.

Keep `appId`, `editKey` and the HTML. If they ask for changes to that same app,
use `update_app {appId, html, editKey}`: it keeps the data, link and expiry.
Don't create another app for a new version. Never put `editKey` or `claimUrl`
inside the HTML, the data or the public link: `openUrl` is the only thing to share.

With `Authorization: Bearer <personal token>` you publish apps to the account,
with no anonymous expiry, and management tools are enabled (`list_apps`,
`get_app`, `delete_app`, etc.). The token is optional and lives in the client's
settings, never in the HTML. With a token, the `openUrl` from creation opens a new
copy: share from the noback menu to use the same data. Without a token you can't
use account tools; `update_app` requires the private key.

### No MCP client (curl)

The server is stateless: each request is an independent JSON-RPC `POST` to
`https://apps.noback.page/mcp`, with no `Mcp-Session-Id`. Always send these headers
(without the double `Accept` it answers 406):

- `Content-Type: application/json`
- `Accept: application/json, text/event-stream`

The sequence is `initialize`, `notifications/initialized` and then `tools/call`.
The response is JSON; the tool's result comes as JSON text in
`result.content[0].text`.

```sh
MCP=https://apps.noback.page/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s "${H[@]}" $MCP -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_app","arguments":{"name":"Hello","html":"<!doctype html><html><body><h1>Hello</h1></body></html>"}}}'
```

## Rules

1. **One HTML file or a `.zip` package.** For small apps, inline CSS and JS. For
   heavy code and your own assets, use the package format below.
2. **No network of its own.** The app can't do external `fetch`, `XMLHttpRequest`,
   WebSocket, or load scripts, fonts or images from the internet. All of that is
   blocked. If you need icons, use emojis or SVG written in the HTML. If the
   app really needs data from the internet (the weather, an exchange rate), use
   `app.net.fetch` (see below): the person has to give permission.
3. **No CDN.** Libraries must go inline or inside the package; they can use
   WASM on the main thread, with no `eval`, workers or storage of their own.
4. **All state goes in `app.db`.** Don't use `localStorage`, `sessionStorage`,
   `indexedDB` or cookies: they don't work and the data would be lost. What you
   save in `app.db` syncs by itself and is seen by everyone the app is shared
   with.
5. **Mobile-first design, readable for older people.** Large text (18 px or
   more), large buttons (44 px tall or more), good contrast, simple texts
   in the language of the person who asked for the app (if unclear, Spanish),
   with no technical words.
6. **Data that lasts across versions.** If later they ask you to improve the app,
   keep the same collection and field names. If you add a new field, treat its
   absence as a default value: old data doesn't have it.
7. **Respect the sandbox blocks** in the following table. Don't try to enable
   them by changing permissions or loading libraries.

| Blocked inside the app | Alternative |
|---|---|
| Workers (`Worker`, `SharedWorker`, service workers) | Process on the main thread in small batches, yielding with `setTimeout` so the interface doesn't freeze. |
| `eval` and `new Function` (also `Function(...)`) | Write normal JavaScript functions; for data, use `JSON.parse`. |
| WASM with threads / SharedArrayBuffer | Use single-threaded WASM builds. `WebAssembly.compile`, `instantiate` and `instantiateStreaming` are allowed. |
| Downloads with `<a download>` or `blob:` links to download | There is no `app.download` yet. Show the text/CSV/JSON in a `textarea` to select and copy by hand. Don't promise a download; "take it with you" in the noback menu exports the whole app with its data. |
| `alert`, `confirm`, `prompt` | Make your own modals with HTML (`<dialog>` or page elements). |
| Popups (`window.open`, links with `target="_blank"`) | Show the information inside the page; don't design flows that open another window. |
| `localStorage`, `sessionStorage`, IndexedDB and cookies | Persist with `app.db`; use variables for temporary state. |
| External `fetch`, XMLHttpRequest and WebSocket | `fetch("file.wasm")` serves your own assets; for the internet use `app.net.fetch` with permission. |

## noback packages (.zip)

Deliver a zip with `index.html` at the root, an optional `noback.toml` and its assets.
Don't add a wrapping folder. Allowed: `.js`, `.mjs`, `.css`, `.wasm`,
`.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`, `.mp3`, `.ogg`, `.wav`, `.json`,
`.woff`, `.woff2`, `.ttf` and `.otf`. No other HTML, symbolic links, special
files, encryption, ZIP64 or split zips. At most 4096 entries. Paths are
relative, with no leading `/`, `..`, empty segments, `\`, `%`, spaces, query or fragment.
Names are case-sensitive. Each version includes all its assets and
its descriptor, with an immutable hash independent of the zip's compression.

Limits compressed **and uncompressed**: 5 MiB anonymous, 25 MiB Hobby, 100 MiB Pro.
`index.html` keeps the plan's HTML limit: 512 KiB / 2 MiB / 5 MiB.
`boxy.toml` is accepted as an obsolete alias for older packages; use only one descriptor.
`noback.toml` allows up to 16 KiB. Full example (the keys stay in Spanish):

```toml
nombre = "Mom's meds"
descripcion = "To write down the meds so the family can see them"
red = ["api.open-meteo.com"]
```

`nombre` (name) is non-empty text (up to 80 characters), `descripcion` (description) up to 500.
`nombre` is used if the publish doesn't give an explicit name in `name`.
`red` (network) allows up to 32 exact domains with no scheme, port or wildcards. It
doesn't include subdomains automatically. `red = []` rejects all mediated network.
Without `red`, the usual per-domain permission applies. The bottom line asks
for each site; if `red` is declared, any domain outside that list fails
**without asking**, even if it had a saved permission. Declaring a site doesn't authorize it: it still needs
consent and a plan with network. Anonymous never has mediated network.
Future keys (`login`, `email`, shared database, etc.) are ignored with a warning;
they don't enable capabilities.

When publishing, relative scripts and styles are inlined; images and audio
up to 256 KiB become `data:`. Larger media load through the
bridge as `blob:`; fonts in CSS are embedded as `data:` (`blob:` is not enabled
for fonts). The order of classic scripts with `defer` is kept by
placing them at the end of the body. The transformed HTML has a budget of
up to twice the package limit; avoid repeating large assets inline.
CSS `url()` resolves from the stylesheet. `@import`, relative `srcset`
and JS imports between files are not resolved: bundle the JS into one bundle (classic or module)
and the CSS into one stylesheet. Don't include the SDK: noback injects it first.

For files loaded at runtime:

```js
await app.ready
const response = await fetch("engine.wasm") // relative GET, doesn't go to the network
const module = await WebAssembly.instantiateStreaming(Promise.resolve(response), imports)
const bytes = await app.asset("data.json") // ArrayBuffer transferred through the bridge
const imageURL = await app.assetURL("photo.png") // blob: with the file's MIME type
image.src = imageURL
// When you no longer use it:
URL.revokeObjectURL(imageURL)
```

The `fetch` shim accepts relative strings (optional leading `./`), GET and
options like `signal`; it rejects absolute URLs, `Request`, other methods and `..`.
Missing paths reject the promise. It doesn't fall back to native fetch or to
`app.net`. Binaries come from the player for that app and hash; the session never
enters the iframe. Up to four loads at a time per frame, with the bridge's
120 operations per second limit. Response binaries can
exceed 1 MiB; incoming messages keep their limit.
Aborting a `fetch` rejects with `AbortError`; a read of the asset already started
in the player may still finish.

`XMLHttpRequest` stays blocked; set up the library to use `fetch` and
relative paths (`locateFile: path => path` in Emscripten), or pass it `wasmBinary`.
Libraries that build absolute URLs from `import.meta.url` need
that setup. There are no workers, threads, SharedArrayBuffer, OPFS or iframe
storage. Don't promise automatic compatibility with all of Godot/Pyodide:
they need single-threaded builds, no `eval`, and bundles with no dynamic imports.
The official SQLite demo turns off its storage VFS and saves the notes in `app.db`.

Publish the zip by dropping it on `https://noback.app`, like an HTML file. MCP
`validate_app`, `create_app` and `update_app` accept `zipBase64` instead of
`html`. Don't send both. With an account, the `POST /api/publish` API also
accepts `{appId, zipBase64, name?}` instead of `html`.
If you have the noback repo: `node cli/publish.mjs <appId> app.zip` publishes and
`node cli/validate.mjs app.zip [anon|hobby|pro]` validates without publishing.
"take it with you" also includes the package's assets in the standalone HTML;
"export everything" keeps each original zip and its data separately.
Bridge assets need to reach the relay; they aren't cached yet to
run the package offline. The standalone export does carry them inside.
Keep the original zip to edit and republish: the current chat gets the transformed
HTML and doesn't rebuild a package with its assets.

## The API: `window.app`

The platform injects `window.app` before your code runs. You don't need to
include anything.

```js
await app.ready                       // wait for this before using the rest

const me = await app.user()           // { id, name } of whoever is using the app

const tasks = app.db.collection("tasks")

const id = await tasks.add({ text: "Buy bread", done: false })
await tasks.update(id, { done: true })     // changes only those fields
await tasks.remove(id)
const one = await tasks.get(id)            // the object, or null
const all = await tasks.all()              // list sorted by createdAt

// watch calls your function at the start and every time something changes,
// also when another person changes it, or the same user on another device.
const stopWatching = tasks.watch(list => draw(list))

await app.share()                     // opens noback's share
```

Don't add a share button of your own: noback already offers sharing from its
little tab on the side of the app. Use `app.share()` only if the person asks for it.

`update(id, patch)` does a **shallow merge**: it only changes the fields
mentioned and keeps the rest. Two concurrent edits of different fields
merge; if they edit the same field, one of the two wins (not necessarily the
latest by time). Each text, list or nested object is replaced whole: there is no
deep merge or collaborative text editing. Keep growing lists, like
comments, in a separate collection, with one record per entry.

### Version changes and data

noback saves a checkpoint before running each version for the first time
on a copy. The copy's owner can recover their collections from
**more → data history → Back to here**, and undo that restore.
Recovered data syncs with whoever uses the same copy. The history
and checkpoints take up the plan's space.

Checkpoints live in the player's private metadata: `app.db` only gives access
to collections; `_boxy`, `__proto__`, `constructor` and `prototype` are reserved
names. Don't try to access that metadata or keep your migrations there.

Keep names and fields compatible across versions. A transformation
done by your code may run again every time the app opens: check
each record's state first and avoid deleting the originals. For now there is no
`app.db.migrate`; checkpoints let you recover data but don't
convert between schemas automatically.

### Internet with permission: `app.net.fetch`

**Use it only if the app needs data from the internet.** If the app works without
network, don't use it. The first time the app asks for a site, the platform
asks the person whether to allow it. If they say no, `app.net.fetch` fails with
an error: show a kind message and let the app keep working.

```js
const res = await app.net.fetch(url, { method: "GET", headers: {}, body: "text" })
// res = { ok, status, headers, text, json }
// json is the object if the response was JSON; otherwise, null.
```

- Only `https` addresses, only `GET` and `POST`. The `body` goes as text
  (`JSON.stringify(...)`) and the response can't be over 2 MiB.
- It only works with APIs that allow requests from the browser (CORS) and that
  don't ask for a secret key: a key written in the HTML can be seen by anyone.
- Wrap each call in `try/catch`: it can fail because of permission or
  connection.
- Always use the same base address: each different site is another question
  for the person.

Example with Open-Meteo, which is free and needs no key:

```js
async function weather(city) {
  const geo = await app.net.fetch(
    "https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&name=" + encodeURIComponent(city))
  const place = geo.json?.results?.[0]
  if (!place) throw new Error("I couldn't find that city")
  const forecast = await app.net.fetch(
    "https://api.open-meteo.com/v1/forecast?latitude=" + place.latitude + "&longitude=" + place.longitude +
    "&current=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min&timezone=auto")
  return { place: place.name, now: forecast.json.current, days: forecast.json.daily }
}
```

Notice that geocoding and forecast are two different sites: the person will see
two questions.

- Each object automatically gets `id`, `createdAt` (ISO date) and
  `createdBy` (`{ id, name }` of whoever created it), which the app can't change.
  It also gets `updatedAt` and `updatedBy`: on creation they match the creation
  and each `update` sets them to whoever edits and their device's time.
  The SDK ignores these five fields if you send them in `add` or `update`.
  Older records may not have `updatedAt` or `updatedBy` until
  their next edit. They are SDK metadata, not an audit against modified
  clients; the dates depend on the device's clock.
- Values have to be simple data: text, numbers, booleans, `null`,
  lists and objects. Don't save functions, `Date` objects (use ISO text) or
  `undefined`.
- Always draw the screen from the list that `watch` receives. Don't keep a
  separate copy that can get out of sync.

## Skeleton

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>My app</title>
  <style>
    body { font: 18px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
    button { font-size: 18px; min-height: 44px; }
  </style>
</head>
<body>
  <h1>My app</h1>
  <ul id="list"></ul>
  <script>
    app.ready.then(async () => {
      const things = app.db.collection("things")
      things.watch(list => {
        document.getElementById("list").innerHTML = ""
        for (const t of list) {
          const li = document.createElement("li")
          li.textContent = t.text
          document.getElementById("list").append(li)
        }
      })
    })
  </script>
</body>
</html>
```

For a simple app, return only the full HTML. If you need separate assets,
deliver the full zip package with the structure described.

### Compatibility and bridge limits

noback checks the iframe's navigation before running the app. If it can't
verify isolation, anonymous apps don't run. Account apps
show an incomplete-protection notice only when the probe confirms a way
out. If the measurement is inconclusive, it retries once; anonymous apps
stay blocked until isolation is verified. The technical results are
kept in the tests, outside the product interface.

The bridge accepts up to 120 operations per second (including the SDK's handshake).
If that is exceeded, it rejects the extra operations; the app can wait a second
and try again. In games and clickers don't save on every click: collect the
changes in memory and write every 1–2 seconds.

```js
let pending = null
function save(data) { pending = data }  // on each click: memory only
setInterval(() => { if (pending) { void points.update(id, pending); pending = null } }, 1500)
```

Each message sent to the player has a conservative maximum budget of
1 MiB (text, keys and structure included). Messages that are too large,
cyclic, binary or of unknown types/operations are ignored. Split large
loads into small operations. Replacing or navigating the iframe invalidates its bridge,
including pending responses.
