docs
qué es noback
tu IA te hizo una app. pegala acá y tenés un link. listo.
cómo
- pedile una app a tu IA.
- pegala en noback.
- compartí el link.
bueno saber
- nada que configurar. sin servidor ni base de datos. lo que guarda la app se sincroniza solo.
- sin cuenta. entrá solo si querés que tus apps no venzan.
- segura. cada app corre aislada de tu cuenta.
- tuya. bajate cualquier app con sus datos cuando quieras.
para tu IA
decile que lea noback.app/llms.txt. o que publique sola: https://apps.noback.page/mcp. para que publique en tu cuenta, creá un token en tu cuenta → «conectá tu IA».
abajo, lo que lee tu IA (también en /sdk.md).
Cómo hacer una app para noback
Vas a escribir una app web que corre en noback, una plataforma donde las apps corren en el navegador de la persona, dentro de un iframe sandbox. noback comprueba el aislamiento de red antes de ejecutarlas (ver compatibilidad al final). La plataforma se ocupa de guardar los datos, sincronizarlos entre dispositivos y compartirlos con otras personas. Vos solo escribís la interfaz y la lógica.
Publicar desde tu IA, sin configurar nada
Conectá el cliente MCP a https://apps.noback.page/mcp (Streamable HTTP) sin Authorization. No hace falta cuenta ni token: leé get_sdk, generá el HTML, validá con validate_app {html} y publicá con create_app {html, name?}. get_sdk devuelve {"markdown": ...} con el mismo contenido que https://apps.noback.page/sdk.md (este archivo). Sin MCP, la persona también puede pegar o soltar el HTML en https://noback.app.
La app recibe un id a-xxxxxx, vence a los 7 días y tiene los límites del modo anónimo (512 KiB de HTML, 10 publicaciones por hora por IP y sin app.net.fetch). Usá app.db: sus datos persisten y se sincronizan igual.
Al publicar, dale siempre los dos links a la persona, en palabras simples:
openUrl: «Abrí y compartí tu app». Todos los que abran este link ven y editan los mismos datos.claimUrl: «Para guardarla en tu cuenta antes de que venza». Es privado: lleva adentro la clave de edición y permite quedarse con la app, así que va solo a la persona que la pidió, nunca a otros. Al abrirlo, noback pide entrar o crear una cuenta con passkey o Google; la app pasa a Mis apps y deja de vencer.
Conservá appId, editKey y el HTML. Si te piden cambios de esa misma app, usá update_app {appId, html, editKey}: conserva datos, link y vencimiento. No crees otra app para una nueva versión. Nunca pongas editKey ni claimUrl dentro del HTML, los datos o el link público: openUrl es lo único para compartir.
Con Authorization: Bearer <token personal> se publican apps de la cuenta, sin vencimiento anónimo, y se habilitan herramientas de gestión (list_apps, get_app, delete_app, etc.). El token es opcional y vive en la configuración del cliente, nunca en el HTML. Con token, openUrl de creación abre una copia nueva: compartí desde el menú de noback para usar los mismos datos. Sin token no se pueden usar herramientas de cuenta; update_app exige la clave privada.
Sin cliente MCP (curl)
El servidor es sin estado: cada pedido es un POST JSON-RPC independiente a https://apps.noback.page/mcp, sin Mcp-Session-Id. Mandá siempre estos headers (sin el Accept doble responde 406):
Content-Type: application/jsonAccept: application/json, text/event-stream
La secuencia es initialize, notifications/initialized y después tools/call. La respuesta es JSON; el resultado de la herramienta viene como texto JSON en 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":"Hola","html":"<!doctype html><html><body><h1>Hola</h1></body></html>"}}}'
Reglas
- Un HTML o un paquete
.zip. Para apps chicas, CSS y JS inline. Para código pesado y recursos propios, usá el formato de paquete de abajo. - Sin red propia. La app no puede hacer
fetchexterno,XMLHttpRequest, WebSocket, ni cargar scripts, fuentes o imágenes de internet. Todo eso está bloqueado. Si necesitás íconos, usá emojis o SVG escritos en el HTML. Si la app de verdad necesita datos de internet (el clima, una cotización), usáapp.net.fetch(ver más abajo): la persona tiene que dar permiso. - Sin CDN. Las librerías deben ir inline o dentro del paquete; pueden usar WASM en el hilo principal, sin
eval, workers ni almacenamiento propio. - Todo el estado va en
app.db. No useslocalStorage,sessionStorage,indexedDBni cookies: no funcionan y los datos se perderían. Lo que guardes enapp.dbse sincroniza solo y lo ven todas las personas con las que se comparte la app. - Diseño mobile-first y legible para gente mayor. Letra grande (18 px o más), botones grandes (44 px de alto o más), buen contraste, textos simples y en el idioma de la persona que pidió la app (si no está claro, español), sin palabras técnicas.
- Datos que duran entre versiones. Si más adelante te piden mejorar la app, mantené los mismos nombres de colección y de campos. Si agregás un campo nuevo, tratá su ausencia como un valor por defecto: los datos viejos no lo tienen.
- Respetá los bloqueos del sandbox de la tabla siguiente. No intentes habilitarlos cambiando permisos ni cargando librerías.
| Bloqueado dentro de la app | Alternativa |
|---|---|
Workers (Worker, SharedWorker, service workers) | Procesá en el hilo principal por tandas chicas, cediendo con setTimeout para no congelar la interfaz. |
eval y new Function (también Function(...)) | Escribí funciones JavaScript normales; para datos, usá JSON.parse. |
| WASM con threads / SharedArrayBuffer | Usá builds WASM de un solo hilo. WebAssembly.compile, instantiate y instantiateStreaming están permitidos. |
Descargas con <a download> o enlaces blob: para descargar | Todavía no existe app.download. Mostrá el texto/CSV/JSON en un textarea para seleccionar y copiar manualmente. No prometas una descarga; “Llevátela” en el menú de noback exporta la app completa con sus datos. |
alert, confirm, prompt | Hacé modales propios con HTML (<dialog> o elementos de la página). |
Popups (window.open, enlaces con target="_blank") | Mostrá la información dentro de la página; no diseñes flujos que abran otra ventana. |
localStorage, sessionStorage, IndexedDB y cookies | Persistí con app.db; usá variables para el estado temporal. |
fetch externo, XMLHttpRequest y WebSocket | fetch("archivo.wasm") sirve recursos propios; para internet usá app.net.fetch con permiso. |
Paquetes noback (.zip)
Entregá un zip con index.html en la raíz, noback.toml opcional y sus recursos. No agregues una carpeta contenedora. Se admiten .js, .mjs, .css, .wasm, .png, .jpg, .jpeg, .svg, .webp, .gif, .mp3, .ogg, .wav, .json, .woff, .woff2, .ttf y .otf. No otros HTML, enlaces simbólicos, archivos especiales, cifrado, ZIP64 ni zips divididos. Máximo 4096 entradas. Las rutas son relativas, sin / inicial, .., segmentos vacíos, \, %, espacios, query ni fragmento. Los nombres distinguen mayúsculas. Cada versión incluye todos sus recursos y su descriptor, con un hash inmutable independiente de la compresión del zip.
Límites comprimido y descomprimido: 5 MiB anónimo, 25 MiB Hobby, 100 MiB Pro. index.html conserva el límite HTML del plan: 512 KiB / 2 MiB / 5 MiB. boxy.toml se acepta como alias obsoleto para paquetes anteriores; usá solo un descriptor. noback.toml admite hasta 16 KiB. Ejemplo completo:
nombre = "Remedios de mamá"
descripcion = "Para anotar los remedios y que la familia los vea"
red = ["api.open-meteo.com"]
nombre es texto no vacío (hasta 80 caracteres), descripcion hasta 500. nombre se usa si la publicación no indica un nombre explícito en name. red admite hasta 32 dominios exactos sin esquema, puerto ni comodines. No incluye subdominios automáticamente. red = [] rechaza toda red mediada. Sin red, sigue el permiso por dominio habitual. La línea inferior pregunta por cada sitio; si se declara red, cualquier dominio fuera de esa lista falla sin preguntar, aun si tenía un permiso guardado. Declarar un sitio no lo autoriza: sigue haciendo falta consentimiento y un plan con red. Anónimo nunca tiene red mediada. Claves futuras (login, email, base compartida, etc.) se ignoran con un aviso; no habilitan capacidades.
Al publicar, scripts y estilos relativos se incorporan inline; imágenes y audio de hasta 256 KiB se convierten en data:. Los medios mayores se cargan por el puente como blob:; las fuentes en CSS se incrustan como data: (no se habilita blob: para fuentes). Se preserva el orden de scripts clásicos con defer ubicándolos al final del body. El HTML transformado tiene un presupuesto de hasta el doble del límite del paquete; evitá repetir recursos grandes inline. CSS url() resuelve desde la hoja. No se resuelven @import, srcset relativo ni imports JS entre archivos: empaquetá el JS en un bundle (clásico o módulo) y el CSS en una hoja. No incluyas el SDK: noback lo inyecta primero.
Para archivos cargados en ejecución:
await app.ready
const response = await fetch("engine.wasm") // GET relativo, no sale a la red
const module = await WebAssembly.instantiateStreaming(Promise.resolve(response), imports)
const bytes = await app.asset("datos.json") // ArrayBuffer transferido por el puente
const imageURL = await app.assetURL("foto.png") // blob: con el MIME del archivo
imagen.src = imageURL
// Cuando ya no lo uses:
URL.revokeObjectURL(imageURL)
El shim de fetch acepta strings relativos (./ inicial opcional), GET y opciones como signal; rechaza URLs absolutas, Request, otros métodos y ... Las rutas inexistentes rechazan la promesa. No delega a fetch nativo ni a app.net. Los binarios vienen del player para esa app y hash; la sesión nunca entra al iframe. Hasta cuatro cargas simultáneas por frame, con el límite de 120 operaciones por segundo del puente. Los binarios de respuesta pueden superar 1 MiB; los mensajes de entrada conservan su límite. Abortar un fetch rechaza con AbortError; la lectura ya iniciada del recurso en el player puede terminar igualmente.
XMLHttpRequest sigue bloqueado; configurá la librería para usar fetch y rutas relativas (locateFile: path => path en Emscripten), o pasale wasmBinary. Las librerías que construyen URLs absolutas desde import.meta.url requieren esa configuración. No hay workers, threads, SharedArrayBuffer, OPFS ni storage del iframe. No prometas compatibilidad automática con todo Godot/Pyodide: se necesitan builds de un hilo, sin eval, y bundles sin imports dinámicos. La demo SQLite oficial desactiva sus VFS de storage y guarda las notas en app.db.
Publicá el zip soltándolo en https://noback.app, como un HTML. MCP validate_app, create_app y update_app aceptan zipBase64 en lugar de html. No envíes ambos. Con una cuenta, la API POST /api/publish también acepta {appId, zipBase64, name?} en lugar de html. Si tenés el repo de noback: node cli/publish.mjs <appId> app.zip publica y node cli/validate.mjs app.zip [anon|hobby|pro] valida sin publicar. “Llevátela” incluye también los recursos del paquete en el HTML autónomo; “Exportar todo” conserva cada zip original y sus datos por separado. Los recursos del puente requieren llegar al relay; todavía no se cachean para ejecutar el paquete sin conexión. La exportación autónoma sí los lleva adentro. Conservá el zip original para editar y republicar: el chat actual recibe el HTML transformado y no reconstruye un paquete con sus recursos.
La API: window.app
La plataforma inyecta window.app antes de que corra tu código. No hace falta incluir nada.
await app.ready // esperá esto antes de usar el resto
const yo = await app.user() // { id, name } de quien está usando la app
const tareas = app.db.collection("tareas")
const id = await tareas.add({ texto: "Comprar pan", hecha: false })
await tareas.update(id, { hecha: true }) // cambia solo esos campos
await tareas.remove(id)
const una = await tareas.get(id) // el objeto, o null
const todas = await tareas.all() // lista ordenada por createdAt
// watch llama a tu función al principio y cada vez que algo cambia,
// también cuando cambia otra persona o el mismo usuario en otro dispositivo.
const dejarDeMirar = tareas.watch(lista => dibujar(lista))
await app.share() // abre el compartir de noback
No agregues un botón de compartir propio: noback ya ofrece compartir desde su pestañita al costado de la app. Usá app.share() solo si la persona lo pide.
update(id, patch) hace un merge superficial: solo cambia los campos mencionados y conserva los demás. Dos ediciones concurrentes de campos distintos se fusionan; si editan el mismo campo, gana una de las dos (no necesariamente la última por hora). Cada texto, lista u objeto anidado se reemplaza entero: no hay merge profundo ni edición colaborativa de texto. Guardá listas que crecen, como comentarios, en una colección aparte, con un registro por entrada.
Cambios de versión y datos
noback guarda un punto de control antes de ejecutar por primera vez cada versión sobre una copia. El dueño de la copia puede recuperar sus colecciones desde más → historial de datos → Volver acá, y deshacer esa restauración. Los datos recuperados se sincronizan con quienes usan la misma copia. La historia y los puntos de control ocupan espacio del plan.
Los puntos viven en metadatos privados del player: app.db solo permite acceder a colecciones; _boxy, __proto__, constructor y prototype son nombres reservados. No intentes acceder a esos metadatos ni guardes tus migraciones ahí.
Mantené compatibles los nombres y campos entre versiones. Una transformación hecha por tu código puede ejecutarse de nuevo cada vez que abre la app: comprobá primero el estado de cada registro y evitá borrar los originales. Por ahora no existe app.db.migrate; los puntos de control permiten recuperar datos pero no convierten automáticamente entre esquemas.
Internet con permiso: app.net.fetch
Usalo solo si la app necesita datos de internet. Si la app funciona sin red, no lo uses. La primera vez que la app pide un sitio, la plataforma le pregunta a la persona si lo permite. Si dice que no, app.net.fetch falla con un error: mostrá un mensaje amable y dejá que la app siga funcionando.
const res = await app.net.fetch(url, { method: "GET", headers: {}, body: "texto" })
// res = { ok, status, headers, text, json }
// json es el objeto si la respuesta era JSON; si no, null.
- Solo direcciones
https, soloGETyPOST. Elbodyva como texto (JSON.stringify(...)) y la respuesta no puede pasar de 2 MiB. - Solo funciona con APIs que permiten pedidos desde el navegador (CORS) y que no piden clave secreta: una clave escrita en el HTML la ve cualquiera.
- Envolvé cada llamada en
try/catch: puede fallar por permiso o por conexión. - Usá siempre la misma dirección base: cada sitio distinto es otra pregunta para la persona.
Ejemplo con Open-Meteo, que es gratis y no pide clave:
async function clima(ciudad) {
const geo = await app.net.fetch(
"https://geocoding-api.open-meteo.com/v1/search?count=1&language=es&name=" + encodeURIComponent(ciudad))
const lugar = geo.json?.results?.[0]
if (!lugar) throw new Error("No encontré esa ciudad")
const pron = await app.net.fetch(
"https://api.open-meteo.com/v1/forecast?latitude=" + lugar.latitude + "&longitude=" + lugar.longitude +
"¤t=temperature_2m,weather_code&daily=temperature_2m_max,temperature_2m_min&timezone=auto")
return { lugar: lugar.name, actual: pron.json.current, dias: pron.json.daily }
}
Fijate que geocoding y forecast son dos sitios distintos: la persona va a ver dos preguntas.
- Cada objeto recibe automáticamente
id,createdAt(fecha ISO) ycreatedBy({ id, name }de quien lo creó), que la app no puede modificar. También recibeupdatedAtyupdatedBy: al crear coinciden con la creación y cadaupdatelos actualiza con quien edita y la hora de su dispositivo. El SDK ignora estos cinco campos si los enviás enaddoupdate. Los registros anteriores pueden no tenerupdatedAtniupdatedByhasta su próxima edición. Son metadatos del SDK, no una auditoría contra clientes modificados; las fechas dependen del reloj del dispositivo. - Los valores tienen que ser datos simples: texto, números, booleanos,
null, listas y objetos. No guardes funciones, fechasDate(usá texto ISO) niundefined. - Dibujá la pantalla siempre desde la lista que recibe
watch. No mantengas una copia aparte que se pueda desincronizar.
Esqueleto
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mi 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>Mi app</h1>
<ul id="lista"></ul>
<script>
app.ready.then(async () => {
const cosas = app.db.collection("cosas")
cosas.watch(lista => {
document.getElementById("lista").innerHTML = ""
for (const c of lista) {
const li = document.createElement("li")
li.textContent = c.texto
document.getElementById("lista").append(li)
}
})
})
</script>
</body>
</html>
Para una app simple, devolvé solo el HTML completo. Si necesitás recursos aparte, entregá el paquete zip completo con la estructura indicada.
Compatibilidad y límites del puente
noback comprueba la navegación del iframe antes de ejecutar la app. Si no puede comprobar el aislamiento, las apps anónimas no se ejecutan. Las apps de cuentas muestran un aviso de protección incompleta solo cuando la sonda comprueba una vía de salida. Si la medición es inconclusa, se reintenta una vez; las apps anónimas siguen bloqueadas hasta comprobar el aislamiento. Los resultados técnicos se conservan en los tests, fuera de la interfaz del producto.
El puente acepta hasta 120 operaciones por segundo (incluido el saludo del SDK). Si se supera, rechaza las operaciones excedentes; la app puede esperar un segundo y volver a intentar. En juegos y clickers no guardes en cada clic: juntá los cambios en memoria y escribí cada 1–2 segundos.
let pendiente = null
function guardar(datos) { pendiente = datos } // en cada clic: solo memoria
setInterval(() => { if (pendiente) { void puntos.update(id, pendiente); pendiente = null } }, 1500)
Cada mensaje enviado al player tiene un presupuesto máximo conservador de 1 MiB (texto, claves y estructura incluidos). Los mensajes demasiado grandes, cíclicos, binarios o de tipos/operaciones desconocidos se ignoran. Dividí cargas grandes en operaciones chicas. Reemplazar o navegar el iframe invalida su puente, incluidas las respuestas pendientes.