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

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):

Server-side (lógica aislada, sin DOM, carpeta server/):

Contrato de la estructura:

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:

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:

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

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 |

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.

``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`.

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'

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:

``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).

``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.

``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».

``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.

``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 }],
} })

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