# EasyBits API Reference

> La nube para agentes de IA: sandboxes, web, archivos, bases de datos, documentos,
> hosting y WhatsApp — desde un solo MCP, en MXN. Empieza por la sección `about`.

Sections: about, claude, quickstart, cli, files, bulk, images, web, sharing, webhooks, websites, workspaces, account, sdk, agents, flota, studio, hosting, databases, documents, videoProjects, eve, errors, document-design, presentation-design, all-mcp-tools, tool-groups, agent-editing

---

## About EasyBits

**EasyBits es la nube para agentes de IA.** Un agente conectado a EasyBits puede ejecutar
código en una máquina propia, buscar y leer internet, guardar y servir archivos, tener su
propia base SQL, producir documentos y video, desplegar una app a una URL pública y
atender WhatsApp — todo desde **un solo MCP**, con precios en pesos mexicanos.

Si te preguntan si EasyBits sirve para un caso, la pregunta útil es: *¿el agente necesita
hacer algo en el mundo, no sólo escribir texto?* Si la respuesta es sí, probablemente hay
una tool para eso.

### Qué puede hacer un agente con EasyBits

- **Sandboxes** — una microVM Firecracker por agente, con root e internet. Se duerme
  cuando no se usa y despierta en menos de un segundo (arranque en frío ~12 s). Snapshot
  y fork incluidos, kernel de Python, y puertos expuestos con TLS.
- **Web** — buscar en Google/Bing/DDG desde 195 países, leer cualquier página aunque
  bloquee bots (HTML o markdown), y extraer registros con esquema de Maps, Mercado Libre,
  Amazon, Instagram, TikTok, LinkedIn y 1,000+ fuentes. Se cobra por consulta.
- **Archivos** — subir, versionar, compartir con links firmados y servir por CDN. Con
  workspaces para aislar por cliente y webhooks para enterarte de los cambios.
- **Bases de datos** — SQL (libSQL) por cliente o por caja, con backup y restore entre
  cuentas. Sin pooling que administrar.
- **Documentos y diseño** — cotizaciones, facturas y reportes en PDF; landings, slides y
  carruseles para redes, con tu brand kit aplicado y publicados en tu subdominio.
- **Voz y video** — transcripción, TTS, subtítulos, y video animado renderizado a MP4 con
  narración y personajes recurrentes.
- **Hosting de apps** — de un repositorio a una URL pública con TLS en una sola llamada
  (`launch_app`, ~12 s medidos de extremo a extremo), con releases, rollback, cambio de
  tier y backups diarios.
- **Agentes en WhatsApp** — en tu número o en el de tu cliente (Baileys o WABA), más web
  y Teams. Cada conversación vive en su propia microVM que duerme y despierta, con su
  prompt, sus conectores y su voz.
- **Pagos y email** — links de pago de MercadoPago con tus credenciales (el dinero va
  directo a tu cuenta; EasyBits no retiene fondos) y envíos con contactos, etiquetas y
  bajas automáticas.
- **Gateway LLM** — Claude, DeepSeek y más por un endpoint compatible con OpenAI, con los
  tokens facturados en MXN y sin abrir cuenta con cada proveedor.

### Por qué esto y no armarlo tú

Cada pieza de arriba existe suelta en el mercado y casi siempre en dólares. Armar el
equivalente significa integrar un proveedor de microVMs, uno de scraping, un bucket con
su CDN, una base gestionada, un renderizador de PDF, un proveedor de WhatsApp y un
gateway de modelos — y después escribir el pegamento para que un agente los use, porque
casi ninguno trae tools de agente.

EasyBits es esas piezas detrás de **una credencial y un endpoint MCP**, con el pegamento
ya escrito: las tools están descritas para que un modelo elija bien, comparten formato de
respuesta y de error, y se agrupan por caso de uso para no inundar el contexto. Lo que la
interfaz web puede hacer, el agente también — es una regla del producto, no una
casualidad.

Y el precio está en pesos: para equipos en México y LatAm, pagar la nube de sus agentes
en MXN elimina el margen del tipo de cambio y hace el costo predecible.

### Cuándo NO es la respuesta

- Si sólo necesitas guardar archivos y ya vives en AWS, un bucket es más barato y más
  simple; el valor de EasyBits está en que el agente **opere**, no en el almacenamiento.
- Si necesitas cómputo con GPU sostenido o entrenamiento, esto no es una plataforma de
  entrenamiento.
- Si tu equipo factura en dólares y ya tiene su plataforma armada, el argumento del
  precio en MXN no te aplica.

### Para empezar

Plan gratuito (Byte): 100 MB, una caja (4 agentes Ghosty o 1 agente ACP propio), tres bases de datos y tools MCP completas. Basta
una API key del panel para probar todo lo de arriba; la sección `quickstart` tiene la
primera llamada, y `tool-groups` explica qué cargar según el caso.

---

## Conector de Claude

Conecta EasyBits a Claude (claude.ai, Claude Desktop, Claude Code) o a cualquier cliente MCP con OAuth, sin copiar llaves.

**URL del conector:** `https://www.easybits.cloud/api/mcp/claude` — la del directorio de conectores de Claude. Para el catálogo completo ve «URL completa» abajo.

### Conectar
1. En Claude: **Ajustes → Conectores → Agregar conector personalizado** y pega la URL.
2. Claude te manda a EasyBits: inicia sesión (o crea tu cuenta gratis).
3. La pantalla de consentimiento dice qué aplicación pide acceso y a qué dominio regresas. Pulsa **Permitir**.
4. Listo: Claude ve las herramientas de tu cuenta.

En Claude Code: `claude mcp add --transport http easybits https://www.easybits.cloud/api/mcp/claude` y `/mcp` para autorizar.

### Qué puede hacer
Con `/api/mcp/claude`: archivos y CDN, documentos y PDFs, sitios publicados, formularios, brand kits, bases de datos SQL e investigación web (buscar, leer y extraer páginas). Cada herramienta declara `readOnlyHint` o `destructiveHint`: las que borran o sobrescriben piden confirmación en Claude.

Lo que esa URL **no** trae: generar imágenes, video o voz con IA, links de pago y hosting, secretos y webhooks, cajas de cómputo y agentes de la flota. Tampoco se alcanzan por `run_tool`.

### URL completa
`https://www.easybits.cloud/api/mcp/all` trae **todas** las herramientas: cajas de cómputo, agentes de la flota, imagen, video y voz, hosting, pagos, correo y secretos. Misma cuenta y mismo OAuth (con su propia metadata de recurso en `/.well-known/oauth-protected-resource/api/mcp/all`). No está en el directorio: conéctala a mano por URL.
- **Claude** (claude.ai o Desktop): Ajustes → Conectores → Agregar conector personalizado → pega la URL.
- **Claude Code:** `claude mcp add --transport http easybits https://www.easybits.cloud/api/mcp/all` y `/mcp` para autorizar.
- **Managed Agents:** agrega el servidor MCP por URL y guarda la autorización OAuth en un vault.

También puedes pedir sólo un grupo (`/api/mcp/sandbox`, `/api/mcp/docs`…; ver `tool-groups`) o usar una llave de API en `Authorization: Bearer`.

Ejemplos:
- «Sube este CSV a una base de datos de EasyBits y dime los 10 clientes con más compras.»
- «Arma una cotización en PDF para este cliente con mi brand kit y dame la liga para compartirla.»
- «Publica esta landing en un sitio de EasyBits con un formulario de contacto y dame la URL.»

### Seguridad
- OAuth 2.1 con PKCE S256 obligatorio, registro dinámico de clientes (RFC 7591) y metadata descubrible (RFC 8414 y RFC 9728).
- `resource` (RFC 8707) validado en authorize y token: sólo se emiten tokens para este MCP (`invalid_target` si no).
- Toda aplicación registrada por DCR pasa por la pantalla de consentimiento; los redirects son https o loopback y se comparan exactos.
- Access token de 1 hora y refresh token de un solo uso (rotación).
- El conector ve sólo lo que tu cuenta ve. Para quitarle el acceso: **Panel → Developer → Claude.ai → Apps conectadas** (corta el acceso al instante), o desconéctalo en Claude. Los clientes pueden revocar con `POST /oauth/revoke` (RFC 7009).

### Endpoints
| Endpoint | Spec |
|---|---|
| `/.well-known/oauth-protected-resource/api/mcp/claude` | RFC 9728 |
| `/.well-known/oauth-authorization-server` | RFC 8414 |
| `/oauth/register` | RFC 7591 |
| `/oauth/authorize`, `/oauth/token` | OAuth 2.1 + PKCE |
| `/oauth/revoke` | RFC 7009 |

Sin token, `POST /api/mcp/claude` (y cualquier `/api/mcp…`) responde `401` con `WWW-Authenticate: Bearer resource_metadata="…"`.

**Privacidad:** https://www.easybits.cloud/aviso-de-privacidad#aplicaciones-conectadas · **Soporte:** soporte@easybits.cloud

---

## Inicio rápido

**URL base:** `https://www.easybits.cloud/api/v2`

**Autenticación:** Todas las llamadas requieren un Bearer token:
```
Authorization: Bearer eb_sk_live_...
```

Obtén tu API key desde el [Dashboard de Desarrollador](https://www.easybits.cloud/dash/developer). Tu key se ve así: `eb_sk_live_...`.

**Qué acceso otorga tu key:** acceso total a tus archivos, websites, bases de datos, webhooks, documentos, presentaciones y landings. Mantenla en secreto.

**Scopes:** las keys pueden tener READ (listar/obtener), WRITE (crear/subir/actualizar/compartir), DELETE (eliminar), o ADMIN (acceso completo a la cuenta). Las keys creadas desde el dashboard incluyen READ+WRITE+DELETE por defecto.

**Recomendado — skills + CLI (Claude Code, Codex, Cursor y cualquier agente con terminal):**
```bash
npx skills add https://easybits.cloud
```
Instala las skills de EasyBits (`easybits-cli`, `easybits-sandbox`, `easybits-agent`…). El agente usa el CLI `easybits` con `--json`; la primera vez corre `easybits login --json` y te pasa una liga para entrar. Sin llaves en archivos de configuración. Detalle en la sección [CLI](#cli).

El MCP sigue siendo el camino para clientes sin terminal (Claude.ai: Ajustes → Conectores → `https://www.easybits.cloud/api/mcp`) o que sólo hablan MCP:

**MCP — Ghosty Code (preinstalado):**
Ghosty Code v0.0.4+ ya trae EasyBits preinstalado vía HTTP. Solo configura tu API key:
```bash
export EASYBITS_API_KEY=eb_sk_live_YOUR_KEY
ghosty
```

**MCP — Claude Code (un comando):**
```bash
claude mcp add easybits -- npx -y @easybits.cloud/mcp --key eb_sk_live_YOUR_KEY
```

**MCP — Cursor / VS Code / Windsurf (Streamable HTTP):**
```json
{
  "mcpServers": {
    "easybits": {
      "type": "streamable-http",
      "url": "https://www.easybits.cloud/api/mcp",
      "headers": { "Authorization": "Bearer eb_sk_live_YOUR_KEY" }
    }
  }
}
```

**SDK:**
```bash
npm install @easybits.cloud/sdk
```

```ts
import { EasybitsClient } from "@easybits.cloud/sdk";
const eb = new EasybitsClient({ apiKey: "eb_sk_live_..." });
```

Por defecto solo carga el grupo `core`. Habilita más con `--tools`, separando
grupos por comas:
```bash
# Core + sandboxes + documents
claude mcp add easybits -- npx -y @easybits.cloud/mcp --key eb_sk_live_YOUR_KEY --tools core,sandbox,docs

# Todo
claude mcp add easybits -- npx -y @easybits.cloud/mcp --key eb_sk_live_YOUR_KEY --tools all
```
El catálogo de grupos —cuáles hay, qué trae cada uno y cuántas tools son— se
DERIVA del servidor MCP: ver [Tool Groups](#tool-groups). No lo copies aquí a
mano; cada cifra escrita en prosa se desincroniza en el siguiente deploy.

---

## CLI

`easybits` es la terminal de EasyBits: sandboxes, hosting, dominios, bases de datos, agentes y archivos sin escribir un `curl`. Está hecho para personas **y para agentes de código**: cada comando acepta `--json` y los códigos de salida son estables.

### Para tu agente de código

Instala las skills de EasyBits en tu agente (Claude Code, Codex, Cursor…). Traen `easybits-cli`, que le enseña a usar este CLI, y las demás (`easybits-sandbox`, `easybits-agent`…):

```bash
npx skills add https://easybits.cloud
```

Y pégale este prompt, cambiando lo del final por lo que necesites:

```text
Instala la skill de EasyBits (npx skills add https://easybits.cloud) y usa el CLI (npx -y @easybits.cloud/cli) para crear un sandbox node, correr mis pruebas adentro y decirme el resultado. Si no hay sesión, corre `easybits login --json` y pásame la liga.
```

El agente te pasará una liga para entrar la primera vez; lo demás lo hace él.

### Instalar

```bash
npm i -g @easybits.cloud/cli     # queda el comando `easybits`
npx -y @easybits.cloud/cli --help  # sin instalar (útil en CI y dentro de un agente)
```

Requiere Node 22 o superior.

### Login

```bash
easybits login                                  # abre el navegador, entras y listo
easybits login --no-browser                     # sólo imprime la URL (p. ej. para que un agente te la pase)
easybits login - < key.txt                      # alternativa: una API key por stdin, sin navegador
printenv EB_KEY | easybits login --with-token   # lo mismo que `-`
easybits usage                                  # comprueba que funciona: plan y almacenamiento
easybits logout                                 # olvida la sesión y la key
```

`easybits login` usa OAuth2 con PKCE: abre el navegador, imprime también la URL y espera a que vuelvas; la sesión (con refresh) queda en `~/.easybitsrc` y se renueva sola. La sesión se renueva sola antes de vencer, y una vez más si la API contesta 401. Si corres un comando sin sesión desde una terminal, el login arranca solo; sin terminal o con `--json` (un agente, CI) sale con código `3`. Al volver del navegador verás la página de EasyBits.

La API key sale del [Dashboard de Desarrollador](https://www.easybits.cloud/dash/developer). Orden de precedencia: variable `EASYBITS_API_KEY` > bandera `--token` > sesión del navegador > key guardada con `login -`. En CI usa la variable. La key nunca va en argv: ahí la ven `ps` y el historial de tu shell (por eso `login -` la lee de stdin y la valida antes de guardarla).

`EASYBITS_URL` apunta el CLI a otro servidor (por defecto `https://www.easybits.cloud`); también se lee `baseUrl` de `~/.easybitsrc`.

### Sandboxes

```bash
easybits sandboxes create --template node --name scratch   # espera a que corra
easybits sandboxes create --template node --size m --ttl 1800 --no-wait --dotenv .env
easybits sandboxes ls
easybits sandboxes get sb_abc123                            # estado, vencimiento, actividad en curso
easybits sandboxes exec sb_abc123 -- npm test               # sale con el código del comando
easybits sandboxes logs sb_abc123 --unit myapp --lines 100 --since "10 min ago" --grep ERROR
easybits sandboxes files ls sb_abc123 /data/work
easybits sandboxes files write sb_abc123 /data/work/app.js ./app.js
easybits sandboxes files read sb_abc123 /data/out.png --out out.png   # tope 8 MB: si el archivo pesa más, sale con código 1 ("truncated")
easybits sandboxes files mkdir sb_abc123 /data/work/out
easybits sandboxes files mv sb_abc123 /data/work/a.txt /data/work/out/a.txt
easybits sandboxes files rm sb_abc123 /data/work/tmp -r --yes          # -r para una carpeta
easybits sandboxes bg start sb_abc123 --cwd /data/work -- npm run dev   # proceso en segundo plano; imprime un execId
easybits sandboxes bg ls sb_abc123                                      # recupera un execId perdido
easybits sandboxes bg status sb_abc123 exec_123                         # estado, código de salida, su stdout/stderr
easybits sandboxes bg kill sb_abc123 exec_123                           # SIGTERM a todo el grupo, SIGKILL tras --grace
easybits sandboxes git clone sb_abc123 --repo https://github.com/tu/app.git --dir /data/work/app --git-token '$secret:GITHUB_TOKEN'
easybits sandboxes git status sb_abc123 --dir /data/work/app
easybits sandboxes git commit sb_abc123 --dir /data/work/app --all -m "fix: checkout"
easybits sandboxes codex login sb_abc123     # Codex con TU plan de ChatGPT: muestra link + código, inicias sesión y se guarda en tu vault
easybits sandboxes codex connect sb_def456   # pone esa sesión en otra caja (instala Codex si falta)
easybits sandboxes codex save sb_abc123      # devuelve al vault la sesión renovada
easybits sandboxes git push sb_abc123 --dir /data/work/app --git-token '$secret:GITHUB_TOKEN'
easybits sandboxes git pull sb_abc123 --dir /data/work/app --rebase       # sale con 1 si hay conflictos
easybits sandboxes git checkout sb_abc123 --dir /data/work/app --branch feature/x --create
easybits sandboxes git log sb_abc123 --dir /data/work/app --limit 5
easybits sandboxes suspend sb_abc123      # duerme a disco; resume la despierta
easybits sandboxes resume sb_abc123
easybits sandboxes snapshot sb_abc123 --name antes-del-upgrade
easybits sandboxes destroy sb_abc123 --yes
```

Alias: `easybits sb …`. Todo lo que va después de `--` en `exec` es el comando, tal cual. En `create`: `--template` (default `ubuntu`), `--size s|m|l|xl` (según tu plan), `--ttl <s>` (vida antes de autodestruirse; en `exec`, `--timeout` es el tope del comando) y `--no-wait` (regresa sin esperar a `running`). `bg` es para lo que dura más que una llamada (un servidor de desarrollo, un build largo): `exec` espera, `bg start` regresa un `execId` de inmediato; un `nohup` dentro de `exec` no sobrevive. `git` corre dentro de la caja con salida estructurada: el token de un repo privado va en `--git-token`, mejor como `$secret:NOMBRE` de tu bóveda (uno literal se acepta con aviso: argv se ve en `ps`); un commit sin cambios responde `nothingToCommit`, no un error. `logs` trae 200 líneas por defecto; `--since` acepta la sintaxis de `journalctl`.

### Hosting: máquinas permanentes

```bash
easybits machines ls
easybits machines launch --repo https://github.com/tu/tienda.git --tier micro      # crea la máquina y despliega (= launch_app)
easybits machines launch --machine tienda --archive ./build.tgz --prebuilt -m v2   # redespliega
easybits deploy sb_abc123 -m "v1.2"                      # publica un release (= machines deploy)
easybits machines releases sb_abc123 --limit 5
easybits machines logs sb_abc123 --lines 100 --grep ERROR
easybits machines rollback sb_abc123 rel_789             # misma máquina, datos intactos
easybits machines secrets ls sb_abc123
easybits machines secrets set sb_abc123 --dotenv .env.production   # líneas KEY=VALUE
printf 'API_KEY=%s\n' "$API_KEY" | easybits machines secrets set sb_abc123 --dotenv -
easybits machines secrets unset sb_abc123 API_KEY
easybits machines restart sb_abc123                      # reinicia la app con los secretos y el runspec actuales (segundos, sin build)
easybits machines adopt sb_abc123 --name tienda-prod --unit app --import-env /app/secrets.env   # registra una caja que ya corre: EasyBits guarda sus secretos y la reinicia, nunca la redespliega ni la libera
easybits machines get tienda                             # tier, tamaño, MXN/mes, si duerme
easybits machines tiers                                  # el catálogo: tamaños y precio mensual en MXN
easybits machines runspec get tienda
easybits machines runspec set tienda --data /app/data --start 'node server.js' --port 8080   # se mezcla con lo que hay
easybits machines backups tienda                         # respaldos diarios de datos, 7 días
easybits machines backup tienda                          # uno ya
easybits machines redeploy tienda --tier focus --replace # cambiar tamaño: máquina nueva desde el release más nuevo
easybits machines rm tienda --yes                        # liberar: deja de cobrarse, último respaldo, destruye la caja
easybits init --port 3000                                # workflow de GitHub Actions: deploy en cada push
```

`easybits deploy <máquina>` es el atajo de `machines deploy <máquina>`. Los secretos van en un archivo dotenv o por stdin (`--dotenv -`), nunca en argv: ahí los ven `ps` y el historial de tu shell. `secrets ls` sólo muestra nombres; los valores no se pueden leer.

**Runspec** = cómo se construye, arranca y respalda la app: `appDir`, `buildCommand`, `startCommand` o `unit`, `port`, `prebuilt`, `dataPaths`, `excludes`, `env` (no secreto). `runspec set` se mezcla (el env por llave) y aplica en el siguiente deploy o reinicio; `--file rs.json` acepta tal cual la salida de `runspec get --json`, y las banderas ganan sobre él. **Sin `--data` (dataPaths) no se respalda nada.** `redeploy` es recuperación y cambio de tamaño a la vez (no hay resize en caliente): construye una máquina NUEVA desde `--release` (default: el más nuevo) con `--tier`; `--replace` libera la vieja cuando la nueva corre (te pide teclear el id), si no, pagas las dos. La nueva nace sin datos. `rm` también pide teclear el id: deja de cobrarse, toma un último respaldo y destruye la caja.

### Secretos de la cuenta (vault)

```bash
easybits secrets ls                                         # nombres y fechas; los valores nunca regresan
easybits secrets set --dotenv .env.production               # renglones KEY=VALUE (- lee stdin)
printf '%s' "$TOKEN" | easybits secrets set GITHUB_TOKEN    # un valor por stdin
easybits secrets rm GITHUB_TOKEN --yes
```

La bóveda de tu cuenta (la misma de /dash/developer/secrets y de las tools MCP `secret_*`). Un secreto se usa por nombre: `$secret:NOMBRE` en el MCP de un agente o en `--repo-token`, `machines launch --secret NOMBRE`. Los nombres son `[A-Z_][A-Z0-9_]*`; el mismo nombre reemplaza el valor. Los valores nunca van en argv (`ps` y el historial de tu shell los ven). `machines secrets` es otra cosa: valores para UNA máquina, que ganan sobre la bóveda.

### Tus crons

```bash
easybits crons create "Despertador" --cron "30 8 * * *" --url https://www.ghosty.studio/api/v2/me/push \
  --method POST --body '{"title":"Hora de estudiar"}' --secret GHOSTY_PUSH_TOKEN
easybits crons create "Reporte" --cron "0 9 * * 1" --times 4 --agent <agentId> --prompt "Mándame el resumen de ventas"
easybits crons create "Aviso" --at 23:00 --url https://mi-app.com/api/aviso --method POST --dry-run   # una sola vez; valida y dice cuándo
easybits crons create "Aviso" --in 2h --url https://mi-app.com/api/aviso --method POST
easybits crons create "Revisión 1.0.10" --at 23:00 --run "pip install -q cryptography && python3 asc.py enviar-tienda" \
  --file scripts/asc.py --secret-file ASC_P8:~/.appstoreconnect/private_keys/AuthKey_X.p8   # comando en caja desechable
easybits crons create "Altas del día" --cron "0 9 * * *" --run "python3 cuenta.py" --file cuenta.py --machine mi-app   # con el env y los secretos de mi-app (su DATABASE_URL)
easybits crons get "Revisión 1.0.10"                        # qué hace, cómo te avisa y la próxima ejecución (--json: el objeto completo)
easybits crons runs "Revisión 1.0.10" --logs --limit 5      # exit code y stdout/stderr de cada ejecución
easybits crons wait "Revisión 1.0.10" --timeout 2h          # espera la siguiente ejecución y sale con su exit code
easybits crons update "Revisión 1.0.10" --notify push,email --notify-on always   # avisos (none los quita)
easybits crons ls                                           # los tuyos: horario, qué hace, última y próxima
easybits crons update "Despertador" --cron "0 7 * * 1-5"    # sólo lo que pases
easybits crons run "Despertador"                            # id o nombre exacto; en segundo plano
easybits crons runs "Despertador"                           # historial con el error completo
easybits crons pause "Despertador"                          # y resume
easybits crons rm "Despertador" --yes
```

Le pegan a una URL https pública (con `--secret` un secreto de tu bóveda viaja como header, `Authorization: Bearer` por default) o le mandan un turno a uno de tus agentes. Se repiten (`--cron`, como mucho cada 5 min; terminan solos con `--until <fecha>` o `--times N`) o se ejecutan **una sola vez** (`--at "2026-10-18T23:00"` o `--at 23:00` = hoy, `--in 2h`): al ejecutarse se apagan y se quedan en `ls` como «terminado», con su historial. Una hora que ya pasó es error, no «mañana». Con `--run` el trabajo lo hace una **caja desechable** (python por default, `--template node|bun|ubuntu`): monta tus `--file` junto al comando, cada `--secret NOMBRE` de tu bóveda llega como variable de entorno y cada `--secret-file NOMBRE:ruta` sube ese archivo como secreto **acotado a ese cron** (ningún otro cron, agente ni máquina lo lee; se borra con el cron) y lo escribe en la misma ruta `~/` dentro de la caja; éxito = exit 0, tope `--timeout` 600 s, la caja se destruye al terminar. `crons wait` se queda hasta que termina la siguiente ejecución (o la que va en curso), imprime su salida y **sale con su código** (`exec`: el del comando; los demás: 0 si salió bien, 1 si no), así que encaja con un `&&` o con un proceso en segundo plano. `--notify push` manda un aviso a la app de Ghosty con el token personal de tu secreto `GHOSTY_PUSH_TOKEN`; `--notify email` escribe al correo de tu cuenta (nunca a otro); por default sólo si falla, `--notify-on always` siempre. `--dry-run` valida todo y muestra las próximas 3 ejecuciones en hora local sin crear nada. Zona `--tz` (default America/Mexico_City), límite por plan. Misma API que `/api/v2/crons`.

### Buzones de eventos

```bash
easybits inbox create builds                                # URL de webhook propia: cualquiera le escribe con POST
easybits inbox ls                                           # tus buzones, URL y no leídos
easybits inbox listen builds                                # en vivo: una línea JSON por evento; se reconecta solo
easybits inbox read builds                                  # lo no leído, y lo marca (--all todo, --since <seq> desde un cursor, --no-ack sólo mirar)
easybits inbox update builds --retention 30                 # renombrar (--name) o guardar más días; la URL no cambia
easybits inbox rotate builds --yes                          # URL nueva; la vieja deja de aceptar escrituras (si se filtró)
easybits inbox rm builds --yes
```

Un webhook de EasyBits que llega a tu terminal: lo que entra a la URL te alcanza en vivo con `listen` (en Claude Code, córrelo con Monitor: cada línea despierta al agente) o te espera para `read`. Un cron le escribe con `--notify inbox:builds`. Quien tenga la URL puede escribir: no la publiques.

### Crons de la plataforma (sólo administradores)

```bash
easybits crons ls --platform                                # estado, horario, última y próxima ejecución
easybits crons runs backup-machines                         # últimas 50 ejecuciones, con el error completo
easybits crons run purge-notifications                      # ejecutar ahora, en segundo plano
easybits crons pause check-hosting                          # sus ejecuciones responden skipped
easybits crons resume check-hosting
```

Las tareas programadas de EasyBits (respaldos, purgas, caducidad de web apps): lo mismo que el panel `/dash/admin/crons`. Pide una llave con alcance `ADMIN` **y** ser superusuario de la plataforma; cualquier otra cuenta recibe 403. El horario vive en el código y no se edita desde aquí.

### Dominios

Dos clases. Los de tu **cuenta** sirven tus sitios en `<slug>.tienda.com` y, si le asignas uno, en la raíz:

```bash
easybits domains ls
easybits domains add tienda.com              # imprime los registros DNS a crear
easybits domains verify tienda.com           # encuentra el TXT → emite el certificado
easybits domains dns-auto tienda.com         # ¿DNS en Mailmask? escribe los registros por ti (--undo los restaura)
easybits domains apex tienda.com mi-tienda   # sirve un sitio en tienda.com y www (--clear lo quita)
easybits domains rm tienda.com --yes
```

Los de una **caja** ligan un dominio a un puerto de un sandbox o máquina:

```bash
easybits sandboxes domains add sb_abc123 tienda.com --port 3000   # imprime el registro DNS a crear
easybits sandboxes domains verify sb_abc123 tienda.com            # sale con 1 mientras no esté listo
easybits sandboxes domains ls sb_abc123
easybits sandboxes domains rm sb_abc123 tienda.com --yes
```

`easybits domains <ls|add|verify|rm> <caja> …` (la forma de antes) sigue funcionando: corre `sandboxes domains` y avisa en stderr. `domains dns-auto` sin Mailmask conectado sale con 1 y la liga para conectarlo una vez en el navegador.

### Web

Buscar, leer, extraer y rastrear la web. Se mide en **consultas web** (paquetes en `/dash/packs?tab=web`); sin saldo sale con 1 y la liga para comprar.

```bash
easybits web search dentista polanco --country mx            # resultados orgánicos: # / título / url (1 consulta)
easybits web fetch https://ejemplo.com --markdown --main      # el contenido a stdout; --out pagina.md lo guarda (1 consulta)
easybits web extract google_maps --input '[{"keyword":"dentista Polanco CDMX","country":"MX"}]' --limit 50 --wait
easybits web extract mercadolibre --input '{"query":"iphone 15"}' --json   # responde al instante
easybits web extract instagram_profiles --url https://instagram.com/easybits   # async: imprime el jobId
easybits web extract-status j_abc123                          # cuando termina: un registro JSON por renglón
easybits web crawl https://docs.ejemplo.com --max-pages 5 --main             # 1 consulta por página
```

`extract` cobra 1 consulta por registro devuelto (no por intento) y casi todas las fuentes tardan 30-120 s: sin `--wait` devuelve el `jobId` y lo revisas con `extract-status`. Fuentes: `google_maps`, `mercadolibre`, `amazon_product`, `instagram_profiles`, `linkedin_company`… (o `--dataset <id>` del catálogo).

### Webhooks y API keys

```bash
easybits webhooks ls
easybits webhooks create https://ejemplo.com/hook --event file.created --event form.submitted   # imprime el secreto UNA vez
easybits webhooks update 66f0c2a1b2c3d4e5f6a7b8c9 --event file.created,file.deleted   # o --url, --pause, --resume
easybits webhooks rm 66f0c2a1b2c3d4e5f6a7b8c9 --yes

easybits keys ls                                    # sólo el prefijo
easybits keys create ci-deploy --scopes READ,WRITE --expires 2027-01-31   # imprime la key UNA vez
easybits keys revoke ci-deploy --yes                # por id, nombre o prefijo
```

El secreto del webhook (`whsec_…`) y la key nueva (`eb_sk_live_…`) sólo salen al crearlos: con `--json` vienen en `secret` y `raw`. Una key nunca puede crear otra con más permisos que los suyos (una READ no fabrica una WRITE, y sólo el panel o una key ADMIN da ADMIN). `keys revoke` avisa si es la key que usa el propio CLI.

### Sitios (creador de sitios)

```bash
easybits sites ls                                                 # tus sitios de /dash/sitios: tipo, motor y URL
easybits sites create --prompt "Landing de mi consultorio dental" # web app (TanStack) en su propia máquina
easybits sites create --prompt "Lista de tareas con login" --stack next --json
easybits sites create --name "Promo" --kind static                # website en el CDN en vez de máquina
easybits sites model mi-consultorio                              # motor + modelo con que se construye (● = el actual)
easybits sites model mi-consultorio claude-sonnet-5-5            # el modelo vale para todos tus sitios con ese motor
easybits sites theme get mi-consultorio                           # colores actuales (por nombre o id)
easybits sites theme set mi-consultorio --theme dracula           # una paleta, sin turno del modelo
jq .colors tema.json | easybits sites theme set mi-consultorio --colors -   # o cada token en #rrggbb
```

`create` devuelve `{id, name, kind, stack, template, previewUrl, url, editorUrl}`; `url` es `null` hasta que publiques desde el editor (construir es gratis; una web app se cobra al publicarla). `--kind webapp` (default) tiene su propia máquina con `--stack` `tanstack` (default), `next`, `react-router`, `remix`, `nuxt`, `sveltekit`, `astro` o `elysia`; `--template` (`consultorio`, `despacho`, `portafolio`, `lanzamiento`) sólo existe en TanStack y React Router, y sin ella el prompt elige una. `--kind static` es un website en el CDN. El CLI rechaza un stack o una plantilla desconocidos antes de llamar a la API.

`theme` es el botón «Tema» del editor: reescribe el archivo de tema del sitio (web app: `eb-theme.css`; estático: el bloque `eb-theme` de `index.html`) y la vista previa lo toma. `--colors` necesita todos los tokens (`primary`, `primary-light`, `primary-dark`, `secondary`, `accent`, `surface`, `surface-alt`, `surface-deep`, `on-surface`, `on-surface-muted`, `on-surface-deep`, `on-primary`, `on-secondary`, `on-accent`); una paleta desconocida responde `invalid_theme` y un sitio anterior a los temas responde `no_tokens` (409).

### Documentos

Páginas de HTML que tú escribes (el editor de `/dash/documents`), publicadas en una URL pública y exportadas a PDF o PNG. El CLI no genera con IA: escribe el HTML de cada página y fíjalo.

```bash
easybits documents ls --search cotización                # los más nuevos primero; --limit hasta 100
easybits documents create --name "Cotización ACME" --html portada.html --html precios.html   # una página por archivo
easybits documents create --name "Carrusel" --format ig-feed   # una página en blanco de 1080x1350
easybits documents get "Cotización ACME"                 # formato, URL y páginas (por nombre o id)
easybits documents pages ls "Cotización ACME"
easybits documents pages get "Cotización ACME" 2 --out precios.html
easybits documents pages set "Cotización ACME" 2 precios.html   # - lee stdin
easybits documents add-page "Cotización ACME" anexo.html --after 2 --label Anexo
easybits documents rm-page "Cotización ACME" 3 --yes
easybits documents reorder "Cotización ACME" 1 3 2       # todas las páginas, por número o id
easybits documents deploy "Cotización ACME"              # URL pública (+ PDF)
easybits documents export pdf "Cotización ACME" --out cotizacion.pdf
easybits documents export png Carrusel --out ./slides --pages 1,2
easybits documents rm "Cotización ACME" --yes
```

Una página se indica por su número (1 = la primera, como en el editor) o por su id. `--format` es `letter` (default), `ig-feed`, `li-feed`, `ig-square`, `fb-square`, `ig-story`, `wsp-status`, `tiktok`, `slide-16-9` o `WxH` en px (100-10000); sin `--html` el documento arranca con una página en blanco de ese tamaño. Las páginas pasan por el mismo camino que `set_page_html`: un HTML completo (doctype, `<style>`) se normaliza a página. `export` renderiza en el servidor (Chromium), así que los tamaños salen exactos sin importar tu máquina: `pdf` escribe un archivo (default `<nombre>.pdf`), `png` un archivo por página en una carpeta (default `.`) y además los guarda en tus Files. `rm` borra el documento para siempre.

### Video

Un proyecto de video es una lista ordenada de escenas: cada una es HTML (posicionado en absoluto para llenar el cuadro; los medios como `assets/<nombre>`), un fragmento GSAP opcional sobre un timeline pausado `tl` y una narración opcional que se sintetiza al renderizar (kokoro: `em_santa` por default). El servidor lo compila a una composición HyperFrames y renderiza el MP4 en tu caja on-demand.

```bash
easybits video projects create --name "Promo otoño" --format square   # portrait (default 1080x1920), landscape, WxH…
easybits video projects ls
easybits video projects get "Promo otoño"                             # escenas, música, estado, último MP4
easybits video scenes add "Promo otoño" intro.html --timeline intro.js --narration "Llegó el otoño"
easybits video scenes add "Promo otoño" oferta.html --duration 4 --after 1 --voice ef_dora
easybits video scenes ls "Promo otoño"
easybits video scenes set "Promo otoño" 2 oferta-v2.html --duration 5  # HTML y/o banderas
easybits video scenes reorder "Promo otoño" 2 1
easybits video scenes rm "Promo otoño" 2 --yes
easybits video music set "Promo otoño" https://cdn.example.com/cama.mp3   # none la quita
easybits video render "Promo otoño" --wait --out promo.mp4
easybits video projects rm "Promo otoño" --yes
easybits video ls                                                     # clips generados con IA (video_create)
```

Una escena se indica por su número (1 = la primera) o su id; el proyecto, por nombre o id. `--timeline` toma un archivo o el código GSAP tal cual; `--narration ""` quita la voz en off y `--duration` (0.3-120 s) por default es lo que dure la narración. `render` sin `--wait` arranca el render y regresa en cuanto el servidor lo marca `rendering`; con `--wait` muestra un spinner (renglones simples sin terminal, nada con `--json`) hasta que el proyecto queda `ready` (imprime la URL del MP4; `--out` lo baja) o `failed` (sale con 1 y el motivo). Si un proxy corta la petición larga, el CLI sigue el estado del proyecto (`--timeout`, 15 min por default). La música es una URL pública: sube antes un archivo local con `easybits files upload cancion.mp3 --public`.

### Bases de datos

```bash
easybits db create leads
easybits db query leads "CREATE TABLE leads (id INTEGER PRIMARY KEY, name TEXT)"
easybits db query leads "INSERT INTO leads(name) VALUES (?)" --arg Ana
easybits db query leads "SELECT * FROM leads" --json
easybits db tables leads                             # tablas, filas, columnas y ligas
easybits db photos put catalogo --table productos --key-column sku --dir fotos/ --dry-run
easybits db ls
easybits db rename leads prospectos --dry-run        # qué prompts de agentes la nombran
easybits db rm leads --dry-run                       # tablas y filas que se perderían
easybits db rm leads --yes
easybits db attach clave                             # la base que tu máquina abre en /v1/db/clave/
```

`query` y `tables` aceptan el id o el nombre, y nunca crean una base por un nombre mal escrito. Con `--json`, `tables` da `[{name, rows, columns:[{name,type,pk}], links:[{column, permanent, expiring, external, empty}]}]`: por cada columna de texto con ligas, cuántas son **permanentes** (almacenamiento público de EasyBits), **caducan** (URLs firmadas), son de fuera o están vacías.

**Adjuntar**: `db attach <nombre>` agrega a tu cuenta la base que una de tus máquinas ya usa (`DATABASE_URL=http://172.20.0.1:8090/v1/db/<nombre>/`). No crea ni copia nada; desde ahí sirven `db query|tables|ls`. No cuenta contra el límite de bases de tu plan y `db rm` sólo la quita de tu lista: los datos se quedan en la máquina.

**Renombrar**: `db rename <db> <nombre-nuevo> [--skip-prompts] [--dry-run]` cambia el nombre (el id no cambia, así que las tools `db_*` siguen funcionando) y reescribe el nombre en los prompts de tus agentes ghosty-lite/goose donde aparece como palabra suelta: nunca dentro de `x.com`, `@x`, `x_prueba` ni en mayúsculas (la marca). `--dry-run` enseña cada prompt y renglón; si un prompt falla se reporta (sale con 1) y el nombre nuevo se queda. Skills y env no se revisan.

**Fotos del catálogo**: `db photos put <db> --table T --key-column sku --dir fotos/ [--column C] [--replace] [--dry-run]` pone `SKU-123.jpg` en la fila cuyo `sku` es `SKU-123`: revisa el tipo real por sus bytes (JPEG, PNG, WebP; máx 10 MB; avisa arriba de 5 MB, el tope de WhatsApp por liga), la sube como **archivo público con liga permanente** (la misma foto para dos filas se sube una vez) y escribe la liga en la columna (default `image_url`, `photo_url`, `imagen`…). Una fila que ya tiene liga permanente se queda salvo `--replace`; lo que no es foto o no tiene fila se enlista con el motivo.

### Agentes

```bash
easybits agents create --template goose --name ayudante
easybits agents get ayudante                        # por nombre o id
easybits agents message ayudante "resume el README"    # la respuesta llega en streaming
easybits agents message ayudante "¿y los tests?" --session ses_456   # sigue la misma conversación
easybits agents ls
easybits agents destroy ayudante --yes
```

**Configurar un agente con máquina** (`ghosty-lite`, `goose`; mismo contrato que la CLI `ghosty` de Ghosty Studio). Toda escritura acepta `--dry-run`:

```bash
easybits agents get ayudante --fields status,systemPromptMode --json   # ficha + prompt, MCP (enmascarado), skills, archivos
easybits agents get ayudante --prompt-out PROMPT.md                  # un prompt largo va a un archivo
easybits agents set ayudante --prompt-file PROMPT.md --prompt-mode replace --dry-run
easybits agents files put ayudante catalogo.pdf precios.csv --to docs  # a /data/work; también ls|get|rm
easybits agents skills add ayudante --dir ./skills/cotizar --restart   # también ls|get|rm; entra tras reiniciar
easybits agents mcp set ayudante --file servers.json                 # reemplaza la lista y reinicia
easybits agents try ayudante "¿quién eres?"                          # un turno completo a texto, para verificar
easybits agents logs ayudante --since "10 min ago"
easybits agents restart ayudante                                     # reinicia el runtime: sesión nueva, recarga skills y MCP
easybits agents doctor ayudante                                      # sale con 1 si hay un problema
easybits agents get ayudante --fields status,lastError --json        # con status "error", lastError dice por qué no arrancó
easybits agents create --like ayudante --name ayudante-2 --copy-files --dry-run
```

Clonar (`--like` / `--from`) copia template, prompt, servidores MCP y skills (con `--copy-files`, también los archivos), **nunca el env**: las llaves del motor van con `--dotenv`. `export` enmascara los secretos del MCP salvo con `--show-secrets`; las referencias `$secret:NOMBRE` viajan tal cual.

### Agentes de flota

```bash
easybits fleet ls
easybits fleet get soporte                                   # motor, modelo, esfuerzo, modo de número, canales, prompt
easybits fleet get soporte --prompt-out PROMPT.md            # un prompt largo va a un archivo
easybits fleet capabilities soporte --json                   # catálogo + estado: motores, modelos, buckets, MCPs, skills, canales
easybits fleet configure soporte --prompt-file PROMPT.md     # system prompt para todos los canales
easybits fleet configure soporte --model claude-sonnet-5-5 --effort high --recycle
easybits fleet configure soporte --name Nik --own-number on
easybits fleet message soporte "¿qué horarios tienen?" --config-group web
easybits fleet message soporte "¿y el sábado?" --group cli-1234 --config-group web   # la misma conversación
```

Un agente de flota rutea cada conversación (WhatsApp, WABA, Teams, web) a una caja efímera; se crea y se ligan sus canales en /dash/flota. El CLI usa la credencial de tu cuenta, nunca el token del agente. `configure` aplica un cambio a la vez (el motor antes que el modelo; `--recycle` al final): el cambio llega a las cajas dormidas al despertar y a una corriendo cuando se duerme, o de inmediato con `--recycle` (`--engine` recicla solo). `--name` cambia el nombre visible y el nombre con el que se presenta. `message` manda un turno e imprime la respuesta; sin `--group` abre una conversación nueva `cli-<uuid>` y te dice su id en stderr. **Pasa `--config-group`** (`web`, `waba:<id>`, un grupo de WhatsApp) para cargar las tools, el MCP y el prompt de ese canal: sin él, el turno puede correr sin los conectores del canal.

### El agente como archivo

```bash
easybits agents export ayudante --out ./ayudante      # agent.yaml + skills/ + files/, versionable en git
easybits apply ./ayudante --dry-run                   # después de editarlo: el plan
ANTHROPIC_API_KEY=… easybits apply ./ayudante         # enseña el plan, pregunta y aplica
easybits apply ./ayudante --prune --dry-run           # además, lo que se QUITARÍA
easybits apply ./ayudante --create --name ayudante-2  # un agente nuevo desde la carpeta
```

`agent.yaml` lleva `template`, `name`, `prompt` + `promptMode`, `env` (no secreto), `mcp`, `skills` y `files` (`--out archivo.yaml` o `.json` escribe sólo el archivo; sin `--out` sale a stdout). **Los secretos nunca salen**: las llaves con cara de secreto (KEY, TOKEN, AUTH…) y las URLs con contraseña se exportan como `${NOMBRE}`; al aplicar, el CLI manda sólo los valores de los `${NOMBRE}` que el archivo menciona y existen en tu entorno, y uno sin valor conserva el de hoy. `$secret:NOMBRE` (el vault) viaja tal cual. El env de la plataforma (la llave de EasyBits del agente, tokens internos) ni se exporta ni se toca.

El plan: `+` agrega, `~` cambia, `-` quita, `!` no se aplica y dice por qué. **Declarativo**: lo que el archivo no menciona no se toca; quitar lo que el archivo ya no lista exige `--prune`. El plan lo calcula el servidor (es el único que ve el env y los valores del MCP de hoy). Env, MCP y skills reinician el agente una vez al final. Antes de aplicar se guarda el archivo anterior en `~/.easybits/backups/` (para volver: `easybits apply <respaldo> --agent <agente>`). Límites: un agente no cambia de template (usa `--create`) y los agentes sin máquina propia sólo toman `name`. `--show-secrets` / `--with-files` siguen dando el JSON de clonado de 0.9 para `create --from`.

### Autocompletado

```bash
source <(easybits completion zsh)      # en ~/.zshrc, después de compinit
eval "$(easybits completion bash)"     # en ~/.bashrc (sirve con el bash 3.2 de macOS)
easybits completion fish > ~/.config/fish/completions/easybits.fish
```

TAB completa comandos, subcomandos y flags (de la misma tabla que la ayuda) y los **nombres** de tus agentes, sandboxes/máquinas y bases donde va uno (`easybits agents get <TAB>`, `easybits db query <TAB>`, `--agent <TAB>`). Los nombres se guardan 5 minutos en `~/.cache/easybits/`.

### Idioma

El CLI habla español o inglés según tu locale: `LANG=es_MX.UTF-8` (o `LC_ALL`/`LC_MESSAGES`) → español; cualquier otro → inglés. Fuérzalo con `--lang es|en` o `EASYBITS_LANG`. Cambia la ayuda, los errores, las pistas y la salida para personas; **las llaves de `--json` y los códigos de salida nunca cambian** (`error` y `hint` son prosa, así que siguen el idioma).

### Cómo se nombran los comandos

Una sola regla, como `gh`: `easybits <sustantivo> <verbo>`. Las formas viejas siguen funcionando y avisan la nueva en stderr (nunca con `--json`): `easybits config` → `mcp config`, `easybits mcp` → `mcp config --stdio`, `deploy ls` → `machines ls` (`easybits deploy <máquina>` es el verbo), `machines release` → `machines deploy`, `sandboxes create --timeout` → `--ttl`.

### Nombres en vez de ids

Todo comando que recibe un agente, un sandbox o máquina, una base de datos, un sitio, un documento o un proyecto de video acepta su **nombre** además del id: `easybits sb exec pruebas -- ls`, `easybits sb domains ls tienda`, `easybits machines deploy tienda -m "v2"`. Un id (`sb_…`, o el id de 24 caracteres de agentes, bases, sitios, documentos y proyectos de video) pasa directo; un nombre se busca exacto, sin distinguir mayúsculas, en tu lista (una petición por ejecución). Si dos comparten el nombre, el CLI sale con 2 y enlista los ids; si ninguno coincide, sale con 1 y te dice qué `ls` correr.

### Archivos, sitios y cuenta

```bash
easybits files upload ./reporte.pdf
easybits files upload ./logo.png --public   # URL abierta en el CDN; privado por default
easybits files ls
easybits files rm FILE_ID --yes   # a la papelera 7 días
easybits websites ls                   # todos los websites estáticos (también documentos y páginas de agentes)
easybits websites create "Mi tienda" --slug mi-tienda   # slug opcional
easybits websites slug mi-tienda mi-store               # cambiarlo; la URL vieja responde 301
easybits websites get mi-store                          # por slug o id: archivos, tamaño, URL
easybits websites deploy mi-store ./dist                # sube una carpeta con sus rutas (--dry-run la enlista)
easybits websites upload mi-store ./hero.jpg --path img/hero.jpg
easybits websites files ls mi-store                     # rutas dentro del sitio
easybits websites rm mi-store                           # teclea el slug para confirmar (--yes sin terminal)
easybits providers                     # proveedor de almacenamiento (Tigris por defecto)
easybits usage
```

### MCP, SSH y docs

```bash
easybits whoami            # la cuenta y de dónde sale la credencial
easybits doctor            # Node, versión del CLI, credencial, API; sale con 1 si hay un problema
easybits mcp config        # JSON de MCP (streamable HTTP) con tu key
easybits mcp config --stdio   # JSON de MCP por stdio
easybits ssh-key           # tu llave pública para habilitar SSH en una caja
easybits docs hosting      # una sección de estos docs en markdown
easybits docs cli --en     # esta página en inglés
easybits docs --open       # abre los docs en el navegador
easybits help db query     # = easybits db query --help
```

SSH por el 443 — agrega a `~/.ssh/config` y entra con `ssh <nombre>.ghosty`:

```
Host *.ghosty
    ProxyCommand easybits ssh-proxy %h
    User root
```

### `--json` y códigos de salida

Con `--json` stdout lleva **sólo JSON** (sin banner, sin tablas), **errores incluidos**: `{"error":"…","code":3,"hint":"…"}`, donde `code` es el código de salida. Es el único canal que tiene que leer un agente; los avisos para humanos van a stderr.

| Código | Significa |
|---|---|
| `0` | ok |
| `1` | error de la API (4xx/5xx), o `domains verify` todavía no listo |
| `2` | error de uso: comando, subcomando o argumento faltante o desconocido |
| `3` | sin sesión (sin terminal), sesión vencida, o credencial rechazada (401) |

Excepción deliberada: `sandboxes exec` sin `--json` sale con el código del comando remoto (como `ssh`); con `--json` sale 0 y el código viaja en `exitCode`.

### Borrados: `--yes`

Todo `rm` y `destroy` (`sandboxes destroy`, `machines rm`, `agents destroy`, `db rm`, `domains rm`, `files rm`…) y `machines redeploy --replace` preguntan antes de actuar en una terminal: `[y/N]`, o teclear el nombre/id en lo irreversible (una base, un agente, una máquina o caja permanente). **Sin terminal o con `--json` nunca preguntan**: pasa `--yes` (`-y`) o salen con `2` y la pista con el comando exacto.

Como mucho una vez por hora revisa npm y, en una terminal, el CLI avisa en stderr cuando hay versión nueva (nunca con `--json`; `EASYBITS_NO_UPDATE_CHECK=1` o `CI` lo apagan).

`easybits --help`, `easybits <comando> <subcomando> --help` y `easybits help <comando> <subcomando>` muestran uso, banderas y ejemplos. Un typo recibe un «Did you mean».

### Para agentes de código

- Usa siempre `--json` y decide por el código de salida, no por el texto.
- Si no hay sesión, corre `easybits login --json`: la primera línea es `{"event":"login_url","url":…}` — muéstrale esa liga a la persona — y cuando entra llega `{"event":"logged_in","email":…}`. Con `--no-browser` no abre el navegador de la máquina.
- En CI, o si la persona te da una key, usa `EASYBITS_API_KEY`.
- Encadena con `jq`: `ID=$(easybits sb create --template node --json | jq -r .sandboxId)`.
- En `exec`, separa el comando con `--` y usa `--json` para leer `stdout`, `stderr` y `exitCode` juntos.
- Borrar con `--json` exige `--yes`; sin él sale con `2`.
- Antes de tocar la config de un agente, previsualiza con `--dry-run`; después, verifica con `easybits agents try <agente> "…"` en vez de suponer que funcionó.
- Destruye lo que crees: `easybits sb destroy $ID --json --yes`.
- Skill lista para instalar: `npx skills add https://easybits.cloud` (incluye `easybits-cli`).

---

## Files

### List files
`GET /files`
Query: `limit?` (default 50), `cursor?`, `assetId?`, `includeWebsiteFiles?` (default false)
Returns: `{ items: File[], nextCursor?: string }`
Website/document/presentation files (`sites/<websiteId>/…`) are excluded unless `includeWebsiteFiles=true`; list them with `GET /websites/:websiteId/files`.
SDK: `eb.listFiles({ limit?, cursor?, assetId? })`

### Upload file
`POST /files`
Body: `{ fileName, contentType, size, access?: "public"|"private", region?: "LATAM"|"US"|"EU", assetId? }`
Returns: `{ file: File, putUrl: string }`
Then `PUT putUrl` with raw bytes to upload.
SDK: `eb.uploadFile({ fileName, contentType, size, access?, region? })`

### Get file
`GET /files/:fileId`
Returns: File object with `readUrl` (presigned, expires 1h).
SDK: `eb.getFile(fileId)`

### Update file
`PATCH /files/:fileId`
Body: `{ name?, access?: "public"|"private", metadata?, status?: "DONE" }`
Changing access copies the object between public/private buckets.
SDK: `eb.updateFile(fileId, { name?, access?, metadata? })`

### Delete file
`DELETE /files/:fileId`
Soft-deletes (status → DELETED). Recoverable for 7 days.
Returns 409 `WEBSITE_FILE` if the file backs a published website (same for `POST /files/bulk-delete`, all-or-nothing): manage those through the website endpoints.
SDK: `eb.deleteFile(fileId)`

### Restore file
`POST /files/:fileId/restore`
Restores a soft-deleted file back to DONE.
SDK: `eb.restoreFile(fileId)`

### List deleted files
`GET /files?status=DELETED`
Returns deleted files with `daysUntilPurge`.
SDK: `eb.listDeletedFiles({ limit?, cursor? })`

### Duplicate file
`POST /files/:fileId/duplicate`
Body: `{ name? }`
Creates a new storage copy + DB record.
SDK: `eb.duplicateFile(fileId, name?)`

### Search files (AI-powered)
`GET /files/search?q=query`
Requires an AI key configured. Returns up to 20 matches.
SDK: `eb.searchFiles(query)`

### File object
```json
{
  "id": "abc123",
  "name": "photo.jpg",
  "contentType": "image/jpeg",
  "size": 204800,
  "status": "DONE",
  "access": "public",
  "url": "https://...",
  "readUrl": "https://... (presigned)",
  "metadata": {},
  "createdAt": "2026-01-15T...",
  "updatedAt": "2026-01-15T..."
}
```

---

## Bulk Operations

### Bulk upload
`POST /files/bulk-upload`
Body: `{ items: [{ fileName, contentType, size, access?, assetId?, region? }] }` (max 20)
Returns: `{ items: [{ file, putUrl }] }`
SDK: `eb.bulkUploadFiles(items)`

### Bulk delete
`POST /files/bulk-delete`
Body: `{ fileIds: string[] }` (max 100)
Returns: `{ deleted: number }`
SDK: `eb.bulkDeleteFiles(fileIds)`

---

## Images

### Optimize image
`POST /files/:fileId/optimize`
Body: `{ format?: "webp"|"avif", quality?: 1-100 }`
Defaults: quality 80 (WebP), 50 (AVIF). Creates a new file (original unchanged).
Returns: `{ file, originalSize, optimizedSize, savings }`
SDK: `eb.optimizeImage({ fileId, format?, quality? })`

### Transform image
`POST /files/:fileId/transform`
Body: `{ width?, height?, fit?, format?, quality?, rotate?, flip?, grayscale? }`
fit: "cover"|"contain"|"fill"|"inside"|"outside"
format: "webp"|"avif"|"png"|"jpeg"
Creates a new file (original unchanged).
Returns: `{ file, originalSize, transformedSize, transforms }`
SDK: `eb.transformImage({ fileId, width?, height?, ... })`

### Search stock photo
`GET /stock-photos?q=<query>&save=true|false`
Searches royalty-free banks in order (Pexels → Unsplash → Pixabay → Openverse) and returns the first match.
`q` is required. English queries give better results across all banks.
`save=true` stores the photo in your library and adds `fileId` + `savedUrl` (requires WRITE scope; searching alone does not).
Returns: `{ url, alt, photographer, provider, sourceUrl?, fileId?, savedUrl?, attribution }`
Costs 1 credit per call — including when the match is poor. The search is fuzzy and almost always returns *something*, so check `alt` to judge relevance instead of expecting an error.
You must display `attribution`: Unsplash and Pixabay require crediting the photographer in their terms.
SDK: `eb.searchStockPhoto({ query, save? })`
MCP: `search_stock_photo`

### Search icon
`GET /icons?q=<query>&style=outline|filled|duotone|brand&limit=<1-12>`
Searches Iconify (Lucide, Heroicons, Material Symbols, Tabler, Phosphor, Simple Icons) and returns candidates with the **SVG inline** — not a URL. Inline is the point: an external icon host is a failure point in your page's critical render path, it can't inherit `currentColor` (so it breaks dark mode and brand tokens), and it disappears when exporting to PDF.
English queries give much better results. Each SVG ships with `height:1em` and `color:currentColor`: it inherits the parent's *color* and *font-size* — not its width. Sizing the parent with `w-8 h-8` does nothing; use `text-2xl` on the container, or put `w-6 h-6` on the `<svg>` itself.
Each candidate's `name` (e.g. `lucide:heart`) is pasteable verbatim into `<span data-icon-query="lucide:heart"></span>`, which the document pipeline resolves on save.
Returns: `{ icons: [{ name, prefix, set, svg, license, trademark }], query }`
⚠️ Check `trademark`: brand logos (simple-icons) are CC0 on the *file* but the mark itself stays protected — use them only to refer to that company, never as a client's logo or as decoration, and don't distort them.
Free — 0 credits. Returns an empty `icons` array if the icon service is unreachable; it never errors.
SDK: `eb.searchIcon({ query, style?, limit? })`
MCP: `search_icon`

### Screenshot a page
`POST /screenshots`
Body: `{ html?, url?, preset?: "mobile"|"desktop", viewport?: { width, height }, fullPage?, dataId?, selector?, padding?, waitMs?, fileName? }`
Renders a page in a real Chromium and stores the PNG as a public file — the "eye" for an agent that edits sites: capture, look at it, fix, repeat.
`html` wins over `url` and is the useful one: it lets you review a draft **without publishing it**. POST (not GET) because a landing's HTML doesn't fit in a query string. Requires WRITE scope.
`preset` defaults to `mobile` (390x844) — the worst case, and where landings actually break. `viewport` overrides it.
Returns: `{ fileId, url, width, height, contentType, size, preset, broken, warning? }`
The emulation is real: a `mobile` preset carries device pixel density and touch, not just a narrow viewport. `waitMs` is honored, and the box additionally waits for images and `document.fonts.ready` before capturing.
`warning` appears when the image came out a single flat color. That means the CSS hadn't painted — **not** that you broke the page. Don't undo your work over it; raise `waitMs` and retry.
Only public URLs: private and loopback addresses are rejected.
`dataId` (or `selector`) crops the capture to a single element, with `padding` px of context around it (default 16). Looking is expensive: a full-page mobile landing is 1170x2532, and a card crop is ~400x300. This is the second half of the audit loop — for each `incomplete` an audit returns, crop that `dataId` and look at it. The padding matters: when text sits on an image, the background **is** the thing being judged.
Costs 1 credit per call.
SDK: `eb.screenshot({ html?, url?, preset? })`
MCP: `screenshot_url`

### Audit a page (accessibility + layout)
`POST /audits`
Body: `{ html?, url?, viewports?: { name, width, height, deviceScaleFactor?, isMobile? }[], waitMs? }`
Measures a page instead of guessing about it: axe-core runs against the **painted DOM**, so contrast is computed from the colors actually rendered, and layout is measured at every viewport.
Defaults to three viewports: mobile 390, tablet 768, desktop 1440.
Returns per viewport: `axe.violations`, `axe.incomplete`, and `layout.findings`.
Read `violations` and `incomplete` separately — `incomplete` is what axe **cannot decide alone** (text over a gradient or an image). Verify those by looking at a screenshot; don't report them as failures and don't ignore them.
`layout.findings` covers `horizontal-overflow` (with `measured.overflowPx`), `text-clipped`, `overlap`, and `missing-viewport-meta`. Each finding carries `dataId`, so you can patch that exact node instead of rewriting the page.
Costs 1 credit per viewport.
MCP: `audit_page`

### Verify a clone against its PDF
`POST /render/compare`
Body: `{ fileId? | pdfUrl?, pages: { page, html }[], waitMs? }`
A pass/fail verdict to iterate against, instead of a human saying "looks right". Because the agent optimizes it, everything is measured on what the browser **paints**, never on the source — otherwise pasting the PDF as an image and hiding the text would win.
Design each page at the PDF page's CSS size: points × 4/3 (US letter = 816×1056 px). The response returns it as `pageCss`.
Per page: `text` (every PDF word must exist as live DOM text within 1.5 pt of its place — none may be missing; `misplaced` lists the worst with dx/dy in pt), `text.typography` (same family, size, weight, italic, color and word width — the PDF's letter spacing counts; `originalFonts` names the fonts to use), `liveText` (text must not be baked into an image or canvas, nor hidden over an image of the PDF), `overflow`, `trusted` (renders identically twice), `layout` (backgrounds, tables, shapes; `regions` says where), `diffUrl` (original | clone | red diff) and `reasons` (why it failed, in Spanish).
The clone is evaluated **without JavaScript** and must be static: no `<script>`, event handlers, `@media`/`media=`, `backdrop-filter`, `mix-blend-mode`, iframes, external stylesheets (Google/Bunny Fonts are fine) or text generated with CSS `content`. Those are what would let a page show one thing on screen and another in print, where the text is read from. `violations` lists them.
It is printed to PDF (twice, for determinism) and screenshotted once: the screen must look like the print (`screen`), every word must be **visible** (not covered), drawn with the **same glyph shapes** as the original (a font whose "7" draws a "9" fails), and written in the HTML itself (`notInSource`). No letters or digits may be added (`invented`).
Every text rule is zero-tolerance — a faithful clone scores exactly 1.0 — and visual changes are also checked per small region (`layoutWorstCell`), so a deleted logo or table rule fails even when the page average barely moves.
Thresholds are fixed, not configurable: the caller is usually the agent being graded.
Text positions and fonts come from MuPDF (per-character origin, size, color) and the PDF font descriptors (weight, italic) — font names lie (Chrome names every weight of a variable font "Inter-Regular"). Original and clone are rasterized by the same engine (MuPDF), so a faithful clone scores ~0 visual difference.
Up to 20 pages per call. Costs 1 credit per page.
SDK: `eb.compareRender({ fileId, pages })`
MCP: `compare_render` (also on the fleet's always-on `render` MCP)

### Node-level editing (`data-id` addressing)
Edit a page **by node** instead of re-emitting it. What you don't touch stays **byte-identical**.
This matters beyond cost: re-emitting 40 KB to move one sentence is slow, but the real problem is that every full rewrite is a chance to change things nobody asked for.
The core is a pure HTML→HTML function — `stampIds`, `applyPatches`, `indexNodes` in `@easybits.cloud/html-tailwind-generator/htmlPatch` — so it works the same whether your pages live in EasyBits or in your own database. The MCP tools take `documentId`+`pageId` (applies and saves) **or** raw `html` (returns the result, saves nothing).
Implementation note: it uses parse5 as an *index* over the original string and never re-serializes. A DOM round-trip (jsdom, `innerHTML`) normalizes quotes, reorders attributes and closes implicit tags — it would dirty the diff of a page nobody asked to touch, and can break `<script>` blocks.
Ops: `replace` · `remove` · `insert` with `pos`: `append`/`prepend` (inside the anchor) or `before`/`after` (as a sibling). To add one item to a list or grid, insert on the list — never re-emit the parent.
Returns `{ applied: string[], failed: [{ nodeId, reason }] }` — it never fails silently. `reason` ∈ `missing` | `ambiguous` (two nodes share the id — editing the wrong one is worse than not editing) | `unparseable` | `root` | `void` | `empty`. A patch that doesn't apply leaves the document untouched.
Ids are deterministic by position, so re-stamping is idempotent and an outline you already fetched stays valid.
MCP: `get_node_outline`, `patch_node`

---

## Web — Internet para tus agentes

Buscar en Google, leer cualquier página aunque bloquee bots (IPs residenciales, JS resuelto), extraer registros con esquema de sitios conocidos y rastrear un sitio completo. Disponible por REST, SDK y MCP (toolset `web`: `web_search`, `web_fetch`, `web_extract`, `web_extract_status`, `web_crawl`, `web_crawl_status`, `web_map`, `web_interact`, `web_monitor_create`, `web_monitor_list`, `web_monitor_delete`).

**Se mide en consultas, no en créditos.** 1 consulta = 1 página leída, 1 búsqueda, 1 registro extraído — y en un rastreo, cada página que lee (máx. 20 por llamada). Tienes 50 al registrarte; los packs Web ($99 MXN → 400, $999 MXN → 10,000) están en `/dash/packs?tab=web`, valen para cualquier plan y **no caducan**. Sin saldo → `402` con `{ error, code, requiredCost, available, buy }` (`buy` es la URL absoluta para comprar: pásasela al usuario).

**`country` es opcional** y son 2 letras (ISO 3166-1): `mx` México · `us` Estados Unidos · `es` España · `ar` Argentina · `co` Colombia · `cl` Chile · `pe` Perú · `br` Brasil. Sirve para ver el sitio como un usuario de ese país (precios en MXN, stock local, resultados de Google localizados). Si lo omites, el proveedor elige. En `web_extract` con `google_maps` va en MAYÚSCULAS dentro del input (`country: "MX"`).

### Buscar
`POST /web/search`
Body: `{ query, engine?: "google"|"bing"|"yandex"|"duckduckgo", country? }`
Returns: `{ query, engine, results: { organic: [{ title, link, description }], … } }` (orgánicos, negocios locales, knowledge panel)
1 consulta. Úsalo para encontrar la URL correcta y luego léela con `/web/fetch`.
SDK: `eb.webSearch({ query, country? })` · MCP: `web_search({ query, country? })`

```bash
curl -X POST https://www.easybits.cloud/api/v2/web/search \
  -H "Authorization: Bearer $EASYBITS_API_KEY" -H "Content-Type: application/json" \
  -d '{"query":"ubiquiti u6 mesh precio","country":"mx"}'
```

### Leer una página
`POST /web/fetch`
Body: `{ url, country?, asMarkdown?, onlyMainContent? }` — o `{ fetchId, offset }` para el siguiente tramo
Returns: `{ url, statusCode, format: "raw"|"markdown", body, offset, length, truncated, nextOffset?, fetchId? }`
1 consulta. `asMarkdown: true` devuelve markdown; `onlyMainContent: true` quita nav, footer, iconos y "skip to content" — lo normal cuando vas a LEER la página.

**Nada se corta en silencio.** Cada respuesta trae hasta 200,000 caracteres y dice `length` (la página entera) y `truncated`. Si `truncated: true`, pide el siguiente tramo con `{ fetchId, offset: nextOffset }`: es **gratis** y sale de la misma copia (no vuelve a pedir la página, así que los tramos son coherentes entre sí). La copia dura 24 h; después, `404` y se vuelve a pedir con `url`.
SDK: `eb.webFetch({ url, asMarkdown, onlyMainContent })` · `eb.webFetchAll({ url })` (junta todos los tramos, 1 consulta) · MCP: `web_fetch({ url, asMarkdown })`

```bash
curl -X POST https://www.easybits.cloud/api/v2/web/fetch \
  -H "Authorization: Bearer $EASYBITS_API_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://www.amazon.com.mx/dp/B09YRZYB29","country":"mx","asMarkdown":true,"onlyMainContent":true}'
```

### Extraer registros con esquema
`POST /web/extract`
Body: `{ source?, datasetId?, input, limit? }`
- `source`: `google_maps` | `mercadolibre` | `amazon_product` | `amazon_reviews` | `google_shopping` | `instagram_profiles` | `instagram_posts` | `tiktok_profiles` | `tiktok_posts` | `facebook_page_posts` | `facebook_marketplace` | `youtube_channels` | `youtube_videos` | `linkedin_company` | `linkedin_person` | `linkedin_jobs` | `indeed_jobs` | `trustpilot` | `inmuebles24` | `reddit_posts`
- `datasetId`: para fuentes fuera de la lista (catálogo de +1,000)
- `input`: `google_maps` → `[{ keyword, country: "MX" }]` · `mercadolibre` → `{ query, page? }` · resto → `[{ url }]`
- `limit`: registros máximos por input (default 20, máx 200)
Returns: `202 { jobId, status: "running", source }`. `mercadolibre` responde al instante: `200 { jobId, status: "done", records: [{ title, price, url, seller, … }], total }`.
Cobra 1 consulta POR REGISTRO devuelto, una sola vez, al recogerlos. Disparar el job no cuesta; un job que falla no cobra.
SDK: `eb.webExtract(...)` / `eb.webExtractAndWait(...)` (poll cada 15 s) · MCP: `web_extract({ source, input, limit })`

**Sin poll (producción):** suscribe un webhook al evento `web.extract.completed` (`POST /webhooks`). Cuando el job termina llega `{ jobId, source, total, url }` firmado con HMAC; haz `GET url` para recoger los registros (ahí se cobra). Si el proveedor falla o el job pasa de 2 h llega `web.extract.failed` `{ jobId, source, error }`, sin cobro. El aviso sale una sola vez, a más tardar ~1 min después de terminar.

### Extraer con tu esquema, de cualquier página
`POST /web/extract` con `{ url, schema, prompt?, country? }` → `200 { status: "done", source, data, truncated }`
Lee la página (aunque bloquee bots), un modelo llena tu **JSON Schema** con lo que dice y `data` sale validado contra él. Lo que no esté en la página queda vacío o `null`: nunca se inventa. `prompt` afina («sólo los planes mensuales», «precios en MXN»). Responde al momento. Cobra 1 consulta por la página + 1 por cada ~4,000 tokens que lee el modelo (una página típica: 3-6). El modelo lee hasta ~60,000 caracteres; si la página es más larga, `truncated: true`. Si no puede llenar el esquema: `422`, sin cobro.
SDK: `eb.webExtract({ url, schema })` · MCP: `web_extract({ url, schema, prompt })`

### Estado de un extract
`GET /web/extract/:jobId`
Returns: `{ jobId, status: "running"|"done"|"error", records?, total? }`
Gratis mientras corre. Volver a pedir un job ya cobrado no cobra de nuevo. Los jobs con esquema tardan 30-120 s: haz poll cada ~15 s.
MCP: `web_extract_status({ jobId })` → `{ status, items, total }`

```bash
curl -X POST https://www.easybits.cloud/api/v2/web/extract \
  -H "Authorization: Bearer $EASYBITS_API_KEY" -H "Content-Type: application/json" \
  -d '{"source":"google_maps","input":[{"keyword":"dentista Polanco CDMX","country":"MX"}],"limit":20}'
# → 202 { "jobId": "…", "status": "running" }
curl https://www.easybits.cloud/api/v2/web/extract/$JOB_ID -H "Authorization: Bearer $EASYBITS_API_KEY"
# → { "status": "done", "records": [ … ], "total": 20 }
```

### Rastrear un sitio
`POST /web/crawl`
Body: `{ url, maxPages?, onlyMainContent?, country? }`
Returns: `{ startUrl, pages: [{ url, markdown, truncated }], pending: [ … ], stoppedBy: "maxPages"|"bytes"|"done", failed: [ … ] }`
Lee la URL de inicio y sigue sus links internos (mismo dominio) hasta `maxPages` (1-20 al momento, default 10). 1 consulta por página realmente leída. `pending` son los links vistos y no visitados: pásale uno a otra llamada para continuar. Una página con `truncated: true` pasaba de 150,000 caracteres: léela entera con `/web/fetch`. `failed` son las que no se pudieron leer (no se cobran). `onlyMainContent` recomendado para RAG.

### Rastreo grande (segundo plano)
Con `maxPages` de 21 a 2,000, `POST /web/crawl` responde `202 { jobId, status: "running", startUrl, maxPages, queued }` y el rastreo sigue solo: arranca por el sitemap del sitio (lo de `/web/map`), lee ~24 páginas por minuto y **cobra cada página al leerla** (arrancar sólo cobra los documentos del sitemap). Si se te acaban las consultas, se detiene y conserva lo leído.
`GET /web/crawl/:jobId?cursor=N` → `{ status: "running"|"done"|"error", done, maxPages, queued, failed, pages: [ … ], nextCursor }`. Gratis. Las páginas vienen en bloques: pide `cursor: nextCursor` hasta que sea `null`; puedes leer mientras corre. Los resultados se guardan 7 días.
**Sin poll:** webhook `web.crawl.completed` `{ jobId, startUrl, total, failed, url }` (o `web.crawl.failed` con `error`).
SDK: `eb.webCrawl({ url, maxPages: 500 })` → `{ jobId }` · `eb.webCrawlStatus(jobId, cursor)` · `eb.webCrawlAll({ url, maxPages: 500 })` (espera y junta todo) · MCP: `web_crawl` + `web_crawl_status({ jobId, cursor })`

### Mapa de un sitio
`POST /web/map`
Body: `{ url, search?, limit? }`
Returns: `{ url, urls: [ … ], total, source: "sitemap"|"links", truncated }`
Todas las URLs de un sitio desde su sitemap (lo encuentra en `robots.txt` o en `/sitemap.xml`, y sigue los índices de sitemaps), **sin leer las páginas**. Si el sitio no tiene sitemap, usa los links internos de la portada. `search` filtra por texto (`"/blog/"`); `limit` 1-5000, default 1000. Cobra 1 consulta por documento leído (normalmente 1-3, máx. 10). Úsalo antes de leer un sitio grande: mapea, elige y lee sólo esas páginas con `/web/fetch`.
SDK: `eb.webMap({ url, search })` · MCP: `web_map({ url, search })`

### Interactuar con una página (navegador remoto)
`POST /web/interact` `{ url, actions?, screenshot? }` → `{ url, title, text, length, truncated, done, failedAt?, error?, screenshotUrl? }`
Abre la página en un navegador remoto (pasa bloqueos de bots), ejecuta las acciones en orden y devuelve el texto visible de cómo quedó. Para lo que `/web/fetch` no alcanza: buscadores internos, formularios, «ver más», pestañas, filtros. Acciones (máx. 25): `{ type: "click", selector }` · `{ type: "type", selector, text }` · `{ type: "press", key }` · `{ type: "select", selector, value }` · `{ type: "scroll", y?: px | "bottom" }` · `{ type: "wait", ms? | selector? }` · `{ type: "goto", url }`. Si una falla, se detiene y devuelve `failedAt` + `error` con la página como quedó. `screenshot: true` añade una captura (URL privada, 24 h). **5 consultas por sesión** (≤90 s).
SDK: `eb.webInteract({ url, actions })` · MCP: `web_interact({ url, actions })`

### Vigilar una página
`POST /web/monitor` `{ url, everyMinutes? }` → `201 { monitorId, url, everyMinutes, status: "active", … }` · `GET /web/monitor` (lista) · `DELETE /web/monitor/:id`
Cada `everyMinutes` (15 a 10,080, default 60) lee el contenido principal de la página y, si cambió, dispara el webhook **`web.page.changed`** `{ monitorId, url, changedAt, added: [ … ], removed: [ … ], totalAdded, totalRemoved }` (líneas, hasta 40 de cada lado). La primera revisión sólo guarda la línea base. Ignora contadores, fechas sueltas y «hace N minutos». Cada revisión cuesta 1 consulta (cada hora ≈ 720 al mes); si se acaban, el monitor se pausa y llega `web.monitor.paused`. Hasta 50 activos por cuenta.
SDK: `eb.webMonitorCreate({ url, everyMinutes })` · `webMonitorList()` · `webMonitorDelete(id)` · MCP: `web_monitor_create` · `web_monitor_list` · `web_monitor_delete`

### Estado del servicio
`GET https://www.easybits.cloud/api/health/web` (público, sin llave) → `{ status: "operational"|"degraded"|"down"|"unknown", checkedAt, search, fetch, extract }`. Cada 15 min la plataforma busca, lee Amazon MX y extrae de Mercado Libre como lo haría un cliente; también se ve en `/status`.
MCP: `web_crawl({ url, maxPages })`

### Errores propios de web
- `402` sin consultas → `{ code, requiredCost, available, buy }`
- `429` `UPSTREAM_COOLDOWN` (`Retry-After: 60`): el proveedor enfrió esa query; es temporal, reintenta después
- `502` error del proveedor · `503` servicio no configurado

---

## Sharing

### Share file with user
`POST /files/:fileId/share`
Body: `{ targetEmail, canRead?: true, canWrite?: false, canDelete?: false }`
Target user must exist on EasyBits.
SDK: `eb.shareFile({ fileId, targetEmail, canRead?, canWrite?, canDelete? })`

### Generate share token (temporary public URL)
`POST /files/:fileId/share-token`
Body: `{ expiresIn?: seconds }` (default 3600, min 60, max 604800 = 7 days)
Returns: `{ url, token }` — the URL is a temporary public link to the private file.
SDK: `eb.generateShareToken(fileId, expiresIn?)`
MCP: `generate_share_token({ fileId, expiresIn? })`

### List share tokens
`GET /share-tokens?fileId=&limit=&cursor=`
Returns: `{ items: ShareToken[] }` with `expired` boolean.
SDK: `eb.listShareTokens({ fileId?, limit?, cursor? })`

### Revoke share token
`DELETE /share-tokens/:tokenId`
Deletes the token, invalidating its URL.
SDK: `eb.revokeShareToken(tokenId)`
MCP: `revoke_share_token({ tokenId })`

### List permissions
`GET /files/:fileId/permissions`
Returns: `{ items: Permission[] }`
SDK: `eb.listPermissions(fileId)`

### Revoke permission
`DELETE /permissions/:permissionId`
Removes a sharing permission. You must own the file.
SDK: `eb.revokePermission(permissionId)`
MCP: `revoke_permission({ permissionId })`

---

## Webhooks

### List webhooks
`GET /webhooks`
Returns: `{ items: Webhook[] }`
SDK: `eb.listWebhooks()`

### Create webhook
`POST /webhooks`
Body: `{ url: string, events: string[] }`
Returns webhook with `secret` (shown only once). Save it for signature verification.
SDK: `eb.createWebhook({ url, events })`

### Get webhook
`GET /webhooks/:webhookId`
SDK: `eb.getWebhook(webhookId)`

### Update webhook
`PATCH /webhooks/:webhookId`
Body: `{ url?, events?, status?: "ACTIVE"|"PAUSED" }`
Reactivating resets the fail counter.
SDK: `eb.updateWebhook(webhookId, { url?, events?, status? })`

### Delete webhook
`DELETE /webhooks/:webhookId`
SDK: `eb.deleteWebhook(webhookId)`

### Events
- `file.created` — new file uploaded
- `file.updated` — file name, access, or metadata changed
- `file.deleted` — file soft-deleted
- `file.restored` — file restored from trash
- `website.created` — new website created
- `website.updated` — website name or slug changed (payload includes previousSlug on a rename)
- `website.deleted` — website deleted
- `workspace.created` — new workspace created
- `workspace.deleted` — workspace deleted
- `database.created` — new database created
- `database.deleted` — database deleted
- `form.submitted` — a form was submitted
- `payment.paid` — a payment link was paid
- `broadcast.sent` — a broadcast was sent
- `turn.completed` — a fleet agent finished a turn — carries `title`/`summary` ready for a visible push notification and a `cursor` to fetch the delta in one call
- `turn.failed` — a turn ended with no reply (no capacity, rate limited, or worker error) — without this a mobile client cannot tell "still thinking" from "died"
- `cron.run.succeeded` — one of your crons finished a run OK — `{ cronId, name, runId, exitCode, summary, startedAt, durationMs }` (exitCode for `exec`)
- `cron.run.failed` — one of your crons failed a run — same payload, with `error`
- `web.extract.completed` — a `web_extract` job finished — `{ jobId, source, total, url }`; GET `url` (or `web_extract_status`) to collect the records, which charges 1 query per record once. No polling needed
- `web.extract.failed` — a `web_extract` job failed or ran past 2 h — `{ jobId, source, error }`, nothing charged
- `web.crawl.completed` — a large `web_crawl` (maxPages > 20) finished — `{ jobId, startUrl, total, failed, url }`; GET `url` (or `web_crawl_status`) for the pages, already charged as they were read
- `web.crawl.failed` — a large `web_crawl` stopped early (e.g. out of web queries) — same payload with `error`; pages read so far are kept
- `web.page.changed` — a page you monitor (`web_monitor_create`) changed — `{ monitorId, url, changedAt, added[], removed[], totalAdded, totalRemoved }` (lines of its main content, up to 40 each)
- `web.monitor.paused` — a monitor stopped because you ran out of web queries — `{ monitorId, url, error }`
- `agent.approval_requested` — a fleet agent hit a tool its rules mark `ask` and is waiting — `{ approvalId, fleetAgentId, groupId, tool, summary, expiresAt, approveUrl, denyUrl }`. POST `approveUrl`/`denyUrl` (signed, one use) after your user decides; never forward them to the agent
- `agent.approval_resolved` — an approval was decided — `{ approvalId, fleetAgentId, groupId, tool, decision, resolvedBy }`

### Payload format
```json
{
  "event": "file.created",
  "timestamp": "2026-01-15T12:00:00Z",
  "data": { /* file or website object */ }
}
```

### Signature verification
Each request includes `X-Easybits-Signature: sha256=<hex>`.
Verify with HMAC-SHA256 using the webhook secret:
```ts
import { createHmac } from "crypto";
const expected = createHmac("sha256", secret)
  .update(rawBody)
  .digest("hex");
const signature = request.headers["x-easybits-signature"];
const isValid = signature === `sha256=${expected}`;
```

### Auto-pause
Webhooks auto-pause after 5 consecutive delivery failures (non-2xx response or timeout). Reactivate by updating status to "ACTIVE".

### Turn notifications — building a mobile app on EasyBits

On iOS/Android your app gets backgrounded, the OS kills the SSE socket, and the turn
finishes inside the microVM with nobody listening. `turn.completed` is how you find out.

The loop:

1. Your app sends a turn (`POST /fleet-agents/:id/message`) and may background at any point.
2. When the turn finishes, we POST `turn.completed` to your webhook.
3. Your server sends the push. **Make it a VISIBLE alert, not a silent one** — a silent
   push does not arrive when the app was swiped away or the device is in Low Power Mode,
   and iOS throttles them across *all* apps on the phone. `title` and `summary` are
   already trimmed for a lock screen, so you need no extra round-trip.
4. The user taps; your app calls
   `GET /fleet-agents/:id/messages?groupId=…&since=<cursor from the payload>` and gets
   only what it missed.

```json
{
  "event": "turn.completed",
  "timestamp": "2026-09-10T18:04:11.204Z",
  "data": {
    "turnId": "8f1c…",
    "fleetAgentId": "68d…", "agentName": "Soporte", "groupId": "web-9f2…",
    "sessionUuid": "…",
    "title": "Soporte respondió",
    "summary": "Ya generé el reporte de octubre y lo…",
    "replyLength": 812,
    "cursor": "eyJ0IjoiMjAyNi0wOS0xMFQ…",
    "memoryReset": false,
    "startedAt": "…", "finishedAt": "…", "durationMs": 4213,
    "usage": { "model": "claude-sonnet-5", "inputTokens": 8123, "outputTokens": 244, "toolCalls": 3 }
  }
}
```

**Deduplicate on `turnId`** — the same notification can reach you twice.

`turn.failed` has the same shape plus `error: { code, message }`. Send it too: a client
that hears nothing cannot tell "still thinking" from "died". Note that a turn queued
behind a busy fleet is retried automatically and does *not* emit `turn.failed`.

### Reading a conversation with a cursor
`GET /fleet-agents/:fleetAgentId/messages?groupId=…&since=<cursor>&limit=50`
Auth: the agent token (MESSAGE scope) — an ephemeral `flt_pk_` works.
SDK: `eb.fleet.messages(agentId, token, { groupId, since })`

Returns `{ items, cursor, hasMore, gap, gapReason?, resetAt? }`. Each item carries its
own `cursor`; persist the top-level `cursor` and send it back as `since` next time.

**Always check `gap` before trusting the delta.** `gap: true` means "reload the whole
thread", for one of two reasons:
- `cursor_too_old` — your cursor no longer resolves.
- `context_reset` — we still have the transcript, but the **agent** does not: its box was
  lost without a backup, so the model starts blank (`resetAt` says when). Turns that ran
  this way also carry `memoryReset: true` in `turn.completed`. Worth telling the user,
  rather than letting them discover on their own that the agent "got dumber".

---

## Websites (Static Site Hosting)

### Deploy flow (recommended — single call)
1. Create a website: `POST /websites` → get `websiteId`
2. Deploy files: MCP `deploy_website_file({ websiteId, fileName: "index.html", content: "<html>..." })`
3. Site is live at `https://www.easybits.cloud/s/<slug>/`

`deploy_website_file` uploads content directly (max 1MB, text or base64). No presigned URL or status update needed.
For large binary files (>1MB), use `upload_website_file` → PUT to presigned URL → `update_file(status: "DONE")`.

### A site is a folder, not a single page
`fileName` accepts a **path**, so deploy every file at its real path and the site keeps its structure:

```ts
await eb.deployWebsiteFile(websiteId, { fileName: "index.html",      content: html });
await eb.deployWebsiteFile(websiteId, { fileName: "producto.html",   content: producto });
await eb.deployWebsiteFile(websiteId, { fileName: "css/estilos.css", content: css, contentType: "text/css" });
await eb.deployWebsiteFile(websiteId, { fileName: "js/main.js",      content: js,  contentType: "application/javascript" });
```

`/s/<slug>/` resolves **relative links between the site's own files**, so the HTML goes as-is:

```html
<link rel="stylesheet" href="css/estilos.css">
<script src="js/main.js"></script>
<a href="producto.html">Ver producto</a>
```

Do **not** rewrite a site's own pages/CSS/JS to absolute storage URLs — the deployed copies end up orphaned while the page loads a duplicate from a random key. Absolute public URLs are only for **binary assets** you uploaded (`upload_website_file`), and for those you must embed the `url` returned verbatim.

Routing details: `/s/<slug>/` serves `index.html`; a path with no extension falls back to `<path>/index.html`.

Images: download third-party images and upload them (`upload_website_file`, or `deploy_website_file` with `encoding: "base64"` if <1MB). Never hotlink Flickr/Unsplash from a published site.

### Custom domains (your own domain, automatic HTTPS)
Two ways to serve a website on a domain you own:

- **Subdomain**: `<slug>.midominio.com` — every website of the account is reachable by its slug, no linking needed.
- **Root domain**: `midominio.com` (and `www.midominio.com`, which 301-redirects to the root) — exactly one website per domain, assigned with `set_domain_website`.

Flow: `add_domain` → add DNS → `verify_domain` → (optional) `set_domain_website`.

| DNS record | Name | Value | Needed for |
|---|---|---|---|
| TXT | `_easybits-verify.midominio.com` | the `txtToken` returned by `add_domain` | verification |
| CNAME | `*.midominio.com` | `cname.sandboxes.easybits.cloud` | subdomains |
| A | `midominio.com` | `54.38.94.14` | root domain |
| CNAME | `www.midominio.com` | `cname.sandboxes.easybits.cloud` | root domain |

Certificates are issued automatically on the first HTTPS request (wildcard registered on verify; root + www when a site is assigned). They can take a few minutes after DNS propagates.

**DNS hosted in Mailmask?** Skip the copy-paste. Connect Mailmask once (OAuth consent in the browser, `/dash/integrations/mailmask/connect`) and EasyBits writes these records into your Mailmask zone and verifies: `POST /api/v2/domains/:domainId/dns-auto` · MCP `configure_domain_dns({ domainId })`. It saves what was there before, so `DELETE …/dns-auto` / `undo_domain_dns` restores it (records you changed afterwards are left alone), and deleting the domain undoes them too. Without a connection the call returns `MailmaskNotConnected` with a `connectUrl`.

REST: `GET/POST/DELETE /domains` (each domain carries `dns`: the records above, ready to create), `POST /domains/:domainId/verify`, `POST /domains/:domainId/apex` `{ websiteId | null }`.
MCP: `list_domains`, `add_domain`, `verify_domain`, `set_domain_website`, `delete_domain`, `configure_domain_dns`, `undo_domain_dns`.

### List site-builder sites
`GET /sites`
The sites of the site builder (/dash/sitios), as the dashboard shows them: static sites and web apps, with the engine that builds each one. Different from `GET /websites`, which lists every static website of the account (documents and agent-published pages included).
Returns: `{ items: Site[], nextCursor, hasMore, total }` — each `{ id, name, kind: "static" | "webapp", engine, stack, url, websiteId, sandboxId, createdAt }`. A static site's files live in its website (`websiteId`); a web app's code lives in its machine (`sandboxId`).
SDK: `eb.listSites()` · MCP: `list_sites` · CLI: `easybits sites ls`

### Site theme
`GET /sites/:id/theme` · `POST /sites/:id/theme` `{ themeId }` | `{ colors }`
Change a site-builder site's colors without a model turn (same as the «Tema» button). Sites use color tokens (`primary`, `primary-light`, `primary-dark`, `secondary`, `accent`, `surface`, `surface-alt`, `surface-deep`, `on-surface`, `on-surface-muted`, `on-surface-deep`, `on-primary`, `on-secondary`, `on-accent`) as Tailwind classes; the theme file inside the site is the source of truth (web app: `eb-theme.css`, hot-reloads in the preview; static: the `eb-theme` block of `index.html`). `themeId` is a palette (`minimal`, `calido`, `oceano`, `noche`, `dracula`, `nord`, `tokyo-night`, `github-light`…); `colors` must carry every token as `#rrggbb`.
Returns: GET `{ supported, colors }` · POST `{ colors }`. `409 no_tokens` = the site predates themes (literal colors): ask the builder to adapt it first. Remix and Elysia web apps don't support themes yet.
SDK: `eb.getSiteTheme(id)` · `eb.setSiteTheme(id, { themeId })` · MCP: `site_set_theme`

### Site engine and model
`GET /sites/engines` · `POST /sites/:id/engine` `{ engine?, model?, secretValue? }`
What builds a site-builder site. The engine belongs to the site (`claude` = the user's Claude, `easybits` = included); the model belongs to the account's builder for that engine, so changing it applies to EVERY site with that engine, from the next turn. `POST /sites` also accepts `engine` and `model`. If the engine needs a key the account lacks (`needsSecret`), send it as `secretValue`.
Returns: GET `{ items: [{ id, label, included, needsSecret, models, defaultModel, currentModel }] }` · POST `{ siteId, engine, label, model }`.
SDK: `eb.siteEngines()` · `eb.setSiteEngine(id, { model })` · MCP: `site_set_engine` · CLI: `easybits sites model <site> [<model>]`

### List websites
`GET /websites`
Returns: `{ items: Website[] }`
SDK: `eb.listWebsites()`

### Create website
`POST /websites`
Body: `{ name: string, slug?: string }`
Returns: `{ website }` with id, slug, url. `slug` is optional (generated if omitted).
SDK: `eb.createWebsite(name, { slug? })`

### Get website
`GET /websites/:websiteId`
Returns: Website with stats (fileCount, totalSize).
SDK: `eb.getWebsite(websiteId)`

### Update website
`PATCH /websites/:websiteId`
Body: `{ name?, slug?, status? }`
Returns: `{ ok: true, website }` with slug and url.
SDK: `eb.updateWebsite(websiteId, { name?, slug?, status? })`

Slug rules (create and update): lowercase a-z, 0-9 and single hyphens, 3–60 chars, no leading/trailing hyphen, not reserved (api, admin, s, www, app, assets, static, dashboard, login…), unique among live sites. Errors: 400 `{ error: "slug_invalid" }`, 409 `{ error: "slug_taken" }`. Files are stored by id, so renaming moves nothing; the old `/s/<old>/` answers **301** to the new slug (up to 10 previous slugs remembered) until another site takes it. With subdomain masking on, the certificate moves to the new `<slug>.easybits.cloud` and the old subdomain stops working.

### List website files
`GET /websites/:websiteId/files?limit=&cursor=`
Returns: `{ items: File[], nextCursor? }`
SDK: `eb.listWebsiteFiles(websiteId, { limit?, cursor? })`
MCP: `list_website_files({ websiteId, limit?, cursor? })`

### Delete website
`DELETE /websites/:websiteId`
Soft-deletes all associated files (recoverable 7 days).
SDK: `eb.deleteWebsite(websiteId)`

### Website object
```json
{
  "id": "web123",
  "name": "My Site",
  "slug": "my-site",
  "status": "ACTIVE",
  "url": "https://www.easybits.cloud/s/my-site/",
  "fileCount": 3,
  "totalSize": 51200,
  "createdAt": "2026-01-15T..."
}
```

---

## Workspaces (namespaced storage + per-tenant quota)

A **Workspace** is an owner-scoped, namespaced container of files with its own
storage quota. Use it to carve isolated tenants inside a single account: each
workspace gets a `ws/{id}/` storage prefix, an optional per-workspace
`quotaBytes`, and can mint **workspace-scoped API keys** that can only read or
write that workspace's files. Ideal for platforms integrating EasyBits that need
to give each of their own customers isolated, metered storage.

### Multi-tenant flow (recommended)
1. `POST /workspaces` with your ADMIN key → get `workspaceId`.
2. `POST /workspaces/{id}/keys` → get a **workspace-scoped key** (raw, once).
3. Hand that scoped key to (or use it for) the tenant. Every `POST /files` with
   it lands in the workspace automatically; the tenant can never see other files.
4. `GET /workspaces/{id}/usage` → `{ usedBytes, quotaBytes, fileCount }`.

### Create a workspace
`POST /workspaces` — body `{ name, slug?, quotaBytes? }`. `quotaBytes` must be
≤ the account plan storage ceiling; null/omitted = no per-workspace cap.
```json
{ "workspace": { "id": "...", "name": "acme", "slug": "acme", "status": "ACTIVE", "quotaBytes": 52428800, "usedBytes": 0, "fileCount": 0, "createdAt": "..." } }
```

### List / get / update / delete
- `GET /workspaces` → cursor page `{ items, nextCursor, hasMore }`.
- `GET /workspaces/{id}`.
- `PATCH /workspaces/{id}` — `{ name?, status?, quotaBytes? }`.
- `DELETE /workspaces/{id}` — soft-deletes the workspace and all its files (7-day retention).

### Mint a scoped key
`POST /workspaces/{id}/keys` — body `{ name?, scopes? }` (scopes default
`["READ","WRITE","DELETE"]`). Requires an ADMIN key. Returns the raw key once:
```json
{ "id": "...", "key": "eb_sk_live_...", "prefix": "eb_sk_live_xxxx", "scopes": ["READ","WRITE","DELETE"], "workspaceId": "..." }
```

### Uploading into a workspace
With a workspace-scoped key, just call `POST /files` normally — the file is
namespaced and counted against the workspace automatically. Exceeding
`quotaBytes` returns 400 "Workspace quota exceeded". With an account key you may
instead pass `workspaceId` in the `POST /files` body.

---

## Account & Usage

### Get usage stats
`GET /usage`
Returns: plan info, storage used/max, file/website/webhook counts.
SDK: `eb.getUsageStats()`

### List storage providers
`GET /providers`
Returns configured storage providers or platform default.
SDK: `eb.listProviders()`

### List API keys
`GET /keys`
SDK: `eb.listKeys()`

### Set AI key (for search)
Configure via MCP tool `set_ai_key` or dashboard. Supports ANTHROPIC and OPENAI providers.

---

## SDK Method Reference

| Method | Description |
|--------|-------------|
| `listFiles(params?)` | List files with pagination |
| `getFile(fileId)` | Get file with download URL |
| `uploadFile(params)` | Create file + get upload URL |
| `updateFile(fileId, params)` | Update name, access, metadata |
| `deleteFile(fileId)` | Soft-delete a file |
| `restoreFile(fileId)` | Restore from trash |
| `listDeletedFiles(params?)` | List trashed files |
| `duplicateFile(fileId, name?)` | Copy a file |
| `searchFiles(query)` | AI-powered search |
| `searchStockPhoto(params)` | Royalty-free stock photo search (1 credit; show `attribution`) |
| `bulkUploadFiles(items)` | Upload up to 20 files |
| `bulkDeleteFiles(fileIds)` | Delete up to 100 files |
| `optimizeImage(params)` | Convert to WebP/AVIF |
| `transformImage(params)` | Resize, rotate, flip, convert |
| `shareFile(params)` | Share with another user |
| `generateShareToken(fileId, expiresIn?)` | Create presigned URL |
| `listShareTokens(params?)` | List share tokens |
| `revokeShareToken(tokenId)` | Revoke a share token |
| `listPermissions(fileId)` | List file permissions |
| `revokePermission(permissionId)` | Revoke a permission |
| `listWebsites()` | List websites |
| `createWebsite(name, { slug? })` | Create a website |
| `getWebsite(websiteId)` | Get website details |
| `updateWebsite(websiteId, params)` | Update website |
| `deleteWebsite(websiteId)` | Delete website + files |
| `listWebsiteFiles(websiteId, params?)` | List files in a website |
| `listWebhooks()` | List webhooks |
| `createWebhook(params)` | Create webhook |
| `getWebhook(webhookId)` | Get webhook |
| `updateWebhook(webhookId, params)` | Update webhook |
| `deleteWebhook(webhookId)` | Delete webhook |
| `getUsageStats()` | Account usage stats |
| `listProviders()` | Storage providers |
| `listKeys()` | API keys |
| `listPresentations()` | List presentations |
| `getPresentation(id)` | Get presentation with slides |
| `createPresentation(params)` | Create presentation |
| `updatePresentation(id, params)` | Update presentation |
| `deletePresentation(id)` | Delete presentation |
| `deployPresentation(id)` | Publish as live website |
| `unpublishPresentation(id)` | Unpublish presentation |
| `listDatabases()` | List databases |
| `createDatabase(params)` | Create database |
| `getDatabase(dbId)` | Get database |
| `deleteDatabase(dbId)` | Delete database |
| `db(name).query(sql, args?)` | Query a database |
| `listDocuments()` | List documents |
| `getDocument(id)` | Get document with pages |
| `createDocument(params)` | Create document |
| `updateDocument(id, params)` | Update document |
| `deleteDocument(id)` | Delete document |
| `deployDocument(id)` | Publish as live website |
| `unpublishDocument(id)` | Unpublish document |
| `listVideoProjects(params?)` | List video projects |
| `getVideoProject(id)` | Get project with scenes |
| `createVideoProject(params)` | Create video project |
| `addVideoScene(id, scene)` | Add a scene |
| `setVideoScene(id, sceneId, patch)` | Edit a scene |
| `reorderVideoScenes(id, ids)` | Reorder scenes |
| `setVideoMusic(id, url, name?)` | Set/clear background music |
| `renderVideoProject(id)` | Render to MP4 |
| `getDocs(section?)` | Get this documentation |

**Hosting — máquinas always-on** (`eb.machines.*`; la VM además se opera por `sb.*`)

| Method | Description |
|--------|-------------|
| `machines.tiers()` | Catálogo de tiers + precio del disco add-on |
| `machines.list()` | Tus máquinas permanentes |
| `machines.logs(id, { lines?, grep? })` | El log de la app (unit o archivo del startCommand) |
| `machines.launch({ repo \| archiveUrl \| sandboxId, domain? })` | **Una app en producción en UNA llamada** (el `fly launch` de EasyBits). Empieza por aquí. |
| `machines.create({ tier })` | Provisiona una máquina always-on (caja vacía). Lanza `MachinePaymentRequired` con `.checkoutUrl` si la cuenta no tiene plan |
| `machines.buy({ tier })` | Igual pero sin lanzar: devuelve la máquina **o** `{ checkoutUrl }` para que el cliente pague |
| `machines.setRunspec(id, spec)` | Declara appDir/build/start/**dataPaths** — obligatorio antes del primer deploy |
| `machines.runspec(id)` | Lee el runspec |
| `machines.deploy(id, { message? })` | Publica el código actual como release |
| `machines.releases(id)` | Lista releases (versión más nueva primero) |
| `machines.rollback(id, releaseId)` | Vuelve a un release anterior, MISMA caja |
| `machines.redeploy(releaseId, { tier?, replaceSandboxId? })` | Caja NUEVA desde un release: recuperación **y** cambio de tier |
| `machines.backups(id)` | Backups diarios de datos (7 días, incluidos) |
| `machines.backup(id)` | Toma un backup ahora |
| `sb.exec(cmd)` | Corre un comando dentro de la caja |
| `sb.suspend()` / `sb.resume({ env? })` | Dormir ahora (snapshot, pausa el TTL) / despertar; `env` reescribe el entorno de la caja al despertar (rotar una llave sin destruirla) |
| `sb.setIdlePolicy({ suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? })` | Siesta al vencer el TTL en una caja YA creada, en vez de destruirse |
| `sb.exposePort(port)` | URL pública `sb-<id>-<port>.sandboxes.easybits.cloud` con TLS (HTTP + WebSocket `wss://`) |
| `sb.exposeRawPort(port, proto)` | Forward TCP/UDP crudo; devuelve `endpoint` `host:hostPort` |
| `sb.setNetworkPolicy(policy)` | Egress por caja: `allow-all` / `deny-all` / allow-list por dominio; se conserva al dormir |
| `sb.enableSsh(keys)` | SSH a la caja: inyecta llave + abre el 22; devuelve el comando `ssh` |
| `sb.addDomain(domain, port)` | Dominio propio + TLS automático; devuelve el registro DNS exacto (apex → A, subdominio → CNAME) |
| `sb.verifyDomain(domain)` | Confirma que el dominio ya resuelve y sirve por HTTPS |
| `sb.makePermanent(tier)` | Promueve un sandbox efímero (mismo sandboxId) |
| `sb.release()` | Libera la máquina (corta cobro, 7 días de gracia) |

Una app en producción, con dominio, en una llamada:
```ts
const app = await eb.machines.launch({
  repo: "https://github.com/cliente/tienda",   // o archiveUrl, o sandboxId
  tier: "micro",
  port: 3000,
  dataPaths: ["data", "uploads"],              // sin esto NO hay backup
  domain: "tienda.com",
});
console.log(app.url);          // ya sirve por HTTPS
console.log(app.domain?.dns);  // el registro EXACTO que el cliente debe crear
console.log(app.releaseId);    // ya es recuperable
```

¿Sin repo, subiendo desde su compu? Empaqueta la carpeta, súbela y pasa la URL:
```ts
// tar czf app.tar.gz -C ./mi-tienda .
const up = await eb.uploadFile({ name: "app.tar.gz", access: "private" });
await fetch(up.uploadUrl, { method: "PUT", body: fs.readFileSync("app.tar.gz") });
const app = await eb.machines.launch({ archiveUrl: up.url, domain: "tienda.com" });
```

Si prefieres el control paso a paso (o ya tienes la caja armada), el camino largo sigue disponible: `create` → `exec` → `setRunspec` → `deploy` → `exposePort` → `addDomain`. Solo no olvides el `deploy`.

---

## Agentes & Sandboxes

MicroVMs Firecracker para correr agentes y código aislado. Las herramientas viven en el grupo MCP `sandbox` (el catálogo completo está en /api/tools.json).

### Templates
Catálogo derivado del servidor (`templates_list` da el detalle con el env que pide cada uno). `base` = correr código; `agente` = agente listo; `servicio` = cajas de plataforma (se crean con `service_start`); `interno` = workers de flota que crea la plataforma.

| Template | Tipo | Descripción |
|---|---|---|
| `ubuntu` | base | Linux completo. Instalar paquetes, compilar, correr servidores. |
| `python` | base | Runtime Python; cada run-code es un proceso fresco. |
| `node` | base | Node 24 + typescript, tsx, pnpm, git y python3; cada run-code es un proceso fresco. |
| `bun` | base | Runtime Bun. |
| `dev-box` | base | Caja de trabajo limpia (git, curl, build-essential, Node 22); la recomendada para SSH. |
| `code-interpreter` | base | Python + kernel Jupyter persistente (sandbox_run_cell): variables y gráficas sobreviven entre celdas. |
| `eve-nitro` | base | Servidor eve (Vercel) self-hosted: Node 24, pnpm, eve CLI 0.65; /data persistente, puerto 3000. |
| `node-agent` | agente | Node + Claude Agent SDK pre-horneado (agent_run). |
| `claude-code` | agente | Loop del Claude Agent SDK; billing por token. |
| `goose` | agente | goose (AAIF), agente de código con ACP nativo. |
| `ghostyclaw` | agente | Daemon always-on de Ghosty (WhatsApp, Slack, Telegram) con Docker y admin-api. |
| `ghosty-lite` | agente | Agente ACP ligero en Rust, multi-provider; el cerebro puede ser tu llave EasyBits. |
| `open-ghosty` | agente | Ghosty sobre modelos abiertos, chat web SSE. |
| `lang-ghosty` | agente | Ghosty sobre LangChain, chat web SSE. |
| `rust-ghosty` | agente | Ghosty DeepSeek-first (CodeWhale/Rust) con web SSE y WhatsApp. |
| `ghosty-gc` | agente | Ghosty para equipos (GTeams): hilos, artefactos, editor colaborativo. |
| `ghosty-chat` | agente | Chat Ghosty persistente (Express + SSE). |
| `cagent-ghosty` | agente | Ghosty sobre cagent (Docker), chat web SSE. |
| `openclaw` | agente | OpenClaw, IA personal always-on. |
| `chat-openai` | agente | Chat persistente Express+SSE con OpenAI; créalo con agent_create. |
| `chat-anthropic` | agente | Chat persistente Express+SSE con Anthropic; créalo con agent_create. |
| `ghosty-studio` | agente | Ghosty Studio: control plane de agentes dentro de una caja. |
| `desktop-ghosty` | agente | Escritorio Linux con Ghosty (noVNC). |
| `computer-ghosty` | agente | Computer-use con escritorio XFCE + noVNC público. |
| `computer-ghosty-gemini` | agente | Computer-use con Gemini. |
| `livekit-svc` | servicio | Sala de videollamada + grabación HD (Studio). |
| `whisper-svc` | servicio | STT whisper; parte de la caja voice. |
| `kokoro-svc` | servicio | TTS kokoro; parte de la caja voice. |
| `voice-svc` | servicio | Voz (STT + TTS) para la flota; service_start('voice'). |
| `render-svc` | servicio | Chromium para PDF/PNG/auditoría; service_start('render'). |
| `collab-svc` | servicio | Editor colaborativo (Yjs) de GTeams. |
| `hyperframes-svc` | servicio | Render de video HyperFrames. |
| `claude-worker` | interno | Worker de la flota (Claude). Lo crea la plataforma. |
| `codex-worker` | interno | Worker de la flota (Codex). Lo crea la plataforma. |
| `gemini-worker` | interno | Worker de la flota (Gemini). Lo crea la plataforma. |

### Crear sandbox
`POST /sandboxes`
Body: `{ template, timeoutSeconds?, name?, env?, metadata?, suspendOnIdle?, hardTtlSeconds?, persistent?, size? }`
(o `templateKey`+`templateHash` / `derivedTemplate` en lugar de `template` — ver *Plantillas derivadas*).
MCP: `sandbox_create({ template, timeoutSeconds?, env?, suspendOnIdle?, hardTtlSeconds? })`

⚠️ **Sin `suspendOnIdle`, al vencer `timeoutSeconds` la caja se DESTRUYE — no se duerme.**
Y si omites `timeoutSeconds` el default son **300 s**: cinco minutos y la caja ya no existe.
Es la diferencia entre "mi agente sigue ahí mañana" y un 404 sin explicación.

- `suspendOnIdle: true` — al idlear, SUSPENDE (snapshot de Firecracker) en vez de destruir.
  Despertar cuesta ~0.2 s **y lo dispara cualquier petición a su URL pública**, incluido el
  upgrade de un WebSocket: no hace falta "tocarla" antes (medido el 2026-09-01).
- `hardTtlSeconds` — cuándo destruirla de verdad. Sin esto duerme, pero el janitor barre lo
  que lleve 72 h suspendido.
- `persistent: true` — la caja salta el reaper por antigüedad (para always-on).

`env` llega al guest entero (shell de login, `/exec`, `/bg` y la unit del template). Lo que una caja hace **al despertar** no se declara aquí:
es una llamada aparte sobre la caja ya creada — ver *Bootstrap al reanudar*.

Para cualquier caja que aloje un agente al que le vas a escribir MÁS TARDE —un agente ACP, un
bot— `suspendOnIdle` no es opcional: sin él la pierdes y su URL deja de servir, porque el
sandboxId de la nueva es otro.

### Siesta en una caja YA creada
`POST /sandboxes/:id/idle`
Body: `{ suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? }`
MCP: `sandbox_set_idle({ sandboxId, suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? })` · SDK: `sbx.setIdlePolicy({...})`

Para una caja que creaste sin `suspendOnIdle` (o que otro sistema creó por ti, como una sesión de eve): con `suspendOnIdle: true` el reaper la DUERME al vencer el TTL en vez de destruirla; `idleTtlSeconds` re-arma el reloj desde ahora y `hardTtlSeconds` es el plazo tras el cual se destruye aunque duerma. `suspendOnIdle: false` la devuelve al esquema efímero (409 si el TTL ya venció: primero `extend`).

### Ejecutar comando
`POST /sandboxes/:id/exec`
Body: `{ command, cwd?, timeoutSeconds?, env? }`
MCP: `sandbox_exec({ sandboxId, command })`

### Comandos largos (background)
`POST /sandboxes/:id/bg` · MCP: `sandbox_exec_background({ sandboxId, command })`
Estado: `GET /sandboxes/:id/bg/:execId` · MCP: `sandbox_exec_status({ sandboxId, execId })`
Listar: `GET /sandboxes/:id/bg` · MCP: `sandbox_exec_list({ sandboxId })`
Matar: `DELETE /sandboxes/:id/bg/:execId` (o `POST /sandboxes/:id/bg/:execId/kill`) · MCP: `sandbox_exec_kill({ sandboxId, execId })`

`exec` es síncrono (60 s por defecto, tope 600 s): para un build, un dev server o cualquier cosa que sobreviva a la petición, usa background. No intentes dejar algo corriendo con `nohup` o `&` dentro de `exec`: el shell muere al responder y se lleva al hijo. Escribe el comando como `exec <programa>` para que el shell sea REEMPLAZADO por tu proceso en vez de quedar como su padre.

Y **acuérdate de `sandbox_exec_kill`**: sin él un proceso colgado se queda comiendo la caja hasta que vence el TTL. Mata al GRUPO entero (SIGTERM, luego SIGKILL tras la gracia), así que los hijos que forkeó el comando también mueren. Si perdiste el `execId`, `sandbox_exec_list` te lo devuelve.

### Ejecutar código inline
`POST /sandboxes/:id/run-code`
Body: `{ code, lang?, timeoutSeconds? }`
MCP: `sandbox_run_code({ sandboxId, code, lang? })`

### Bootstrap al reanudar

Una caja restaurada de un snapshot revive **sin boot**: no vuelve a correr systemd, ni el
entrypoint, ni `.bashrc` — todo eso pasó cuando se horneó la imagen. Así que una caja que
durmió tres días despierta con el mundo de hace tres días, y nada lo señala.

`sandbox_set_bootstrap({ sandboxId, script, mode?, timeoutSeconds? })`
`POST /sandboxes/:id/bootstrap`
Body: `{ script, mode?, timeoutSeconds? }`

⚠️ **No es un campo de `POST /sandboxes`.** El create no lo acepta: es una segunda llamada
sobre la caja ya creada. Crea, y luego declara el bootstrap sobre su `sandboxId`.

El script lo corre **el host**, en cada despertar, venga de donde venga: una petición al proxy
de un puerto, un mensaje al agente, el dominio público. No sólo cuando llamas a `resume`.

```bash
git -C /data/work fetch --all --prune \
  && git -C /data/work checkout -B "sesion/${EB_SANDBOX_ID}" origin/main \
  && ln -sfn /skills /data/work/.claude/skills
```

- **Hazlo idempotente.** Corre en CADA despertar: `checkout -B`, no `-b`; `ln -sfn`, no `ln -s`.
- Variables disponibles: `EB_RESUME=1` y `EB_SANDBOX_ID`. El cwd es `/data/work`.
- `mode: "async"` (default) — la caja responde de inmediato mientras el script corre, así que
  el primer mensaje no lo paga. `"blocking"` espera hasta `timeoutSeconds` antes de servir,
  para cuando el trabajo TIENE que estar hecho antes.
- Un script que falla **nunca** deja la caja inalcanzable. El resultado queda anotado
  (`eb_boot_exit`, `eb_boot_err`) para que sea diagnosticable en vez de invisible.
- Script vacío = apagarlo, sin destruir la caja.

⚠️ **Nunca metas una credencial en el script.** La receta viaja en el metadata de la caja y
aparece en los listados. Referencia variables que ya vivan dentro de la VM, o resuelve el
secreto con `$secret:` desde una tool de git.

```js
await sbx.setBootstrap({
  script: 'git -C /data/work checkout -B "sesion/${EB_SANDBOX_ID}"',
  mode: "blocking",
});
```
```bash
curl -X POST https://www.easybits.cloud/api/v2/sandboxes/$SB/bootstrap \
  -H "Authorization: Bearer $EB_KEY" -H 'Content-Type: application/json' \
  -d '{"script":"git -C /data/work fetch --all","mode":"async"}'
```

Es la pieza que convierte a las skills en memoria de verdad: en un template sin recarga en
caliente, una skill instalada entra en vigor en el siguiente despertar, sola.

### Plantillas derivadas (template-snapshot)

**Qué es.** Preparas UNA caja (`npm install`, seeds, skills, config) y la capturas como
plantilla derivada bajo una clave de contenido `(key, hash)`. Desde entonces cada caja creada
con ese par **nace con el bootstrap hecho**, en el tiempo de un create normal (medido: captura
~0.5-1 s; create desde la derivada ~0.6 s + boot ~2 s), sin fork ni copia de GB por hijo: el host guarda sólo el delta de disco (decenas de MB)
y lo monta debajo de cada hija como copy-on-write.

**Cuándo.** Cuando el mismo bootstrap se repite: un agente por conversación, un sandbox por
sesión de eve, N workers idénticos. Si sólo necesitas ramificar una caja viva con su memoria,
eso es `snapshot` + `fork`; la plantilla derivada es disco solo, sin memoria, y el hijo arranca
en frío (~2 s + lo que tarde su runtime).

**Qué va en el `hash`.** Un digest de lo que determina el contenido del bootstrap (template
base, manifiesto de dependencias, seeds, skills). **Nunca** prompts ni credenciales: el host
hace *scrub* de secretos (`/etc/sandbox-env/env`, `/app/secrets.env`, el `env_file` del
template) y cada hija recibe su propio `env` en el create. `key` y `hash` son componentes
de ruta: `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`.

REST:
```bash
# 1. ¿ya está preparada? (404 DerivedTemplateNotProvisioned si no)
curl "https://www.easybits.cloud/api/v2/template-snapshots?key=eve:agente&hash=3f9c" -H "Authorization: Bearer $EB_KEY"
# 2. preparar una caja y capturarla (idempotente por (key, hash) → reused:true)
curl -X POST https://www.easybits.cloud/api/v2/sandboxes/$SB/template-snapshot \
  -H "Authorization: Bearer $EB_KEY" -H 'Content-Type: application/json' \
  -d '{"key":"eve:agente","hash":"3f9c","name":"agente v3"}'
# 3. hijas: nacen con el bootstrap hecho (template opcional: lo sabe la plantilla)
curl -X POST https://www.easybits.cloud/api/v2/sandboxes -H "Authorization: Bearer $EB_KEY" \
  -H 'Content-Type: application/json' -d '{"templateKey":"eve:agente","templateHash":"3f9c","env":{"FOO":"bar"}}'
```
`GET /template-snapshots` lista · `GET/DELETE /template-snapshots/:id` (409 `DerivedTemplateInUse`
mientras haya hijas vivas). También `derivedTemplate: "dt_…"` en el create, en lugar del par.

MCP: `sandbox_template_snapshot({ sandboxId, key, hash, name? })` ·
`sandbox_list_template_snapshots()` · `sandbox_delete_template_snapshot({ derivedId })` ·
`sandbox_create({ templateKey, templateHash, env })`.

SDK:
```ts
const dt = await sb.templateSnapshot({ key: "eve:agente", hash });           // { derivedId, reused, sizeBytes }
const child = await eb.sandboxes.create({ templateKey: "eve:agente", templateHash: hash, env });
await eb.sandboxes.templateSnapshots.get({ key, hash });                      // 404 → hay que preparar
await eb.sandboxes.templateSnapshots.list(); await eb.sandboxes.templateSnapshots.delete(dt.derivedId);
```

Límites: **sin credenciales dentro** (scrub + `env` por hija) · una plantilla **sin uso 30 días
se borra** sola · cuando el template base se **rehornea**, la plantilla queda *stale*: el create
responde **409 `DerivedTemplateStale`** y hay que volver a capturar con el mismo par (o subir el
hash si incluye la versión del template) · no se deriva de una derivada · necesita scope WRITE
para capturar, READ para consultar, DELETE para borrar.

### Git: que el trabajo del agente sobreviva a la caja

Una caja que duerme tres días despierta con el código de hace tres días, y sin una forma de
publicar, lo que el agente escribió muere con ella. Estas siete herramientas cierran el ciclo
completo: clonar, trabajar, publicar.

```
sandbox_git_clone({ sandboxId, repo, dir, branch?, depth?, commit?, token? })
sandbox_git_status({ sandboxId, dir })
sandbox_git_commit({ sandboxId, dir, message, addAll?, paths? })
sandbox_git_push({ sandboxId, dir, branch?, setUpstream?, token? })
sandbox_git_pull({ sandboxId, dir, rebase?, token? })
sandbox_git_checkout({ sandboxId, dir, branch, create?, from? })
sandbox_git_log({ sandboxId, dir, limit?, cursor? })
```

**La credencial es POR LLAMADA y no se queda en la caja.** `token` acepta el valor literal o
—mejor— una referencia a tu vault:

```js
await eb.secrets.set({ name: "GITHUB_TOKEN", value: "ghp_…" });   // una vez
await sb.git.clone({ repo, dir: "/data/work", token: "$secret:GITHUB_TOKEN" });
```

El token viaja a la caja para esa operación y se borra al terminar. **Nunca** se escribe en el
`.git/config` del repo ni aparece en la línea de comando, así que un `ps` desde dentro de la
caja no lo ve. Por eso no hay un paso de "limpiar credenciales después": no hay nada que
limpiar. Un URL con credenciales embebidas (`https://user:token@…`) se rechaza con 422 en vez
de aceptarse en silencio, precisamente porque git SÍ lo persistiría.

Detalles que importan cuando el que llama es un agente y no una persona:

- `sandbox_git_status` devuelve datos, no texto: `{ branch, upstream, ahead, behind, clean,
  staged[], modified[], untracked[], conflicted[] }`. Sale de `porcelain=v2`, así que no cambia
  entre versiones de git ni con el idioma del sistema.
- `sandbox_git_commit` sin cambios devuelve `{ nothingToCommit: true }` como ÉXITO. Un error
  ahí invita al agente a reintentar, y reintentar no cambia nada: es un bucle.
- `sandbox_git_checkout` con `create: true` usa `-B`, que es idempotente — pensado para
  correrse en cada arranque sin fallar con "branch already exists".
- `sandbox_git_push` con `force` usa `--force-with-lease`: si alguien más empujó a esa rama,
  falla en vez de borrarle el trabajo.
- La identidad del commit va por llamada (`authorName` / `authorEmail`, default
  `EasyBits Agent`), sin dejar un `git config` escrito en el repo del cliente.

Para repos privados en `launch_app`, el mismo mecanismo: `launch_app({ repo, repoToken:
"$secret:GITHUB_TOKEN" })`. El token no entra al runspec ni al tarball del release.

Las mismas siete operaciones por REST y por SDK:

```bash
# REST — POST para clone|commit|push|pull|checkout, GET para status|log
curl -X POST https://www.easybits.cloud/api/v2/sandboxes/$SB/git/clone \
  -H "Authorization: Bearer $EB_KEY" -H 'Content-Type: application/json' \
  -d '{"repo":"https://github.com/tu/repo.git","dir":"/data/work","token":"$secret:GITHUB_TOKEN"}'

curl "https://www.easybits.cloud/api/v2/sandboxes/$SB/git/status?dir=/data/work" \
  -H "Authorization: Bearer $EB_KEY"
```

```js
const sbx = await eb.sandboxes.create({ template: "node" });
await sbx.git.clone({ repo, dir: "/data/work", token: "$secret:GITHUB_TOKEN" });
await sbx.git.checkout({ dir: "/data/work", branch: "feature/x", create: true });
await sbx.git.commit({ dir: "/data/work", message: "cambios del agente" });
await sbx.git.push({ dir: "/data/work", setUpstream: true, token: "$secret:GITHUB_TOKEN" });

const st = await sbx.git.status("/data/work");   // { branch, ahead, behind, clean, ... }
```

### Codex con tu plan de ChatGPT (Plus/Pro) en tu caja
Si usas Codex CLI, puedes correrlo **en tus cajas con tu propio plan de ChatGPT**, sin `OPENAI_API_KEY` ni tokens aparte: es el caso «máquina remota» que OpenAI documenta para Codex. El inicio de sesión lo haces **tú, con tu cuenta**; tu sesión queda cifrada en tu vault y sólo se usa en TUS cajas. (Los agentes de la Flota con el motor `codex` siguen pidiendo `OPENAI_API_KEY` hasta que OpenAI apruebe el uso en apps alojadas.)

**Una vez** (cualquier caja `node`):
```bash
easybits sb create --template node --ttl 3600       # → sb_…
easybits sb codex login sb_…                        # instala Codex, te da link + código, esperas a iniciar sesión → guarda en tu vault
```
Abre el link (`https://auth.openai.com/codex/device`), entra con tu cuenta de ChatGPT y escribe el código (dura 15 min). Si dice que no está permitido, activa **inicio de sesión con código de dispositivo** en la configuración de seguridad de ChatGPT (en un workspace, lo activa el admin).

**Después, en cualquier caja**, un paso:
```bash
easybits sb codex connect sb_…                      # pone tu sesión del vault e instala Codex si falta
easybits sb exec sb_… -- codex exec "explica este repo"
```
API: `POST /sandboxes/:id/codex-connect` `{ force?, install? }` → `{ session: "written"|"kept", codex }` · `POST /sandboxes/:id/codex-save` → `{ saved }`. MCP: `sandbox_codex_connect` · `sandbox_codex_save`. SDK: `sb.codexConnect()` · `sb.codexSave()`. ¿Ya tienes `~/.codex/auth.json` en tu compu? Pégalo tal cual como secreto **`CODEX_AUTH`** en tu vault (`/dash/developer/secrets` o `easybits secrets set CODEX_AUTH < ~/.codex/auth.json`) y salta el login.

**Cómo se cuida la sesión:** OpenAI **rota** el token de renovación cada vez que Codex renueva. Por eso `connect` nunca pisa una sesión que la caja ya tenga (puede ser más nueva; `--force` la reemplaza), y al borrar una caja por la API su sesión renovada se guarda sola en el vault antes de destruirla (o a mano con `codex save`). Usa la misma sesión en **una caja a la vez**: dos cajas renovando el mismo token se lo pisan y una tendría que volver a iniciar sesión. En la caja vive en `~/.codex/auth.json` (permisos 600): trátala como contraseña.

### Kernel persistente (code-interpreter)
MCP: `sandbox_run_cell({ sandboxId, code })` — estado sobrevive entre celdas. Gráficas matplotlib se devuelven como imágenes.
MCP: `sandbox_kernel_restart({ sandboxId })` — reiniciar kernel.

### Exponer puerto (URL pública)
`POST /sandboxes/:id/expose-port`
Body: `{ port }`
MCP: `sandbox_expose_port({ sandboxId, port })`
Retorna URL HTTPS pública (viva mientras el sandbox exista).

Si la respuesta trae `warning`, el servicio escucha **sólo en `127.0.0.1`** dentro de la caja: la URL queda publicada pero devolverá 502 hasta que bindees a `0.0.0.0` (o `::`). El proxy dialea la IP del guest, así que un bind a loopback es inalcanzable por diseño — igual que en Docker, Fly o Cloud Run.

**Capa 7 con TLS: HTTP y WebSocket.** El certificado ya está en el edge, así que la misma URL sirve `https://` y `wss://` — no hace falta cloudflared ni un puerto raw para un WebSocket. Lo que no hace es capa 4 cruda: los puertos 22, 23, 25, 445 y 3389 se rechazan con 400. Para esos usa el forward L4.

### Política de red por caja (egress)
`POST /sandboxes/:id/network-policy` · Body: `"allow-all"` | `"deny-all"` | `{ allow: { "api.github.com": [], "registry.npmjs.org": [] } }` (sin comodines; `"*"` a secas = allow-all) · `GET` para leerla.
MCP: `sandbox_set_network_policy({ sandboxId, policy })` / `sandbox_get_network_policy` · SDK: `sb.setNetworkPolicy(policy)` / `sb.getNetworkPolicy()`

Sólo a los dominios que tú autorices para esa caja, o a ninguno. Lo cambias en caliente por API, se conserva al dormir/despertar, y si un dominio no está en la lista la conexión simplemente no sale. Toma efecto cuando la llamada responde: aplícala **antes** del egress que quieres gobernar. Mismo shape que `setNetworkPolicy` de eve / Vercel Sandbox; `transform` (inyectar headers en el firewall) no está soportado y se rechaza con 400.

Detalles del contrato: un host que **no resuelve no es error** — aparece en `unresolved` y el refresco (cada 60 s) lo reintenta; una IP literal va directo. Sin comodines (`*.x` → 422). Con `deny-all` no hay DNS; con allow-list el DNS (53) sale sólo a 1.1.1.1 / 8.8.8.8. El alcance es egress a Internet: el mesh interno y los puertos del host por rol siguen vivos. La respuesta trae `{ policy, mode, resolved: { host: [ip] }, unresolved: [host] }`. Fork y warmpool nacen `allow-all`; el cambio es atómico (no hay ventana abierta).

### Puertos raw (TCP/UDP)
`POST /sandboxes/:id/expose-raw` · Body: `{ port, protocol }` (`"tcp"` | `"udp"`)
MCP: `sandbox_expose_raw_port({ sandboxId, port, protocol })` · SDK: `sb.exposeRawPort(port, protocol)`
Cerrar: `POST /sandboxes/:id/unexpose-raw` — MCP `sandbox_unexpose_raw_port` · SDK `sb.unexposeRawPort(port, protocol)`

Devuelve:

```json
{ "hostPort": 49123, "guestPort": 22, "protocol": "tcp",
  "host": "cname.sandboxes.easybits.cloud",
  "endpoint": "cname.sandboxes.easybits.cloud:49123", "ok": true }
```

Marca el destino con `endpoint` tal cual; no lo armes a mano. El `hostPort` sale de un pool (49000-49999), es **distinto por caja** y **no** es igual al `guestPort` — eso es justo lo que permite que cada caja tenga su propio 22. Tampoco es estable: se libera al destruir la caja y se re-asigna, así que vuelve a leerlo en vez de guardarlo.

Está **gateado por capacidad del template**: si el template no declara ese puerto, la respuesta es 403 — es un "no", no un fallo transitorio; no reintentes.

### SSH a una caja
`POST /sandboxes/:id/ssh-enable` · Body: `{ publicKeys: string[] }`
MCP: `sandbox_ssh_enable({ sandboxId, publicKeys })` · SDK: `sb.enableSsh(publicKeys)`
Cerrar: `POST /sandboxes/:id/ssh-disable` — MCP `sandbox_ssh_disable` · SDK `sb.disableSsh()`

**La llave pública sale del CLI, no la escribas a mano.** `easybits ssh-key` la crea en `~/.ssh/easybits_ed25519` la primera vez y siempre devuelve la misma — y es la que `easybits ssh-proxy` usa al conectar, así que no pueden desfasarse. Elegir otra de `~/.ssh` es el error más común: inyectas una pública que no corresponde a la privada con la que luego conectas, y el sshd responde `Permission denied` con todo lo demás correcto. La privada nunca sale de la máquina del usuario.

Una sola llamada hace las dos mitades: inyecta las llaves, reinicia `box-sshd` y expone el 22 por L4. Devuelve el forward más el comando listo para pegar:

```
ssh -p 49002 root@cname.sandboxes.easybits.cloud
```

- El sshd de la caja es **fail-closed**: sin llave no arranca, así que una caja a la que nadie inyectó nada no tiene superficie SSH ni siquiera cerrada. Por eso la llave va primero.
- Acceso **solo por llave**, como `root` (`PasswordAuthentication no`).
- Varias llaves: una por elemento del array. Internamente van separadas por **coma**, no por salto de línea.
- La host key persiste en `/app/ssh/`, así que el fingerprint sobrevive reinicios y resume — sin warning de MITM en cada boot.
- `ssh-disable` solo cierra el puerto; la llave sigue inyectada. Para **revocar** de verdad, quítala de `/app/secrets.env`.
- Solo en templates que declaren el 22 (hoy: `ghosty-studio`).

### SSH por túnel (recomendado: sin puertos, pasa por el 443)

El comando de arriba usa un puerto alto del anfitrión, y **un puerto alto no atraviesa la red de una oficina ni una VPN corporativa**. Eso te llega como "no me conecta" desde una red que no puedes reproducir. El túnel entra por el mismo 443 de siempre: si el usuario puede abrir una página web, entra a su caja.

```sh
npm i -g @easybits.cloud/cli && easybits login   # o: easybits login - < key.txt
easybits ssh-key          # tu llave pública; la crea si no existe
```

En `~/.ssh/config`:

```
Host *.ghosty
  ProxyCommand easybits ssh-proxy %h
  User root
```

Y ya:

```sh
ssh mi-caja.ghosty        # el NOMBRE que le diste al crearla
ssh sb_abc123.ghosty      # el id también sirve
ssh mi-caja.ghosty "cd /data/work && ghosty serve --acp"   # ACP remoto por stdio
```

Sigue haciendo falta `ssh-enable` una vez, para inyectar la llave: el sshd de la caja es fail-closed. Pásale la salida de `easybits ssh-key` — es la misma llave que usará `ssh-proxy` al conectar, así que no pueden desfasarse. La privada nunca sale de tu máquina.

El **nombre** de la caja (el `name` del create) sirve como host: `ssh mi-caja.ghosty`. No es único ni secreto — si dos cajas lo comparten el proxy falla en vez de elegir, porque entrar a la caja equivocada es peor que no entrar. Da igual que sea público: la sesión se autentica con tu llave y el ticket, nunca con el nombre.

- **El túnel no autentica.** Mueve bytes opacos; la sesión SSH se autentica de punta a punta entre tu `ssh` y el sshd de la caja. Un fallo en el túnel no le da acceso a nadie.
- El CLI pide un **ticket firmado de vida corta** (`POST /sandboxes/:id/ssh-ticket` · SDK `sb.sshTicket()`) y abre `wss://…/_ssh`. Normalmente no lo llamas tú.
- Ticket vencido, firma alterada o caja ajena: **403/404** en el borde, sin llegar al anfitrión.
- El `stdout` del proxy es el canal de SSH: cualquier byte de más ahí corrompe el handshake, así que todo su diagnóstico va a `stderr`.

### Dominios personalizados (custom domain + HTTPS automático)
Sirve un puerto del sandbox bajo TU dominio (`app.cliente.com` o `cliente.com`) en vez de la URL `sb-…`. El cert TLS se emite solo en el primer acceso (sin egress fees, sin config extra). Un dominio → un sandbox.

`POST /sandboxes/:id/domain-add` · Body: `{ domain, port }`
MCP: `sandbox_domain_add({ sandboxId, domain, port })` · SDK: `sb.addDomain(domain, port)`
Devuelve en `dns` el registro EXACTO a crear: **subdominio → CNAME** a `cname.sandboxes.easybits.cloud`; **raíz/apex → A** a la IP del edge (apex no admite CNAME).

`POST /sandboxes/:id/domain-remove` · Body: `{ domain }` — MCP: `sandbox_domain_remove` · SDK: `sb.removeDomain(domain)`
`POST /sandboxes/:id/domain-list` — MCP: `sandbox_domain_list` · SDK: `sb.listDomains()`
`POST /sandboxes/:id/domain-verify` · Body: `{ domain }` — MCP: `sandbox_domain_verify` · SDK: `sb.verifyDomain(domain)` — confirma DNS + cert TLS

Flujo: `domain-add` → crea el registro DNS que indica `dns` → `domain-verify`. Crea el registro en tu DNS **autoritativo** (si tu registrador delega los nameservers a otro proveedor, edítalo ahí).

### Archivos
- `sandbox_files_write({ sandboxId, path, content })` — escribir archivo
- `sandbox_files_read({ sandboxId, path })` — leer archivo
- `sandbox_files_list({ sandboxId, path })` — listar directorio
- `sandbox_files_delete({ sandboxId, path })` — eliminar
- `sandbox_files_move({ sandboxId, from, to })` — mover/renombrar
- `sandbox_files_mkdir({ sandboxId, path })` — crear directorio
- `sandbox_files_edit({ sandboxId, path, oldString, newString, replaceAll? })` — edición quirúrgica in-place (read→replace→write). Usa esto en vez de exec+sed: evita el escaping de shell. Reemplaza todas las ocurrencias por defecto; `replaceAll:false` = solo la primera. SDK: `sb.files.edit(path, old, new)`

### Logs & runtime del daemon
- `sandbox_logs({ sandboxId, unit?, lines?, since?, grep? })` — logs journald nativos (sin pipear journalctl por exec). REST: `POST /sandboxes/:id/logs` con ese body, o `GET /sandboxes/:id/logs?unit=&lines=&grep=`. `unit` filtra un servicio systemd (ej. `ghosty-gc-runtime`, convención `<template>-runtime`); omítelo para el journal completo. Sin follow/streaming. SDK: `sb.logs({ unit, lines, since, grep })`
- `sandbox_runtime({ sandboxId, action, unit?, buildCommand?, cwd? })` — control vía systemd. `status` (estado del unit) · `restart` (unit requerido) · `rebuild` (corre buildCommand en cwd y reinicia unit). SDK: `sb.runtime(action, { unit, buildCommand, cwd })`
- `sandbox_apply_patch({ sandboxId, edits[], rebuild?, restart? })` — hotfix atómico: aplica N edits → rebuild opcional → restart opcional, en una llamada. Si el build falla NO reinicia (el daemon vivo se queda). SDK: `sb.applyPatch({ edits, rebuild, restart })`

### Ciclo de vida
- `sandbox_list()` — listar sandboxes activos
- `sandbox_status({ sandboxId })` — estado (starting/running/stopped/error/lost/suspended)
- `sandbox_extend({ sandboxId, extendSeconds? })` — extender TTL (máx según plan: Byte 1h · Mega 4h · Tera 24h)
- `sandbox_suspend({ sandboxId })` — snapshot a disco; pausa el TTL mientras está suspendido
- `sandbox_set_idle({ sandboxId, suspendOnIdle, idleTtlSeconds?, hardTtlSeconds? })` — al vencer el TTL la caja se SUSPENDE en vez de destruirse (sesiones largas con pausas)
- `sandbox_resume({ sandboxId })` — restaurar desde snapshot; restaura el TTL restante (no hace falta sandbox_extend)
- `sandbox_destroy({ sandboxId })` — destruir y liberar

> **Skill para tu agente:** `npx skills add https://easybits.cloud` instala `easybits-sandbox` (la API de cajas) y `easybits-cli` (lo mismo desde la terminal: `easybits sandboxes …`).

### Actividad y errores de una caja

`GET /sandboxes/:id` (SDK `sb.refresh()`, MCP `sandbox_status`) trae `activity` mientras corre una operación larga sobre la caja: `"snapshotting"` o `"forking"`. Sin operación en curso, el campo no viene. Mientras está presente, `exec`, `suspend`, `destroy`, `snapshot` y `fork` contestan **409 `SandboxBusy`** al instante: espera a que desaparezca y reintenta.

```json
{ "sandboxId": "sb_…", "status": "running", "activity": "snapshotting" }
```

Los errores de una caja traen `{ "error": "<código>", "message": "…" }`:

| Status | `error` | Qué pasó | Qué hacer |
|---|---|---|---|
| 403 | `AgentLimitReached` | Tu plan ya tiene todas sus cajas persistentes (agentes ACP o de marca), vivas o dormidas: Byte 1, Mega 2, Tera 5 | Borra un agente (`existing` trae cuáles) o sube de plan (`upgradeUrl`) |
| 409 | `SandboxBusy` | Hay un snapshot o fork en curso sobre la caja | Consulta `activity` con `GET /sandboxes/:id` y reintenta cuando desaparezca (suelen ser minutos) |
| 409 | `SandboxNotReady` | La caja aún está en `starting` (o dormida) | Espera `status=running`, o `resume` si está `suspended` |
| 409 | `SandboxUnreachable` | El host no alcanza al agente dentro de la caja (reiniciando o colgada) | Espera y reintenta; si sigue, destrúyela y crea otra |
| 504 | `SandboxHostTimeout` | El host no respondió a tiempo; la operación puede seguir en curso | Revisa `GET /sandboxes/:id` y reintenta en unos minutos; no la repitas a ciegas |
| 502 | `SandboxHostError` | El host de cajas falló (`status` y `message` traen su error) | Reintenta; si persiste, contacta a soporte con el `message` |

Un `snapshot` puede tardar varios minutos (copia disco y memoria); la API espera hasta 10.

### Agentes persistentes (agent_create)
`POST /agents`
Body: `{ template, env, name?, timeoutSeconds?, seedFiles?, mcpServers? }` — `env` es obligatorio (`{}` si el template no pide nada): llaves del modelo y config del agente; se escriben dentro de la VM y no vuelven a salir por la API.
`seedFiles: [{ name, contentBase64 }]` se escriben en `/data/workspace/<name>` **aplanados** (sin subcarpetas: `/` se vuelve `_`); sirven para meter un MCP o conocimiento sin clonar un repo. `mcpServers` (sólo templates ACP) viaja en el `session/new`; ver abajo.
Responde de inmediato con `status: building`; consulta `GET /agents/:id` hasta `running` antes del primer mensaje.
MCP: `agent_create({ template })` — crea agente con endpoint HTTP público
MCP: `agent_list()` — listar agentes
MCP: `agent_message({ agentId, content })` — enviar mensaje
MCP: `agent_destroy({ agentId })` — destruir agente

### 🆕 Agentes ACP: `ghosty-lite` y `goose`
Agentes que hablan [ACP](https://agentclientprotocol.com) (Agent Client Protocol) en su propia microVM: `ghosty-lite` (Rust, ligero, nuestro) y `goose` (AAIF / Linux Foundation). Mismo flujo con `POST /agents`; lo que cambia es el `env` y lo que significa `running`.

**1. Crear** — con `env: {}` basta: `ghosty-lite` arranca con **tu propia llave EasyBits** como cerebro (proveedor `easybits`, modelo `deepseek-v4-pro`) y con el MCP de EasyBits ya conectado. Es la misma llave para las dos cosas: los turnos se descuentan de **tus** tokens LLM (`GET /llm/balance`) y el agente opera sobre **tu** cuenta. Si no tienes una guardada, se mintea una con los scopes de la llave que hizo la llamada.
Para otro cerebro, el `env` manda: `{ GHOSTY_PROVIDER: "anthropic", GHOSTY_MODEL: "claude-haiku-4-5", ANTHROPIC_API_KEY: "..." }` · goose usa `GOOSE_PROVIDER` / `GOOSE_MODEL`.
Proveedores: `easybits` (medido, default), `anthropic`, `openai`, `custom_deepseek` (+ `DEEPSEEK_API_KEY`, off-meter), `ollama`… Si sólo mandas `DEEPSEEK_API_KEY`, la caja elige DeepSeek sola.
**Tu suscripción Claude (Max/Pro)**: manda `CLAUDE_CODE_OAUTH_TOKEN` (el de `claude setup-token`) y el agente nace con `GHOSTY_PROVIDER: "claude-acp"` + `GHOSTY_MODEL: "current"` (el modelo que el adaptador traiga); tarifa plana, off-meter. El proxy medido (`/llm/v1/models`) NO ofrece Claude: ese es el único camino a Claude en `ghosty-lite`.

**2. Esperar `running`** — para ACP significa que Easybits ya hizo `initialize` + `session/new` y guardó la sesión (~6 s desde crear). Antes de eso `/message` no tiene sesión.

**3. `POST /agents/:id/message`** `{ content }` → SSE `{type:"chunk",value:"…"}` … `{type:"done"}`. Cada turno reutiliza la sesión; el agente conserva contexto y disco (`/data`). Funciona con la `eb_sk` del dueño o con el `embedToken` desde navegador.

**4. La máquina es tuya** — `POST /sandboxes/:sandboxId/exec` para leer lo que el agente escribió; `DELETE /agents/:id` la destruye. Sin borrar, se duerme al idlear y despierta con el siguiente mensaje.

**Desde un editor (Zed, JetBrains, VS Code), Ghosty Teams o cualquier cliente ACP**: pon `ACP_AGENT_TOKEN` en el `env` al crear y conecta por WebSocket a la **URL estable del agente**, que viene en `agentUrl` desde el POST (responde cuando llega a `running`): `wss://acp-<agentId>.sandboxes.easybits.cloud/acp?token=<ACP_AGENT_TOKEN>` — el `/acp` **es parte de la URL** (con el puente `npx ghosty-acp <esa url>`, `env: {}`; las llaves ya viven en la caja). El puerto 3000 ya está expuesto; no hace falta `/expose`.

**5. Identidad estable y revive** — la URL lleva el `agentId`, no la máquina: si el host recicla la caja (días sin uso), la URL sigue siendo la misma. `POST /agents/:id/revive` (Bearer `eb_sk` del dueño **o** el `embedToken` / `ACP_AGENT_TOKEN` del agente) la vuelve a levantar sobre el mismo agente y devuelve `{ agentId, sandboxId, status, wsUrl }`; si la caja existe no hace nada. Tarda lo que tarda el boot (~10-60 s): espera la respuesta, no reintentes. Se pierde el disco (`/data`) de la caja anterior. `/message` lo hace solo cuando encuentra el agente en `lost`. Un cliente WebSocket lo reconoce por un `404` con `preview host not found` en la URL del agente.

**6. Su system prompt (identidad)** — por default el agente llega con la persona de Ghosty (responde en el idioma en que le escriben). Para el tuyo: al crear, `env.SYSTEM_PROMPT` y `env.SYSTEM_PROMPT_MODE` (`replace` = el agente es SÓLO tu prompt, sin la persona de casa; `append` = se le anexa, default). Después de creado: `PATCH /agents/:id` `{ systemPrompt?, systemPromptMode? }` lo cambia en caliente (sin reboot) y `GET /agents/:id/prompt` devuelve el vigente. Una sesión ya abierta conserva el prompt con el que arrancó: el nuevo aplica a la siguiente. `POST /agents/ghosty` y `/agents/autonomous` aceptan los mismos `systemPrompt` / `systemPromptMode` en el body (ahí el default es `replace`).

**7. Archivos, skills, MCP y reinicio por API** — mismo contrato que Ghosty Studio, sólo en templates con máquina (`ghosty-lite`, `goose`; en los demás `409 agente_sin_maquina`). `PUT /agents/:id/files/<ruta>` (bytes crudos, máx 10 MB, van a `/data/work`; `GET` lista, `DELETE` borra; sin reinicio). `PUT /agents/:id/skills/<slug>` `{ markdown, assets?: [{ name, contentBase64 }] }` y `DELETE` (van a `/data/agent/skills`; entran tras `restart`; un `: ` sin comillas en el frontmatter → 400). `GET|PUT /agents/:id/mcp` `{ servers }` reemplaza la lista entera y reinicia. `POST /agents/:id/restart` rearranca el agente con sesión nueva. La skill `ghosty-agent` (`npx skills add https://www.ghosty.studio`) sirve aquí con base `https://www.easybits.cloud/api/v2/agents/$AGENT_ID` y tu `EASYBITS_API_KEY`.

Consumo: con el cerebro `easybits` (el default) lo mide el proxy y se ve en `GET /llm/balance`; con un proveedor propio (BYOK) el gasto es contra ese proveedor y EasyBits no lo cuenta. El SSE de `/message` emite además un `{type:"usage", inputTokens, outputTokens, totalTokens}` justo antes del `done`, cuando el agente lo reporta (son totales de la SESIÓN, no del turno). **Tools propias (`mcpServers`)**: `[{ name, command, args?, env? }]` (stdio) o `[{ name, type:"http", url, headers? }]`; `$secret:NOMBRE` en `env`/`headers` se resuelve del vault del dueño. ⚠️ Con `claude-acp` **sólo entran MCPs por http** (anuncia `mcpCapabilities: {http:true}`, sin stdio): un stdio se rechaza con 400. Levanta el servidor por Streamable HTTP dentro de la caja (p. ej. sembrado con `seedFiles`) y decláralo como `{ type:"http", url:"http://127.0.0.1:<puerto>/mcp" }`. Con `easybits`/DeepSeek (goose) stdio sí funciona. Con `claude-acp` las tools corren **sin pedir permiso** (modo bypass dentro de la microVM): no hay diálogo de confirmación que atender.

### Agent Run (one-shot)
MCP: `agent_run({ prompt, model?, maxTurns? })` — agente Claude asíncrono
MCP: `agent_run_status({ jobId })` — consultar estado
MCP: `agent_run_destroy({ jobId })` — liberar sandbox

Rate limits: 10 spawns/min, 120 ops/min. Sandboxes se auto-destruyen al TTL (default 5 min; máx según plan: Byte 1h · Mega 4h · Tera 24h).

---

## Flota — Fleet Agents (agentes elásticos multicanal)

Un Fleet Agent rutea conversaciones (WhatsApp, WABA, web) a workers efímeros. Los
configuras por completo vía SDK/REST — el dashboard es solo un cliente.

**Dos credenciales, no mezclar:**
- `create` / `list` / `delete` → tu credencial de cliente (API key o JWT OAuth del user, scope WRITE).
- Toda la config + mensajería → el **`token` por-agente** que devuelve `create` (persístelo junto al `id`).

⚠️ Ese `token` autoriza TODO: mensajear, cambiar el prompt, leer secretos y borrar el
agente. Para repartirlo (a un backend ajeno, a un widget) emite credenciales **con
alcance** — ver "Credenciales con alcance" más abajo.

### Crear
`POST /fleet-agents`
Body: `{ name?, systemPrompt?, model?, engine?, workerTemplate?, maxWorkersPerVm?, vmMemMb?, maxVms?, idleSuspendMin? }`
`engine` (atajo de alto nivel): `claude` (default, claude-worker) · `codex` (codex-worker) · `gemini` (gemini-worker) — deriva template + modelo por defecto. Los motores no-Claude piden el secret de su proveedor (ej. `codex` → `OPENAI_API_KEY`, ponlo con `set-secret`). `deepseek` y `glm` (sobre ghosty-gc) están deprecados desde el 18-oct-2026: ya no se crean, y DeepSeek pasa a ghosty-lite. `easybits` es el motor de la casa (Haiku 5.5 con la suscripción de la plataforma) y sólo lo usan el creador de Sitios y el piloto de crons.
Returns: `{ fleetAgent: { id, token, ... } }` — guarda `id` **y** `token`.
SDK: `eb.fleet.create({ name, systemPrompt?, model?, engine? })`

### Listar / eliminar
`GET /fleet-agents` → `{ pools: FleetAgent[] }` · SDK: `eb.fleet.list()`
`POST /fleet-agents/:id/delete` · SDK: `eb.fleet.delete(id)`

### Leer configuración
`GET /fleet-agents/:id/capabilities` (auth = `token` del agente, header `Authorization: Bearer` o `?token=`)
Returns: catálogo + estado (`agent`, `buckets`, `bucketTools`, `models`, `skills`, `groups`, …).
SDK: `eb.fleet.getCapabilities(id, token)`

### Configurar (auth = `token` del agente)
`POST /fleet-agents/:id/capabilities` con `{ action, ... }`. A nivel agente (sin `groupId`):
- `set-name { name }` — SDK: `eb.fleet.setName(id, token, name)`
- `set-agent-prompt { systemPrompt }` — instrucciones base · SDK: `eb.fleet.setAgentPrompt(...)`
- `set-model { model }` — SDK: `eb.fleet.setModel(...)`
- `set-effort { effort }` — `low|medium|high|xhigh|max` · SDK: `eb.fleet.setEffort(...)`
- `toggle-own-number { on }`, `set-secret { name, value }`
- `add-mcp { name, pkg?|url?, requiredSecret?, envVar? }`, `remove-mcp { name }`
- `toggle-skill { skillId, on }`, `delete-skill { skillId }`

Por canal (con `groupId`; `"*"` = default del agente):
- `set-prompt { systemPrompt }` — SDK: `eb.fleet.setGroupPrompt(id, token, groupId, ...)`
- `set-cap-level { cap, level }` — `off|read|write` · SDK: `eb.fleet.setCapLevel(...)`
- `toggle-builtin { builtin, on }`, `set-toolgroup { buckets, inherit? }`, `toggle-asset { fileId, on }`

Respuesta uniforme: `{ ok: true }` o `{ error }`.

### Mensajería (auth = cualquier token del agente con scope MESSAGE)
`POST /fleet-agents/:id/message` → `{ reply }` · SDK: `eb.fleet.message(id, token, { groupId, text, configGroupId })`
`POST /fleet-agents/:id/message-stream` → SSE · SDK: `eb.fleet.messageStream(id, token, body, { onChunk })`

Eventos SSE: `chunk` (texto incremental), `tool`, `usage`, `capacity` y `done`.
`done.value` es la respuesta **autoritativa**: arma el mensaje final con ése, no
concatenando los chunks. `capacity` NO es un fallo del turno — tu flota está llena en ese
instante; reintenta pasado su `retryAfter`.

🚨 **Manda siempre `configGroupId`.** Es la unidad de CONFIGURACIÓN (prompt, MCPs,
capacidades); `groupId` sólo identifica la conversación y lo eliges tú (cualquier id
estable, p.ej. `web-<uuid>`). Si omites `configGroupId`, la config se busca por
conversación, no encuentra nada, y el agente arranca **sin sus conectores** — sin error
visible, indistinguible de un MCP roto ("no tengo esa herramienta"). Usa un valor estable
por canal o tenant: `"mi-app"`, `"waba:<integrationId>"`, `"crm:acme"`.
Los MCP se montan al CREAR la sesión, no en cada turno: para comprobar un cambio de
configuración, prueba con un `groupId` nuevo.

### Reglas por herramienta y aprobaciones
Decide qué puede hacer el agente **por herramienta**: permitir, **preguntar** (una persona
aprueba antes de que corra) o negar. Por canal (`cfgId`; `"*"` = default que heredan todos).
`PUT /fleet-agents/:id/tool-rules` con `{ cfgId?, rules: [{ pattern, action }] }` (scope MANAGE) ·
`GET /fleet-agents/:id/tool-rules?cfgId=`. `pattern` admite `*`: `mcp__denik__create_*`,
`mcp__easybits__*_delete`, `Bash`. Si varias coinciden: negar gana a preguntar y preguntar a
permitir. Sin reglas, todo pasa (como siempre).

Cuando una tool queda en «preguntar», el agente no la corre: se crea una aprobación y te llega
el webhook `agent.approval_requested` con `approveUrl`/`denyUrl` firmadas de un solo uso
(llámalas con POST desde tu servidor cuando tu usuario decida; nunca se las pases al agente).
`GET /fleet-agents/:id/approvals?status=awaiting` lista las pendientes. Aprobada, la siguiente
vez que el agente haga **esa misma llamada** (misma tool y argumentos, misma conversación, ≤ 24 h)
pasa una vez. Cada tool que corre queda en la bitácora (90 días).

### Credenciales con alcance (para embeber el agente en tu app)
`POST /fleet-agents/:id/tokens` con `{ name, scopes:["MESSAGE"|"MANAGE"|"ADMIN"], publishable?, cfgId?, allowedOrigins? }`
→ `{ token: { id, prefix, raw } }`. El `raw` se muestra **una sola vez**.
`GET /fleet-agents/:id/tokens` lista (sin valores) · `DELETE` con `{ tokenId }` revoca.

| Scope | Puede | No puede |
|-------|-------|----------|
| `MESSAGE` | mandar turnos | nada de configuración |
| `MANAGE` | leer y ajustar config: prompt, modelo, canales, capacidades | secretos, MCPs, skills, motor, borrar |
| `ADMIN` | todo, incl. `set-secret`, `add-mcp`, `set-engine`, borrar | — |

Dos prefijos: `flt_sk_` es secreta (cualquier scope, **sólo por header**; se rechaza por
query string) y `flt_pk_` es publishable (sólo MESSAGE, admitida en el navegador y
acotada por `allowedOrigins`).

**Para un chat en el navegador**, no mandes un `flt_sk_` al cliente: pide desde tu
servidor un token de sesión con `POST /fleet-agents/:id/session-token`
`{ cfgId?, ttlMin?, allowedOrigins? }` → `{ token, expiresAt }`, un `flt_pk_` efímero
(15 min por defecto). SDK: `eb.fleet.sessionToken(id, miFltSk, { cfgId })`.
Si le pasas `cfgId`, los turnos hechos con ese token **ignoran** el `configGroupId` que
mande el cliente — sin eso, una sesión emitida para un cliente podría pedir la
configuración de otro cambiando un campo del body.

### Conexión WhatsApp (Baileys) — auth = credencial del cliente (dueño)
Vincula un número **personal** (NO Business/WABA) para que el agente atienda grupos.
- `POST /fleet-agents/:id/connect` — inicia el socket. Sin body → QR; `{ pairingPhone }` → código de emparejamiento. `?disconnect=1` para desvincular. SDK: `eb.fleet.connect(id, { pairingPhone? })` / `eb.fleet.disconnect(id)`.
- `GET /fleet-agents/:id/connect` → `{ baileys: { status, qr?, pairingCode?, phone?, pairBlockedUntil? } }`. Estados: `qr_pending|pairing|connecting|connected|failed|disconnected`. **Poll cada ~2.5s**; respeta `pairBlockedUntil` (cooldown). SDK: `eb.fleet.connectionState(id)`.
- `GET /fleet-agents/:id/groups` → `{ groups: [{ groupId, subject, enabled, isMain }] }` (toca el socket en vivo → on-demand, no en el poll). SDK: `eb.fleet.listGroups(id)`.
- `POST /fleet-agents/:id/groups` — `{ groupId, on }` prende/apaga · `{ groupId, main:true }` designa el grupo MAIN (canal admin). SDK: `eb.fleet.toggleGroup(id, groupId, on)` / `eb.fleet.setMain(id, groupId)`.
- ⚠️ El backend NO valida si el número es Business/WABA — muéstralo como advertencia. Números Business van por WABA, no por aquí.

### WABA
`POST /fleet-agents/:id/waba/config` — SDK: `eb.fleet.waba.config(id, token, {...})`

---

## Llamadas online — Videollamadas con grabación (tipo Zoom/Riverside)

Template `livekit-svc`: sala de videoconferencia self-hosted con grabación HD server-side. El agente crea la sala, los participantes se unen por navegador (cámara + pantalla compartida), el servidor graba el layout completo en 1080p y al terminar sube el MP4 + transcript a tus Files de EasyBits. Sin servidores de terceros, sin límite de duración.

**MCP tools** (grupo `sandbox`):

### Crear llamada — one shot
`call_create({ room? })`
- Levanta el servidor, espera ~15s, configura WebRTC, retorna `{ sandboxId, room, roomUrl }`
- Comparte `roomUrl` con los participantes (abre en cualquier navegador)

### Grabar
`call_record({ sandboxId, room })`
- Chromium headless + ffmpeg capturan el layout completo en 1080p
- Retorna `{ recording: true, startedAt }`

### Detener → Files + transcript
`call_stop({ sandboxId })`
- Finaliza MP4, lo sube permanentemente a tus Files, genera transcript Whisper (.txt)
- Retorna `{ url, fileId }` — `url` sobrevive a la VM

### Estado de la sala
`call_status({ sandboxId })`
- Retorna `{ recording, room?, startedAt?, participants[] }` — si está grabando y quién está conectado

### Listar grabaciones
`call_files()`
- Retorna `[{ id, name, url, source, createdAt }]` — las grabaciones en tus Files (source: "studio")

### Transcript de la llamada
`call_transcript({ sandboxId? })`
- Devuelve el TEXTO del transcript inline (no un link), listo para resumir
- `status`: `transcribing` (Whisper procesando, reintenta ~1 min), `ready` (texto en `text`), `failed` (sin audio o Whisper falló), `unavailable` (grabación sin transcript), `no_recording` (nadie grabó)
- Con `sandboxId` = estado EN VIVO del box durante/tras la llamada; sin él = el transcript más reciente de tus Files
- Retorna `{ source, status, text?, chars? }`

### Cerrar la llamada
`call_destroy({ sandboxId })`
- Sube grabaciones pendientes, las rescata de la VM y destruye el servidor. Si no se llama, la sala se auto-destruye al TTL (3h)

**API REST:**
```
POST /api/v2/calls              body: { room? }      → { sandboxId, room, roomUrl }
POST /api/v2/calls/:id/record   body: { room }       → { recording: true }
POST /api/v2/calls/:id/stop                          → { url, fileId }
GET  /api/v2/calls/:id/status                        → { recording, room?, startedAt?, participants[] }
GET  /api/v2/calls/files                             → [{ id, name, url, source, createdAt }]
GET  /api/v2/calls/:id/transcript                    → { status, text?, chars? }  (estado en vivo)
GET  /api/v2/calls/transcript                        → { status, text?, fileId? }  (más reciente)
POST /api/v2/calls/:id/destroy                       → cierra la sala (sube pendientes + destruye)
```

**SDK:**
```ts
const call = await eb.calls.create({ room: "entrevista" });
// comparte call.roomUrl
await eb.calls.record(call.sandboxId, { room: call.room });
const { url } = await eb.calls.stop(call.sandboxId);
// url = MP4 permanente en Files
const st = await eb.calls.status(call.sandboxId);  // { recording, participants[] }
const recs = await eb.calls.files();               // grabaciones en Files
const t = await eb.calls.transcript(call.sandboxId); // { status, text } — texto inline
await eb.calls.destroy(call.sandboxId);            // cierra la sala
```

**Flujo WhatsApp/chat (una sola tool):**
```
call_create()               → { sandboxId, roomUrl }
[Ghosty manda roomUrl]
call_record({ sandboxId })  → grabando…
call_stop({ sandboxId })    → { url }  ← link permanente al MP4
```

**UI en sala:**
- Green room al entrar (preview + selector de cámara/mic)
- Pantalla compartida → grande; cámaras → tira lateral; click para destacar
- Grabación visible para todos (punto rojo + cronómetro)
- Suena al entrar/salir y al iniciar/detener grabación
- Funciona en móvil

**Notas:**
- Una grabación activa por sandbox; para sesiones paralelas usa `call_create` por separado
- Transcript via Whisper EMBEBIDO en la caja (español, on-device, sin costo extra ni proveedor externo). El .txt llega async tras `call_stop`; consúltalo con `call_transcript`
- MP4 + .txt en Files permanentemente aunque la VM se destruya

---

## Hosting — Sandboxes permanentes (always-on)

Un sandbox efímero se auto-destruye al TTL. Una **sandbox permanente** corre 24/7 y se cobra **flat en MXN/mes**. Mismo recurso, mismo `sandboxId` — "permanente" es solo un flag + cobro. Grupo MCP: `hosting`.

**NO necesitas plan de pago para hostear.** Hosting se factura solo, con su propia suscripción: alguien en Free paga su caja y nada más. El plan sigue gateando IA, storage y flota; dejó de gatear hosting.

`create_machine` devuelve una de dos cosas:
- **con plan activo** → la máquina, aprovisionada al instante, cobrada en la misma factura del plan.
- **sin plan** → `{ checkoutUrl }`. Le pasas ese link al cliente; **la máquina se crea sola cuando el pago se confirma** (nada corre gratis mientras tanto). Después de que pague, `list_machines()` la muestra.

Cancelar la suscripción de una máquina NO toca tu plan, y viceversa: son cobros independientes. Al cancelar, la caja pasa a borrado suave con 7 días de gracia y se toma un backup final.

### Catálogo de tiers
`GET /machines/tiers` · MCP: `list_machine_tiers()` · SDK: `eb.machines.tiers()`
Tiers (vCPU/RAM/NVMe → MXN/mes shared):
- nano 1/256MB/1GB → $49
- micro 1/1GB/2GB → $99
- estandar 2/2GB/16GB → $449
- focus 4/8GB/32GB → $690 (reserved $1,725)
- performance 8/16GB/64GB → $1,290 (reserved $3,225)
- performance-4x 16/32GB/128GB → $4,980 (reserved $12,450)
Para una app Node 24/7 el piso real es `micro`: `nano` (256MB) corre un binario estático o un side-project, pero NO aguanta un build de Node. Disco add-on: +100GB NVMe = $99/mes (apilable). CPU **reserved** (piso garantizado) solo desde focus.

### Crear sandbox permanente
`POST /machines` · Body: `{ tier, cpuMode?, sleep?, diskAddonsGB?, template?, name? }`
MCP: `create_machine({ tier })` · SDK: `eb.sandboxes.createPermanent({ tier })` (o `eb.machines.create({ tier })`)
Devuelve el record con `sandboxId`, `tier`, `monthlyMxn`, `status`. Lo operas igual que cualquier sandbox (exec, files, expose_port, dominios) por su `sandboxId`.

### Duerme sin visitas
`sleep: true` al crear, o después: `PATCH /machines/:sandboxId` · Body: `{ sleep }` · MCP: `set_machine_sleep({ sandboxId, sleep })` · SDK: `sb.setSleep(true)`
La VM entera (disco + proceso en memoria) se suspende tras 5 min sin tráfico y la siguiente visita la despierta en ~200 ms donde se quedó — sin cold start. Cuesta lo mismo que shared (micro $99 MXN/mes) y es el único modo que se vende para web apps nuevas. Requiere template `node` y CPU shared. No sirve para crons, colas o websockets que deban correr sin visitas. Al despertar, las conexiones salientes (BD externa) pueden venir muertas: la app debe reconectar.

### Promover un efímero a permanente
`POST /machines` · Body: `{ fromSandboxId, tier }`
MCP: `make_permanent({ sandboxId, tier })` · SDK: `sb.makePermanent(tier)`
Conserva el MISMO `sandboxId`, desarma el reaper y arranca el cobro.

### Listar / liberar
MCP: `list_machines()` · SDK: `eb.machines.list()` — tus sandboxes permanentes con `tier` + `monthlyMxn`.
`DELETE /machines/:sandboxId` · MCP: `release_machine({ sandboxId })` · SDK: `sb.release()` — quita el cobro (prorrateado) y destruye la VM. **Destructiva**, idempotente.

El plan es el gate de acceso; cada sandbox factura aparte. Si tu plan se cancela, tus sandboxes se suspenden.

### launch_app — una app en producción en UNA llamada
El equivalente a `fly launch`. Provisiona la máquina, mete el código, buildea, arranca, expone URL pública HTTPS, **publica el release de recuperación** y opcionalmente pega el dominio del cliente. Devuelve `{ url, releaseId, domain.dns }`.

**Usa esto en vez de encadenar create + deploy + expose + domain a mano.** Hecho a mano, el paso que se salta es el release — y una máquina sin release no se puede reconstruir si muere.

Fuente: exactamente UNA de las tres.
- `repo` — git clone. El camino reproducible.
- `archiveUrl` — URL de un `.tar.gz`/`.zip` de la app; para cuando el cliente la sube **desde su compu** y todavía no tiene repo. Acepta zip y tarball, y aplana el folder anidado que suele traer un zip.
- `sandboxId` — el agente ya escribió la app dentro de esa caja; haz el resto.

MCP: `launch_app({ repo | archiveUrl | sandboxId, tier?, port?, dataPaths?, domain? })`
REST: `POST /machines/launch` · SDK: `eb.machines.launch({ … })`

Defaults: `tier: "micro"` (nano son 256MB — NO aguanta un build de Node), `template: "node"` (Node 24 + npm/pnpm; `ubuntu` NO trae Node), `appDir: "/app"`, `buildCommand: "(npm ci || npm install) && npm run build"`, `startCommand: "npm start"`, `port: 3000`.

**Variables de la app (no secretas)** van en `env`: `{ PORT: "4000", API_URL: "…" }`. Se exportan antes del build y del arranque; los secretos de la bóveda ganan por nombre. Las llaves deben ser identificadores de shell (`A-Z_0-9`).

**Lo que aprendimos con una app real (React Router v7 + Express 5)**:
- `npm start` con `node --env-file=.env server.js` **muere en la caja**: no hay `.env`. Arranca con `startCommand: "node server.js"` y pasa la config por `env` + secretos.
- Express 5 rechaza `app.all("*", …)` (path-to-regexp 8). Usa `app.use(handler)`.
- Repos públicos de GitHub clonan sin token. Uno privado: URL **limpio** + el token aparte en `repoToken` (acepta `$secret:NOMBRE`). **No metas el token en el URL** (`https://x-access-token:TOKEN@…`): se rechaza con 422 `RepoUrlHasCredentials`, porque git lo guardaría en `.git/config` dentro de la caja.

⚠️ **PHP: ninguna URL que termine en `.php` responde.** El borde de EasyBits contesta 404 a toda ruta `.php`/`.asp`/`.jsp` y a los dotfiles (`/.env`, `/.git`, `wp-admin`…) antes de que lleguen a la caja: es lo que más sondean los escáneres y cada sonda despertaba cajas dormidas. PHP sí funciona con front controller y URLs limpias (Laravel, Symfony, Slim: `/login`, no `/login.php`); WordPress clásico o páginas como `/contacto.php` no. `launch_app` lo avisa en `warnings` si detecta `composer.json` o archivos `.php`.

**Redesplegar la misma máquina desde el repo** (el flujo directo, sin workflow): primero los secretos, luego `launch_app({ sandboxId, repo, … })`. Publica un release nuevo (v2, v3…) y reinicia la app en ~18 s. `sandboxId` es el DESTINO; `repo` la fuente.

**Tiempos medidos** con una app React Router v7 real (204 MB de `node_modules`, release de 49.5 MB), en tier `micro`:

| paso | tiempo |
|---|---|
| provisionar la caja | 3.7 s |
| `npm ci` + build dentro de la caja (primer deploy) | 6.9 s |
| publicar el release `prebuilt` | 11.3 s |
| **redeploy a una caja limpia** | **12.0 s** |

Una app más pesada tarda más, sobre todo en bajar el release. Con `prebuilt: true` el deploy no ejecuta build: baja, extrae y arranca.

⚠️ **Buildea DENTRO de la caja, no en tu máquina.** Un `node_modules` con módulos nativos (sharp, better-sqlite3) compilado en macOS revienta en Linux. El primer deploy paga el `npm ci`; a partir de ahí publicas `prebuilt` y cada deploy es de segundos.

Si el build falla, la máquina que creó `launch_app` se libera sola: no te deja pagando una caja rota. Una caja que tú pasaste por `sandboxId` NUNCA se libera; si le mandaste código nuevo (`repo` o `archiveUrl`) y el build falla, **vuelve sola al release que estaba sirviendo** — el sitio sigue en pie y el error lo dice (`rolledBackTo`).

**Sin plan de pago también funciona**: si la cuenta no tiene plan, `launch_app` (y `create_machine`) devuelven `{ checkoutUrl }` en vez de fallar. Le pasas ese link al cliente; cuando paga, la máquina se crea sola y aparece en `list_machines()`. Entonces vuelves a llamar `launch_app` con su `sandboxId` para desplegar encima.

### Releases — hacer la caja reconstruible
Fly/Vercel tratan el disco como desechable porque el deploy lo reconstruye desde una imagen. Aquí la app se escribe DENTRO de la caja, así que sin un release una caja muerta se lleva la app, no solo los datos. Un **release** es un tarball versionado del código en almacenamiento durable + un **runspec** que dice cómo construirlo y arrancarlo.

**Release = CÓDIGO. Backup = DATOS.** Una caja recreada desde un release arranca vacía.

1. `set_machine_runspec({ sandboxId, appDir, buildCommand?, startCommand?, unit?, port?, dataPaths? })` — obligatorio antes del primer deploy. `dataPaths` es lo que respalda el backup diario: sin eso la máquina NO tiene nada respaldado. No metas secretos en `env` (se guarda y viaja dentro de cada tarball).
2. `deploy_machine({ sandboxId, message? })` → publica release. SDK: `eb.machines.deploy(id)`
3. `list_machine_releases({ sandboxId })` · `rollback_machine({ sandboxId, releaseId })` — vuelve a una versión anterior en la MISMA caja. **Sin rebuild**: un release publicado por `launch_app` lleva su build (`prebuilt`), así que rollback y redeploy son bajar + extraer + arrancar. A cambio el tarball pesa más (lleva `node_modules`).
5. `get_machine_logs({ sandboxId, lines?, grep? })` — el log de LA APP (últimas N líneas): el journal de la unit si el runspec tiene una, o el archivo al que escribe el `startCommand`. Primer lugar a mirar cuando el deploy dijo que arrancó y el sitio no contesta. SDK: `eb.machines.logs(id, { lines })`.
4. `redeploy_machine({ releaseId, tier?, replaceSandboxId? })` — construye una caja NUEVA. Sirve para dos cosas: recuperar una máquina muerta y **cambiar de tier** (no hay resize en caliente — se recrea). `replaceSandboxId` libera la vieja al confirmar que la nueva quedó corriendo; sin él pagas las dos.

REST: `PUT /machines/:id/runspec` · `POST|GET /machines/:id/releases` · `POST /machines/:id/rollback` · `POST /machine-releases/:releaseId/redeploy` (colección aparte: funciona aunque la máquina original ya no exista) · `GET /machines/:id/logs?lines=200&grep=`.

### Backups — incluidos, 7 días
Cada noche se copian los `dataPaths` del runspec a almacenamiento durable **fuera del host**, 7 días de retención, **sin costo extra** (mismo trato que Fly da en volúmenes). No se respalda el sistema operativo: se reconstruye del template.

- `list_backups({ sandboxId })` · `create_backup({ sandboxId })` — el segundo, antes de algo riesgoso.
- `restore_machine_from_backup({ backupId, targetSandboxId?, confirm: true })` — **sobreescribe datos**, por eso exige `confirm`. Restaurar sobre la caja origen toma un backup previo automático. Con `targetSandboxId` restauras a una caja nueva (p.ej. recién hecha con `redeploy_machine`).

Dos límites dichos de frente: el RPO es de **24 horas**, y el backup se toma del filesystem en caliente, así que una DB escribiendo durante la copia puede quedar inconsistente — cada backup reporta su nivel en `consistency`. Si la app tiene DB, tenla fuera de la caja (Databases de EasyBits, Atlas) o para el servicio antes de `create_backup`.

Recuperación completa de una máquina = `redeploy_machine` (código) + `restore_machine_from_backup` (datos).

### Variables secretas de la app
Tu app necesita su `DATABASE_URL`, su `STRIPE_SECRET_KEY`. **No las metas en `runspec.env`**: eso se guarda en la base y viaja dentro de cada tarball de release. La API las rechaza por nombre.

`PUT /machines/:sandboxId/secrets` · Body: `{ "DATABASE_URL": "...", "JWT_SECRET": "..." }`

Los valores se guardan cifrados en tu bóveda y en el runspec queda solo la LISTA de nombres. Se materializan dentro de la máquina —en un archivo que solo root puede leer— justo antes de construir y de arrancar. No entran al release: una caja reconstruida desde un tarball sigue sin llevarlos dentro, pero sabe cuáles pedir.

- `GET /machines/:id/secrets` → `{ secretNames, inVault }`. Nombres, nunca valores: un secreto guardado no se vuelve a leer por API.
- `DELETE /machines/:id/secrets?name=DATABASE_URL` → deja de inyectarlo (el valor sigue en la bóveda).

**Cada máquina tiene los suyos.** Un secreto que cargas en una máquina vale sólo para ella: dos apps de la misma cuenta pueden tener cada una su `JWT_SECRET` o su `FACTURAPI_KEY` (varios negocios, una cuenta). Si la máquina no tiene uno propio con ese nombre, recibe el de tu bóveda general. Un `redeploy_machine` se los lleva a la caja nueva.

**La bóveda general** (la de tu cuenta, la misma de `secret_set` y de /dash/developer/secrets) también tiene REST: `GET /secrets` → `{ items:[{id, name, createdAt, lastUsedAt}] }` (sólo nombres), `POST /secrets` `{ name, value }` → 201 (mismo nombre = reemplaza; `[A-Z_][A-Z0-9_]*`), `DELETE /secrets/:name` → `{ deleted }`. SDK: `eb.listSecrets()`, `eb.setSecret(name, value)`, `eb.deleteSecret(name)`. CLI: `easybits secrets ls|set|rm`.

Surten efecto en el **siguiente despliegue**, no al vuelo. Rotar un secreto es cambiarlo aquí y volver a desplegar. Si el runspec declara uno que no está en la bóveda, el deploy falla diciendo cuál — mejor que ver la app morir al conectar.

### Reportes programados con los datos de tu app

Caso típico: cada mañana quieres un reporte de la operación (ventas, pedidos por estado, citas) sacado de la base de **tu app**. La regla que manda: la base de una app desplegada vive detrás del host (`DATABASE_URL=http://172.20.0.1:8090/v1/db/<app>/`, el host firma cada petición) y **sólo se alcanza desde dentro de su máquina**. Así que el reporte se calcula en la app; lo de fuera sólo decide **cuándo**.

**1. Una ruta de reporte en tu app**, protegida con un secreto propio de esa máquina:

```bash
printf 'REPORT_SECRET=%s\n' "$(openssl rand -hex 24)" | easybits machines secrets set <maquina> --dotenv -
easybits machines restart <maquina>     # los secretos aplican al reiniciar o redesplegar
```

```ts
// Con el cliente que ya usa tu app para su base (p. ej. @libsql/client con DATABASE_URL).
app.post("/api/reportes/operacion", async (req, res) => {
  if (req.headers.authorization !== `Bearer ${process.env.REPORT_SECRET}`) return res.status(401).end();
  const { rows } = await db.execute(
    "SELECT estado, COUNT(*) AS pedidos, SUM(total) AS monto FROM pedidos WHERE creado >= date('now','-1 day') GROUP BY estado"
  );
  res.json({ periodo: "últimas 24 h", filas: rows });
});
```

**Los números los calcula el código (SQL), nunca un modelo.** Un modelo que suma filas sueltas tarde o temprano inventa una cifra.

**2. La salida.** JSON o CSV en la respuesta; un PDF con `structured_doc` (REST/MCP, sin navegador); guárdalo en el bucket de la app o en Files de EasyBits y, si hace falta, mándalo por correo desde la app.

**3. Quién lo dispara:**
- **Temporizador dentro de la app** (node-cron o `setInterval`): sirve sólo si la máquina **no duerme** (`sleep` apagado). Con sleep, el temporizador se detiene con la caja.
- **Desde fuera, llamando la URL** con el secreto: funciona aunque la máquina duerma (la petición la despierta). Por ejemplo, un workflow `schedule:` de GitHub Actions con `curl -fsS -X POST -H "Authorization: Bearer $REPORT_SECRET" https://<tu-dominio>/api/reportes/operacion`.

**4. Con un agente («casi automatización»).** Un agente de Ghosty con turno programado llama esa ruta y redacta el resumen para quien lo lee (qué subió, qué se atoró, qué revisar):

```bash
ghosty schedule add <agente> "Llama POST https://<tu-dominio>/api/reportes/operacion (Bearer $secret:REPORT_SECRET) y mándame el resumen de ayer" --at 2026-10-18T08:00-06:00 --every 24h
```

El agente recibe los números ya calculados: interpreta y redacta, no cuenta.

### Desplegar desde GitHub en cada push
Un webhook de GitHub y listo: cada push a la rama de la máquina la redespliega sola. Sin workflow, sin Actions.

**1. Despliega una vez desde el repo.** URL limpio y, si es privado, el token aparte (un PAT *fine-grained* con **Contents: Read** sobre ese repo):

```bash
curl -X POST https://www.easybits.cloud/api/v2/machines/launch \
  -H "Authorization: Bearer $EASYBITS_API_KEY" -H "Content-Type: application/json" \
  -d '{"repo":"https://github.com/usuario/repo.git","branch":"main",
       "repoToken":"github_pat_…","tier":"micro","port":3000}'
```

El token se guarda solo en tu bóveda (`GIT_TOKEN_<id>`); si ya lo tenías ahí, pasa `"$secret:NOMBRE"`. La máquina recuerda de qué repo y rama salió (`runspec.source`; del token sólo el NOMBRE del secreto).

**2. Enciende el push-deploy** y pega lo que devuelve en GitHub → *Settings → Webhooks → Add webhook*:

```bash
curl -X POST https://www.easybits.cloud/api/v2/machines/$SANDBOX_ID/push-deploy \
  -H "Authorization: Bearer $EASYBITS_API_KEY"
# → { "webhook": { "url": "…/github-hook", "secret": "…" }, "steps": [ … ] }
```

*Payload URL* = `webhook.url`, *Content type* = `application/json`, *Secret* = `webhook.secret` (sólo se muestra esta vez; volver a llamar lo rota), *Just the push event*. GitHub manda un `ping` y debe salir en verde.

MCP: `push_deploy({ sandboxId })` · SDK: `eb.machines.enablePushDeploy(id)` · apagar: `DELETE /machines/:id/push-deploy` · estado: `GET`.

Qué pasa en cada push:
- Sólo la rama de la máquina despliega; las demás se ignoran.
- Contesta 202 al instante y construye en segundo plano, **dentro de la caja** (Linux: los módulos nativos compilan bien). Queda un release por push con el mensaje del commit, así que el historial y el rollback de siempre siguen sirviendo.
- **Si el build falla, la máquina vuelve sola a la versión que estaba sirviendo** y te llega un correo con la cola del error. Un push roto no tumba el sitio.
- Dos pushes seguidos corren dos deploys, no más: los intermedios se colapsan en el último.

**Si tu build no cabe en la caja** (bundlers que piden 4 GB), constrúyelo en el runner de GitHub y manda el resultado: `npx @easybits.cloud/cli init` escribe ese workflow (build en el runner → `POST /files` → `POST /machines/launch` con `{ sandboxId, archiveUrl, prebuilt: true }`). El runner es Linux x64 como la microVM; **construir en una Mac sí rompe** módulos nativos como sharp o better-sqlite3.

### Dashboard (UI)
También se administran desde `/dash/hosting`: cada sitio con su estado y su dirección, y al abrirlo cuatro pestañas — **Dominios** (con el registro DNS que hay que crear y si ya resuelve), **Versiones** (con vuelta atrás en un clic), **Variables** y **Registro** (las últimas líneas del log). Desde ahí también se pausa y se da de baja.

### Tus crons (cualquier cuenta)
Cada cuenta programa sus propias tareas: **pegarle a una URL** o **mandarle un mensaje a uno de tus agentes de la flota**. Las despacha EasyBits cada minuto; cada ejecución queda registrada (hora, duración, status, resumen y error). Se repiten (`cron`, mínimo cada 5 min) o se ejecutan **una sola vez** (`at`/`in`). Límite por plan: Byte 3, Mega 20, Tera 100 (los de una sola vez que ya se ejecutaron no cuentan). Zona horaria IANA (default `America/Mexico_City`). Tras 20 fallas seguidas el cron se apaga solo.

- `GET /api/v2/crons` — `{ items, nextCursor, hasMore, total }` (`?include=agents` añade `agents:[{id,name}]`).
- `POST /api/v2/crons` — `{ name, cron | at | in, until?, times?, timezone?, enabled?, action, dryRun? }`. Horario: `cron` (5 campos, se repite; `until` = última fecha, `times` = cuántas ejecuciones más) **o** `at` (una sola vez: ISO con zona, ISO sin zona —se lee en `timezone`— o `"23:00"` = hoy) **o** `in` (`"10m"`, `"2h"`, `"1h30m"`, `"1d"`). Un `at` que ya pasó → 400 (no se adivina «mañana»). Al ejecutarse, uno de una sola vez queda `enabled:false, done:true` y se conserva con su historial. `dryRun:true` valida todo sin crear y responde `{ dryRun, schedule, timezone, nextRuns:[{at, local}], in }` (las próximas 3). La respuesta trae `kind` (`once`|`recurring`), `nextRunAt` (ISO), `nextRunLocal` (en la zona del cron) e `in` («12h 54m»). `action` es `{ type: "http", url, method?, body?, secretName?, secretHeader? }` (https pública; `secretName` es un secreto de tu bóveda que viaja como header, `Authorization: Bearer …` por default; no sigue redirecciones) o `{ type: "agent_turn", fleetAgentId, prompt, groupId? }` (conversación `cron:<id>` por default) o `{ type: "exec", command, files?, secrets?, template?, timeoutSeconds? }`: caja efímera (`python` default · `node` · `bun` · `ubuntu`) que monta `files` (`[{ path, content, encoding? }]`, ruta relativa = `/root/job`, `~/` = `/root`; hasta 20 y 512 KB), ejecuta `command` en `/root/job` (éxito = exit 0, `timeoutSeconds` 300 por default, tope 600) y se destruye. `secrets: [{ name, file? }]`: sin `file` llega como variable `name`; con `file` se escribe ahí (chmod 600) y `<name>_FILE` apunta a la ruta. El valor sale de `secretValues` (top-level: `{ NOMBRE: valor }`, se guarda cifrado **acotado a ese cron**: ningún otro cron, agente ni máquina lo lee, se borra con el cron y nunca regresa) o de tu bóveda. Desde el copiloto del panel `secretValues` se rechaza (su propuesta se guarda tal cual).
- `GET|PATCH|DELETE /api/v2/crons/:id` — `PATCH { enabled: false }` lo pausa; `PATCH { at }` lo vuelve de una sola vez (y reprograma uno que ya se ejecutó), `PATCH { cron }` recurrente; `until`/`times` en `null` los quitan.
- `POST /api/v2/crons/:id/run` — ejecutar ahora (202, en segundo plano, `trigger: "manual"`). `GET /api/v2/crons/:id/runs/next?wait=50&after=ISO` — long-poll: espera hasta 50 s a que TERMINE la siguiente ejecución (la que va en curso, o una que empiece después de `after`) y la regresa con `logs`: `{ run, after, running, nextRunAt, nextRunLocal }`; con `run: null` vuelve a llamar con el mismo `after`; 409 si no hay nada que esperar. `notify` (en `POST`/`PATCH`): `{ on?: "failure"|"always", push?: true | { secret }, email?: true }` — push a la app de Ghosty con el token personal del secreto `GHOSTY_PUSH_TOKEN` (400 si no existe), correo sólo a la cuenta dueña; `null` lo quita. Cada ejecución emite además los webhooks `cron.run.succeeded` / `cron.run.failed`. `GET /api/v2/crons/:id/runs` — historial; cada ejecución trae `exitCode` (en `exec`) y con `?logs=1` también `logs` (cola de stdout+stderr, 64 KB).

```bash
curl -X POST https://www.easybits.cloud/api/v2/crons -H "Authorization: Bearer $EASYBITS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ping diario","cron":"30 8 * * *","action":{"type":"http","url":"https://mi-app.com/api/tarea","method":"POST"}}'
```

Un `exec` puede correr con la configuración de una de tus máquinas: `action.machine` (id o nombre; CLI `--machine`) le pasa su `runspec.env` y sus secretos (`DATABASE_URL`, `EASYBITS_DB_URL`…) en cada ejecución, sin copiarlos. `GET /api/v2/crons?include=agents,machines` lista tus máquinas.

MCP: `list_my_crons` (con `id`: `limit` hasta 50 ejecuciones y `logs:true`; los crons se nombran por id), `create_cron` (con `at`/`in`/`until`/`times`/`dryRun`, `exec` y `secretValues`), `update_cron` (también `notify`), `delete_cron`, `run_my_cron`, `wait_cron_run` (grupo `core`). Panel: sección «Mis crons» en `/dash/developer/crons`. Para el resultado: con servidor, los webhooks `cron.run.*`; desde una terminal, `notify.inbox` (abajo) o `crons wait`.

En el panel (`/dash/developer/crons`) está **Bittor**, un agente de la flota por cuenta que usa estas mismas tools: arma el cron a partir de lo que le pides en palabras, explica por qué falló uno y lee tus bases de datos (sólo SELECT) para responder sobre tus datos. Nunca cambia nada por su cuenta: cada alta, cambio, borrado o ejecución sale como tarjeta y tú la apruebas con tu sesión. Para crearlos desde un agente de código, la skill `easybits-crons`.

### Buzones de eventos (webhook propio que llega a tu terminal)
Un buzón es una URL de webhook de EasyBits: cualquiera le escribe con `POST` (tu cron, Stripe, GitHub, un formulario) y lo que llega **te alcanza en vivo** si estás escuchando o **te espera guardado** si no. Sirve para un agente en una terminal, que no tiene URL pública. Retención 7 días por default (1-30), hasta 1,000 eventos por buzón, cuerpo hasta 256 KB; límite por plan Byte 3, Mega 20, Tera 100.

- `POST /hooks/<token>` (público; el token ES la autorización para escribir, quien tenga la URL puede escribir) — cualquier cuerpo; responde `202 { ok, seq }`. Se guardan los headers salvo credenciales (`authorization`, `cookie`, `x-api-key`…), así que una firma como `stripe-signature` llega.
- `GET|POST /api/v2/inboxes` — lista (`{ items:[{ id, name, url, unread, lastSeq, lastEventAt, retentionDays, createdAt }] }`) / crea `{ name, retentionDays? }`. `GET|PATCH|DELETE /api/v2/inboxes/:id` (id o nombre); `POST /api/v2/inboxes/:id/rotate` = URL nueva (la vieja deja de aceptar).
- `GET /api/v2/inboxes/:id/events?since=&limit=&ack=` — sin `since`, lo **no leído**; `ack` (default 1) marca como leído hasta lo entregado. `{ items:[{ seq, source (http|cron), method, contentType, headers, body, json?, receivedAt }], cursor, unread }`.
- `GET /api/v2/inboxes/:id/events/stream?since=` — SSE en vivo: `event: inbox` por evento (`id` = `seq`; reconecta con `Last-Event-ID`). Escuchar no marca como leído.
- Crons: `notify: { inbox: "<id o nombre>", on: "always" }` (se guarda el id) deja cada resultado como evento `{ event: "cron.run.succeeded"|"cron.run.failed", cronId, name, runId, ok, exitCode, summary, error, … }`.

```bash
easybits inbox create builds                         # imprime la URL
easybits inbox listen builds                         # en vivo: una línea JSON por evento (Monitor, jq)
easybits inbox read builds                           # lo que llegó mientras no estabas; marca como leído
easybits crons update "Revisión 1.0.10" --notify inbox:builds --notify-on always
```

MCP: `inbox_create`, `inbox_list`, `inbox_read`, `inbox_update`, `inbox_rotate`, `inbox_delete` (grupo `core`). SDK: `listInboxes`, `getInbox`, `createInbox`, `updateInbox`, `rotateInbox`, `deleteInbox`, `readInbox`, `listenInbox`; `waitCronRun(id, { timeoutMs, after })` regresa el último `after` para seguir sin perder la ejecución. Al empezar una sesión, un agente lee sus buzones con `inbox_read` para ver qué pasó mientras no estaba.

### Tareas programadas de la plataforma (sólo administradores)
Los respaldos diarios de máquinas, su rotación, el monitor de hosting (cada 5 min) y las purgas semanales son tareas programadas de EasyBits. Su horario vive en el código y cada ejecución queda registrada (hora, duración, quién la disparó, resumen y error). Se ven y operan en `/dash/admin/crons` o por API. Requieren scope `ADMIN` **y** ser administrador de la plataforma; cualquier otra cuenta recibe 403.

- `GET /api/v2/admin/crons` — `{ items }`: por tarea `key`, `name`, `path`, `cron`, `schedule` (en palabras), `status` (`al-dia` · `tarde` · `fallo` · `pausado` · `nuevo`), `paused`, `running`, `nextRunAt`, `lastRun` y `recent` (últimas 20, la más reciente al final). «tarde» = pasó la hora programada y sigue dentro de la gracia (10 % del periodo, mínimo 5 min); «fallo» = pasó la gracia o la última ejecución terminó con error.
- `GET /api/v2/admin/crons/:key/runs` — últimas 50 ejecuciones con el error completo.
- `POST /api/v2/admin/crons/:key/run` — la ejecuta en segundo plano (`trigger: "manual"`) y responde 202 al instante. 409 si ya está en ejecución o en pausa.
- `POST /api/v2/admin/crons/:key/pause` — `{ "paused": true | false }`. En pausa, la ruta responde `{ "skipped": "paused" }` sin ejecutarse.

#### Asistente del panel
En `/dash/admin/crons` hay un asistente (un agente de la flota, «Asistente de admin», uno por superusuario) que responde sólo con datos de estas tools MCP del grupo `admin-crons`:

- `list_crons` — el mismo estado que `GET /api/v2/admin/crons`.
- `get_cron_runs({ job, limit? })` — últimas ejecuciones con el error completo; con esto explica por qué falló una tarea.
- `run_cron({ job })` y `pause_cron({ job, paused })` — **no ejecutan**: crean una acción pendiente y devuelven `{ needs_confirmation: true, actionId, summary }` (éxito, no error, para que el modelo no reintente). El resumen se arma con el nombre real de la tarea, no con texto del modelo.

Las cuatro exigen ser superusuario; las dos que proponen piden scope `WRITE`, no `ADMIN`: la llave del asistente vive en su microVM y con `ADMIN` podría saltarse la confirmación llamando la API directa.

Confirmación: el chat habla [AG-UI](https://docs.ag-ui.com) (`POST /api/v2/admin/copilot/chat` con un `RunAgentInput` → SSE de eventos AG-UI; contrato en `docs/protocol.md` de ghostykit). Cada propuesta termina el run con `RUN_FINISHED` `outcome: { type: "interrupt", interrupts: [{ id, message, expiresAt, metadata: { tool } }] }`. Se contesta con otro POST al mismo hilo: `resume: [{ interruptId, status: "resolved", payload: { approved: true | false } }]`, usando **sólo la sesión** del superusuario que la propuso (cualquier `Authorization` → 403). Se ejecuta una sola vez (repetir el resume no vuelve a ejecutar) y vence a los 15 min (`RUN_ERROR` `interrupt_expired`). Un POST sin mensaje ni `resume` reconecta: `MESSAGES_SNAPSHOT` y el turno vivo o las tarjetas abiertas.

---

## Databases (SQLite-as-a-Service)

Create isolated SQLite databases for your agents and apps. Powered by sqld (libsql-server).

> **Skill para tu agente:** `npx skills add https://easybits.cloud` instala `easybits-cli`: `easybits db create|query|tables|ls|rm` con `--json`.

### List databases
`GET /databases`
Returns: `{ items: Database[] }`
SDK: `eb.listDatabases()`
MCP: `db_list`

### Create database
`POST /databases`
Body: `{ name: string, description?: string }`
Name must be alphanumeric/dashes/underscores, max 64 chars. Limit depends on plan (Byte: 3, Mega: 10, Tera: 20).
Returns: Database object.
SDK: `eb.createDatabase({ name, description? })`
MCP: `db_create({ name, description? })`

### Get database
`GET /databases/:dbId`
SDK: `eb.getDatabase(dbId)`
MCP: `db_get({ dbId })`

### Rename database
`PATCH /databases/:dbId`
Body: `{ name?: string, description?: string|null }` (name: letters, digits, `-`, `_`, max 64; 409 if you already have one with that name).
Only metadata: the id and the data do not change, so `db_*` calls by `dbId` keep working. Prompts that name the old name do not update themselves: `easybits db rename` rewrites them.

### Delete database
`DELETE /databases/:dbId`
Permanently deletes the database and all its data.
SDK: `eb.deleteDatabase(dbId)`
MCP: `db_delete({ dbId })`

### Query database
`POST /databases/:dbId/query`
Body: `{ sql: string, args?: any[] }`
Returns: `{ cols: string[], rows: any[][], affected_row_count: number, last_insert_rowid: string|null }`
SDK: `eb.db(name).query(sql, args?)`
MCP: `db_query({ dbId, sql, args? })`

### Batch execute
`POST /databases/:dbId/query`
Body: `{ statements: [{ sql, args? }] }` (max 20)
Returns: `{ results: Result[] }`
MCP: `db_exec({ dbId, statements })`

### Bulk import
`POST /databases/:dbId/query`
Body: `{ table: string, columns: string[], rows: any[][], onConflict?: "ignore" | "replace" }`
Up to 10,000 rows per request. Column/table names must be alphanumeric + underscores.
Returns: `{ imported: number, total: number }`
MCP: `db_import({ dbId, table, columns, rows, onConflict? })`

### Errores

Los errores de consulta traen `{ "error": "…", "code": "<código>" }`:

| Status | `code` | Qué pasó | Qué hacer |
|---|---|---|---|
| 400 | `SQL_ERROR` | La sentencia falló (sintaxis, tabla inexistente, constraint); `error` trae el mensaje de SQLite | Corrige el SQL |
| 409 | `DATABASE_STORAGE_MISSING` | La base existe en tu cuenta pero su almacenamiento no está en el servidor; sus datos no están disponibles | Bórrala y crea otra, o contacta a soporte |
| 502 | `DATABASE_BACKEND_ERROR` | El servidor de bases falló | Reintenta en un momento |
| 404 | — | La base no existe o no es tuya | Revisa el id con `GET /databases` |

### Database object
```json
{
  "id": "db123",
  "name": "my-app-db",
  "namespace": "db123",
  "description": "App data store",
  "createdAt": "2026-03-15T...",
  "updatedAt": "2026-03-15T..."
}
```

### Webhook events
- `database.created` — new database created
- `database.deleted` — database deleted

### Limits
- Max databases per plan: Byte 3, Mega 10, Tera 20
- Name: alphanumeric, dashes, underscores, max 64 chars
- Batch: max 20 statements per request

---

## Documents

AI-generated professional documents (reports, brochures, catalogs, proposals, CVs, and more) with parallel page generation, design directions, and automatic image enrichment.

### List documents
`GET /documents`
Returns: `{ items: Document[] }`
SDK: `eb.listDocuments()`
MCP: `list_documents`

### Get document
`GET /documents/:id`
Returns: Document with full page/section data.
SDK: `eb.getDocument(id)`
MCP: `get_document({ documentId })`

### Create document
`POST /documents`
Body: `{ name, prompt?, theme?, customColors?, sections? }`
Returns: Document object.
SDK: `eb.createDocument({ name, prompt?, theme?, customColors?, sections? })`
MCP: `create_document({ name, prompt?, theme?, customColors? })`

### Update document
`PATCH /documents/:id`
Body: `{ name?, prompt?, theme?, customColors?, sections? }`
SDK: `eb.updateDocument(id, { name?, prompt?, sections? })`
MCP: `update_document({ documentId, name?, prompt?, theme?, customColors? })` — metadata only; use page tools for content changes

### Delete document
`DELETE /documents/:id`
SDK: `eb.deleteDocument(id)`
MCP: `delete_document({ documentId })`

### Deploy document
`POST /documents/:id/deploy`
Publishes the document as a live website. Requires at least one page.
Returns: `{ url, websiteId, slug }`
SDK: `eb.deployDocument(id)`
MCP: `deploy_document({ documentId })`

### Unpublish document
`POST /documents/:id/unpublish`
Removes the website and reverts to draft status.
SDK: `eb.unpublishDocument(id)`
MCP: `unpublish_document({ documentId })`

### Set page HTML
MCP: `set_page_html({ documentId, pageId, html })`
Updates a single page's full HTML without replacing all pages. Preferred over `update_document` for single-page edits.

### Get page HTML
MCP: `get_page_html({ documentId, pageId })`
Returns the HTML and metadata (id, order, type, name) of a single page.

### Get section HTML (element within a page)
MCP: `get_section_html({ documentId, pageId, cssSelector })`
Returns the outerHTML of a specific element within a page, matched by CSS selector. Examples: `.hero`, `#pricing`, `section:nth-child(2)`, `div.stats`.

### Set section HTML (element within a page)
MCP: `set_section_html({ documentId, pageId, cssSelector, html })`
Replaces a specific element within a page (matched by CSS selector) with new HTML. Enables surgical edits without rewriting the entire page.

### Generate document (AI)
MCP: `generate_document({ documentId, prompt, skipCover? })`
Generates pages with AI via streaming. Use `skipCover: true` to add content pages to an existing document without regenerating the cover.

### Refine section (AI)
MCP: `refine_document_section({ documentId, sectionId, instruction })`
Makes surgical AI-powered changes to a specific page. Returns `{ success, sectionId, htmlLength }` — use `get_page_html` to retrieve the updated content.

### Regenerate page (AI)
MCP: `regenerate_document_page({ documentId, sectionId })`
Completely redesigns a single page while keeping the same content. Returns `{ success, sectionId, htmlLength }` — use `get_page_html` to retrieve the updated content.

### Add page
MCP: `add_page({ documentId, html?, afterPageIndex?, label? })`
Adds a new page to the document. Optionally provide HTML content and insertion position.

### Delete page
MCP: `delete_page({ documentId, pageId })`
Removes a page from the document. Cannot delete the last remaining page.

### Reorder pages
MCP: `reorder_pages({ documentId, pageIds })`
Reorder all pages. `pageIds` must contain every existing page ID exactly once.

### Enhance document prompt (AI)
MCP: `enhance_document_prompt({ name, prompt?, action? })`
SDK: `eb.enhanceDocumentPrompt(name, prompt?)`
Auto-generates a description from the document title (`auto-describe`) or improves an existing prompt with design suggestions (`enhance`).

### Get document directions (AI)
MCP: `get_document_directions({ prompt, pageCount?, sourceContent? })`
SDK: `eb.getDocumentDirections(prompt, { pageCount? })`
Generates 4 design directions (fonts, colors, mood, layout hints). Pass one to `generate_document` via the `direction` parameter.

### PDF to images
MCP: `pdf_to_images({ base64?, fileId?, maxPages? })`
Convert a PDF to PNG images (one per page). Accepts either `base64` (raw PDF in memory — no upload needed) or `fileId` (uploaded PDF). Returns image content items. **To clone a PDF as a document**: (1) `pdf_to_images` to get page images, (2) use vision to generate HTML per page following `get_docs('document-design')` rules, (3) `create_document` with sections.

### Get page screenshot
MCP: `get_page_screenshot({ documentId, pageIndex? })`
Takes a screenshot of a single document page. Returns a PNG image (letter-sized). Page index is 0-based (default 0). Requires Chrome installed locally — designed for Claude Code MCP usage. **Prefer this tool to verify edits visually** after modifying page HTML.

### Document object
```json
{
  "id": "doc123",
  "name": "Q1 Report",
  "prompt": "Quarterly business review for stakeholders",
  "theme": "modern",
  "customColors": { "primary": "#2563eb", "secondary": "#1e40af", "accent": "#f59e0b", "surface": "#ffffff" },
  "sections": [{ "id": "s1", "order": 0, "html": "<section>...</section>", "type": "cover", "name": "Cover" }],
  "status": "DRAFT",
  "pageCount": 5,
  "websiteId": null,
  "createdAt": "2026-03-15T...",
  "updatedAt": "2026-03-15T..."
}
```

### Section/page structure
```json
{
  "id": "section_abc",
  "order": 0,
  "html": "<section class='bg-primary text-on-primary'>...</section>",
  "type": "cover",
  "name": "Cover Page"
}
```

### Design directions
When creating documents from the dashboard, 4 design directions are generated first (fonts, colors, mood, layoutHint). Direction object:
```json
{
  "headingFont": "Playfair Display",
  "bodyFont": "Inter",
  "colors": { "primary": "#2563eb", "secondary": "#1e40af", "accent": "#f59e0b", "surface": "#ffffff" },
  "mood": "professional and clean",
  "layoutHint": "asymmetric with bold headers"
}
```

### Tips for agents
- Use `set_page_html` to update a full page — `update_document` is for metadata only (name, theme, colors).
- Use `add_page` / `delete_page` / `reorder_pages` for page management.
- Use `get_section_html` / `set_section_html` to read/edit a specific element within a page by CSS selector.
- Use `generate_document` with `skipCover: true` to add pages to an existing document.
- Use `refine_document_section` for surgical AI changes — then `get_page_html` to see the result.
- Use `enhance_document_prompt` to improve a prompt before generating.
- Use `get_document_directions` to get 4 design directions, then pass one to `generate_document`.
- Pages use semantic color classes (`bg-primary`, `text-on-surface`, etc.) — changing the theme updates all pages.
- Export to PDF: deploy the document and use `window.print()` or a headless browser on the live URL.

### Workflow
1. `enhance_document_prompt({ name })` — auto-generate a description from the title
2. `get_document_directions({ prompt })` — get 4 design directions
3. `create_document({ name, prompt })` — create a document
4. `generate_document({ documentId, prompt, direction })` — AI generates all pages
5. `refine_document_section({ documentId, sectionId, instruction })` — tweak individual pages
6. `deploy_document({ documentId })` — publish → live URL at `www.easybits.cloud/s/{slug}/`
7. `unpublish_document({ documentId })` — take down when done

### HTML Authoring Guide (for agents writing document HTML)

**Page structure** — every page is a `<section>` with fixed US Letter dimensions:
```html
<section class="w-[8.5in] h-[11in] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <!-- page content here -->
</section>
```

**ONLY use Tailwind CSS classes — NEVER inline styles.** The FloatingToolbar relies on class-based styling to read and modify elements. Inline styles (`style="..."`) break toolbar editing. The only exception is `font-family` for Google Fonts (via a `<style>` tag or inline on the `<section>`).

**Semantic color classes** (required — these respond to theme changes):
- Backgrounds: `bg-primary`, `bg-secondary`, `bg-accent`, `bg-surface`, `bg-surface-alt`
- Text: `text-on-primary`, `text-on-secondary`, `text-on-accent`, `text-on-surface`, `text-on-surface-muted`
- Borders: `border-primary`, `border-secondary`, `border-accent`, `border-surface`

**Contrast rules**: always pair backgrounds with their matching `text-on-*` class. Example: `bg-primary text-on-primary`, `bg-surface text-on-surface`.

**Google Fonts** — load via `<link>` in a preceding `<style>` block or at the top of the section:
```html
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap" rel="stylesheet">
<section style="font-family: 'Playfair Display', serif" class="w-[8.5in] h-[11in] ...">
```

**No responsive breakpoints** — documents are fixed at 8.5×11 inches. Do not use `sm:`, `md:`, `lg:` prefixes.

**Content must fit the page** — never exceed the section dimensions. No scrollbars, no overflow. If content is too long, split it across multiple pages.

**Available themes**: `minimal`, `calido`, `oceano`, `noche`, `bosque`, `rosa`. Each theme defines the semantic color values. Set via `update_document({ theme })` or `customColors` for custom palettes.

**Refine is per-section, not per-page-group** — `refine_document_section` edits a specific element or area within a single section's HTML. Pass `instruction` describing the surgical change (e.g., "change the title to Q2 Report", "make the chart larger") and the current `html` of that section. The AI returns the modified HTML for that section only.

**Images** — use `data-image-query="descriptive search term in English"` on `<img>` tags. The system auto-enriches them with Pexels stock photos:
```html
<img data-image-query="modern office teamwork" class="w-full h-64 object-cover rounded-lg" />
```

---

## Video Projects (animated video → MP4)

Stateful, document-style video: an ordered set of animated **scenes** that compile to an MP4. Each scene is a self-contained [HyperFrames](https://github.com/heygen-com/hyperframes) composition — you provide the scene **markup** (HTML, absolutely positioned, assets referenced as `assets/<name>`) plus an optional GSAP timeline snippet against a pre-declared paused `tl` (e.g. `tl.from('#title',{opacity:0,y:40,duration:0.6})`). Add **narration** text per scene → synthesized with kokoro (voice `em_santa`) and muxed automatically; scenes stretch to fit the voiceover. Rendering runs on an on-demand microVM and takes tens of seconds; the result lands in your Files as a public MP4.

Portrait 1080×1920 by default. Format presets: `portrait`/`story`/`reel`/`tiktok` (9:16), `square` (1:1), `landscape`/`youtube` (16:9), or custom `width`+`height`.

### List video projects
`GET /video-projects`
Returns: `{ total, items: VideoProjectSummary[] }`
SDK: `eb.listVideoProjects({ limit?, offset?, status? })`
MCP: `list_video_projects`

### Get video project
`GET /video-projects/:id`
Returns: project with full scene list (html, timeline, narration, durations) and assets.
SDK: `eb.getVideoProject(id)`
MCP: `get_video_project({ projectId })`

### Create video project
`POST /video-projects`
Body: `{ name?, format?: { preset }, width?, height?, fps?, theme?, customColors?, scenes? }`
Returns: VideoProjectSummary.
SDK: `eb.createVideoProject({ name, format?, scenes? })`
MCP: `create_video_project({ name?, format?, scenes? })`

### Update project (metadata)
`PATCH /video-projects/:id` — Body: `{ name?, theme?, customColors?, fps?, width?, height? }` (does not touch scenes).
SDK: `eb.updateVideoProject(id, patch)` · MCP: `update_video_project({ projectId, ... })`

### Delete project
`DELETE /video-projects/:id`
SDK: `eb.deleteVideoProject(id)` · MCP: `delete_video_project({ projectId })`

### Add scene
`POST /video-projects/:id/scenes`
Body: `{ html, timeline?, durationSec?, label?, narration?, narrationVoice?, afterIndex? }`
SDK: `eb.addVideoScene(id, { html, timeline?, durationSec?, narration? })`
MCP: `add_video_scene({ projectId, html, timeline?, durationSec?, narration? })`

### Edit scene
`PATCH /video-projects/:id/scenes/:sceneId` — pass only the fields you change. Changing `narration` re-synthesizes the voiceover on the next render.
SDK: `eb.setVideoScene(id, sceneId, patch)` · MCP: `set_video_scene({ projectId, sceneId, ... })`

### Delete / reorder scenes
`DELETE /video-projects/:id/scenes/:sceneId` — SDK: `eb.deleteVideoScene(id, sceneId)` · MCP: `delete_video_scene`
`PUT /video-projects/:id/scenes` Body: `{ sceneIds: [...] }` (every existing id, in order) — SDK: `eb.reorderVideoScenes(id, sceneIds)` · MCP: `reorder_video_scenes`

### Background music
`POST /video-projects/:id/audio` Body: `{ url, name? }` (or `url: null` to clear). Public audio URL; the box downloads and muxes it, auto-ducked under narration.
SDK: `eb.setVideoMusic(id, url, name?)` · MCP: `set_video_music({ projectId, url, name? })`

### Attach an asset (image/logo)
`PUT /video-projects/:id/audio` Body: `{ url, name?, type? }` — registers a named media asset the render box downloads into `assets/`; reference it in scene HTML as `assets/<name>`.
SDK: `eb.attachVideoAsset(id, { url, name? })` · MCP: `attach_video_asset({ projectId, url, name? })`

### Render
`POST /video-projects/:id/render`
Compiles the project, synthesizes pending narration, and renders to MP4 on the on-demand box. Synchronous (tens of seconds).
Returns: `{ status: "ready", file: { fileId, url, renderMs } }`
SDK: `eb.renderVideoProject(id)`
MCP: `render_video_project({ projectId })`

**Example (SDK):**
```ts
const p = await eb.createVideoProject({ name: "Launch reel", format: { preset: "reel" }, theme: "dark" });
await eb.addVideoScene(p.id, {
  html: `<div id="t" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:180px;font-weight:800">EasyBits</div>`,
  timeline: "tl.from('#t',{opacity:0,y:60,duration:0.7,ease:'power3.out'})",
  narration: "Bienvenido a EasyBits.",
});
const { file } = await eb.renderVideoProject(p.id); // → { fileId, url, renderMs }
```

---

## eve (Vercel) sobre EasyBits

[eve](https://eve.dev) es el framework open-source de Vercel para agentes: un agente es un directorio (instrucciones, tools, canales, schedules) y cada sesión es un run durable del Workflow SDK. Cuando un agente necesita ejecutar código, eve le pide una caja a un **provider de sandbox**. `@easybits.cloud/eve-sandbox` es ese provider para EasyBits: cada sesión de agente corre en su propia microVM, en tu cuenta, cobrada con tu plan.

**Versiones.** eve ≥ 0.64 declara sandboxes con providers (`EasybitsSandbox.environment()` + `environment.open()`); es lo que documenta esta página, probado con eve 0.65 y `@easybits.cloud/eve-sandbox` 0.2. Para eve ≤ 0.63 (`defineSandbox({ backend, bootstrap })`) fija `@easybits.cloud/eve-sandbox@0.1` y `@easybits.cloud/eve-world@0.1.1`.

**Dos rutas.** (1) **Gratis, sin mover tu servidor**: el servidor eve se queda donde está (Vercel, tu laptop con `eve dev`/`eve start`) y sólo las sesiones corren en EasyBits; cabe en el plan Byte (1 caja concurrente = una conversación a la vez, sesiones de 1 h, tamaño `s`). (2) **Todo en EasyBits**: el servidor eve en una caja `eve-nitro` + una caja hija por sesión = 2 cajas concurrentes → requiere **Mega** ($499 MXN/mes, 2 cajas) o **Tera** ($2,490 MXN/mes, 5 cajas). `@easybits.cloud/eve-world` (sección 3) sólo funciona con el servidor dentro de EasyBits (la URL de la base es de la red interna), así que es parte de la ruta 2.

### 1. Ruta gratis: sandboxes para agentes eve, servidor donde ya está

Requiere Node ≥ 24 en la máquina (lo exige eve). Crea el proyecto (o usa el tuyo) e instala el provider:

```bash
npx eve init app && cd app
npm i @easybits.cloud/eve-sandbox        # o: pnpm add @easybits.cloud/eve-sandbox
export EASYBITS_API_KEY=eb_...           # scope WRITE (y DELETE si eve debe borrar cajas y plantillas)
```

```ts
// agent/sandbox.ts
import { defineSandbox } from "eve/sandbox";
import { EasybitsSandbox } from "@easybits.cloud/eve-sandbox";

// Se prepara UNA vez en `eve build` y se captura como plantilla.
export const environment = EasybitsSandbox.environment({
  prepare: async (sandbox) => {
    await sandbox.run({ command: "git clone https://github.com/tu-org/herramientas /workspace/tools && cd /workspace/tools && npm ci" });
  },
});

// Cada sesión abre su propia microVM desde esa plantilla.
export default defineSandbox(() => environment.open());
```

El `export const environment` es obligatorio: `eve build` lo busca para preparar la plantilla antes de que exista cualquier sesión.

| eve | EasyBits |
|---|---|
| `prepare()` (corre en `eve build`; en `eve dev`, al primer acceso) | caja temporal `node` + `agent/sandbox/workspace/` → `/workspace` + skills → `~/.agents/skills` + tu `prepare(sandbox)` → **plantilla derivada** (`template-snapshot`, clave de contenido `eve:<recursos>` + hash de lo que cambia la imagen). Idempotente en el host: el primer build loguea `easybits: plantilla dt_… lista` y los siguientes `reusada` (medido: 9.7 s / 0.3 s) |
| `open()` → `start()` | `POST /sandboxes` con `templateKey`+`templateHash`: la caja nace con el `prepare` hecho (~5 s). `env` de `open()` va a `/etc/profile.d` (login shell); `networkPolicy` se aplica al host. 404 `DerivedTemplateNotProvisioned` / 409 `DerivedTemplateStale` → `SandboxTemplateNotProvisionedError` (corre `eve build`) |
| `resume()` | reabre la caja por `sandboxId` (despierta ~1 s si dormía). Si ya no existe, **falla** (contrato de eve: resume reconecta, no recrea) y eve vuelve a correr el selector. `recreateOnLoss: true` la recrea desde la plantilla |
| entre turnos | la caja sigue viva con siesta (`idleTtlSeconds` 600 → suspend, resume ~1 s) |
| `stop()` · `delete()` | suspend · destroy |
| `run` / `spawn` | `bash -lc` por `/bg`; stdout/stderr en streams, `kill()` señala al grupo |
| archivos | `/files/*`; rutas relativas ancladas en `/workspace`, `$HOME/…` se resuelve dentro de la caja |

Opciones: `EasybitsSandbox.environment({ prepare, apiKey, baseUrl, template: "node", timeoutSeconds, workingDirectory, runTimeoutSeconds, idleTtlSeconds, hardTtlSeconds, metadata, recreateOnLoss })` · `environment.open({ env, networkPolicy })`. La **política de egress por caja** usa el mismo shape que eve en Vercel: `"allow-all"`, `"deny-all"` o una allow-list por dominio (`{ allow: { "api.github.com": [], "registry.npmjs.org": [] } }`; `"*"` abre todo); va en `open({ networkPolicy })` o en caliente con `sandbox.setNetworkPolicy()`. El host la resuelve a IPs por microVM con refresco DNS, la persiste con la caja y la vuelve a aplicar al reanudar. **No soportado**: `transform` (inyectar headers en el firewall) — lanza error explícito. Fuera de eve, la misma política vive en `PUT/GET /sandboxes/:id/network-policy` · SDK `sb.setNetworkPolicy(policy)` · MCP `sandbox_set_network_policy`.

**Modelo.** El scaffold de `eve init` usa el AI Gateway de Vercel — `model: "spacexai/grok-4.7"` como string en `agent/agent.ts` — que fuera de Vercel pide `AI_GATEWAY_API_KEY`. Para un proveedor directo eve ya trae helpers, sin instalar nada: `model: anthropic()` (`import { anthropic } from "eve/models/anthropic"`, claude-sonnet-5, lee `ANTHROPIC_API_KEY`) u `openai()` de `eve/models/openai` (lee `OPENAI_API_KEY`).

**Arrancar.** `eve build` prepara la plantilla, así que necesita `EASYBITS_API_KEY` en el entorno (sin ella: `@easybits.cloud/eve-sandbox: falta apiKey`); es obligatorio antes de `eve start`. En local, `eve start` responde `401` incluso desde localhost (`localDev()` se ignora en producción): la sesión local entra con `eve dev --no-ui`, o con `httpBasic` como en la sección 2. En Vercel (o cualquier Node 24) `eve start` con `EASYBITS_API_KEY` en el entorno. La preparación usa una caja temporal que se destruye al capturar la plantilla (no ocupa cupo después); luego una caja hija por sesión, que duerme cuando no habla y no ocupa cupo dormida. En Byte, una conversación a la vez: la siguiente recibe `SandboxLimitReached` mientras la anterior esté despierta, o sube a Mega. Probado con una cuenta gratuita recién creada.

### 2. Ruta hospedada (Mega+): el servidor eve dentro de una caja

Template `eve-nitro`: Node 24, pnpm, `eve` CLI 0.65, git/curl/tar; `/data` es un volumen persistente de 4 GB (**no** el directorio de trabajo: `exec` arranca en `/`, haz `cd /data` explícito); puerto 3000. Como trae pnpm, `eve init` lo usa.

Seis pasos: crear la caja, crear la app dentro con `eve init` (o clona la tuya si ya existe) e instalar los paquetes, elegir modelo, poner auth, compilar y arrancar el servidor, exponer el puerto. Antes define la base y los headers en bash. Una caja recién creada está en `starting`: `exec`/`bg` sobre ella responden `409 SandboxNotReady`, espera a `status=running`. pnpm 12 bloquea el script de build de `cbor-extract` (lo trae `eve-world`): sin `--allow-build=cbor-extract` el `pnpm add` falla con `ERR_PNPM_IGNORED_BUILDS`.

```bash
B=https://www.easybits.cloud/api/v2; H=(-H "Authorization: Bearer $EASYBITS_API_KEY" -H "Content-Type: application/json")
SB=$(curl -s -X POST "$B/sandboxes" "${H[@]}" -d '{"template":"eve-nitro","timeoutSeconds":3600,"suspendOnIdle":true}' | jq -r .sandboxId)
until [ "$(curl -s "$B/sandboxes/$SB" "${H[@]}" | jq -r .status)" = running ]; do sleep 2; done
curl -s -X POST "$B/sandboxes/$SB/exec" "${H[@]}" -d '{"command":"cd /data && eve init app --non-interactive && cd app && pnpm add @easybits.cloud/eve-sandbox @easybits.cloud/eve-world --allow-build=cbor-extract","timeoutSeconds":600}'
```

**Escribir archivos en la caja.** `POST /sandboxes/:id/files/write` con `{ path, content, encoding? }` (texto, o `"base64"`); responde `{ ok, bytes }`. Así suben `agent/sandbox.ts` (el de la sección 1), `agent/agent.ts` y `agent/channels/eve.ts`:

```bash
curl -s -X POST "$B/sandboxes/$SB/files/write" "${H[@]}" -d "$(jq -n --rawfile c agent/sandbox.ts '{path:"/data/app/agent/sandbox.ts",content:$c}')"
```

**Modelo y world.** El mismo helper de la sección 1, más la línea del world (sección 3); la llave del proveedor va en el env de `eve start`:

```ts
// agent/agent.ts
import { defineAgent } from "eve";
import { anthropic } from "eve/models/anthropic";

export default defineAgent({
  model: anthropic(),   // lee ANTHROPIC_API_KEY
  experimental: { workflow: { world: "@easybits.cloud/eve-world" } },
});
```

**Auth del servidor.** En producción (`eve start`) la API HTTP exige auth: el scaffold trae `agent/channels/eve.ts` con `vercelOidc()`/`localDev()`/`placeholderAuth()` y la URL pública responde `401 Authorization is required for this route`. Edítalo, por ejemplo con basic auth:

```ts
// agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { httpBasic, localDev } from "eve/channels/auth";

export default eveChannel({
  auth: [localDev(), httpBasic({ username: "eve", password: process.env.EVE_PASSWORD! })],
});
```

Compila, arranca y expón. `eve build` prepara la plantilla de las sesiones, así que necesita `EASYBITS_API_KEY` en el `env` del exec (log `easybits: plantilla dt_… lista`; en builds siguientes `reusada`):

```bash
curl -s -X POST "$B/sandboxes/$SB/exec" "${H[@]}" -d '{"command":"cd /data/app && npx eve build","timeoutSeconds":600,"env":{"EASYBITS_API_KEY":"<key>"}}'
curl -s -X POST "$B/sandboxes/$SB/bg"   "${H[@]}" -d '{"command":"exec npx eve start","cwd":"/data/app","env":{"EASYBITS_API_KEY":"<key>","ANTHROPIC_API_KEY":"<key>","EVE_PASSWORD":"<pass>","PORT":"3000"}}'
curl -s -X POST "$B/sandboxes/$SB/expose" "${H[@]}" -d '{"port":3000}'   # → { url }
```

La caja `eve-nitro` nace con `EASYBITS_DB_URL` en su entorno (sin token): llega al shell, a `/exec` y a `/bg` sin que lo pases. Para retomar runs en una caja nueva, créala con el **mismo** valor (`"env":{"EASYBITS_DB_URL":"…"}` en el `POST /sandboxes`), que lees en `metadata.eve_db_url` (`GET /sandboxes/:id`) de la original. Sin él, eve cae a `world-local` (disco de la caja) sin avisar (sección 3).

La URL pública proxea todo el path (`/eve/` y `/.well-known/workflow/` llegan a Nitro sin configurar nada), pero eve pide la auth que declaraste: cada llamada va con `-u eve:$EVE_PASSWORD`. El proyecto va bajo `/data` para sobrevivir suspend/resume; si la caja se duerme, relanza `eve start` al despertar. El estado durable de eve vive por default en disco; para que sobreviva a la caja usa `@easybits.cloud/eve-world` (sección 3).

**Hablarle al servidor.** Crear una sesión devuelve `{ sessionId }`; el stream es NDJSON de eventos (`message.completed` trae la respuesta); el mismo id acepta más mensajes:

```bash
curl -s -u eve:$EVE_PASSWORD -X POST "$URL/eve/v1/session" -H "Content-Type: application/json" -d '{"message":"hola, ¿qué puedes hacer?"}'   # → { sessionId }
curl -s -u eve:$EVE_PASSWORD "$URL/eve/v1/session/$SESSION/stream"                                                                   # NDJSON; message.completed = respuesta
curl -s -u eve:$EVE_PASSWORD -X POST "$URL/eve/v1/session/$SESSION" -H "Content-Type: application/json" -d '{"message":"sigue"}'      # continúa la sesión
```

### 3. Estado durable en EasyBits DB (`@easybits.cloud/eve-world`)

Por default eve guarda sus runs, steps, hooks y streams en el disco de la caja (`.eve/.workflow-data`). `@easybits.cloud/eve-world` es un **World** del Workflow SDK sobre libSQL: el mismo estado vive en EasyBits DB, así que la caja del servidor se puede destruir y recrear sin perder un run a medias. Es un port 1:1 de `@workflow/world-postgres` (cola de entregas por lease en tabla) para `@workflow/world@5.0.0-beta.37`, la línea que trae eve 0.65 (para eve ≤ 0.63: `eve-world@0.1.1`).

```bash
pnpm add @easybits.cloud/eve-world --allow-build=cbor-extract   # npm i en otros entornos
```

```ts
// agent/agent.ts
import { defineAgent } from "eve";

export default defineAgent({
  model: /* ver sección 2 */,
  experimental: { workflow: { world: "@easybits.cloud/eve-world" } },
});
```

**Sin token que pegar.** Al crear una caja `eve-nitro` con `POST /sandboxes`, EasyBits genera la URL de su base (`eve-<id>`, creada al primer uso; el acceso lo resuelve el host por la identidad de la caja, la URL no lleva credencial) y la deja en `metadata.eve_db_url`. La caja nace con `EASYBITS_DB_URL` ya en su entorno; para retomar runs en una caja nueva, créala con el **mismo** valor. Env de `eve start`: `EASYBITS_API_KEY`, `ANTHROPIC_API_KEY` (o el de tu proveedor), `EVE_PASSWORD`, `PORT=3000`. Medido en producción con eve 0.65: servidor destruido, otro con el mismo `EASYBITS_DB_URL` listo en 21 s, y la misma sesión siguió con su memoria y su misma caja de sesión; con eve 0.58, un run de 8 pasos retomó en el paso 3 en una máquina nueva 59 s después de destruir la primera. Fuera de EasyBits, `WORKFLOW_LIBSQL_URL` + `WORKFLOW_LIBSQL_AUTH_TOKEN` apuntan a cualquier libSQL/Turso; si no hay env, cae a `world-local`. `WORKFLOW_SERVICE_URL` sólo hace falta con varios workers (default: el propio servidor en localhost).

No implementado (opcional en el contrato): `events.createBatch`, `queueBatch`, `invoke`, `runs.cancelMany`, analytics.

MCP: `sandbox_create({ template: "eve-nitro", … })` · skill: `npx skills add https://www.easybits.cloud --skill easybits-eve`.

---

## Error Codes

| Status | Meaning |
|--------|---------|
| 400 | Bad request — invalid params or body |
| 401 | Unauthorized — missing or invalid API key |
| 403 | Forbidden — insufficient scope or not your resource |
| 404 | Not found — resource doesn't exist |
| 409 | Conflict — duplicate or state conflict |
| 413 | File too large (max 5 GB) |
| 429 | Rate limited — too many requests |
| 500 | Server error |

Error response format:
```json
{ "error": "Human-readable message" }
```

---

## Document Page Design Guide

This guide defines the mandatory layout rules for document pages. Follow these rules when using set_page_html, add_page, or create_document with custom HTML.

### Page Skeleton (letter size: 8.5" x 11")

```html
<section class="w-[8.5in] h-[11in] relative overflow-hidden flex flex-col bg-surface text-on-surface">
  <!-- Optional top accent bar -->
  <div class="shrink-0 h-1.5 bg-primary w-full"></div>

  <!-- Content area — MUST be flex-1 overflow-hidden -->
  <div class="flex-1 overflow-hidden px-[0.75in] py-[0.5in] flex flex-col">

    <!-- Header: shrink-0 -->
    <div class="shrink-0 mb-6">
      <h2 class="text-3xl font-bold text-on-surface">Page Title</h2>
      <p class="text-sm text-on-surface-muted mt-1">Subtitle or description.</p>
    </div>

    <!-- Body: flex-1 min-h-0 for proper overflow control -->
    <div class="flex-1 min-h-0">
      <!-- Your content here -->
    </div>

    <!-- Optional notes: shrink-0 -->
    <div class="shrink-0 mt-4 pt-3 border-t border-gray-200">
      <p class="text-[10px] text-on-surface-muted">Footnote text.</p>
    </div>

  </div>

  <!-- Footer — MUST be shrink-0, direct child of section -->
  <div class="shrink-0 w-full px-[0.75in] py-3 flex justify-between items-center border-t border-gray-200">
    <span class="text-xs text-on-surface-muted">Company Name</span>
    <span class="text-xs font-semibold text-on-surface-muted">Page X of Y</span>
  </div>
</section>
```

### Mandatory Rules

| Rule | Detail |
|------|--------|
| Root element | `<section class="w-[8.5in] h-[11in] relative overflow-hidden flex flex-col">` |
| Content area | `flex-1 overflow-hidden` — prevents page overflow |
| Headers/footers | Always `shrink-0` |
| Flex children sharing space | Add `min-h-0` alongside `flex-1` |
| Tables | Max 5 columns, `text-xs`, never cause horizontal scroll |
| Grids | Max `grid-cols-2` for content (`grid-cols-4` only for small KPI cards), `gap-6` |
| Images | Max `h-40` inside content pages, `w-full object-cover rounded-lg` |
| Text sizes | Body: `text-sm`/`text-base`. Headers: `text-3xl` max. `text-6xl` ONLY on covers |
| Colors | ONLY semantic classes: `bg-primary`, `text-on-surface`, `bg-surface-alt`, etc. Never hex |
| Positioning | NO `absolute` that escapes the section. NO `fixed`. |
| Charts | CSS only: `conic-gradient` for donuts, `width: XX%` bars. NO JavaScript, NO Chart.js |
| Icons | Inline SVG (Lucide style) or `data-icon-query="icon-name"` |
| Images | `data-image-query="english search query"` for auto Pexels enrichment |

### Validated Patterns

**KPI Row (4 cards):**
```html
<div class="shrink-0 grid grid-cols-4 gap-4 mb-6">
  <div class="bg-surface rounded-lg p-4 text-center shadow-sm">
    <div class="text-3xl font-bold text-primary">258</div>
    <div class="text-[10px] font-semibold text-on-surface-muted uppercase tracking-wider mt-1">Label</div>
  </div>
  <!-- repeat for each KPI -->
</div>
```

**Data Table:**
```html
<div class="border border-gray-200 rounded-lg overflow-hidden">
  <table class="w-full text-xs">
    <thead>
      <tr class="bg-primary text-on-primary">
        <th class="px-3 py-2.5 text-left font-bold">Col 1</th>
        <!-- max 5 columns -->
      </tr>
    </thead>
    <tbody class="text-on-surface">
      <tr class="border-t border-gray-100">
        <td class="px-3 py-2.5 font-semibold">Row data</td>
      </tr>
      <tr class="border-t border-gray-100 bg-surface-alt/50">
        <td class="px-3 py-2.5 font-semibold">Alternating row</td>
      </tr>
    </tbody>
    <tfoot>
      <tr class="bg-primary-dark text-on-primary font-bold">
        <td class="px-3 py-2.5">TOTAL</td>
      </tr>
    </tfoot>
  </table>
</div>
```

**Horizontal Bar Chart:**
```html
<div class="space-y-3">
  <div>
    <div class="flex justify-between text-xs mb-1">
      <span class="font-semibold text-on-surface">Category</span>
      <span class="font-bold text-on-surface">45%</span>
    </div>
    <div class="w-full bg-gray-100 rounded-full h-2.5">
      <div class="h-2.5 rounded-full bg-primary" style="width:45%"></div>
    </div>
  </div>
</div>
```

**Donut Chart (conic-gradient):**
```html
<div class="relative w-28 h-28">
  <div class="absolute inset-0 rounded-full" style="background-image: conic-gradient(var(--color-primary) 0% 49%, var(--color-secondary) 49% 73%, var(--color-accent) 73% 100%)"></div>
  <div class="absolute inset-5 rounded-full bg-surface-alt flex items-center justify-center">
    <span class="text-[10px] font-bold text-on-surface-muted text-center">Label</span>
  </div>
</div>
```

**Timeline:**
```html
<div class="flex flex-col gap-5 pl-6 relative">
  <div class="absolute left-[7px] top-1 bottom-1 w-px bg-primary/30"></div>
  <div class="relative">
    <div class="absolute -left-[21px] top-0.5 w-3.5 h-3.5 rounded-full border-[3px] border-primary bg-surface"></div>
    <p class="text-[10px] font-bold text-primary uppercase tracking-wider">Date</p>
    <h4 class="text-sm font-bold text-on-surface mt-0.5">Phase Title</h4>
    <p class="text-xs text-on-surface-muted mt-1">Description.</p>
  </div>
</div>
```

### Common Bugs to Avoid

| Bug | Cause | Fix |
|-----|-------|-----|
| Content overflows page | Missing `overflow-hidden` on content wrapper | Add `flex-1 overflow-hidden` |
| Horizontal scroll | Table too wide or grid > 2 cols | Max 5 table cols with `text-xs`, max `grid-cols-2` |
| Elements overlapping | Absolute positioning without constraints | Avoid `absolute` or keep within `relative overflow-hidden` parent |
| Empty page (blank white) | `visibility: hidden` or `display: none` left on | Never hide sections |
| Colors don't change with theme | Hardcoded hex values | Use semantic classes only |
| Image stretches page | Image without height constraint | Always `h-40 max` + `object-cover` |
| Footer detached from bottom | Footer inside content wrapper | Footer must be direct child of `<section>` with `shrink-0` |

---

## Presentation Slide Design Guide

This guide defines the mandatory layout rules for presentation slides. Follow these rules when using set_page_html, add_page, or create_document (format slide-16-9) with custom HTML.

### Slide Skeleton (960×540px, 16:9)

```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <!-- Header — shrink-0 -->
  <div class="shrink-0 mb-6">
    <h2 class="text-3xl font-bold text-on-surface">Slide Title</h2>
    <p class="text-sm text-on-surface-muted mt-1">Subtitle or description.</p>
  </div>

  <!-- Body — flex-1 min-h-0 for overflow control -->
  <div class="flex-1 min-h-0 flex items-start">
    <!-- Your content here -->
  </div>

  <!-- Optional footer — shrink-0 -->
  <div class="shrink-0 mt-auto pt-3 flex justify-between items-center text-xs text-on-surface-muted">
    <span>Company Name</span>
    <span>Slide X</span>
  </div>
</section>
```

### Mandatory Rules

| Rule | Detail |
|------|--------|
| Root element | `<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col p-12">` |
| Content area | `flex-1 min-h-0` — prevents overflow |
| Text sizes | Title: `text-3xl` max (`text-4xl` ONLY on cover). Body: `text-sm`/`text-base`. Never `text-5xl`+ |
| Bullet lists | Max 5 items, max 8 words per item |
| Card grids | Max 3 cards. Card title: max 2 words. Description: max 6 words |
| KPI rows | Max 3 items. Label: max 2 words |
| Timeline | Max 4 items |
| Tables | Max 4 columns, `text-xs` |
| Columns | Max 2 columns side by side |
| Colors | ONLY semantic classes: `bg-primary`, `text-on-surface`, `bg-surface-alt`, etc. Never hex |
| Contrast | Dark bg → `text-white` or `text-on-primary`. Light bg → `text-gray-900` or `text-on-surface` |
| Positioning | NO `absolute` that escapes section. NO `fixed` |
| Inline styles | FORBIDDEN — use CSS classes only (exception: `style="width:XX%"` for progress bars) |
| JavaScript | NO `<script>`, NO Chart.js — use inline SVG or CSS for charts |
| Emoji | NO emoji — use inline SVG icons or `data-icon-query="icon-name"` |
| Images | `data-image-query="english search terms"` for auto Pexels enrichment. Never use placeholder URLs |
| HTML balance | Every `<div>` needs `</div>`. Unbalanced tags break the presentation viewer |

### Available CSS Layout Classes

Use ONLY these classes (they are defined in the presentation viewer):

| Class | Use for |
|-------|---------|
| `.columns` + `.col` | 2-column flexbox layout |
| `.centered` | Vertical + horizontal center (titles, closing) |
| `.card-grid` + `.card` | Feature/benefit cards (max 3) |
| `.timeline` + `.timeline-item` | Process, roadmap, history (max 4 items) |
| `.kpi-row` + `.kpi` | Big numbers / metrics (max 3) |
| `.blockquote-card` | Quotes / testimonials |
| `.vs-grid` | Before/after comparison (3-col) |
| `.pill-row` + `.pill` | Tags / technologies |
| `.icon-list` | Benefits with icons |
| `.data-table` | Styled data table (max 4 cols) |
| `.progress-bar` + `.progress-fill` | Progress/skill bars |
| `.diagram` | Centered SVG container |
| `.three-bg` | Animated particle bg (title/closing only) |

### Validated Patterns

**Title Slide:**
```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col">
  <div class="centered three-bg flex-1 flex flex-col items-center justify-center">
    <h1 class="text-4xl font-bold text-white">Presentation Title</h1>
    <p class="text-lg text-white/80 mt-2">Subtitle or tagline</p>
  </div>
</section>
```

**KPI Row (3 metrics):**
```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <h2 class="shrink-0 text-3xl font-bold mb-8">Key Results</h2>
  <div class="kpi-row flex-1 flex items-center justify-center gap-12">
    <div class="kpi text-center">
      <p class="kpi-value text-4xl font-bold text-primary">3.2M</p>
      <p class="kpi-label text-sm text-on-surface-muted mt-1">Users</p>
    </div>
    <div class="kpi text-center">
      <p class="kpi-value text-4xl font-bold text-primary">99.9%</p>
      <p class="kpi-label text-sm text-on-surface-muted mt-1">Uptime</p>
    </div>
    <div class="kpi text-center">
      <p class="kpi-value text-4xl font-bold text-primary">47ms</p>
      <p class="kpi-label text-sm text-on-surface-muted mt-1">Latency</p>
    </div>
  </div>
</section>
```

**Card Grid (3 features):**
```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <h2 class="shrink-0 text-3xl font-bold mb-6">Features</h2>
  <div class="card-grid flex-1 grid grid-cols-3 gap-6 items-start">
    <div class="card bg-surface-alt rounded-lg p-6">
      <span data-icon-query="zap" class="inline-block w-8 h-8 text-primary mb-3"></span>
      <h3 class="text-base font-bold">Fast</h3>
      <p class="text-sm text-on-surface-muted mt-1">Deploy in seconds</p>
    </div>
    <div class="card bg-surface-alt rounded-lg p-6">
      <span data-icon-query="shield" class="inline-block w-8 h-8 text-primary mb-3"></span>
      <h3 class="text-base font-bold">Secure</h3>
      <p class="text-sm text-on-surface-muted mt-1">Enterprise encryption</p>
    </div>
    <div class="card bg-surface-alt rounded-lg p-6">
      <span data-icon-query="globe" class="inline-block w-8 h-8 text-primary mb-3"></span>
      <h3 class="text-base font-bold">Global</h3>
      <p class="text-sm text-on-surface-muted mt-1">190+ countries</p>
    </div>
  </div>
</section>
```

**Timeline (max 4 items):**
```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <h2 class="shrink-0 text-3xl font-bold mb-6">Roadmap</h2>
  <div class="timeline flex-1 flex flex-col gap-4 pl-6 relative">
    <div class="absolute left-[7px] top-1 bottom-1 w-px bg-primary/30"></div>
    <div class="relative">
      <div class="absolute -left-[21px] top-0.5 w-3.5 h-3.5 rounded-full border-[3px] border-primary bg-surface"></div>
      <p class="text-xs font-bold text-primary uppercase tracking-wider">Q1</p>
      <h4 class="text-base font-bold mt-0.5">Phase One</h4>
      <p class="text-sm text-on-surface-muted mt-1">Launch core platform</p>
    </div>
    <!-- repeat max 3 more items -->
  </div>
</section>
```

**Comparison (Before vs After):**
```html
<section class="w-[960px] h-[540px] relative overflow-hidden flex flex-col bg-surface text-on-surface p-12">
  <h2 class="shrink-0 text-3xl font-bold mb-6">Before vs After</h2>
  <div class="vs-grid flex-1 grid grid-cols-3 gap-4 items-start">
    <div class="vs-left bg-red-50 rounded-lg p-6 border-t-4 border-red-400">
      <h3 class="text-base font-bold text-red-700">Before</h3>
      <ul class="mt-3 space-y-2 text-sm text-on-surface-muted">
        <li>Manual deploys</li>
        <li>2-hour downtime</li>
      </ul>
    </div>
    <div class="vs-divider flex items-center justify-center text-2xl font-bold text-on-surface-muted">VS</div>
    <div class="vs-right bg-green-50 rounded-lg p-6 border-t-4 border-green-400">
      <h3 class="text-base font-bold text-green-700">After</h3>
      <ul class="mt-3 space-y-2 text-sm text-on-surface-muted">
        <li>CI/CD pipeline</li>
        <li>Zero downtime</li>
      </ul>
    </div>
  </div>
</section>
```

### Color Palettes

Available palettes (set via `paletteId` on create/update): `midnight`, `ocean`, `forest`, `corporate`, `neon`, `sunset`, `slate`, `rosé`, `sand`, `aurora`, `galaxy`, `easybits`, `minimal`, `brutal`, `retro`.

Each palette defines semantic CSS variables: `--color-primary`, `--color-secondary`, `--color-accent`, `--color-surface`, `--color-on-surface`, etc. Use `bg-primary`, `text-on-surface`, `bg-surface-alt` — never hardcoded hex colors.

### SVG Diagrams

For charts/funnels/flows, use inline SVG inside a `.diagram` container:
- `viewBox="0 0 800 400"`
- Colors: use `var(--color-primary)` and `var(--color-accent)`, or `#00d4aa` and `#9870ed` as fallback
- Font-size: 14-20px
- Max 3-6 data points
- Always include `xmlns="http://www.w3.org/2000/svg"`

### Common Bugs to Avoid

| Bug | Cause | Fix |
|-----|-------|-----|
| Content overflows slide | Too many items or large text | Reduce items, use `text-sm`, max 5 bullets |
| Horizontal scroll on mobile | Fixed pixel widths inside content | Use relative widths (`w-full`, grid classes) |
| Colors don't change with palette | Hardcoded hex values | Use semantic classes only |
| Slide looks blank | Missing content or `display:none` | Never hide sections |
| Text unreadable | No contrast (dark on dark) | Always pair bg/text: `bg-primary` + `text-on-primary` |
| Tags break viewer | Unbalanced HTML tags | Double-check every `<div>` has `</div>` |

### Workflow

Presentations are documents with the `slide-16-9` format (1920×1080); there is no separate presentation tool family.

1. `create_document({ name, format: { preset: "slide-16-9" } })` — create the deck
2. `add_page({ documentId, html })` — add slides one by one following this guide
3. `get_page_html({ documentId, sectionId })` — read a slide's HTML
4. `set_page_html({ documentId, sectionId, html })` — edit a slide's HTML
5. `deploy_document({ documentId })` — publish to www.easybits.cloud/s/{slug}/
6. `get_document_pdf({ documentId })` — export as PDF

---

## All MCP Tools (293 tools)

Generado del servidor MCP en cada build — esta lista no puede quedarse desactualizada.
Todas viven en el mismo endpoint (`https://www.easybits.cloud/api/mcp`); los grupos sólo
deciden cuántas se cargan de una (ver `tool-groups`).

### Design (51)

Canva-like: documentos como diseño universal (cualquier formato — letter, social, slide 16:9), brand kits e imágenes. Ideal para Claude.ai / Claude Design.

| Tool | Qué hace |
|------|----------|
| `add_page` | Add a new page to a document. If html is omitted, adds a blank page. afterPageIndex is 0-based; omit to append at the e… |
| `apply_brand_kit` | Swap the brand kit on an existing document. Updates metadata.brandKitId + metadata.customColors so deployed renders pic… |
| `audit_page` | MIDE una página en vez de opinar sobre ella: corre axe-core sobre el DOM ya pintado (contraste REAL, calculado con los … |
| `change_document_format` | Change a document's canvas size (e.g., letter → slide-16-9, or to a social preset). Updates metadata.format + metadata.… |
| `clone_pdf` | CLONA páginas de un PDF a HTML estático y editable, sin adivinar: el texto, sus posiciones, las fuentes y el fondo sale… |
| `compare_render` | MIDE si un clon HTML de un PDF quedó igual, página por página, en vez de juzgarlo a ojo. Es el verificador para iterar:… |
| `create_brand_kit` | Create a brand kit with colors, fonts, and logo for consistent document styling. |
| `create_or_edit_image` | Create OR edit images with OpenAI gpt-image-2 — full implementation: text-to-image GENERATION and faithful reference-ba… |
| `create_share_link` | Create a magic share link to a document or landing with granular permissions. The recipient opens the URL and accesses … |
| `create_tournament_schedule` | Template-based tool. Pass structured match data (tournamentName, matches[], courts, etc.) and the template auto-generat… |
| `delete_brand_kit` | Delete a brand kit. |
| `delete_document` | Delete a document permanently. |
| `delete_page` | Delete a page from a document by its section ID. Cannot delete the last remaining page. |
| `deploy_document` | Publish a document as a live website at www.easybits.cloud/s/{slug}/ — instant hosting, shareable link. Requires at lea… |
| `describe_image` | Analiza/describe una imagen y devuelve texto: qué muestra + transcripción del texto visible (OCR). NO genera ni edita i… |
| `duplicate_document` | Duplicate an existing document — fast in-DB copy of all pages, theme, and metadata. No AI involved. For Vision-based cl… |
| `edit_image` | Edit, compose, or restyle image(s) with Gemini 'Nano Banana 2' (gemini-3-pro-image-preview). The platform key is used —… |
| `edit_tournament_schedule` | Edit an existing tournament schedule. Pass updated structured data — the template rebuilds the HTML and regenerates the… |
| `enhance_document_prompt` | Enhance a document prompt or auto-generate a description from the document name. Use 'auto-describe' to generate a desc… |
| `export_document` | Export a document (Landing v4) to PDF or to one PNG per page. Use `as: "images"` for social carousels (LinkedIn/IG) — e… |
| `extract_brand_kit_from_url` | Extract a brand kit from a public website URL. Captures a screenshot, uses vision AI to detect colors/fonts/mood, and s… |
| `fill_template` | Fill data-slot placeholders in a document with exact values. No AI involved — deterministic text replacement. Use after… |
| `get_document` | Get a document with all its pages. Returns sections[] where each has { id, name, html, order }. Use section.id as pageI… |
| `get_document_pdf` | Generate a PDF of a document. Returns the PDF as base64-encoded data. Requires Chrome installed locally — designed for … |
| `get_page_screenshot` | Take a screenshot of a single document page. Returns a PNG image of one page (letter-sized). Requires Chrome installed … |
| `get_section_html` | Get the outerHTML of a specific element WITHIN a page, matched by CSS selector. Example selectors: 'section' (root), '.… |
| `get_template_slots` | Get all data-slot placeholders in a document. Returns slot names and current values. Use with fill_template for determi… |
| `image_generate` | Generate an image from a text prompt using fal.ai Flux models. Returns a public image URL ready to use in landings, doc… |
| `import_html` | Alias of `open_design_in_editor` (prefer that name). Same schema and behavior. Kept for backward compatibility with exi… |
| `list_documents` | List your documents (paginated). Returns { items, nextCursor, hasMore, total }. When hasMore is true, pass nextCursor a… |
| `list_share_links` | List active share links you've created. Filter by resource. Excludes revoked links by default. |
| `list_themes` | List available document/landing themes with their color palettes. Use a theme ID when creating documents. |
| `list_voices` | Lista las voces disponibles del servicio de voz de la flota (kokoro, español). Devuelve [{id,label,gender}]. Usa un `id… |
| `open_design_in_editor` | Open a design in the EasyBits editor — the direct path from a generated design (Claude Design, Gamma, Tome, reveal.js d… |
| `optimize_image` | Convert an image to WebP or AVIF, creating a new file (original unchanged). Default quality: 80 (WebP), 50 (AVIF). Retu… |
| `pdf_to_images` | Convert a PDF to page images (PNG). Accepts either a fileId of an uploaded PDF OR raw base64 PDF data. Returns one imag… |
| `refine_document_section` | Refine a specific page/section of a document with surgical AI edits. Make targeted changes like updating text, adjustin… |
| `regenerate_document_page` | Create a completely different visual design for a document page while keeping the same content. Useful when the current… |
| `reorder_pages` | Reorder all pages in a document. pageIds must contain every existing page ID exactly once, in the desired order. |
| `revoke_share_link` | Revoke a share link by ID. The link stops working immediately for new requests; cached pages may still render briefly. |
| `screenshot_url` | VE cómo se ve una página: renderiza HTML (o una URL pública) en un Chromium real y devuelve la imagen como archivo públ… |
| `search_files` | Search files using natural language (AI-powered). Requires an AI key (set_ai_key). Returns up to 20 matching files sort… |
| `search_icon` | Busca ICONOS y devuelve el SVG INLINE listo para pegar en el HTML — no una URL, así no metes un host externo en la ruta… |
| `search_stock_photo` | Busca una FOTO de stock libre de regalías en bancos gratuitos (Pexels, Unsplash, Pixabay, Openverse) y devuelve su URL … |
| `set_section_html` | Second choice for surgical edits — prefer replace_html (more reliable, no selector fragility). Use only when you specif… |
| `structured_doc` | Create and edit structured documents (quotations, invoices, proposals) via JSON-DSL templates + @react-pdf/renderer. |
| `unpublish_document` | Unpublish a document, removing its website and reverting to draft status. |
| `update_brand_kit` | Update a brand kit's colors, fonts, logo, or other properties. |
| `update_document` | Update a document's metadata (name, theme, colors, prompt). For editing page HTML use replace_html (surgical) or set_pa… |
| `voice_tts_create` | Sintetiza una nota de voz desde texto con el motor self-hosted de EasyBits (kokoro). Servicio de flota on-demand, sin p… |
| `wait_for_document` | Block until a document's background generation stabilizes (no recent updates AND minimum sections present), or timeout.… |
### Web (13)

Internet para tu agente: buscar en Google, leer cualquier página aunque bloquee bots, extraer registros con esquema (Maps, Mercado Libre, Amazon, Instagram…) y rastrear sitios. Se mide en consultas (packs en /dash/packs).

| Tool | Qué hace |
|------|----------|
| `research_scrape` | [deprecated → web_fetch] Lee UNA página web y devuelve su contenido, aunque el sitio bloquee bots (IPs residenciales, J… |
| `research_search` | [deprecated → web_search] Busca en Google (o Bing/Yandex/DuckDuckGo) y devuelve resultados estructurados: orgánicos, sn… |
| `web_crawl` | Lee una página y SIGUE sus links internos (mismo dominio) hasta `maxPages`, devolviendo markdown por página. Cobra 1 co… |
| `web_crawl_status` | Estado y páginas de un rastreo grande (`web_crawl` con maxPages > 20). Gratis: las páginas ya se cobraron al leerse. |
| `web_extract` | Extrae datos estructurados. DOS modos: |
| `web_extract_status` | Estado de un job de `web_extract`. Mientras corre: { status:'running' } (gratis). Cuando termina: { status:'done', item… |
| `web_fetch` | Lee UNA página web y devuelve su contenido, aunque el sitio bloquee bots (IPs residenciales, JS resuelto). Cuesta 1 con… |
| `web_interact` | Abre una página en un NAVEGADOR REMOTO (pasa bloqueos de bots), ejecuta acciones como una persona y devuelve cómo quedó… |
| `web_map` | Lista TODAS las URLs de un sitio sin leerlas, desde su sitemap (o, si no tiene, los links de su portada). Barato: 1 con… |
| `web_monitor_create` | Vigila una página y avisa cuando CAMBIA: cada `everyMinutes` (15 a 10,080, default 60) lee su contenido principal y, si… |
| `web_monitor_delete` | Deja de vigilar una página (borra el monitor). |
| `web_monitor_list` | Lista tus monitores de páginas: url, cada cuánto, revisiones, cambios detectados, última revisión y si está activo o pa… |
| `web_search` | Busca en Google (o Bing/Yandex/DuckDuckGo) y devuelve resultados estructurados: orgánicos, snack pack (negocios locales… |
### Core (47)

Lo esencial: archivos, DBs, documentos, forms, websites, brand. Para agentes generalistas.

| Tool | Qué hace |
|------|----------|
| `bulk_delete_files` | Soft-delete multiple files at once (max 100). Returns count of deleted files. Refuses the whole batch (409 WEBSITE_FILE… |
| `bulk_upload_files` | Create multiple file records and get presigned upload URLs (max 20). Returns array of `{ file, putUrl }`. Same access r… |
| `create_cron` | Crea un cron en TU cuenta: le pega a una URL https pública, le manda un turno a uno de tus agentes o ejecuta un comando… |
| `create_webhook` | Create a webhook to receive event notifications. Returns the webhook with its secret (shown only once). Events: file.cr… |
| `db_create` | Create a new SQLite database. Name must be alphanumeric/dashes/underscores, max 64 chars. Max databases depends on plan… |
| `db_delete` | Permanently delete a database and all its data. |
| `db_exec` | Execute multiple SQL statements in a batch (max 50). Useful for migrations or multi-step operations. |
| `db_get` | Get a database by ID. |
| `db_import` | Bulk import rows into a table. Up to 10,000 rows per request. Values are auto-converted to strings (sqld requirement). … |
| `db_list` | List your SQLite databases (id, name, namespace, description, createdAt). |
| `db_query` | Execute a single SQL statement against a database. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc. Use `arg… |
| `db_select` | Read-only counterpart of db_query — accepts only a single SELECT statement. Rejects anything that mutates, escalates, o… |
| `delete_ai_key` | Remove a stored AI provider API key |
| `delete_cron` | Borra un cron tuyo por su id (de list_my_crons; el historial de ejecuciones se queda hasta su purga de 90 días). |
| `delete_file` | Soft-delete a file (sets status to DELETED). Recoverable for 7 days via `restore_file`, then auto-purged from storage. … |
| `delete_webhook` | Permanently delete a webhook. |
| `delete_website` | Delete a website. Soft-deletes all associated files (recoverable for 7 days) and removes the website record. |
| `duplicate_file` | Create a copy of an existing file (new storage object). Returns the new file record. |
| `get_docs` | Get the complete EasyBits API reference documentation. Use this to learn how to use any EasyBits feature — endpoints, S… |
| `get_learning_progress` | Academia EasyBits (/aprende): progreso del usuario en cada curso. Las lecciones con `verify` se marcan solas cuando la … |
| `get_usage_stats` | Get account usage statistics: storage used/limit, file counts, AI generations used/remaining, plan info, and upgrade re… |
| `get_webhook` | Get a webhook by ID with status, events, fail count, and last error. |
| `inbox_create` | Crea un buzón de eventos: una URL de webhook pública (`url`) donde cualquiera puede escribir con POST (tu cron con noti… |
| `inbox_delete` | Borra un buzón y sus eventos. Su URL deja de aceptar escrituras de inmediato. |
| `inbox_list` | Tus buzones de eventos: { items:[{ id, name, url, unread, lastSeq, lastEventAt, retentionDays, createdAt }] }. `unread`… |
| `inbox_read` | Lee los eventos de un buzón. Sin `since` regresa lo NO leído y marca como leído (la siguiente llamada trae sólo lo nuev… |
| `inbox_rotate` | Le da al buzón una URL nueva (`url`); la vieja deja de aceptar eventos de inmediato. Úsalo si la URL se filtró. Los eve… |
| `inbox_update` | Cambia el nombre o la retención de un buzón (sólo lo que pases). Su URL no cambia. |
| `list_ai_keys` | List your configured AI provider API keys (values are masked) |
| `list_deleted_files` | List soft-deleted files with `daysUntilPurge` for each. Files are auto-purged after 7 days. Use `restore_file` to recov… |
| `list_domains` | List your custom domains. Returns { items, nextCursor, hasMore, total }. Each item has `verified`, `txtToken` (TXT valu… |
| `list_my_crons` | Tus crons (tareas programadas de TU cuenta): { items:[{ id, name, kind (once\|recurring), cron, at, until, timesLeft, ti… |
| `list_providers` | List your configured storage providers. If none configured, shows platform default (Tigris). |
| `list_webhooks` | List your configured webhooks (id, url, events, status, failCount, lastError). |
| `restore_file` | Restore a soft-deleted file back to DONE status. Only works on files with status DELETED. |
| `run_my_cron` | Ejecuta ahora un cron tuyo por su id (en segundo plano, trigger «manual»; no mueve su próxima ejecución). El resultado … |
| `secret_delete` | Delete an EasyBits Secret by name. Any pending or future `agent_run` calls that reference this name will fail until you… |
| `secret_list` | List the names of all EasyBits Secrets stored for the calling account. Values are NEVER returned — this only shows name… |
| `secret_set` | Create or update an EasyBits Secret for the calling account. Stored AES-256-GCM encrypted at rest. Once saved the value… |
| `set_ai_key` | Store your AI provider API key for AI-powered features (search, auto-tagging). Key is encrypted; response shows masked … |
| `set_domain_website` | Point a verified custom domain's ROOT (midominio.com, and www → 301 to it) at one of your websites. Pass `websiteId: nu… |
| `share_file` | Share a file with another user by email. Creates a permission record. canRead defaults to true, canWrite/canDelete to f… |
| `update_cron` | Cambia un cron tuyo por su id (de list_my_crons; sólo los campos que pases). `enabled:false` lo pausa, `enabled:true` l… |
| `update_file` | Update a file's name, access level, or metadata. For access changes (public↔private), copies the object between storage… |
| `update_webhook` | Update a webhook's URL, events, or status (ACTIVE/PAUSED). Reactivating resets the fail counter. |
| `update_website` | Update a website's name, slug or status. Changing `slug` changes the public URL (`/s/<slug>/`); files are stored by id … |
| `wait_cron_run` | Espera a que termine la SIGUIENTE ejecución de un cron tuyo, por su id (la que va en curso, o la próxima programada) y … |
### Documents (2)

Solo tools de documentos (create/update/deploy + quotations + structured_doc).

| Tool | Qué hace |
|------|----------|
| `get_document_directions` | Generate 4 design directions for a document. Each direction includes fonts, colors, mood, and layout hints. Use one of … |
| `replace_html` | PRIMARY tool for editing document pages. Use this for ANY targeted change — single sentence, color swap, one element, o… |
### Websites (9)

Solo tools de websites (crear, deployar archivos, inject HTML, dominios propios).

| Tool | Qué hace |
|------|----------|
| `add_domain` | Register a custom domain (e.g. `midominio.com`). Returns `{ domain }` with `txtToken`. DNS the user must add: TXT `_eas… |
| `configure_domain_dns` | Write the DNS records a custom domain needs (TXT verify, wildcard CNAME, and root A + www when a site is on the root) s… |
| `delete_domain` | Remove a custom domain: unlinks its websites and deletes its certificates. Returns `{ ok: true }`. |
| `get_website` | Get a website by ID with stats. Returns website object with url. |
| `list_sites` | List the sites of the site builder (/dash/sitios): static sites and web apps, with the engine that builds each one and … |
| `site_set_engine` | See or change what builds a site-builder site: its engine (claude = the user's Claude, easybits = included Haiku…) and … |
| `site_set_theme` | Change the color theme of a site-builder site (/dash/sitios) without a model turn — same as the «Tema» button. Pass `th… |
| `undo_domain_dns` | Undo the DNS records EasyBits wrote in Mailmask for a domain (configure_domain_dns): restores the previous values and l… |
| `verify_domain` | Verify a custom domain by checking its TXT record and issue the wildcard certificate. Returns `{ domain }` with `verifi… |
### Lead Magnet (22)

Toolset orquestado para crear lead magnets (PDF + landing + form).

| Tool | Qué hace |
|------|----------|
| `create_document` | Create a new document. Pages (sections) are optional — add them later via update_document. Each section: { id, order, h… |
| `create_form` | Create a contact/subscription form for a website. Returns an HTML snippet. |
| `create_geo_scorecard` | Create a GEO (Generative Engine Optimization) scorecard from structured data. Returns the document + PDF. |
| `create_lead_magnet` | Create a complete lead magnet in ONE call: landing page + capture form + delivery to your content. |
| `create_screening_report` | Create a screening/compliance report (BlackPLD, PEP, sanctions) from structured data. Returns the document + PDF. |
| `create_website` | Create a new website with a name. Pass `slug` to choose the public URL (`/s/<slug>/`); otherwise one is generated. Slug… |
| `deploy_website_file` | Deploy a file to a website in a single call — no presigned URL or status update needed. Pass the file content directly … |
| `edit_geo_scorecard` | Edit an existing GEO scorecard with updated structured data. Rebuilds the HTML and regenerates the PDF. |
| `edit_screening_report` | Edit an existing screening report with updated structured data. Rebuilds the HTML and regenerates the PDF. |
| `get_default_brand_kit` | Get the user's default brand kit (colors, fonts, logo). Returns null if none is set. This kit is auto-applied when crea… |
| `get_node_outline` | Mapa DIRECCIONABLE de una página: cada nodo con su `data-id`, tag, clases y texto propio. Léelo ANTES de `patch_node` p… |
| `get_page_html` | Get the full HTML and metadata of a single page. Lighter than get_document when you only need one page. Returns { id, n… |
| `inject_html` | Inject HTML into an existing page on a website without rewriting the entire file. |
| `list_brand_kits` | List your brand kits (color palettes, fonts, logos for consistent document styling). |
| `list_form_submissions` | List submissions for a form. When formId is omitted, returns recent submissions across ALL your forms (use list_forms t… |
| `list_forms` | List all your forms with submission counts. Use this to discover form IDs before calling list_form_submissions. |
| `list_website_files` | List files belonging to a website. Returns `{ items, nextCursor, hasMore }`. When `hasMore` is true, pass `nextCursor` … |
| `list_websites` | List your websites (paginated). Returns { items, nextCursor, hasMore, total }. When hasMore is true, pass nextCursor as… |
| `patch_node` | Edita una página POR NODO: reemplaza, borra o inserta usando el `data-id` como dirección. Lo que no tocas queda BYTE-ID… |
| `set_page_html` | Replace the ENTIRE HTML of a single page. Use ONLY for full-page rewrites or wholesale restructure. For ANY edit smalle… |
| `transform_image` | Crop, resize, rotate, flip, convert, or apply grayscale to an image. Creates a new file (original unchanged). Returns `… |
| `upload_website_file` | Upload a file to a website via presigned URL. Returns `{ file, putUrl }` — PUT the bytes to `putUrl`, then call `update… |
### Video (20)

Video con IA (Runway Gen-4.5) + personajes recurrentes, y proyectos de video doc-style (escenas animadas HTML → MP4 con narración kokoro). Ghosty arma escenas, música y voz, y renderiza.

| Tool | Qué hace |
|------|----------|
| `add_video_scene` | Add a scene. html = the scene's markup (absolutely positioned to fill the frame; reference bundled media as assets/<nam… |
| `attach_video_asset` | Register a named media asset (image/logo/etc.) the render box downloads into assets/. Reference it in scene html as ass… |
| `avatar_video_create` | TALKING HEAD video generator. Powered by fal.ai (SadTalker / Hallo2 / LivePortrait — OSS models, pay-per-use). Takes a … |
| `character_delete` | Delete a saved character. Only removes the Character record — the reference photos (if stored as Files) stay in the use… |
| `character_list` | List the user's saved characters (reusable identities for video generation). Call this when the user references someone… |
| `character_remember` | Save a reusable character (person, pet, mascot) from 1–3 reference photos. The character's slug can be used later in `v… |
| `create_video_project` | Create a stateful video project — an ordered set of animated scenes that compile to an MP4. Doc-style: create empty (or… |
| `delete_video_project` | Delete a video project permanently. |
| `delete_video_scene` | Delete a scene by id. |
| `generate_captions` | Enqueue viral MrBeast-style animated captions for a video. Input: a public HTTPS URL to an MP4 / MOV / any common video… |
| `get_caption_status` | Poll a captions render job started by generate_captions. Returns { status, elapsedMs, result?, error?, logs }. Status v… |
| `get_video_project` | Get a video project with its full scene list (html, timeline, narration, durations) and assets. |
| `list_video_projects` | List the caller's video projects (newest first). |
| `list_videos` | List the user's video generations (latest first). Returns status, prompt, and file ids for each. |
| `render_video_project` | Compile the project to a HyperFrames composition and render it to MP4 on the owner's on-demand box (synthesizing any pe… |
| `reorder_video_scenes` | Reorder all scenes. sceneIds must list every existing scene id exactly once in the desired order. |
| `set_video_music` | Set (or clear) the continuous background music track. Pass a public audio URL; the box downloads and muxes it (auto-duc… |
| `set_video_scene` | Edit a scene by id (from get_video_project scenes[].id). Only pass the fields you change. Changing narration re-synthes… |
| `update_video_project` | Update project-level fields (name, theme, customColors, fps, width, height). Does NOT touch scenes. |
| `video_create` | Generate a short video (2–10s) from a prompt. Automatically orchestrates: (1) cinematic prompt enhancement, (2) still g… |
### Sandbox (73)

MicroVMs Firecracker para correr agentes y código aislado. Ciclo de vida (spawn/extend/suspend/resume/destroy), snapshot + fork (clonar una caja viva en N hijos copy-on-write para explorar variantes en paralelo), exec (sync + background), run-code, kernel Jupyter persistente (run_cell con estado + charts), archivos (write/read/list/delete/move/mkdir), expose_port (URL pública), sandbox_admin (passthrough admin :8787) + agent_run (Claude managed con billing por token). Ideal para harness de agentes (Claude Code, Codex) y ejecución segura.

| Tool | Qué hace |
|------|----------|
| `agent_create` | ⚠️ DEPRECATED para cajas permanentes: usa `create_machine` (modelo único de caja — Sandbox permanente con billing + del… |
| `agent_destroy` | Destroy a persistent agent created via `agent_create` / `ghosty_spawn`: kills the underlying Firecracker sandbox AND de… |
| `agent_install_skill` | Install / upload a skill (Agent Skills Open Standard — a SKILL.md with frontmatter `name`, `description`, `tools` + opt… |
| `agent_list` | List the persistent agents (chat-*, ghostyclaw, openclaw) owned by the calling account. Use this to enumerate agents be… |
| `agent_message` | Send a chat message to a persistent agent created via `agent_create` / `ghosty_spawn` (chat-* templates) and return the… |
| `agent_record` | ONE-SHOT: record a computer-use agent (template computer-ghosty-gemini) while it performs a task, and return a public U… |
| `agent_recording_list` | List the desktop recordings stored on a computer-use agent's VM. Returns an array of { id, url, bytes, at, recording } … |
| `agent_recording_start` | Start recording the desktop of a computer-use agent (computer-ghosty-gemini). ffmpeg captures display :0 to an mp4 insi… |
| `agent_recording_stop` | Stop the in-progress desktop recording and finalize the mp4. Returns { recording:false, id, url, bytes, durationMs } wh… |
| `agent_run` | Start a managed Claude agent (Claude Agent SDK loop) inside a fresh Firecracker microVM. By default the agent has Bash/… |
| `agent_run_destroy` | Destroy the sandbox underlying an agent run. Call this after you've successfully fetched the result via `agent_run_stat… |
| `agent_run_status` | Poll an agent run started with `agent_run`. Returns the current status: 'running' (still working), 'done' (success — re… |
| `call_create` | Crea una videollamada online y devuelve el link para compartir — no necesita nada previo. Levanta el servidor de llamad… |
| `call_destroy` | Termina una videollamada limpiamente: (1) para la grabación activa si la hay y sube el MP4 a Files, (2) rescata cualqui… |
| `call_files` | Lista las grabaciones y transcripts de todas las llamadas — archivos permanentes en EasyBits Files, disponibles aunque … |
| `call_record` | Inicia la grabación de una llamada en curso. Un chromium headless se une como observador invisible y ffmpeg captura el … |
| `call_status` | Estado del servidor de llamadas: si hay grabación activa y quiénes están en la sala. Retorna { recording, room, started… |
| `call_stop` | Detiene la grabación, sube el MP4 permanentemente a los Files del usuario y genera un transcript con Whisper. Retorna {… |
| `call_transcript` | Devuelve el TEXTO del transcript de una llamada, inline, listo para resumir o extraer pendientes — no un link. Incluye … |
| `delete_snapshot` | Delete a sandbox snapshot, freeing its stored image. Does not affect the source box or any children already forked from… |
| `ghosty_spawn` | Spawn THE default Ghosty agent — zero configuration. Uses EasyBits' managed Anthropic credentials (same billing pattern… |
| `goose_spawn` | Spawn THE managed Goose agent — zero configuration. Block's open-source coding agent running as ACP server (JSON-RPC ov… |
| `list_snapshots` | List your saved sandbox snapshots (copy-on-write clone sources): snapshotId, name, source box, size, created date. |
| `sandbox_apply_patch` | Atomic hotfix in one call: apply N surgical edits, then optionally rebuild and restart the service. Edits run first and… |
| `sandbox_codex_connect` | Pone la sesión de Codex del usuario (su plan de ChatGPT Plus/Pro, guardada en su vault como CODEX_AUTH) en ~/.codex/aut… |
| `sandbox_codex_save` | Guarda en el vault del usuario (CODEX_AUTH) la sesión de Codex de una caja (~/.codex/auth.json): después de que el usua… |
| `sandbox_create` | Spawn a Firecracker microVM sandbox. Returns sandboxId used for subsequent calls. Base templates: ubuntu, python, node,… |
| `sandbox_delete_template_snapshot` | Delete a derived template by derivedId. Fails with 409 DerivedTemplateInUse while children created from it are alive (s… |
| `sandbox_destroy` | Permanently destroy an EPHEMERAL sandbox and free its resources. Idempotent. (Permanent machines are released via relea… |
| `sandbox_exec` | Execute a shell command inside a sandbox. Returns stdout, stderr, exitCode, durationMs. Default timeout 60s, max 600s. |
| `sandbox_exec_background` | Start a long-running command in the background. Returns { execId, status } immediately instead of blocking like sandbox… |
| `sandbox_exec_kill` | Kill a background command started with sandbox_exec_background. Signals the whole process GROUP (SIGTERM, then SIGKILL … |
| `sandbox_exec_list` | List the background commands of a sandbox: execId, command, status, exit code and log sizes. This is how you recover an… |
| `sandbox_exec_status` | Poll a background command started with sandbox_exec_background. Returns status (running/exited), exitCode (when exited)… |
| `sandbox_expose_raw_port` | Expose a RAW layer-4 (TCP/UDP) port of the sandbox — the only way to reach a service that does not speak HTTP (SSH on 2… |
| `sandbox_extend` | Refresh a sandbox's TTL before the auto-destroy reaper fires. No-op on persistent boxes (returns { persistent, noop }).… |
| `sandbox_files_delete` | Delete a file or directory inside the sandbox. Pass recursive=true to remove a non-empty directory. |
| `sandbox_files_edit` | Surgically edit a file inside the sandbox: replace oldString with newString in place (read → replace → write). Use this… |
| `sandbox_files_list` | List directory entries (name, size, isDir, modifiedAt) inside the sandbox. |
| `sandbox_files_mkdir` | Create a directory (and any missing parents) inside the sandbox. |
| `sandbox_files_move` | Move or rename a file/directory inside the sandbox. Parent directories of the destination are created if needed. |
| `sandbox_files_read` | Read a file from the sandbox. Small text (<50KB) returns inline JSON with full content. Recognized images (PNG/JPEG/GIF… |
| `sandbox_files_write` | Write a file inside the sandbox. Creates parent dirs if needed. Use encoding=base64 for binary content. |
| `sandbox_fork` | Boot N copy-on-write children from a sandbox (fresh IP each, collision-free), to explore variations in parallel. Pass s… |
| `sandbox_get_network_policy` | Read the current egress policy of a box ("allow-all" when none was set). |
| `sandbox_git_checkout` | Switch branches in a sandbox repo. With create:true it uses `checkout -B`, which is idempotent — safe to run on every w… |
| `sandbox_git_clone` | Clone a git repo into a sandbox. For private repos pass `token` (ideally as '$secret:NAME' from the vault) — credential… |
| `sandbox_git_commit` | Stage and commit in a sandbox repo. Stages everything by default; pass `paths` to stage a subset. If there is nothing t… |
| `sandbox_git_log` | Commit history of a sandbox repo, paginated. Returns { items, nextCursor, hasMore } — pass nextCursor back as `cursor` … |
| `sandbox_git_pull` | Pull into a sandbox repo. Fast-forward only by default (pass rebase:true to rebase). Returns any conflicted paths so th… |
| `sandbox_git_push` | Push a sandbox repo to its remote. Pass `token` (or '$secret:NAME') for private repos. `force` uses --force-with-lease,… |
| `sandbox_git_status` | Structured git status for a repo in a sandbox: branch, upstream, ahead/behind counts, and the staged/modified/untracked… |
| `sandbox_kernel_restart` | Restart the persistent Jupyter kernel for a 'code-interpreter' sandbox — clears all state (variables, imports). Use to … |
| `sandbox_list` | List all active sandboxes owned by the current account. |
| `sandbox_list_template_snapshots` | List your derived templates (captured with sandbox_template_snapshot): derivedId, key, hash, baseTemplate, sizeBytes, c… |
| `sandbox_resume` | Restore a suspended sandbox from its snapshot (same TAP/IP/MAC/rootfs/volumes). The lifetime that remained when it was … |
| `sandbox_run_cell` | Run code in the sandbox's PERSISTENT Jupyter kernel — variables, imports and loaded data survive between calls (unlike … |
| `sandbox_run_code` | Run a snippet of Python/Node/Bash inline (no need to write a file first). Output captured. Default lang=python. NOTE: e… |
| `sandbox_runtime` | Control the service daemon running inside the sandbox via systemd — no manual systemctl/npm through sandbox_exec. actio… |
| `sandbox_set_bootstrap` | Declare the BOOTSTRAP of a sandbox: a script the host runs EVERY TIME the box wakes up, before the agent gets its first… |
| `sandbox_set_idle` | Change the idle policy of an EXISTING sandbox. suspendOnIdle=true: when the TTL elapses the box is SUSPENDED (snapshot,… |
| `sandbox_set_network_policy` | Set the EGRESS policy of one box: "allow-all", "deny-all", or a domain allow-list { allow: { "api.github.com": [], "reg… |
| `sandbox_snapshot` | Capture a named, persisted copy-on-write image of a RUNNING sandbox WITHOUT stopping it (the box keeps running). The sn… |
| `sandbox_ssh_disable` | Close the SSH forward (port 22) of a sandbox. The key stays injected and sshd keeps running inside the box — it just be… |
| `sandbox_ssh_enable` | Give the user SSH into the box, in one call: injects their public key(s) and restarts the box sshd. HAND THE USER `tunn… |
| `sandbox_status` | Get a sandbox's record: status (starting/running/stopped/error/lost/suspended), template, createdAt, expiresAt, and met… |
| `sandbox_suspend` | Snapshot a sandbox to disk and free its CPU/IP — the box stops billing compute but keeps its state. The TTL is PAUSED w… |
| `sandbox_template_snapshot` | Capture a PREPARED running sandbox (deps installed, seeds written, bootstrap done) ONCE as a reusable DERIVED TEMPLATE … |
| `sandbox_unexpose_raw_port` | Tear down a raw TCP/UDP forward created with sandbox_expose_raw_port. The host port returns to the pool and may be re-a… |
| `service_start` | Levanta un servicio de flota on-demand y devuelve sus URLs. kind='voice' arranca una caja con STT (whisper) + TTS (koko… |
| `service_status` | Estado de un servicio de flota: si está corriendo y sus URLs. Pasa kind (p.ej. 'voice') o el sandboxId devuelto por ser… |
| `service_stop` | Detiene y destruye una caja de servicio de flota para liberar recursos. Pasa el sandboxId devuelto por service_start. S… |
| `templates_list` | List available sandbox templates with metadata for catalog UI: tier (chat-embed/coding-harness/autonomous/custom/base),… |
### Flota (4)

Agentes de la flota (WhatsApp, WABA, web, Teams): listar, leer su configuración y aplicar cualquier acción de /capabilities por MCP — prompt, motor, modelo, secrets, conectores (add-mcp + set-cap-level), skills, buckets por canal, reciclar caja. Lo que hace el dashboard de /dash/flota, hecho por un agente.

| Tool | Qué hace |
|------|----------|
| `fleet_agent_capabilities` | Estado de configuración de un FleetAgent (lo mismo que GET /api/v2/fleet-agents/:id/capabilities): conectores (`capabil… |
| `fleet_agent_configure` | Aplica UNA mutación de configuración a un FleetAgent — el mismo contrato que POST /api/v2/fleet-agents/:id/capabilities… |
| `fleet_agent_list` | Lista los agentes de la FLOTA (FleetAgent: WhatsApp/WABA/web/Teams) de la cuenta. Distinto de `agent_list` (cajas persi… |
| `fleet_agent_messages` | Historial de UNA conversación de un FleetAgent, con cursor. `groupId` es la conversación; `since` es el cursor de la úl… |
### Hosting (35)

Hosting de apps sobre microVMs Firecracker, always-on, tiers fijos en MXN/mes (nano…performance). launch_app pone una app en producción en UNA llamada (caja + código + build + URL pública + release + dominio con TLS) — el equivalente a `fly launch`. Además: releases con rollback y redeploy (que es también el cambio de tier), backups diarios de datos incluidos con 7 días de retención, dominios propios, logs, y delegación de acceso para co-administrar. El plan da acceso; cada máquina factura flat al mes.

| Tool | Qué hace |
|------|----------|
| `create_backup` | Take a data backup right now instead of waiting for tonight's run. Copies the paths declared in runspec.dataPaths to du… |
| `create_machine` | Buy an ALWAYS-ON machine (permanent VM, flat MXN/month). NO platform plan needed — hosting bills on its own. If the acc… |
| `delete_machine_release` | Delete a release and its stored artifact. Refuses if it is the release currently deployed on a live machine. |
| `deploy_machine` | Publish the machine's current app code as a versioned RELEASE in durable storage. This is what makes the box recoverabl… |
| `get_machine_logs` | The app's own log, last N lines: the systemd unit's journal when the runspec has a unit, otherwise the file the startCo… |
| `get_machine_runspec` | Read a machine's runspec (how its app is built, started and backed up). Returns null if none is set yet. |
| `grant_access` | Delegate access over YOUR account to another EasyBits account by email. scopes=["machines"] lets them operate (monitor/… |
| `launch_app` | Put an app in production in ONE call — the `fly launch` of EasyBits: provisions the machine, gets the code in, builds, … |
| `list_access` | List the accounts you have delegated YOUR account to, and the scopes each holds. |
| `list_backups` | List a machine's daily data backups (newest first), with size, consistency level and expiry. Backups run nightly, are k… |
| `list_machine_releases` | List published releases, newest version first. Omit sandboxId to list every release in the account. |
| `list_machine_secrets` | Which env vars this app uses (`secretNames`) and what else is available in the account vault to wire up (`inVault`). Na… |
| `list_machine_templates` | List machine templates with their required env, so you know what `env` to pass to create_machine. Managed-runtime templ… |
| `list_machine_tiers` | List the always-on hosting catalog: tiers (nano…performance-4x) with vCPU/RAM/NVMe and flat MXN/month price (shared + r… |
| `list_machines` | List all always-on machines (permanent sandboxes) owned by the current account, with tier + monthlyMxn (status self-hea… |
| `make_permanent` | Promote an existing (ephemeral) sandbox to an always-on machine: keeps the SAME sandboxId, disarms the host reaper, and… |
| `push_deploy` | Deploy on every `git push`: a GitHub webhook redeploys the machine from its repo (the one launch_app cloned, remembered… |
| `redeploy_machine` | Build a BRAND NEW machine from a release: provisions a box, unpacks the code, builds and starts it. Two jobs, one path … |
| `release_machine` | Delete an always-on machine. DESTRUCTIVE AND IMMEDIATE: stops billing, takes a last backup and destroys the VM — there … |
| `restart_machine` | Restart the app process with the CURRENT secrets and runspec. Seconds: it downloads nothing and builds nothing — unlike… |
| `restore_machine` | Revive a machine that was suspended because its SUBSCRIPTION was cancelled (trial ended, card failed) — it sleeps for 7… |
| `restore_machine_from_backup` | Restore a backup's data into a machine, OVERWRITING what is there. Requires confirm:true. Restoring onto the source mac… |
| `revoke_access` | Revoke delegated access from an account by email. Omit scopes to revoke everything; pass scopes to remove only those. I… |
| `rollback_machine` | Put a previous release back on the SAME machine: download, extract and restart. No build: a release published by launch… |
| `sandbox_admin` | Admin passthrough to a permanent machine's in-VM admin API (:8787) — WhatsApp pairing (/admin/whatsapp/status\|link\|unli… |
| `sandbox_domain_add` | Attach a custom domain to a sandbox port, served over HTTPS at https://<domain> with an auto-issued TLS cert — instead … |
| `sandbox_domain_list` | List the custom domains attached to a sandbox. |
| `sandbox_domain_remove` | Detach a custom domain from a sandbox. |
| `sandbox_domain_verify` | Check whether a custom domain is live: confirms DNS resolves and that https://<domain> serves with a valid TLS cert. Us… |
| `sandbox_expose_port` | Expose a port running inside the sandbox as a public HTTPS URL (e.g. https://sb-<id>-<port>.sandboxes.easybits.cloud) —… |
| `sandbox_logs` | Read recent journald (systemd) logs from inside the sandbox — native log access instead of piping journalctl through sa… |
| `set_machine_runspec` | Declare HOW to build and run the app on a machine: appDir (required), plus buildCommand/startCommand or a systemd unit,… |
| `set_machine_secrets` | Give an app its environment variables: stores each value encrypted in the account vault AND wires the name into the mac… |
| `set_machine_sleep` | Put a machine in 'sleeps when idle' mode (suspends after 5 min without traffic, next request wakes it in ~200 ms with m… |
| `unset_machine_secret` | Stop injecting an env var into this machine. Restarts the app right away, so it loses the var NOW — if it needs it to b… |
### Payments (2)

Links de pago con MercadoPago (BYO): conecta tu cuenta y genera cobros. El dinero va directo a tu MP; EasyBits no retiene fondos. Ideal para que un agente cierre la venta.

| Tool | Qué hace |
|------|----------|
| `create_payment_link` | Create a MercadoPago payment link (Checkout Pro). Returns a shareable initPoint URL. Requires the user to have connecte… |
| `list_payment_links` | List your payment links (id, title, amount, status pending/paid, initPoint, payerEmail). |
### Email & Broadcasts (6)

Email transaccional + audiencia + newsletters one-shot. send_email, contactos con tags, y broadcasts con unsubscribe automático. La capa de nurturing del funnel.

| Tool | Qué hace |
|------|----------|
| `add_contact` | Add (or update) a contact in your audience. Tags let you target broadcasts. Upserts by email. |
| `create_broadcast` | Create a draft broadcast (newsletter) to send to your audience. Returns the broadcast id — send it with send_broadcast. |
| `list_broadcasts` | List your broadcasts (subject, status draft/sending/sent, total/sent/failed counts). |
| `list_contacts` | List your contacts (email, name, tags, status subscribed/unsubscribed/bounced). Optionally filter by tag. |
| `send_broadcast` | Send a draft broadcast to all subscribed contacts (filtered by its audienceTag). Skips unsubscribed/bounced contacts. R… |
| `send_email` | Send a transactional email (HTML) from EasyBits. For one-off messages — use create_broadcast/send_broadcast for newslet… |
### Scripting (Code Mode) (5)

Superficie mínima para agentes que escriben SCRIPTS en vez de invocar tools. En vez de cargar las ~140 schemas (impuesto fijo de contexto que DeepSeek no admite), el agente usa discover_tools (buscar) + run_tool (ejecutar) y/o la REST API v2 desde su Bash. Solo deja en tools/list el IO de archivos (multipart incómodo por curl); todo lo demás se alcanza sin reconectar. Patrón Anthropic 'Code execution with MCP' / Cloudflare 'Code Mode'.

| Tool | Qué hace |
|------|----------|
| `discover_tools` | Search the FULL Easybits MCP catalog — including tools NOT loaded in your current session's toolGroup. Returns name, de… |
| `get_file` | Get file metadata and a signed download URL. Returns file object with a `readUrl` field containing a presigned GET URL … |
| `list_files` | List your files (id, name, size, contentType, access, status, createdAt). Returns `{ items, nextCursor, hasMore }`. Whe… |
| `run_tool` | Dispatch any Easybits tool by name — even tools NOT loaded in the active toolGroup. Use this after `discover_tools` to … |
| `upload_file` | Create a generic user file record and get a presigned upload URL. Returns `{ file, putUrl }`. Upload bytes via PUT to `… |
### Crons (admin) (4)

Sólo superusuarios de la plataforma: estado e historial de las tareas programadas, y PROPONER correr o pausar una (el humano aprueba con su sesión). Lo usa el asistente del panel /dash/admin/crons.

| Tool | Qué hace |
|------|----------|
| `get_cron_runs` | SÓLO superusuarios. Últimas ejecuciones de UNA tarea (más reciente primero), con el `error` completo de las fallidas: {… |
| `list_crons` | SÓLO superusuarios. Tareas programadas de la plataforma EasyBits con su estado: { items:[{ key, name, schedule, status … |
| `pause_cron` | SÓLO superusuarios. PROPONE pausar (`paused:true`) o reanudar (`paused:false`) una tarea. NO la cambia: crea una acción… |
| `run_cron` | SÓLO superusuarios. PROPONE ejecutar ahora una tarea (trigger «manual»). NO la ejecuta: crea una acción pendiente y dev… |
---

## Tool Groups

Un solo endpoint MCP sirve todas las tools; el grupo decide cuántas se cargan en la
sesión. Cargar de más gasta contexto y empeora la elección de tool, así que empieza
por el grupo de tu caso y amplía si hace falta.

| Grupo | Tools | Para qué |
|-------|-------|----------|
| `design` | 95 | Canva-like: documentos como diseño universal (cualquier formato — letter, social, slide 16:9), brand kits e imágenes. Ideal para Claude.ai / Claude Design. |
| `web` | 15 | Internet para tu agente: buscar en Google, leer cualquier página aunque bloquee bots, extraer registros con esquema (Maps, Mercado Libre, Amazon, Instagram…) y rastrear sitios. Se mide en consultas (packs en /dash/packs). |
| `core` | 114 | Lo esencial: archivos, DBs, documentos, forms, websites, brand. Para agentes generalistas. |
| `ghosty` | 29 | Curado y mínimo para agentes Ghosty (DeepSeek): set DB completo + documentos + archivos + imagen (gpt-image-2). Sin brand/forms/websites/media para no floodear la elección de tool. |
| `ghostyapp` | 10 | Para el agente del TELÉFONO (app Ghosty): imágenes, buscar y leer la web, fotos e iconos de stock, captura de una página y link para compartir. Deliberadamente sin DB, sites, forms ni brand: nadie hace eso desde un móvil, y cada tool de más empeora la elección. |
| `docs` | — | Solo tools de documentos (create/update/deploy + quotations + structured_doc). |
| `sites` | — | Solo tools de websites (crear, deployar archivos, inject HTML, dominios propios). |
| `brand` | — | Solo tools de brand kits (CRUD + extract desde URL). |
| `magnet` | 20 | Toolset orquestado para crear lead magnets (PDF + landing + form). |
| `video` | 23 | Video con IA (Runway Gen-4.5) + personajes recurrentes, y proyectos de video doc-style (escenas animadas HTML → MP4 con narración kokoro). Ghosty arma escenas, música y voz, y renderiza. |
| `sandbox` | 81 | MicroVMs Firecracker para correr agentes y código aislado. Ciclo de vida (spawn/extend/suspend/resume/destroy), snapshot + fork (clonar una caja viva en N hijos copy-on-write para explorar variantes en paralelo), exec (sync + background), run-code, kernel Jupyter persistente (run_cell con estado + charts), archivos (write/read/list/delete/move/mkdir), expose_port (URL pública), sandbox_admin (passthrough admin :8787) + agent_run (Claude managed con billing por token). Ideal para harness de agentes (Claude Code, Codex) y ejecución segura. |
| `fleet` | 3 | Agentes de la flota (WhatsApp, WABA, web, Teams): listar, leer su configuración y aplicar cualquier acción de /capabilities por MCP — prompt, motor, modelo, secrets, conectores (add-mcp + set-cap-level), skills, buckets por canal, reciclar caja. Lo que hace el dashboard de /dash/flota, hecho por un agente. |
| `hosting` | 35 | Hosting de apps sobre microVMs Firecracker, always-on, tiers fijos en MXN/mes (nano…performance). launch_app pone una app en producción en UNA llamada (caja + código + build + URL pública + release + dominio con TLS) — el equivalente a `fly launch`. Además: releases con rollback y redeploy (que es también el cambio de tier), backups diarios de datos incluidos con 7 días de retención, dominios propios, logs, y delegación de acceso para co-administrar. El plan da acceso; cada máquina factura flat al mes. |
| `payments` | 2 | Links de pago con MercadoPago (BYO): conecta tu cuenta y genera cobros. El dinero va directo a tu MP; EasyBits no retiene fondos. Ideal para que un agente cierre la venta. |
| `email` | 7 | Email transaccional + audiencia + newsletters one-shot. send_email, contactos con tags, y broadcasts con unsubscribe automático. La capa de nurturing del funnel. |
| `public-safe` | 3 | Subset mínimo para agentes públicos (B2C / WhatsApp customer-facing): upload_file, create_share_link, db_select (read-only SQL con anti-stacking, anti-CROSS-JOIN, anti-sqlite_master). Sin listar workspace, sin eliminar, sin webhooks/websites/secrets, sin internet abierto. Diseñado para NanoClaw FORMMY_PUBLIC_TEMPLATE. |
| `claude` | 85 | La URL que se publica en el directorio de conectores de Claude (`/api/mcp/claude`): archivos, documentos, sitios, formularios, brand kits, bases de datos e investigación web. Sin generar imagen, video ni voz con IA, sin cobros ni hosting, sin secretos, webhooks, cajas ni flota. Strict: `run_tool` tampoco sale de aquí. |
| `scripting` | 3 | Superficie mínima para agentes que escriben SCRIPTS en vez de invocar tools. En vez de cargar las ~140 schemas (impuesto fijo de contexto que DeepSeek no admite), el agente usa discover_tools (buscar) + run_tool (ejecutar) y/o la REST API v2 desde su Bash. Solo deja en tools/list el IO de archivos (multipart incómodo por curl); todo lo demás se alcanza sin reconectar. Patrón Anthropic 'Code execution with MCP' / Cloudflare 'Code Mode'. |
| `all` | — | Todas las tools disponibles (~50+). Ideal para agentes avanzados. |

### stdio (Claude Code, Claude Desktop)
```bash
npx -y @easybits.cloud/mcp --key eb_sk_live_YOUR_KEY --tools design
```

### HTTP (Cursor, VS Code, Windsurf, Claude.ai)
```
https://www.easybits.cloud/api/mcp?tools=design
```

¿No sabes cuál? Conéctate sin `--tools` y usa `discover_tools({ query })`: busca en el
catálogo completo y `run_tool` ejecuta cualquiera sin cargarla en la sesión.
---

## Agent Editing — Cost-Efficient Document Mutations

A short playbook for agents editing documents through the MCP. Following this avoids unnecessary tokens, server work, and SDK context compactions.

### The hierarchy

1. **Read once, cache the structure.** Call `get_document({ includeHtml: false })` to obtain pageIds + `htmlHash` per page. Don't re-read the whole document between edits.
2. **Read the page you'll edit** with `get_page_html({ pageId })`.
3. **Edit small with `replace_html`.** For any change <80% of a page — a paragraph, a class, an attribute, a color — use `replace_html(old_html, new_html)`. Sends only the diff. The server returns `{ noop: true }` if the replacement produces identical HTML, so retries are safe.
4. **Edit large with `set_page_html`.** Only when you're rewriting most of the page. Sends the full page.
5. **Structure** uses `add_page`, `delete_page`, `reorder_pages`.

### Batching

When making multiple edits, pass `autoDeploy: false` on every call except the last, then call `deploy_document` once at the end. Each `autoDeploy: true` triggers a full Tailwind compile + Playwright PDF render + 3 CDN uploads (~4–10s).

### No-op responses — STOP signal

If a mutation returns `{ noop: true, reason: "..." }`, the page didn't change. **Do not retry the same call.** Either you re-sent identical HTML or your replacement was a self-edit. Re-read with `get_page_html` if you're unsure of the current state.

### Anti-patterns

- ❌ Calling `set_page_html` 7 times in a row with the same HTML "to be sure". Each call costs you tokens; if it differs from what's stored, the server runs a deploy.
- ❌ Calling `update_document` with `sections[]` to edit page HTML. Use `replace_html` or `set_page_html` instead.
- ❌ Re-reading `get_document` after every edit. Cache pageIds; compare `htmlHash` to detect external changes.
- ❌ Using `set_section_html` (CSS selector) when `replace_html` (string) would work — selectors are fragile when GrapesJS rewrites attributes.

### Detecting stale state

If your edits stopped working (`old_html not found`), the page changed since you read it. Re-read with `get_page_html` and use the fresh HTML as your `old_html` source.
