Capítulo core de la guía Neblla — el contrato base del SDK. Imprescindible: el resto de capítulos asumen lo que hay aquí.
Neblla — core
Qué es
La IA hace tu app en minutos. Neblla la mantiene viva durante años. El backend y el backoffice de tu juego, tu app o tu tienda: usuarios, datos, pagos, salas multijugador en tiempo real, comunidad, IA y un panel para gestionarlo todo.
Tú escribes la app, Neblla pone todo lo que va detrás — lo que de otro modo necesitaría un servidor propio: cuentas de usuario que valen en todas las apps Neblla, datos guardados, cobros, contenido publicado por los usuarios, amigos, IA, un backoffice y, cuando hay más de un dispositivo, salas con una autoridad clara. El multijugador es uno de esos módulos, no un requisito. Se carga con una etiqueta <script> — sin servidor propio, sin npm, sin build.
Lo que sustituye no es escribir código, sino levantar y mantener un servidor de sockets, autenticación, una base de datos y un despliegue. Si aún no has decidido si Neblla es la respuesta correcta para esta app, o qué forma darle, lee primero empezar.txt. La misma lógica de servidor vale desde el prototipo hasta producción: los ficheros de servidor corren en Fog, repartidos entre los dispositivos de la sala, sin servidor que montar (ver servidor.txt). El SDK de cliente se carga con esa etiqueta <script>. Para hablar con Neblla desde un servidor tuyo están la API de administración (abajo) y @neblla/server (ver servidor.txt).
Direcciones
- developers.neblla.com — crear, editar y gestionar apps (dashboard y backoffice).
- play.neblla.com — galería pública. Cada app vive en
play.neblla.com/<slug>(slug autogenerado del nombre al crear la app, renombrable en developers.neblla.com). Obtén la URL canónica conneblla_get_app_url(appId)— nunca la construyas a mano. - neblla.com/mcp — servidor MCP: una IA puede crear apps, subir ficheros y publicarlos (ver mcp.txt).
El SDK se sirve desde neblla.com/connect/app/:appId. Esa URL no cambia nunca, se aloje donde se aloje la app. La etiqueta también vale sin el id, neblla.com/connect/app.js: Neblla saca la app de la dirección de la página (su <app>.neblla.app, su dominio propio o uno de sus dominios), y es la que traen las plantillas. Llevan el id una página en localhost (abre el pre, con la llave local: neblla_pre_access) y una app compilada. Si varias apps tienen el mismo sitio en sus dominios, manda la ruta de la página: gana la entrada con la ruta más larga en la que está (ejemplo.com/ajedrez antes que ejemplo.com); si empatan, la página lleva el id.
Conexión
Antes de cualquier código hay que crear la app (en developers.neblla.com o vía MCP); eso devuelve un APP_ID. App mínima:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Mi App</title>
<script src="https://neblla.com/connect/app/APP_ID"></script>
</head>
<body>
<script>
window.onNeblla = (neblla) => {
console.log("hola");
};
</script>
</body>
</html>
Cuando la página carga, onNeblla se ejecuta con el SDK ya conectado. El script tag NO necesita type="module".
Si el SDK no puede arrancar, onNeblla no se ejecuta y se avisa una sola vez: define window.onNebllaError = (detail) => { ... } o escucha el evento DOM neblla:error (mismo detail). detail.code es unreachable (no se pudo contactar con Neblla), not_found (esta página no es de ninguna app: su origen no está en los dominios de la app), refused (la app no deja entrar a nadie nuevo ahora: reason es quota_exceeded, storage_full, migrating, rate_limited si esta dirección abrió demasiadas páginas en un minuto o creó demasiados invitados nuevos, o turnstile_failed si la app pide Turnstile y la página no lo pasó), auth_failed (el servidor rechazó la conexión, con status), timeout (sin login a los 15 s; el SDK sigue reintentando y onNeblla aún puede llegar) o sdk_error. Úsalo para pintar un "sin conexión — reintentar" en vez de una pantalla en blanco.
Lo que tu código lanza y nadie atrapa llega a tu registro (el panel: Construir › Errores y rechazos; tu IA: neblla_get_logs), con su fichero y su línea: un error o una promesa rechazada en la página (client.js y tus otros ficheros) y lo que lanza un manejador de lobby.js, gathering.js o room.js en el par servidor de la sala. Es una muestra: como mucho 10 errores distintos por minuto desde cada página, y el mismo otra vez solo suma a su cuenta. No cambia nada para tu app: el error sigue saliendo en la consola como siempre. Los errores de otros orígenes (el navegador solo dice «Script error.») y los de las extensiones del navegador no se mandan.
Estructura canónica — 6 ficheros
Si la app vive alojada en Neblla, el link público apunta a index.html. La plantilla canónica son seis archivos; solo index.html es obligatorio, el resto existen si la app los necesita.
Front-end (corre en el navegador):
- index.html — markup. Carga
main.css,client.jsy el SDK de Neblla. - main.css — estilos.
- client.js — corre en cada dispositivo conectado. Tiene acceso al DOM. El código va dentro de
window.onNeblla = (neblla) => { ... }.
Server-side (lógica aislada, sin DOM, carpeta server/):
- server/lobby.js — usuarios que aún no están en partida. Matchmaking: recoger "estoy listo", agrupar, reservar sala, anunciar emparejamiento.
- server/gathering.js — sala creada esperando a empezar. Decide *cuándo* arranca la partida (los casos comunes se configuran sin código en el panel, Funciones › Salas).
- server/room.js — la sala en marcha. Autoridad de la sala: valida acciones, mantiene estado, anuncia cambios.
Contrato de la estructura:
index.htmlreferenciamain.cssyclient.jscon<link>y<script>. NUNCA referencia ningún archivo deserver/— el SDK los carga solo.- Los archivos de
server/son top-level:nebllaestá como global, sin callbackonNeblla. - Los archivos de
server/no tocan el DOM. Se ocultan exactamente cuatro nombres comoundefined:window,document,globalThisyself. Todo lo demás que ofrezca el runtime está — incluidossetTimeoutysetInterval(ver servidor.txt). - En server-side no existen
neblla.authnineblla.room.me. Lo que actúa como el usuario (auth.login,db.data,db.setPublicInfo,shop.buy, ...) es solo cliente; en el server,neblla.dblee y escribe tablas (db.save/db.load/db.deletecontable). - Los archivos se suben al pre de la app, que el dev prueba en un enlace privado; los usuarios los reciben cuando se pasa a pro (
neblla_promote, o «Publicar cambios» en el panel, que llama a pre «borrador» y a pro «la publicada»), que los publica en el acto: el enlace publicado los enseña ya. Publicar en la galería es otro paso aparte (ver publicar.txt). - Si estos archivos están presentes, Neblla los activa y desactiva en el momento adecuado para cada sala.
Plantillas de interfaz (Creator › Templates)
El dev puede cambiar el aspecto de una app suya desde Creator › Templates («Cambiar el aspecto» en Construir › Aspecto): la plantilla pone sus colores, letras y formas en el borrador de la app (main.css entre las marcas /* neblla-template:start */ y /* neblla-template:end */, y un <link> de fuentes en index.html), y el dev lo publica o lo quita cuando quiere. Para que sigas pudiendo reescribir client.js y main.css sin romperlo:
- Declara los ids que el código necesita, en una o varias líneas de
client.js:// @neblla-ids newRoomBtn joinRoomBtn roomCodeInput. Una plantilla puede mover cualquier bloque, pero nunca quitar un id declarado: si le falta uno, no se aplica y dice cuál. Si añades o quitas ungetElementById/$('x'), actualiza esa línea. - Nombra las pantallas con
data-screen="<nombre>"(o una<section id>): si una plantilla tiene una página con ese nombre, puede colocarla dentro de la pantalla («Colocar también las pantallas»); las pantallas sin página igual se quedan como están y se avisa. - **Pinta con las variables
--nb-*** (--nb-bg,--nb-surface,--nb-ink,--nb-muted,--nb-primary,--nb-on-primary,--nb-secondary,--nb-alert,--nb-line,--nb-radius,--nb-font-title,--nb-font-body,--nb-space) en vez de colores y letras fijos: así la plantilla recolorea la app. Si ya usas tus nombres (--primary,--bg), alíalos:--primary: var(--nb-primary). - No toques lo que hay entre las marcas
neblla-templatedemain.cssni enindex.html: es de la plantilla y se reescribe entera cada vez. Tus reglas van fuera, antes o después. - Los bloques de la plantilla avisan con eventos del documento; escúchalos si quieres hacerlo tú. Un formulario con tabla lanza
nb:submit(detail: { table, data, done(ok) }), un botón de comprarnb:buyy uno de pagarnb:pay(detail.product). Si no los escuchas, la página guarda la fila conneblla.db.save(data, { table })y llama aneblla.shop.buy(product). Si quieres hacerlo tú (validar, añadir datos, cobrar con una cantidad oseries), escucha el evento y llama aevent.preventDefault()antes de que llegue arriba: la página lo deja entonces en tus manos. En el formulario pondetail.pending = truey, al acabar, llama adetail.done(true|false): así enseña «enviado» o «no se ha podido enviar» (sinpending, da el envío por bueno al instante). La tabla debe dejar escribir a quien visita (permiso de escritura público enneblla_update_app/Config → Tablas); si no, el formulario dirá que no se pudo enviar. - Un
<img>llevaalt; el HTML usa etiquetas con sentido (button,nav,main,h1…h3) y el texto de los botones se lee en cualquier tema (claro y oscuro, contraste 4,5:1).
Imágenes, animaciones y atlas de Neblla Creator › Flat
Lo que el dev dibuja en Creator › Flat sale a su app desde «Exportar» → «Mandar a mi app»: cae en los Medios de la app, colección creator, y cada fichero lleva una nota para ti (neblla_media la devuelve en note): qué es y cómo se usa. Léela antes de usarlo; un JSON de atlas es de tipo data. Lo que sale de Flat:
- Imagen (
PNG,PNG-8,WebP,JPG,SVG): una por tamaño pedido, con el sufijo en el nombre (boton.png,boton@2x.png,boton-48.png). Úsala con<img>obackground-image; con las dos escalas,srcset="boton.png 1x, boton@2x.png 2x". El SVG solo sale cuando todo era vector y lleva un grupo por capa con su nombre comoid. - GIF y WebP animados: se muestran con un
<img>y se repiten solos. - Atlas de sprites: un
PNGy suJSON(a veces varios,sprites-0.png,sprites-1.png…, si no cabían en un atlas). El JSON viene en uno de los dos formatos y lleva, de cada fotograma, dónde está en la hoja (frame), cuánto transparente se recortó (spriteSourceSizeysourceSize), la pausa en milisegundos (duration) y el anclaje (pivoten el formato TexturePacker;meta.slices<nombre>_anchoren el de Aseprite). Si el atlas gira los dibujos altos, esos fotogramas llevanrotated: true(girados un cuarto de vuelta a la derecha;frame.wyframe.hson los de la hoja; Phaser y Pixi lo entienden). Los anclajes con nombre (una mano, un hocico) van enanchors({ nombre: { x, y } }, como parte del tamaño del dibujo, entre 0 y 1) en el formato TexturePacker y comometa.slices<nombre>_anchor_<ancla>en el de Aseprite. Los botones con 9-slice llevan sus márgenes enslice({ l, t, r, b }) o enmeta.slices<nombre>_9slice. - TexturePacker «JSON hash» (Phaser, Pixi):
this.load.atlas('sprites', 'sprites.png', 'sprites.json')y luegothis.add.sprite(x, y, 'sprites', 'andar_00'). Las etiquetas de animación van enmeta.tags([{ name, frames: [nombres], direction }]):this.anims.create({ key: tag.name, frames: tag.frames.map(f => ({ key: 'sprites', frame: f })), frameRate: 10 }). - Aseprite «JSON data» (Godot, Unity y el motor de Flat):
framespor nombre, y las etiquetas enmeta.frameTags({ name, from, to, direction }, índices en el orden deframes). - La pausa de cada fotograma está en
duration(ms): úsala en vez de unframeRatefijo si el dev ajustó tiempos. - Tileset (
*-tileset.pngy*-tileset.tsj): para Tiled, con el tamaño del tile, las columnas, el margen y el espacio en el.tsj.
Si el dev manda al atlas un lienzo con nombre repetido, o un sprite más grande que el atlas, Flat lo dice y no escribe nada: no inventes el fichero que falte.
Modelo cliente / presentador / servidor
- Cliente — cada dispositivo conectado ejecutando
client.js. Tiene DOM,neblla.auth.me(tú en la app) yneblla.room.me(tú en la sala). - Presentador — un cliente con rol social distinguido (el cantador del bingo, la pantalla de un Jackbox). Es el primer dispositivo que entra a la sala; si se va, el SDK promueve a otro (NO por FIFO — lo elige Neblla) y dispara
neblla.room.on('presenter'). Se consulta conneblla.room.me.isPresenter, se le habla conneblla.message.to('presenter', data). Es opcional: muchos juegos no lo necesitan. Es un rol distinto del de servidor: los archivos deserver/corren en el server peer, que puede ser o no el mismo dispositivo. - Servidor — quien ejecuta los archivos de
server/: Fog, tres dispositivos de la sala que elige Neblla (ver servidor.txt). Es la autoridad de la sala. Se le habla conneblla.message.to('server', data). En el cliente no hayneblla.serverniuser.isServer— su identidad es opaca a propósito: cambia durante la partida y tu código no debe depender de ella.
Presentador y servidor son roles distintos. Pueden coincidir en el mismo dispositivo por casualidad; tu código no debe contar con ello.
Contrato events-only
CLIENT y SERVER nunca se llaman directamente. Solo se comunican por eventos:
| Emisor (método) | Receptor (evento) | Semántica | |-----------------------------------------|-----------------------------------------|-----------| | message.send(data) desde cliente | message.on en todos, from: su id | Broadcast entre pares. Te llega a ti mismo con fromMe: true. | | message.send(data) desde server | message.on en todos, from: 'server' | Voz autoritaria del juego, llega a todos los clientes. | | message.to(userId, data) | message.on solo en ese miembro | Privado a un miembro de la sala, desde un cliente o desde el server. Llega con private: true. No se difunde. | | message.to('server', data) (cliente) | message.on en el server, to: 'server' | Privado al server. Llega a room.js/gathering.js/lobby.js según fase. No se difunde. | | message.to('presenter', data) (cliente) | message.on en el presentador, to: 'presenter' | Privado al cliente con isPresenter === true. No se difunde. | | state.set(state) (server) | state.on en todos | Estado autoritario y público. Actualiza neblla.state.get() en todos. Va P2P. Lo secreto va en un sobre de No-code. |
Un mensaje llega a neblla.message.on(cb) como { from, to, data, user, time }: from es el id de quien lo manda o 'server'; to es null (toda la sala), un id, 'server' o 'presenter'. Los mensajes son JSON abierto y efímeros: usa el server para reenviar estado a los nuevos. Cada módulo escucha lo suyo y lo suelta con .off: neblla.message.on(cb) / neblla.message.off(cb), neblla.state.on(cb), neblla.room.on('userJoined', cb) / neblla.room.off('userJoined', cb)… No hay on en la raíz de neblla ni comodín '*': un nombre de evento que el módulo no tiene lanza unknown_event.
Ciclo de vida de una sala
1. lobby — todo usuario conectado aterriza en la sala especial lobby. Para matchmaking, elegir nombre, ver salas — no para jugar. Los eventos llevan un booleano isLobby. 2. gathering — neblla.rooms.book({ config? }) reserva una sala con id único (lo elige el servidor, nunca el cliente). Nace en estado gathering, visible desde el lobby vía neblla.rooms.gathering. Los usuarios entran con neblla.rooms.join(roomId). 3. running — la sala arranca con neblla.room.start(). Desde un cliente solo puede llamarlo el presentador; server/gathering.js también puede llamarlo para arrancarla por su cuenta. Nadie más. El SDK elige al server y arranca server/room.js. A partir de ahí funcionan message.to('server', …), los mensajes del server (from: 'server') y state.set.
neblla.room.leave() vuelve al lobby. Un usuario solo puede estar en una sala a la vez. Mismo nombre de sala = mismo grupo. Detalle completo en salas.txt.
Formas de app
Decide la forma ANTES de codificar. Una app con salas nace con un solo ejemplo: el servidor pone el estado, los clientes lo pintan y hablan por message. Las tres formas de sala de abajo son recetas, con el código del servidor y de los clientes, en salas.txt («Recetas: tres formas de sala»). Sin salas, la app es lobby-only o solo + save. Para elegir: ¿es multi-usuario? ¿hay autoridad (un servidor que valida)? ¿esa autoridad muestra otra pantalla? y, si es multi sin autoridad, ¿salas separadas o un único espacio común?
| Forma | Autoridad | UI doble | Multi | Salas | Ejemplos | |------------------|----------------|----------|-------|-------|----------| | Con árbitro | Sí (server) | No | Sí | Sí | Ajedrez, UNO, conecta 4, hundir la flota | | Pantalla y mandos| Sí (server) | Sí | Sí | Sí | Kahoot, Jeopardy, Pictionary, Hombres lobo | | Todos a la vez | No | No | Sí | Sí | Pizarra compartida, canvas, watch party | | Lobby-only | No | No | Sí | No | Mural global, pixel-canvas, cursores en vivo | | Solo + save | — | — | No | No | Hábitos, sudoku, diario |
- Con árbitro y Pantalla y mandos (recetas): la autoridad en
room.js. En pantalla y mandos,client.jsramifica por quién tiene la pantalla de anfitrión.if (neblla.room.me?.isPresenter)solo es correcto mientras no haya un rolboardasignado; la rama que sobrevive a un board asignado y a perder el presentador esneblla.room.me?.isBoard || (!neblla.room.board && neblla.room.me?.isPresenter && !isLobby)— ver empezar.txt. - Todos a la vez (receta):
room.jsvacío. Salas separadas, cada una una superficie compartida. Todos los peers iguales:neblla.message.sendcon cambios, aplicar los entrantes enneblla.message.on. Last-write-wins salvo CRDT propio. Y el documento en sí, mejor fuera de la sala:room.dataempieza con un límite antiabuso de 10 KB (lo ajustas o lo quitas), pero lo que varias personas editan vive mejor en entries de comunidad (una entry por persona para que no haya conflicto de escritura) y por la sala solo viaja el aviso de que algo cambió. La receta entera está en comunidad.txt, "Un espacio compartido más grande que una sala". - Lobby-only: como «todos a la vez» pero SIN salas — todos comparten el único lobby global. Sin
rooms.join/rooms.book/room.start, singatheringniroom.js: solo presencia (room.on('userJoined')/room.on('userLeft')) +message.sendp2p. El único fichero de servidor esserver/lobby.js, opcional, para guardar una foto canónica a los que entran tarde. Elígelo (en vez de «todos a la vez») cuando todo el mundo va a UN mismo espacio y no hace falta separar grupos. - Solo + save: sin salas ni mensajes. Solo
neblla.db.save()yneblla.db.data.
Se pueden mezclar o inventar formas propias.
Patrón mínimo de app funcional
Lógica autoritaria — el cliente pide, room.js valida y emite:
// client.js — pedir acción:
neblla.message.to('server', { action: 'play-card', card: 5 });
// room.js — validar y emitir estado:
let gameState = neblla.state.get() || initialState();
neblla.message.on(m => {
if (m.to !== 'server') return; // el server también oye lo que se difunde a la sala
gameState = applyAction(gameState, m.from, m.data);
neblla.state.set(gameState);
});
// client.js — reaccionar:
neblla.state.on(state => render(state));
Multiplexar mensajes con un campo type:
neblla.message.send({ type: 'state-update', state });
neblla.message.on(m => {
switch (m.data.type) {
case 'chat': handleChat(m); break;
case 'state-update': handleState(m); break;
}
});
Keys: valores que pones tú y la app lee en directo
Para cambiar algo sin tocar código —un evento activo, un multiplicador de puntos el fin de semana, el reto de hoy, un mensaje del día— el dev pone keys en el panel (Construir › Avanzado › Claves y secretos) o con neblla_update_app({ keys: { reto_de_hoy: 42 } }), y la app las lee con await neblla.api.key('reto_de_hoy'). Siempre devuelve el valor actual. Son texto, número, sí/no o un JSON, y son públicas: cualquiera que abra la app puede leerlas, así que nunca pongas un secreto en una key.
Conectar con otros servicios, sin servidor tuyo
Llamar a una API con tu secreto: api.call. Guarda el secreto una vez con neblla_set_api_secret: un nombre (CLOUDINARY), el valor (se escribe y nunca se vuelve a leer), la única URL base a la que puede llamar (https://…) y dónde va el secreto, con {secret} en una cabecera o en la query ({ "Authorization": "Bearer {secret}" }). La app lo usa así:
const r = await neblla.api.call('CLOUDINARY', '/image/upload', { file, upload_preset: 'x' }); // → { status, contentType, body }
Desde server/ funciona siempre; desde el cliente de un usuario, solo con fromClient: true.
Avisar por email con tu proveedor. Neblla no manda emails en tu nombre: los mandas tú con tu cuenta de Resend, Postmark o el que uses. Guarda su clave como secreto (RESEND, URL base https://api.resend.com, cabecera Authorization: Bearer {secret}) y llama a su API cuando toque, con el email que recibiste al hacer login con el scope email:
await neblla.api.call('RESEND', '/emails', {
from: 'Mi juego <hola@mijuego.com>', to: email,
subject: 'Te toca jugar', html: '<p>Tu rival ya ha movido.</p>',
});
Que Neblla te avise: webhooks salientes. neblla_update_app({ webhooks: [{ event: 'purchase', secret: 'N8N', path: '/compra' }] }): cuando pasa el evento, Neblla hace un POST con { event, appId, at, data } a la URL de ese secreto (por ejemplo, tu n8n). Eventos: purchase (también cada renovación de una suscripción), refund, subscription_end (una suscripción termina), register (un usuario nuevo), erasure_request, ban (baneas a un usuario o le quitas el ban: { userId, status }), flag (se marca una sala, o marcas tú a un usuario a mano: { room, reason, users, by }), community_report (denuncian una entry: { entryId, reason, reports, suspended }, sin decir quién denuncia) y booking (un usuario reserva una plaza, o se confirma la que pagó al reservar: { userId, bookingId, set, slotId, start, title }, más cost y creditKind si gastó créditos, o paid y purchaseId si la pagó). Los usuarios van por su id en tu app. Las últimas entregas, con neblla_list_webhook_deliveries.
Que otro servicio avise a tu app: webhooks entrantes. neblla_create_hook({ name, to: 'room', room }) da una URL (con un token que solo se enseña al crearla) a la que un tercero —Twitch, un proveedor de pagos, n8n— manda un JSON. Neblla se lo entrega a esa sala, cuyo servidor lo oye con neblla.room.on('hook', ({ name, data }) => …), o con to: 'user' al usuario que nombra la llamada (?user=<su id en tu app>). Con to: 'inbox' cada llamada queda como un aviso en tu Buzón (Personas › Buzón), aunque no haya nadie conectado: un pedido de tu tienda de fuera, un formulario de Tally, un aviso de n8n. Sale con el nombre del hook como remitente, una línea legible (el message, text o title del JSON, o sus primeros valores) y el JSON entero debajo; no se contesta, y cuando llega al tope del Buzón por remitente se va el aviso más viejo. Para ver ahí un evento de tu propia app (una compra, un reembolso), crea un secreto con esa URL como dirección y apunta un webhook saliente a él. Hasta 64 KB por llamada.
Tareas programadas: Automations. neblla_set_automation({ name, when, action }) hace algo a una hora o cada cierto tiempo, sin servidor tuyo. when: { at: fecha } una vez, { every: minutos } (de 5 a 10.080) o { daily: 'HH:MM', days?, tz? }. Acciones: openRoom (reserva una sala con su config: la sala del jefe abre a las 11:00), message (entrega datos a una sala viva, cuyo servidor lo oye con neblla.room.on('hook')) y callApi (llama a una API con uno de tus secretos: el recordatorio de la tarde por Resend, tu n8n…). Hasta 50 por app; neblla_list_automations, neblla_run_automation para probarla ya y neblla_delete_automation. Las Automations son atajos para lo pequeño que vive dentro de tu app. Un flujo entre servicios —tu CRM, una hoja de cálculo, Slack, varios pasos con condiciones— va en tu n8n, Make o Zapier: Neblla los avisa con los webhooks salientes, los escucha con los entrantes, y tu flujo actúa sobre la app con la API de administración.
Plantillas de negocio, sin código. Para lo que se repite en muchas apps, Neblla trae acciones ya hechas, atómicas y ejecutadas en Neblla, que solo configuras. Son un punto de partida: si el negocio ya tiene su herramienta, se conecta igual que tu n8n.
- Reservas y bonos. Se configuran una vez:
neblla_update_app({ business: { booking: { clases: { cancelHours: 12, cost: 1, creditKind: 'bono' } }, credits: { bono: { products: { pack10: 10 } } } } }): cada reserva de "clases" gasta 1 bono, cancelar con más de 12 horas lo devuelve, y comprar el productopack10recarga 10. Si el producto es una suscripción, recarga en cada periodo pagado (la prueba gratis no);{ pack10: { n: 20, mode: 'fill' } }llena el saldo hasta 20 en vez de sumar («cada mes vuelves a tener 20 clases», lo que sobra no se acumula; un saldo por encima se queda). Un tipo conunlocks: ['agua'](claves de colecciones de medios, o'*') además desbloquea un original privado de esas colecciones por bono, para siempre (pagos.txt, "Ficheros a la venta"). Un reembolso completo o una disputa perdida retira los bonos de esa compra; los ya gastados no vuelven (el saldo se queda en cero y el webhookrefundtraecredits: { kind, taken, short }). Nadie pasa del aforo aunque reserven a la vez. Desde el cliente (con login):
``javascript const huecos = await neblla.business.booking.slots('clases', { from, to }); // con left, las plazas libres; nunca quién reservó const { booking, left } = await neblla.business.booking.reserve('clases', huecoId); const { cancelled, refunded } = await neblla.business.booking.cancel('clases', huecoId); const mias = await neblla.business.booking.mine('clases'); // con inicio, fin y título del hueco const { balance } = await neblla.business.credits.balance('bono'); ` Errores: login_required, slot_full, already_booked, slot_past, not_enough_credits, booking_not_found, unknown_booking_set`.
- Pagar cada cita al reservar, sin bonos.
{ citas: { cancelHours: 24, pay: 'booking', product: 'cita' } }, concitaun producto de pago único que no dé bonos (si un bono lo usa,invalid_business).reserveguarda la plaza 35 minutos y abre el pago de Stripe en una ventana: devuelve{ booking, held: true, holdUntil, url }, y la plaza es del usuario cuando llega el pago, que avisaneblla.business.booking.on('update', ({ set, booking }) => …)conbooking.status'reserved'(o'cancelled'conreason: 'full'si pagó tarde y ya no quedaba sitio, oreason: 'cancelled'si había cancelado antes de que llegara el pago: el dinero se devuelve solo; yreason: 'refunded'cuando se reembolsa en Stripe, oreason: 'slot_gone'si se borra su hueco, oreason: 'cancelled_by_app'si la app le cancela la reserva). Reservar otra vez mientras paga, o cuando se le acabó el tiempo, es la misma reserva. Cancelar a tiempo devuelve el dinero en Stripe, la comisión de Neblla incluida, y un reembolso hecho en Stripe cancela la reserva. Errores propios:booking_product_missing(el conjunto no tiene producto),invalid_booking_product(una suscripción no paga citas),checkout_failed, ypopup_blockedconurlsi el navegador bloqueó la ventana (ábrela tú desde un clic). Para enseñar el precio antes de reservar,services()trae en ese servicioprice({ amount, currency }, en céntimos: «7,00 €»), también cuando el producto no sale en la tienda. - Automations con plantilla:
neblla_set_automation({ name, when, template, … }). booking.reminder{ set, secret, path?, hoursAhead? }(24 h por defecto): por cada reserva que empieza pronto, y solo una vez, Neblla manda{ event, set, player: { id, name, email }, slot, booking }a la URL de ese secreto (el email solo si el usuario lo dio a tu app). Esa URL es tu n8n, Make o tu servidor, y desde ahí sale el email con tu Resend o el que uses: Neblla avisa a tiempo y tu flujo escribe y envía el mensaje.community.digest{ days?, limit?, secret y/o room }: las entries más valoradas del periodo (7 días y 10 por defecto).game.dailyTournament{ board, day?, top?, secret y/o room }: el top del día de un ranking protegido (neblla.scores, que ningún jugador escribe; ver identidad.txt), que se guarda y se envía como{ event, board, day, ranking: [{ place, player, value, at, info? }] };dayes'today'(UTC, por defecto) o'yesterday'. O{ table, field, top?, hours?, secret y/o room }: el ranking de una tabla tuya en el periodo (sus filas las escriben los jugadores), enviado como{ event, table, field, ranking: [{ place, player, value, row }] }.
Los datos son de tu app (en tu base si tienes BYODB): los nombres de tabla que empiezan por nb_ quedan reservados para esto.
Lo de administración no está en el SDK. Crear y borrar huecos, ver quién reservó, mover o cancelar la reserva de otro, ajustar bonos a mano, consultar cualquier tabla: eso va por el MCP (neblla_booking con createSlot ({ set, start, end?, capacity, title?, repeat?, site?, staff?, resource?, min? }; la sede, el profe y la sala, más abajo; repeat, también), updateSlot, deleteSlot —devuelve los bonos—, series, slots, list, cancel, move, cancelSession, times; neblla_business con las sedes, los recursos, los profesionales y las cuentas; neblla_credits con balance y adjust), por la API de administración (POST /api/apps/<appId>/admin/<tool>/<op>, p. ej. /admin/booking/list o /admin/credits/adjust, con el mismo cuerpo) (con Authorization: Bearer y la clave de servidor; por ejemplo, POST https://neblla.com/api/apps/<appId>/tables/<tabla>/query con { where, orderBy, limit, offset, owner, count }, POST …/tables/<tabla>/rows con { owner, data } para crear o { id, data } para reemplazar, y DELETE …/tables/<tabla>/rows/<id>, estos dos si la tabla tiene server: 'write'), por los webhooks, por las Automations, por los paneles del backoffice y por el propio panel de desarrollador, que hace lo mismo que esas tools con las mismas funciones: la pestaña Negocio (la agenda como un calendario de la semana, quién viene y el saldo de cada cliente, las cuentas, y los servicios, profesionales, recursos y sedes; los bonos, en Dinero › Productos), Funciones › Datos (campos de las tablas y sus filas), Funciones › Puntuaciones y clasificaciones (los rankings de neblla.scores), Personas › Grupos de usuarios, la ficha de cada usuario (usar una compra, créditos, grupos), Construir › Webhooks y Claves y secretos, Dinero › Plan y uso y Cobros (impuestos), Publicar › Tiendas y Ajustes › Tu propio login (proveedores JWT). Lo que no es sencillo (una tabla nueva, una pantalla a medida, una automatización) el panel lo deja en una frase lista para pedírsela a tu IA.
Huecos que se repiten, como en un calendario. repeat es una regla de repetición como la de cualquier calendario (lo habitual de las RRULE de iCalendar, RFC 5545), igual para clases, citas, partidas, torneos o turnos:
// Yoga martes y jueves a las 18:00 de Madrid, hasta junio, sin el jueves de Navidad
neblla_booking({ appId, action: 'createSlot', set: 'clases', title: 'Yoga', capacity: 12,
start: '2026-10-20T18:00:00+02:00', end: '2026-10-20T19:00:00+02:00',
repeat: { freq: 'weekly', byDay: ['TU', 'TH'], until: '2027-06-30', except: ['2026-12-24'], timeZone: 'Europe/Madrid' } })
// El torneo, el último viernes de cada mes, 10 veces
repeat: { freq: 'monthly', byDay: '-1FR', count: 10 } // o la cadena 'FREQ=MONTHLY;BYDAY=-1FR;COUNT=10'
freq:'daily','weekly','monthly'o'yearly';interval: cada cuántos (2con'weekly', cada dos semanas; hasta 1000).byDay: los días de la semana ('MO'…'SU'); en el mes (o en el año), con un número delante:'2TU'es el segundo martes y'-1FR'el último viernes.byMonthDay: un día fijo del mes (15;-1es el último).byMonth: los meses (1–12).wkst: el día en que empieza la semana ('MO'por defecto), que cuenta conintervalmayor que 1 en'weekly'.until(una fecha es ese día entero) ocount(tantas veces, hasta 10 000); sin ninguno, no acaba.except: las fechas que se saltan (hasta 500).timeZone(IANA, p. ej.'Europe/Madrid'): la hora local se mantiene cuando cambia la hora. Sin ella, la zona de su sede (o la de sushours), luego la de lashoursde su profesional; sin ninguna, UTC. Vale también para una regla dada como texto RRULE.- Los casos son los días que da la regla desde
start, a la hora destart(sistartno cae en la regla, ese día no es un caso). Cada caso es un hueco, y los de una serie comparten un idseries(yoccurrence, la hora que le dio la regla). Neblla crea los casos de un año (contado desde ahora o desde el primero, si empieza más tarde) y los siguientes según pasa el tiempo: al leer los huecos del conjunto (los jugadores, un panel, la API de administración o los recordatorios), como mucho una vez por hora, así que no hay tope de casos y una serie sin fin no se acaba. Si uno falla al crearla, no se crea ninguno.createSlotresponde{ slot, slots, series };series{ set, series }devuelve la regla (también comorrule) y hasta dónde llegan sus huecos (ahead, oended: true). - Cambiar y borrar, como en un calendario:
updateSlot{ set, slot, scope?, start?, end?, capacity?, title?, repeat? }ydeleteSlot{ set, slot, scope? }, conscope'one'(por defecto: solo ese caso; uno movido sigue en su serie y uno borrado queda como excepción),'following'(este y los siguientes) o'all'(todos los que no han empezado; los pasados quedan como historia). Unstartnuevo mueve cada caso lo mismo que este (los mismos días, a la nueva hora); unrepeatnuevo los vuelve a dar: un caso cuyo día sigue conserva su hueco y sus reservas, el de un día que desaparece se borra y quien tenía plaza recupera sus bonos o su dinero, y un día nuevo trae un hueco nuevo. Un hueco que se borra (condeleteSloto porque su día desaparece) cancela sus reservas, y el jugador lo oye enneblla.business.booking.on('update')constatus: 'cancelled'yreason: 'slot_gone'. Con menos plazas que reservas nadie pierde la suya: el hueco no admite más. Si quedan casos anteriores por llegar, "este y los siguientes" con otra hora u otra regla pasa los siguientes a una serie nueva y la vieja termina ahí.updateSlottambién cambia un hueco suelto. Una serie semanal creada antes de que hubiera reglas no tiene regla: sus títulos y plazas se cambian, pero su hora o su regla no (invalid_slotconfield: 'repeat'); cambia sus casos uno a uno, o bórrala y créala de nuevo. repeat: { weekly: true, until, timeZone? }sigue valiendo: es{ freq: 'weekly', until, timeZone }.
Un negocio entero: sedes, recursos y profesionales. El mismo sistema sirve para una profe de yoga en varios centros, un centro con salas, una peluquería canina, una escuela de idiomas, un restaurante, un parking o una pista de pádel. Cada conjunto de reservas es un servicio, y Neblla comprueba lo que usa:
neblla_businessguarda las piezas, cada una con suhoursopcional: sedes (kind: 'sites',{ name, timeZone?, address?, hours? }), recursos ('resources', un grupo de unidades iguales:{ name, site?, count, capacity, hours? }, p. ej. «Mesa terraza»,count: 12,capacity: 2; «Sala grande»,count: 1,capacity: 15) y profesionales ('staff',{ name, email?, sites?, hours? }). Accioneslist,save(conid, cambia solo lo que mandas) yremove(conin_usemientras una sesión por venir o un servicio lo use).hours:{ timeZone?, week: { MO: ['09:00-14:00', '16:00-20:00'], … }, closed?: ['2026-12-25'] }; un día que no está, cerrado; sinhours, siempre abierto. Un tramo de un profesional puede decir dónde está:{ from: '17:00', to: '21:00', site: 'centro' }(la profe que da clase en varios centros).- El servicio dice lo que necesita:
{ name?, minutes?, min?, max?, confirmHours?, remindHours?, remindText?, chat?, recoverStanding?, resources?: [ids], staff?: true | [ids], steps?: [{ minutes, resources, staff? }], book? }.stepsva en fila para el mismo cliente (la peluquería:[{ minutes: 30, resources: ['banera'] }, { minutes: 45, resources: ['mesa'] }], con una bañera y dos mesas). book: 'sessions'(por defecto): el negocio abre las sesiones concreateSlot, que ahora aceptasite,staff,resourceymin. La sesión tiene que caber en el horario de su sede, de su profesional y de su recurso, el profesional no puede estar en otra a la vez y el recurso tiene que tener una unidad libre; si no,busyconwhat(site_hours,staff_hours,staff_site,staff_busy,resource_hours,resource_full) eid. Una serie se comprueba caso a caso: o caben todos, obusyconconflicts; conskipBusy: trueesas fechas se saltan. Sinend, dura losminutesdel servicio; sincapacity, sumaxo lo que cabe en el recurso.book: 'times': el cliente elige una hora libre y la sesión es solo suya (la peluquería, una mesa, una plaza de parking).book: 'open': elige una hora libre y otros se apuntan hasta llenarla (el pádel).neblla_bookingtimes{ set, from?, to?, site?, staff?, step? }da las horas libres: un profesional libre y en su horario, y una unidad libre de cada recurso.- Mínimo. Con
min, la sesión está'proposed'hasta tener ese número de personas y'confirmed'al tenerlo.confirmHourshoras antes de empezar (12 por defecto con un mínimo) se decide: con el mínimo queda confirmada para siempre; sin él se cancela sola, todos recuperan sus bonos o su dinero y lo oyen enon('update')conreason: 'below_min'. Una sesión abierta cuando su plazo ya había pasado (a las 18:00 una partida para las 20:00 con 6 horas de plazo, o una ya empezada) no tiene plazo: no se cancela sola y se juega con los que haya; lo mismo si se mueve o se reabre dentro de su plazo.confirmByes entoncesnull.slotslo dice en cada hueco:state,minyconfirmBy, y el nombre de su sede, profesional y recurso (siteName,staffName,resourceName).cancelSession{ set, slot, reason? }cancela una sesión entera (la profe está enferma): todos recuperan lo suyo (reason: 'session_cancelled') y la sesión se queda en la agenda como cancelada. - Recordatorio en el buzón. Con
remindHoursen el servicio (de 1 a 336; p. ej.24), cada reserva recibe esas horas antes, y solo una vez, un mensaje del negocio en el buzón del cliente (neblla.inbox, el mismo canal que tus mensajes), en su idioma y con su nombre: «¡Hola, Marta! Recordatorio: Yoga con Ana, mañana a las 20:00 (Sala 1). Si no puedes venir, cancélala en la app.». La hora va en la zona de la sede o de la serie (sin ninguna, en UTC, y lo dice). ConremindTextel texto es el tuyo, con{name},{service},{day},{time},{staff},{site}y{for}. El mensaje llevadata: { type: 'booking.reminder', set, slot, booking, start }, para que la app lo pinte a su manera. Una reserva hecha cuando ya había pasado su hora de recordatorio no se recuerda (se acaba de reservar). Para mandarlo tú por email, la plantilla de automatizaciónbooking.reminder(arriba). - La ficha del cliente.
neblla_businessuserBookings{ user }da todas sus reservas, de cualquier servicio:upcoming(la próxima primero) ypast(la última primero, 50 como mucho), cada una con su servicio, hora, sitio y estado.userNotes{ user },userNote{ user, text }yremoveUserNote{ id }son las notas privadas del negocio sobre él («lesión de rodilla: sin invertidas»): nunca las ve el cliente, guardan quién del equipo las escribió (by) y se borran con él. En el panel, Personas › la ficha de cada usuario lo enseña, y el nombre de cada reserva en la Agenda y en Reservas abre esa ficha. Un Profesional ve las reservas de sus propias sesiones, lee y escribe notas y borra solo las suyas. - El chat de cada sesión. Con
chat: trueen el servicio, cada sesión tiene su chat privado con quien tiene plaza en ella: reservar mete al cliente y cancelar lo saca (si estaba dentro, vuelve al lobby). Es una sala privada de tus salas (rooms,roomymessageencendidos):reserve,mineyslots(en las suyas) traenroom, y la app entra conneblla.rooms.join(room), lee la historia conneblla.room.history()y escribe conneblla.message.send({ text }); como la sigue, un mensaje escrito mientras no está le llega como aviso. Nadie invita, expulsa ni sale a mano (business_room): la lista son las reservas. El negocio la lee y escribe en ella desde la Agenda del panel, o conneblla_bookingchat{ set, slot, before?, limit? }(de la más nueva a la más antigua; cada mensaje confrom: 'business', o conuserynamedel cliente) ychatPost{ set, slot, text, scope? }: los de dentro lo oyen enneblla.message.onconm.from === 'business'ym.data{ text, from: 'business', name }(en la historia,from: 'business'ybody) (el nombre del profesional, o el de la app). Un Profesional, en sus propias sesiones. La sala no caduca por inactividad y se borra con su sesión. El negocio puede escribir antes de que nadie reserve (un aviso: «El miércoles no os olvidéis las esterillas»): quien reserve lo lee en la historia; conscope"following"o"all"lo deja en esos casos de la repetición (los que ya existen, un año por delante). - Plaza fija.
business.standing: { abonoLunes: { set: 'yoga', series } }(series, el id de una repetición del servicio:neblla_bookingseries{ set }las lista) hace del productoabonoLunesuna plaza fija: comprarlo reserva al cliente en todos los casos de esa serie del periodo pagado, sin gastar bonos. Un producto de pago único cubre lo que queda del mes natural (en la zona de la serie); una suscripción, cada periodo pagado, y cada renovación reserva el siguiente. Un caso que ya está lleno se salta, y el cliente lo oye enon('update', ({ set, standing }) => …)constanding: { productId, series, until, booked: [{ slot, start, booking }], skipped: [{ slot, start, reason: 'slot_full' }] }; uno que ya tenía se queda como estaba. Cancelar un caso libera la plaza y no devuelve nada; volver a reservarlo dentro del periodo es gratis. Un reembolso de la compra cancela los casos por venir.mineyslotsmarcanstanding: productIden lo que vino de ella,neblla.business.booking.standing(set?)da sus plazas fijas en curso ([{ productId, set, series, title, until, choices?, next? }]) yservices()traestandingcon lo que está a la venta ([{ productId, set, series: [ids], choices: [{ series, title, start, rrule, timeZone }] }]). En el panel, Dinero › Productos › Plazas fijas. El producto no puede dar bonos ni pagar una cita (invalid_business). - Plaza fija con varios horarios.
seriestambién es una lista:{ dosSemana: { set: 'yoga', series: [lunesMiercoles, martesJueves, tardes] } }es un solo producto (lo que cobra Stripe) con varios horarios a elegir: «2 clases a la semana, 55 €» en tres horarios, un gimnasio de mañanas o de tardes, un parking con abono de mañanas. Un id solo es una lista de uno y funciona como siempre. Al comprar, el cliente elige:neblla.shop.buy(productId, 1, { series }); sin elegir y sin haber elegido antes, la compra se rechaza conseries_requiredychoices. La elección se guarda en Neblla, no en Stripe, y cada periodo comprado o renovado reserva el horario elegido. El cliente lo cambia sin pagar:neblla.business.booking.changeStanding(productId, series, { when })conwhen: 'next'(desde el siguiente periodo; este se queda como está) o'now'(el de por defecto: desde ya, si hay plaza en todos los casos que quedan del periodo; si no,series_fullconfull: [{ slot, start }]y no cambia nada). Desde ya, reserva los casos nuevos, cancela los del horario de antes y avisa enon('update')constanding: { …, booked, cancelled }. El negocio puede hacer el cambio por el cliente conneblla_bookingchangeStanding { set, user, productId, series, when? }. En el panel, cada plaza fija tiene casillas, una por horario. - El cliente cambia un día suelto. Un servicio con
moveHours(horas) deja que el cliente pase una reserva suya a otra sesión del mismo servicio con plaza, sin pagar nada, hasta esas horas antes de la que deja:neblla.business.booking.move(set, slotId, toSlotId, { for })→{ moved, booking }(«el viernes por el sábado de esa semana»; vale igual para una reserva con bono o pagada, que conserva lo que costó). El caso de una plaza fija solo se mueve dentro de su periodo (outside_periodconuntil), y el día que deja ya no es suyo gratis (reservarlo es una reserva normal). Rechazos:move_not_allowed(el servicio no tienemoveHours; entonces solo el negocio mueve, conneblla_bookingmove),too_late_to_move(conmoveHours),slot_full,already_booked,slot_cancelled,slot_past. En el panel, el servicio dice «El cliente cambia de sesión hasta (horas antes)». - Días cerrados.
neblla_businessclose{ kind?: 'sites' | 'resources' | 'staff' (sin él, todo el negocio), id?, from, to?, reason?, timeZone? }cierra un día o un tramo: «la sede cierra el 29 de octubre» ({ kind: 'sites', id: 'centro', from: '2026-10-29', reason: 'Festivo' }), «la sala grande en obras del 3 al 7», «Marc no está el viernes», la semana de vacaciones del peluquero, una planta del parking, el mantenimiento de un servidor de juego.fromytoson fechas (todo ese día,toel último cerrado) en la zona del sitio (otimeZone; si no, UTC), o instantes ISO. Cada sesión que alcanza y no ha empezado se cancela sola, como si la cancelara el negocio: lo pagado con bonos o al reservar se devuelve; el caso de una plaza fija no (la cuota es la misma), salvo que el servicio tengarecoverStanding: true, que lo devuelve como un bono (la escuela que regala la clase). Cada cliente lo oye enon('update')conreason: 'closed',note(el motivo),refunded(si recupera la clase) ystanding. Los casos de una repetición que se hagan después en esas fechas nacen cancelados, y abrir, mover o reservar a una hora libre una sesión ahí respondebusyconwhat: 'closed'.closures{ from?, to? }los lista;reopen{ closure }lo quita y vuelven las sesiones que no tenían a nadie (las que tenían gente siguen canceladas: ya recuperaron lo suyo). En la app,slotstambién trae las sesiones canceladas (state: 'cancelled', conreason, «Festivo»), y a quien tenía plaza,wasBookedyrefunded;neblla.business.booking.closures({ from, to })da los cierres ([{ id, start, end, reason, of?, site? | resource? | staff?, name? }]) para pintar el día entero como festivo. En el panel, Negocio › Agenda › Días cerrados. - Cuentas.
neblla_businessaccounts{ from?, to? }(este mes por defecto): por profesional, sede y recurso, cuántas sesiones, minutos y personas, para pagarles por fuera de Neblla (la escuela que paga a sus profes).note{ who: 'staff:<id>' | 'site:<id>' | un nombre, amount (céntimos: lo que el negocio debe; en negativo, lo que pagó), date?, note? }apunta a mano (la profe que paga a cada centro una cantidad distinta), yaccountstrae los apuntes con su total porwho. - Todo esto es administración: MCP, API de administración (
/admin/business/<op>) y panel. Lo que hace el cliente va en el SDK de la app, y solo para sí mismo (o para quien apunta): ni crea servicios ni toca la agenda de otros. - Desde la app (con login). El cliente ve lo que ofrece el negocio, las horas libres, y reserva:
``javascript const { services, sites, staff } = await neblla.business.booking.services(); // cada servicio: id, name, book, minutes, min, max, cost, pay, remindHours, chat…; sedes y profesionales por nombre (nunca su email ni su horario) const libres = await neblla.business.booking.times('completo', { from, to, staff: 'luis' }); // [{ start, end, site? }], para 'times' y 'open' const r = await neblla.business.booking.reserveAt('completo', { start: libres[0].start }); // una sesión hecha para él, con lo que esté libre; r.slot es la sesión await neblla.business.booking.reserve('yoga', huecoId, { for: 'Lucía' }); // una plaza para otra persona (la madre apunta a su hija); cancel(set, huecoId, { for: 'Lucía' }) await neblla.business.booking.reserveAt('mesa', { start, party: 4 }); // varias plazas de una vez: una mesa para 4 ` El pádel (book: 'open'): quien elige una hora con reserveAt crea su partido, y los demás lo ven en slots('padel') y se apuntan con reserve; con min: 4, se confirma al llegar a 4 o se cancela solo a las confirmHours. Errores propios: time_taken (esa hora ya no está libre), invalid_party, already_booked, y invalid_args con field en times` (un mes como mucho).
- Negocios que se dan de alta desde la app. Una app con muchos negocios dentro (los clubes de una app de pádel, una red de coworkings, profes que se apuntan a una plataforma de clases) abre su formulario con
neblla_update_app({ business: { apply: { fields: [{ key: 'pistas', label: '¿Cuántas pistas?', type: 'number', required: true }], group: 'encargados' } } })(type:'text','number'o'boolean';group, el id de un grupo del equipo cuyos permisos copia el equipo de cada negocio nuevo; sin él, Negocio para cambiar;apply: truelo abre sin preguntas propias). Un usuario con login lo pide desde la app:
``javascript const { application } = await neblla.business.apply({ name: 'Club Pádel Centro', address: 'Calle de Atocha 120, Madrid', timeZone: 'Europe/Madrid', phone: '+34 600 000 000', about: 'Cuatro pistas cubiertas', photo: 'https://…/club.jpg', fields: { pistas: 4 } }); // application.status === 'pending' const mias = await neblla.business.applications(); // [{ id, status: 'pending' | 'approved' (con site) | 'rejected' (con reason), name, at, … }], la más nueva primero neblla.business.on('update', ({ application }) => pintar(application)); // aprobada o rechazada ` La solicitud espera (pending; una a la vez por usuario: application_pending con su id) y al dev le llega el webhook business_application ({ userId, application, name, address, timeZone }) y un aviso en el panel. La ve en Negocio › Solicitudes o con neblla_business applications { status? } ('pending' por defecto, 'approved', 'rejected', 'all'; la más antigua primero, con el nombre y el email de quien la pidió). approve { application } (solo el dueño de la app: mete a alguien en su equipo) crea la sede con lo que escribió (id sacado del nombre), un grupo del equipo limitado a esa sede con los permisos de la plantilla, y mete al solicitante en ese grupo con la misma cuenta de Neblla con la que entró en la app: le llega como una invitación del equipo y la acepta en developers.neblla.com › Mis apps. Si ya estaba en el equipo (su segundo club), conserva sus grupos y suma el nuevo. reject { application, reason? } no borra nada: lee el motivo y puede volver a pedirlo. Errores: applications_closed (la app no tiene business.apply), invalid_application con field, login_required, applicant_without_account. Las sedes guardan también phone, about, photo (https) y fields, y services()` las trae para pintar la lista de clubes.
- Resultados de partidas. Un servicio con
results: { teams: 2, reportHours: 48, board?: 'nivel', cheater?: 'block' | 'ban', cheaterHint?: { lost: 3, ratio: 0.3 } }hace de cada sesión ya jugada una partida entre el lado'A'y el'B': el pádel, la liga de ajedrez de un club, un torneo amateur de un juego. Al reservar, cada uno queda en un lado (reserve(set, slot, { team: 'A' }), o el que tenga menos; cada lado tiene la mitad de las plazas:team_full,invalid_team), yslotstraeteams: { A, B }con cuántos hay en cada uno. Cuando la partida ha terminado, cada jugador dice quién ganó:
``javascript const p = await neblla.business.matches.report(partidoId, { winner: 'A', score: '6-3 4-6 7-5' }); // winner: 'A' | 'B' | 'draw' // p: { match, set, start, state, deadline, teams: { A: [userId], B: [userId] }, team, reported, winner?, score? } const mias = await neblla.business.matches.mine(); // sus partidas ya jugadas, la más nueva primero, con su estado neblla.business.matches.on('update', (p) => pintar(p)); // alguien lo dijo, se acordó, se disputó, el dev lo decidió (con resolution.reason 'cheater' si un tramposo perdió la suya) ` matches vive en neblla.business, como booking y credits (en la raíz del SDK solo hay módulos: no hay neblla.matches). Los estados: open (falta algún lado por decirlo; puede cambiar el suyo), agreed (todos los que lo dijeron coinciden y lo dijeron los dos lados: cuenta), disputed (alguien dijo otra cosa: no cuenta hasta que lo decida el dev), resolved (lo decidió el dev), none (pasaron las reportHours sin que nadie dijera nada: no hay resultado) y annulled. Al acabar el plazo, lo que se dijo sin desacuerdo cuenta. Errores: match_not_played, not_in_match, invalid_result con field, match_decided, report_closed (con deadline), match_cancelled, cheater_blocked. Un resultado que cuenta mueve el ranking ELO del servicio (board, ver identidad.txt). Una disputa le llega al dev como webhook match_disputed ({ match, set, start, reports: [{ userId, team, winner, score? }] }) y un aviso en el panel, y espera en Personas › Disputas (la más antigua primero, con lo que dijo cada uno, el chat de la sesión y el historial de disputas de cada jugador) o en neblla_matches disputes. La decide con resolve { match, winner, note? } (también una que nadie acordó; cuenta desde ese momento y mueve el ranking entonces; quien dijo otra cosa suma una disputa perdida) o la anula con annul { match, note? }` (deja de contar y el ranking se recalcula sin ella). Las partidas de una sala también pueden serlo: ver «Resultados en las salas».
- Resultados en las salas. Las partidas que se juegan en una sala de Neblla (ajedrez online, un duelo, un 2 contra 2) son partidas con los mismos resultados, el mismo ELO y las mismas disputas, sin servicio ni reservas:
neblla_update_app({ matches: { reportHours: 24, board: 'nivel' } })(la misma forma queresults;truecon los valores por defecto,nulllo apaga). Cada vez que una sala empieza (neblla.room.start()), Neblla abre su partida entre el lado'A'y el'B'de los jugadores con login que están dentro, por orden de llegada (A, B, A, B…), o los lados que diga quien la empieza:neblla.room.start({ teams: { A: [id], B: [id] } })(ids deneblla.room.users; un invitado o alguien de fuera respondeinvalid_teamsy la sala no empieza). Un invitado no queda en ningún lado, y con menos de un jugador por lado no hay partida. Va en el móduloroom:
``javascript neblla.room.match; // { match, room, start, state, deadline, teams: { A: [userId], B: [userId] }, team } o null await neblla.room.matches.report(neblla.room.match, { winner: 'A' }); // al acabar, cada jugador lo dice (también después de salir, hasta el plazo) const mias = await neblla.room.matches.mine(); // sus partidas de salas, la más nueva primero neblla.room.matches.on('update', (p) => pintar(p)); // como neblla.business.matches.on ` Lo demás es igual que en las sesiones: los estados, el plazo (reportHours desde que empezó), el ranking ELO (board), la cola de Personas › Disputas y neblla_matches (con el nombre de la sala como título y sin chat), y los tramposos (cheaterHint, cheater). Si el servidor de la sala (room.js) sabe quién ganó, que lo pinte y que cada jugador lo confirme con report: quien lo niegue abre una disputa. Con el módulo business, neblla.business.matches.mine()` trae también las de las salas.
- Tramposos. Cada jugador lleva su cuenta (
neblla_matchesplayer{ user }→{ reported, disputed, lost, ratio, hint, cheater? }, también en su ficha del panel): conhint: truepasa el umbral del servicio (results.cheaterHint; por defecto, 3 disputas perdidas y más del 30 % de sus partidas en disputa) y la cola lo señala, pero Neblla no decide: el dev lo declara con «Declarar tramposo» en la cola o en su ficha, o condeclareCheater{ user, note? }. Sus disputas abiertas se dan al otro lado (los jugadores lo oyen conreason: 'cheater'), lo que dijo en las partidas sin decidir deja de contar, queda marcado (comoneblla_flag_user) y, segúnresults.cheater(el más estricto de los servicios), ya no puede decir resultados ('block', por defecto:cheater_blocked) o queda baneado ('ban').clearCheater{ user }lo retira (lo ya decidido se queda);cheaterslos lista. - Partidas abiertas por nivel. Una sesión de un servicio
book: 'open'puede pedir un nivel de un ranking ELO:reserveAt('padel', { start, level: { board: 'nivel', min: 1300, max: 1500 } }); sinlevel, si el servicio tieneresults: { board, levelRange: 100 }, la de quien la crea ± ese rango.slots('padel')solo le enseña a cada jugador las sesiones de su nivel (y las suyas, o en las que ya está), conlevel: { board, min, max }en cada una; un invitado las ve todas. Reservar fuera del rango respondelevel_out_of_rangeconmin,maxy surating(quien nunca jugó tiene elstartdel ranking). Quien la creó la amplía mientras le queden plazas:neblla.business.booking.widenLevel('padel', slotId, { min: 1100 })→{ slot, level }(nunca más estrecha:invalid_level; de otro,not_your_match; llena,slot_full). Conmin: 4se confirma al llenarse, como cualquier partido. Sirve igual para una escuela de idiomas con grupos por nivel (un ranking que pone la escuela consetRating) o un club de ajedrez.timesson horas libres, sin partida todavía: no llevan nivel. Errores:invalid_levelconfield,unknown_board,not_elo_board. - Primero lo ven quienes tienen un producto. Un servicio con
early: { products: ['premium'], hours: 12 }reserva las sesiones nuevas (las que abre el negocio, también los casos de una serie, y las partidas abiertas que crea un jugador conreserveAt) a quien tiene uno de esos productos (comprado y sin gastar, o una suscripción viva) durante sus primerashours(12 si no se dice), y a quien la creó: el «Premium» que ve las partidas abiertas antes que nadie, el socio del gimnasio que reserva clase antes, la preventa para suscriptores. A ellosslots()les trae la sesión conearlyUntil(hasta cuándo es solo suya); los demás no la ven hasta esa hora. Reservar antes respondeearly_accessconuntil(yproducts, para ofrecer la compra). Quien ya tiene plaza la sigue viendo; una plaza fija y el panel no esperan. Quitarearlydel servicio la abre a todos al momento. - Valoraciones de ida y vuelta. Tras una sesión ya jugada, cada cliente que tenía plaza valora la sede (Airbnb con anfitrión y huésped, una red de clubes, una plataforma de clases particulares):
``javascript await neblla.business.rate({ site: 'centro', slot: sesionId, rating: 5, text: 'Pistas nuevas y buena luz' }); // 1 a 5; una por sesión, otra vez la cambia → { review } const { rating, ratingCount, reviews } = await neblla.business.reviews('centro'); // las reseñas de una sede, la más nueva primero: [{ rating, text?, name?, at }] ` services() trae rating y ratingCount en cada sede (públicos). El negocio valora a cada cliente de una sesión jugada con neblla_business rateUser { user, slot, rating, text? } (también en su ficha del panel, «Valorar» en sus sesiones pasadas): cualquiera del equipo con permiso en esa sede (C350), y un Profesional solo en sus sesiones. Su media solo la ven los negocios de la app: userRating { user } → { rating, ratingCount, reviews }, su ficha, y clientRating junto a cada reserva en la agenda. Errores: session_not_played, not_in_session, invalid_review con field, slot_cancelled, login_required. El dev las modera en Negocio › Valoraciones o con reviews { kind?: 'sites' | 'users', id?, hidden? } y hideReview { review, hidden? }: una oculta se queda, no cuenta en la media y nadie la lee (aunque su autor la vuelva a escribir); hidden: false` la devuelve.
Paneles a medida y tu equipo. En el panel (Funciones › Pantallas a medida) defines tus propias pantallas —"Reservas de hoy", "Asistencia del mes"— como vistas de tus tablas con filtros y columnas, y botones que llaman a esas acciones —"Cancelar reserva", "Devolver bono"—. Lo puede montar tu agente por MCP:
neblla_set_panel({ appId, name: 'hoy', panel: {
title: 'Reservas de hoy',
source: { booking: 'clases' }, // o { table: 'reservas' }
where: { start: { gte: '$today', lt: '$tomorrow' } },
filters: [{ field: 'slot', label: 'Clase' }],
columns: ['player.name', { field: 'start', label: 'Hora' }],
actions: [{ label: 'Cancelar reserva', call: 'booking.cancel', confirm: true }],
} })
whereacepta fechas relativas:$now,$today,$tomorrow,$dayAfterTomorrow,$weekStart,$monthStart,$nextMonthStart. Así "Asistencia del mes" es{ start: { gte: '$monthStart', lt: '$nextMonthStart' } }.- Un panel de reservas lista cada hueco de su rango, libre o no: una fila por reserva (
id,slot,title,start,end,status—'reserved', o'held'mientras paga—,cost,at,player({ name }: el nombre público que le pone tu app o, si no tiene, el de su cuenta de Neblla),capacity,left—las plazas que quedan— yseriessi el hueco se repite) y una por hueco que nadie ha reservado (status: 'free', sinidni jugador). Así un hueco recién creado se ve al momento: "jueves 17:00 · libre", "18:00 · libre", "19:00 · Ana López". Vale igual para clases, citas, partidas o torneos, y los casos de una serie son filas como cualquier hueco. Los filtros,where(vale tambiénin;{ status: 'reserved' }deja solo las reservas) yorderBy(por defecto,start) siguen valiendo, y los botones de fila solo salen en las filas con reserva: una plaza'held'sale como «Esperando el pago» (en la columnastatuso, sin ella, tras el nombre del jugador), sin botones (booking_heldsi se actúa sobre ella), hasta que se paga o caduca sola a los 35 minutos. Sincolumns, enseñastart,title,player.namey, si algún hueco tiene más de una plaza,left. - Las acciones:
booking.cancel,booking.move,credits.adjust,table.remove,table.save, con susargs, van en cada fila.booking.createSlotes un botón sobre el panel que pide un formulario corto (título, inicio, minutos, plazas y cómo se repite, como en un calendario: cada día, semana, mes o año, cada cuántos, qué días de la semana, en el mes el mismo día o el mismo día de la semana —"el segundo martes", "el último viernes"—, y hasta una fecha o cuántas veces); susargslo rellenan de antemano:{ label: 'Nuevo hueco', call: 'booking.createSlot', args: { title: 'Yoga', capacity: 12, minutes: 60 } }(tambiénstart—una fecha o$tomorrow…—,timeZoney la repetición:freq,interval,byDay,byMonthDay,until,count; sin ella, la hora es la del navegador de quien lo pulsa; conconfirm, pregunta antes de crear). Para pulsarlo, un papel necesita escribir ennb_slots(en el panel, la casilla «Puede abrir huecos nuevos» de Personas › Grupos › Papeles). El inicio y el «hasta» se eligen con el selector de fecha del panel, en su idioma. Así el dueño del estudio monta su horario él solo: "Yoga, martes a las 18:00, hasta junio" son todos los martes hasta junio de una vez. neblla_run_panello ejecuta y te devuelve las filas, para probarlo sin abrir el backoffice.
Y no solo para ti: invitas a tu equipo —el dueño del estudio, los profes, un socio— desde el panel (Negocio › Tu equipo) o con neblla_team({ appId, action: 'add', email, groups }). Primero se busca su dirección entera (lookup; nadie puede ojear quién tiene cuenta). Si tiene cuenta de Neblla, le llega un email y lo acepta entrando en developers.neblla.com con esa dirección, con cualquier cuenta de Neblla (la misma con la que entra en las apps, o una nueva; no hace falta ser desarrollador: verá su backoffice vacío y, en Mis apps, la invitación), o la manda a «Baneadas», y entonces no se le puede volver a invitar hasta que la recupere; la invitación caduca a los 7 días (expiresInDays, hasta 30) y se puede volver a mandar. Si aún no tiene cuenta, la prerregistras: queda todo preparado, la invitación no caduca y le espera el día que se registre con esa dirección (estado not_registered hasta entonces). A un amigo tuyo de Neblla lo invitas sin escribir su dirección (friend, de action: 'friends'), y no la ves hasta que acepta. En la lista, el estado dice «Usuario no registrado», «Invitación no aceptada» o nada, si ya está dentro. Cada persona está en uno o varios grupos (Negocio › Grupos) y sus permisos se suman: una socia que también da clase está en «Socios» y en «Profesionales». Un grupo es un nombre y lo que puede en cada área del panel, ver (read) o cambiar (write), y el servidor lo aplica en cada pantalla del panel y en cada tool del MCP (también entra con su propia cuenta por MCP). Para no empezar de cero hay cinco grupos de ejemplo, que usas tal cual o como plantilla de uno tuyo: Socios (partner, todo menos el dinero, el plan, el dominio propio y borrar la app), Comunidad y soporte (support, la gente de la app y ver las ventas, sin tocar dinero ni código), Solo mirar (viewer, todo en lectura), Solo pantallas a medida (screens: solo los paneles que le des con panels, para la profesora o un cliente) y Profesionales (staff, para la profesora, el fisio o la peluquera de un negocio: en la pestaña Negocio ve y cambia solo lo suyo —las sesiones que da y quién viene, su horario, su línea de las cuentas—; abre sesiones solo a su nombre. Es el profesional de Negocio › Profesionales con su mismo email; sin ficha con ese email, no ve nada). «Solo lo suyo» en Negocio (own) vale mientras todos los grupos que le dan Negocio lo digan. Un grupo también puede quedar limitado a unas sedes (neblla_team group_save con sites: ['centro']; en el panel, «Solo estas sedes»): sus miembros solo ven y cambian las sesiones, recursos, profesionales, días cerrados, reservas, chats, cuentas y fichas de cliente (los clientes con reservas en esas sedes) de esas sedes, por el panel, el MCP o la API de administración, y ese grupo no da Personas ni Moderación (que son de toda la app). Es el encargado de un club en una app de muchos clubes, o el de un centro de una cadena de gimnasios. El equipo y el dinero solo los cambias tú: un grupo puede verlos, nunca cambiarlos (read_only_area). Lo que sus grupos no dejan responde not_allowed. «Editar con IA» y «Preguntar a la IA» de tu equipo van con tu clave de Claude, la del dueño (si no tienes, les responde no_claude_key con ownerKey: true), y el registro de actividad dice quién la usó. Solo tú cambias el equipo, sus grupos y quitas el acceso. Estos grupos son los del backoffice; lo que cada usuario puede dentro de la app son los papeles de la app. Además, a cualquiera puedes darle un papel de la app (role, ver identidad.txt, "Grupos y permisos"): ve los paneles cuya fuente puede leer y pulsa los botones donde puede escribir; sin write, el botón responde not_allowed. En un papel, las reservas son nb_bookings y los bonos nb_credits: { profe: { nb_bookings: 'read write' } }. Los paneles ven todas las tablas y todos los usuarios; por eso viven en el backoffice y nunca en la app.
Nada de esto necesita un servidor tuyo. Y si quieres lógica propia en un servidor, tu room.js puede correr en Server, en la nube de Neblla o en tu servidor (ver servidor.txt).
Reglas clave
message.send()desdeclient.jsllega a TODOS incluyéndote — usam.fromMepara distinguir. Desde server no hay "yo".db.setPublicInfo()es para datos visibles a otros;db.save()para datos privados persistentes.neblla.db.datanunca llega a otros clientes, ni al peer que ejecutaroom.js.db.save()devuelve una promesa: se cumple cuando el guardado está hecho ({ data, mode, pending }) y falla con un código (save_too_large,invalid_save…). No hace falta esperarla; si no la esperas, el fallo llega igual adb.on('error'). Para un modo sin conexión, guarda tú en local y manda luego cada cambio con{ at }, la hora en que pasó (ver identidad.txt).rooms.book()reserva un nombre pero NO entra. Sigue conrooms.join().- Los archivos de
server/corren en tres dispositivos a la vez (Fog). Si se van todos a la vez, el siguiente arrancaroom.jsde nuevo conneblla.state.get()ya cargado: guarda en el estado lo que tiene que sobrevivir (ver servidor.txt). - WebRTC P2P se negocia automáticamente; las rutas son transparentes.
- IA (
neblla.ai) requiere login excepto en modo browser-directo. Las comunicaciones con el dev requieren login. - No hay
createRoom(): una sala se abre conrooms.book → rooms.join → room.start.