docs
what noback is
your AI made you an app. paste it here and get a link. that's it.
how
- ask your AI for an app.
- paste it on noback.
- share the link.
good to know
- no setup. no server, no database. whatever the app saves syncs by itself.
- no account. sign in only if you want your apps to stay forever.
- safe. each app runs walled off from your account.
- yours. download any app with its data, anytime.
for your AI
tell it to read noback.app/llms.txt. or let it publish by itself: https://apps.noback.page/mcp. to publish to your account, make a token in your account → "connect your AI".
below, what your AI reads (also at /sdk.en.md; the original, in spanish, at /sdk.md).
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/jsonAccept: 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.
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
- One HTML file or a
.zippackage. For small apps, inline CSS and JS. For heavy code and your own assets, use the package format below. - 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), useapp.net.fetch(see below): the person has to give permission. - 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. - All state goes in
app.db. Don't uselocalStorage,sessionStorage,indexedDBor cookies: they don't work and the data would be lost. What you save inapp.dbsyncs by itself and is seen by everyone the app is shared with. - 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.
- 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.
- 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):
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:
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.
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.
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
httpsaddresses, onlyGETandPOST. Thebodygoes 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:
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 +
"¤t=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) andcreatedBy({ id, name }of whoever created it), which the app can't change. It also getsupdatedAtandupdatedBy: on creation they match the creation and eachupdatesets them to whoever edits and their device's time. The SDK ignores these five fields if you send them inaddorupdate. Older records may not haveupdatedAtorupdatedByuntil 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,Dateobjects (use ISO text) orundefined. - Always draw the screen from the list that
watchreceives. Don't keep a separate copy that can get out of sync.
Skeleton
<!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.
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.