# Conectá un bot a Puentes

Puentes es un chat tipo WhatsApp donde las personas conviven con bots: Claude y GPT vienen preintegrados, y vos podés sumar los tuyos. Un bot es un participante más: entra a grupos, tiene chat 1 a 1, se lo menciona con `@Nombre` y, si lo publicás, aparece en el catálogo para que otros lo agreguen.

Actualizada el 24 de octubre de 2026. La API no tiene versión en la URL: los cambios suman campos y rutas, nunca rompen las que hay; lo que cambia se anota acá. `GET /api/version` dice qué build está corriendo.

**Cambios recientes**

- **2026-10-24**: Mensajes temporales: `PUT /api/groups/:id/disappear { ms }` (24 h, 7 días o 90 días, o `null`), `disappearMs` en el chat y `expiresAt` en cada mensaje nuevo de ese chat. Al vencer, el mensaje deja de aparecer en el historial, la búsqueda y la vista previa, y llega una sola vez como `message:updated` con `expired: true` (vacío) para sacarlo de la copia local.
- **2026-10-13**: El `mcp` de `POST/PATCH /api/bots` y `/api/bots/:id/mcp/*` son de quien hizo la IA (el servidor trabaja con su cuenta): otra persona que la edita solo puede sacarlo (`mcp: null`); si no, `403` con `code: "server_account"` (o `"server_connector"` si quien la hizo ya no puede manejar ese conector). `PUT /api/bots/:id/connectors` pide poder usar cada conector nuevo.
- **2026-10-13**: Ninguna IA tiene ya un servidor MCP adentro: las `kind: "tools"` pasan a `"ai"` y su servidor es uno de sus `connectors` (un conector privado y exclusivo de quien la hizo, con la misma cuenta), `bot.mcp` no viene más. El `mcp` de `POST/PATCH /api/bots` sigue andando y crea o edita ese conector; `/api/bots/:id/mcp/*` actúan sobre él. Toda tarjeta `mcp_connect` trae `connectorId`, `account` y, si es para alguien, `forUserId`.
- **2026-10-13**: Registrar un conector privado (`POST /api/connectors`) ya no pide el perfil de desarrollador: alcanza una cuenta verificada. El perfil completo de quien lo firma se pide para ponerlo en el catálogo, al crearlo o con `PATCH`, como a un bot.
- **2026-10-13**: `developer` de `GET /api/me` trae `enabled` (la cuenta activó Creadores) además de `complete`; `PUT /api/me/developer` con `{}` lo activa con el nombre de la cuenta, sin pedir bio ni contacto.
- **2026-10-12**: Un toque en un botón cita su tarjeta: el mensaje de la persona trae `replyTo` con el mensaje de la tarjeta que contesta (el del `cardId` de su `choice`), como una respuesta citada, salvo que el cliente haya citado otro. Con varias tarjetas parecidas en el chat, así se sabe a cuál contestó cada toque. Un toque sin `cardId` (una lista, un formulario, una app vieja) sigue sin cita.
- **2026-10-12**: Toda negativa a un invitado (una cuenta sin nada verificado) es un `403` con `code: "guest"`: también invitar por email, buscar gente y la agenda, una tarjeta de contacto, bots, organizaciones, perfil de desarrollador, copiar, pedir acceso y conectar un servicio. Los textos no cambian.
- **2026-10-11**: Buscar mensajes: `GET /api/search` (todos mis chats) y `GET /api/groups/:id/search` (uno), con el pedazo del texto y dónde está cada palabra (`snippet.ranges`). Solo personas.
- **2026-10-09**: Una tarjeta de botones (`options` con `"layout": "buttons"`) se contesta una vez por persona: el `choice` trae `cardId` (el id del mensaje de la tarjeta), Puentes guarda la respuesta de esa persona en la tarjeta (`answers`, avisa con `message:updated`) y un segundo toque de la misma persona no se guarda: devuelve su primera respuesta. Los botones `url`, `copy` y `ask`, las encuestas y los fijados siguen como antes.
- **2026-10-08**: [/openapi.json](https://app.puentes.ai/openapi.json) trae el cuerpo de cada pedido (`requestBody`), la forma de cada respuesta y todos los objetos en `components.schemas` (Mensaje, cada tarjeta, Chat, Bot, tablas, programados, conectores…). Los objetos de esta página salen de esos mismos schemas. Corregidos: `PATCH /api/me` (no cambia el nombre; sí `tz` y `linkPreviews`), `POST /api/reports` (bot, desarrollador u organización) y el texto de un `postUrl` (hasta 4000).
- **2026-10-08**: Esta página se genera del código: quién puede llamar cada ruta y su límite salen de las rutas mismas. Nuevas: [OpenAPI](https://app.puentes.ai/openapi.json), [Markdown](https://app.puentes.ai/docs.md) y [llms.txt](https://app.puentes.ai/llms.txt); secciones de tablas, programados, fijados, conectores y cuentas, errores con `code`, eventos del socket y `/api/config`. Corregidas cifras viejas (descripción 2000, instrucciones 4000, cuentas nuevas por IP) y el paginado del historial.
- **2026-10-05**: Un `webhookUrl` de un bot externo es solo `https` en producción.
- **2026-10-05**: La ficha de un link: `message.link` (la escribe el server después, por `message:updated`), `linkPreview: false` al enviar y `GET /api/link-preview`.
- **2026-10-02**: Niveles (`tier` 1, 2, 3): cada respuesta de una IA descuenta su nivel una vez, use las herramientas que use. El historial va por páginas para una persona o una API key (`?limit`, `?before`, `?to`, `?since`); un bot sin parámetros sigue recibiendo los últimos 500.
- **2026-09-30**: Reacciones: `message.reactions` (`{ userId: emoji }`) y `PUT …/messages/:mid/reaction` (solo personas).
- **2026-09-25**: Las tarjetas `picks`, `catalog` y `confirm` se guardan como `options` con `layout`: leé las dos formas.
- **2026-09-24**: Encuestas (`options` con `poll: true`) y «Marcar varias» (`select`).
- **2026-09-19**: Tablas: `GET/POST /api/groups/:id/tables` y sus filas, también para un bot.

> **Para una IA o un programa**: esta misma página en [Markdown](https://app.puentes.ai/docs.md), la referencia en [OpenAPI 3.1](https://app.puentes.ai/openapi.json) (con quién puede llamar cada ruta en `x-puentes-who` y su límite en `x-puentes-limits`) y el índice [llms.txt](https://app.puentes.ai/llms.txt). Las tres se generan del mismo origen que esta página: lo que dice una lo dicen las otras.

<a id="conceptos"></a>
## Conceptos

| Participante | Quién lo corre | Credencial |
| --- | --- | --- |
| **Persona** | Un humano desde la app | Sesión del dispositivo, o una **API key** `pk_…` que actúa como esa persona desde un script |
| **Bot AI** AI | Puentes llama al modelo (Claude, GPT) con el historial del chat. Los preintegrados y los que creás con tus instrucciones | Ninguna: lo corre el server |
| **Bot externo** BOT | Tu programa, donde quieras | **Token de bot** `pb_…` que actúa como el bot |

Todo chat es un **grupo** (`kind: "group"`, con link de invitación) o un **chat directo** (`kind: "direct"`, exactamente dos participantes, sin invitación). Al crear un bot se crea solo su chat directo con vos. Los mensajes de cualquier bot llevan `senderType: "bot"`; los de personas, `"user"`; los avisos del sistema, `"system"`.

> **Loops.** Los bots AI responden a un bot externo solo si los menciona (`@Claude …`), nunca por el modo "siempre". La cuota gratis de esas respuestas la paga el dueño del bot; con clave propia de modelo en Ajustes, se usa esa.

<a id="auth"></a>
## Autenticación

Todas las rutas viven bajo `https://app.puentes.ai/api` y hablan JSON. La credencial va en el header `Authorization: Bearer <token>`:

- `pk_…` **API key personal**: se crea en Creadores → «Para programadores» → «Nueva API key», en la web (app.puentes.ai) o en la app (Creadores se activa una vez, con una cuenta verificada, desde Ajustes → «Activar Creadores»). Actúa como vos: crea bots, lee y escribe en tus chats, reacciona, vota. Lo que no hace es administrar la cuenta (sesiones, API keys, emails, teléfonos, claves de modelo, formas de entrar): eso es de la app (`403`). Cada ruta dice en [la referencia](#rutas) si la acepta.
- `pb_…` **token de bot**: lo devuelve `POST /api/bots` (o en Creadores → «Para programadores» → «Crear un bot externo», o «Nueva IA» eligiendo «Un programa tuyo», en la web o en la app). Actúa como el bot: lee y escribe en los chats donde está. No entra a ningún chat por su cuenta (ni por link de invitación: alguien del grupo lo agrega) y no puede tocar la cuenta del dueño.

Los dos se muestran **una sola vez** y se guardan hasheados. Si perdés el token de un bot, generá otro (`POST /api/bots/:id/token`); el anterior deja de funcionar al instante. Los errores vuelven como `{ "error": "mensaje", "code"? }` con el status HTTP que corresponde: `error` es una frase en castellano para mostrar, y `code`, cuando viene, es estable para que tu programa decida (ver [Errores](#errores)).

```
curl https://app.puentes.ai/api/me -H "Authorization: Bearer pk_…"
# { "id": "…", "name": "Mauro", "color": "#65aadd", "bot": false, … }
```

```
# Python 3, sin dependencias
import json, os, urllib.request
req = urllib.request.Request('https://app.puentes.ai/api/me', headers={'Authorization': f"Bearer {os.environ['PUENTES_TOKEN']}"})
print(json.load(urllib.request.urlopen(req)))
```

<a id="crear"></a>
## Crear un bot

`POST /api/bots` con tu API key. Devuelve el bot, su chat directo con vos y, si es externo, el token.

```
curl -X POST https://app.puentes.ai/api/bots \
  -H "Authorization: Bearer pk_…" -H "Content-Type: application/json" \
  -d '{
    "runtime": "api",
    "name": "Alertas",
    "description": "Avisos del datacenter",
    "webhookUrl": "https://mi-servidor.com/puentes",
    "public": false
  }'
# { "bot": { "id": "…", "name": "Alertas", "runtime": "api", "tokenPrefix": "pb_ab12cd…", "webhookSecret": "…", … },
#   "token": "pb_…",            <- guardalo: no se vuelve a mostrar
#   "group": { "id": "…", "direct": true, "peer": { "name": "Alertas" }, … } }
```

| Campo | Descripción |
| --- | --- |
| `runtime` | `"api"` (externo, con token) o `"ai"` (lo corre Puentes con un modelo). Default `"ai"`. |
| `name` | Hasta 40 caracteres, único entre tus bots. `AI`, `IA` y `todos` están reservados. |
| `description` | Opcional, hasta 2000 caracteres. Se ve en la ficha. |
| `avatar` | Opcional: la foto del bot, la URL que devolvió `POST /api/upload` con una imagen (solo se aceptan las del bucket de Puentes); `null` la quita. También en `PATCH /api/bots/:id`. |
| `public` | `true` lo publica en el catálogo: cualquiera puede agregarlo a sus grupos o hablarle 1 a 1. |
| `webhookUrl` | Solo `api`. `https://` pública (ver [Webhook](#webhook)). Se puede setear después con `PATCH`. |
| `provider`, `tier` | Solo `ai`. `provider`: `"anthropic"` o `"openai"`. `tier`: el nivel, `1` Básico, `2` Medio o `3` Avanzado (default: Básico); Puentes elige el modelo de ese nivel y lo actualiza solo cuando cambia. Cada respuesta pagada con mensajes gratis o créditos descuenta 1, 2 o 3 por respuesta, use las herramientas que use. `GET /api/config` trae los niveles de cada proveedor (`providers.*.tiers`). Un `model` todavía se acepta y cuenta como su nivel. |
| `mode` | Solo `ai`. El default del bot: `"mention"` (responde cuando lo mencionan) o `"always"` (en toda la conversación). **Cómo responde en cada grupo se elige al agregarlo** (`POST /api/groups/:id/bots { botId, mode }`) y viaja en `members[].mode`; en un chat directo responde siempre. |
| `shortDesc`, `category`, `tags`, `language`, `countries` | La tarjeta del catálogo: una línea (≤ 80), un id de categoría (los de `botCategories` en `/api/config`), hasta 10 etiquetas, idioma (`es` \| `en` \| `pt-BR`) y países ISO-2 (vacío = todos; fuera de ellos no se lista, pero con el link se usa igual). |
| `visibility` | `"private"` (el dueño, los grupos donde ya está y quien recibió el bot compartido: una tarjeta, un link o «agregar persona» del dueño valen en todos los niveles) \| `"link"` (lo mismo, con el compartir a la vista en la interfaz) \| `"authorized"` (los compartidos más cualquier email verificado de los dominios del bot, `PUT /api/bots/:id/domains`; con `requestsOpen` quien llega por el link puede pedir acceso) \| `"catalog"` (todos; exige el perfil de desarrollador completo: 409 con `missing`). `public` sigue existiendo y equivale a `visibility = "catalog"`. |
| `systemPrompt` | Solo `ai`. Instrucciones extra, hasta 4000 caracteres. |
| `menu`, `actions` | Botones arriba del campo de texto en el chat directo con el bot: `[{ title (≤ 24), text? (≤ 200) }]`, hasta 6 en `menu` (fijos) y 20 en `actions` (las que Puentes puede sugerir después de una respuesta). Tocar uno manda `text` (o el título) como mensaje de la persona. |
| `alias` | El nombre corto para mencionarlo (`@alias`): de 2 a 20 letras, números, `-` o `_`. Es el sugerido: en cada grupo puede llevar otro (`PATCH /api/groups/:id/bots/:botId { alias }`). Errores con `code: alias_*`. |
| `orgId` | Crear el bot a nombre de una organización tuya (sus miembros lo editan). |
| `requestsOpen` | Con `visibility: "authorized"`: quien llega por el link puede pedir acceso. |
| `pay`, `ownerKey`, `capMonthUsd`, `perUserDailyReplies`, `perUserMonthUsd` | Solo `ai`. Quién paga las respuestas: `"user"` (la cuota gratis o la clave de cada persona, el default) o `"owner"` (la clave del desarrollador, `ownerKey`, que se guarda cifrada y nunca vuelve; `DELETE /api/bots/:id/owner-key` la olvida). Con `owner` nadie gasta su cuota; los topes (`null` = sin límite) son el total del mes en USD, y por persona las respuestas de las últimas 24 h y los USD del mes. Al llegar a un tope la IA publica una línea de sistema con el adjunto `{ type: "cap", code, botId }` y no responde. Solo un admin del bot los cambia. |
| `clonable`, `idea` | Solo en `PATCH`, solo un admin del bot. `clonable: true` deja que cualquiera que lo use haga su copia (`POST /api/bots/:id/clone`); no se puede con un bot externo, uno que usa un servicio con la cuenta de quien lo hizo, o una copia que sigue a su original (`code: clone_*`). `idea: { title (≤ 40), blurb? (≤ 80), copyName? (≤ 36), opening? (≤ 600) }` lo muestra como idea para copiar en el catálogo (necesita `clonable` y el catálogo: `code: idea_needs_catalog`). |
| `mcp` | Solo `ai`. `{ url, label?, secret?, auth? }`: un servidor [MCP](https://modelcontextprotocol.io) cuyas herramientas el bot puede usar. `url` pública `https://`; `secret` es la clave si el servicio da una (se guarda cifrada, nunca se devuelve); `auth` (`"none" \| "bearer" \| "oauth"`) se detecta solo si no lo mandás. Ver [Bots con herramientas](#mcp). `null` en `PATCH` desconecta. |

Un bot AI creado por API es igual al que se crea desde la app: un "Pirata" con `systemPrompt: "Hablás como pirata"` y `public: true` queda en el catálogo para todos.

<a id="mcp"></a>
### Bots con herramientas (MCP)

Una IA (`ai`) recibe en cada respuesta las herramientas de sus **conectores** (ver [Conectores y cuentas](#ref-conectores)) y Puentes ejecuta las que el modelo pide (hasta 6 rondas, 30 s por herramienta, 90 s por respuesta; cada respuesta descuenta de la cuota gratis de quien le habla lo que cuesta su nivel, una vez, sin importar cuántas rondas use). Cada conector dice con qué cuenta trabaja la IA: la de cada persona, una por chat o la de quien la hizo (`owner`). Si falta esa cuenta, Puentes publica una tarjeta `mcp_connect` con `connectorId`, `account` y, si es para alguien, `forUserId`, y quien tiene que conectarla lo hace desde ahí (OAuth con la `url` de `POST /api/me/connections`, o la clave en la misma tarjeta); al volver, el server guarda los tokens cifrados. Una IA no tiene más un servidor adentro (desde el 2026-10-13): el `mcp` de `POST/PATCH /api/bots`, el camino viejo, crea un conector privado de quien hizo la IA, con su cuenta, y `POST /api/bots/:id/mcp/connect` lo conecta. Cada respuesta lleva el rastro en `attachments[].type = 'tools'` y el socket avisa `ai:tool { groupId, aiId, tempId, tool, label, phase: 'start' | 'end', ok }` mientras la llama (y `phase: 'cards'` cuando ya terminó de escribir y está preparando las tarjetas que devolvieron las herramientas: el texto ya está completo y `message:new` llega enseguida, antes que `ai:done`). Solo disponible si `GET /api/config` trae `mcpEnabled: true`. Un servidor en una red privada no es alcanzable (Puentes corre en la nube): Home Assistant, por ejemplo, con Nabu Casa o un túnel. **Fotos y archivos**: el bot ve en su contexto el link público de cada foto, video o archivo del chat, y si una herramienta recibe una URL (un menú, un ticket, un PDF) se lo pasa como argumento; el archivo en sí no viaja, tu servidor lo descarga de esa URL si lo necesita.

<a id="mcp-meta"></a>
### Para quien escribe un servidor MCP: quién habla y cómo avisar después

Cada `tools/call` lleva `_meta.puentes` con el autor del mensaje que disparó al bot: `{ userId, userName, senderType: 'user' | 'ai' | 'bot', groupId, callbackUrl?, postUrl?, country?, tz?, direct? }`. Sirve para guardar algo **por persona o por chat** en vez de por bot (un carrito por chat, una cuenta de Google por persona). Es una afirmación de Puentes, no una prueba: lo que identifica a Puentes es la credencial del bot; si habló otra AI, `userId` va vacío. `country` (ISO 3166-1 de dos letras: el país que la persona eligió en su perfil, si no el de su teléfono verificado, si no desde dónde escribió) y `tz` (zona horaria IANA: la del perfil o del dispositivo de la persona, si no la del chat) son **pistas** para ordenar resultados y mostrar horas («River» para alguien de Uruguay es el de Montevideo primero); llegan solo cuando se conocen. `direct` (`true` o `false`) dice si el chat es el directo de la persona con el bot: sirve para guardar la dirección del chat privado (`postUrl`) sin que una llamada desde un grupo la pise.

`callbackUrl` viene solo cuando habló una persona y vale 24 horas: es la forma de que el bot retome solo más tarde. Si tu herramienta no puede terminar ahí (necesita que la persona ingrese en tu página, o el trabajo tarda), contestá con lo que la persona tiene que hacer (un link) y guardate la URL; cuando termine, hacé `POST callbackUrl` con JSON `{ "text": "Ana conectó su cuenta. Retomá lo que estaba pidiendo." }` (hasta 500 caracteres). Puentes responde `202`, publica ese texto como mensaje de sistema en el chat y despierta al bot obligado a responder: con la conversación a la vista, vuelve a llamar a la herramienta que había quedado a medias. `404` si el token venció o el bot ya no está en ese chat. La URL es una capacidad (quien la tenga hace hablar al bot en ese chat hasta que venza): guardala cifrada o dentro de un token firmado, y no la loguees.

<a id="mcp-accounts"></a>
### Para quien escribe un servidor MCP: cargar el servidor no es usarlo (dos credenciales)

Quien registra un conector y quien lo usa son personas distintas, y Puentes manda **dos credenciales que no se mezclan**. La **clave del servidor** la pone quien carga el conector (desde la IA, en «Qué puede usar» › «Agregar herramientas», o «Agregar un servicio» › «Cargar mi propio servidor MCP»; o en Creadores › «Servidores MCP») y dice «esto es Puentes», nada más. La **cuenta** es de cada persona: la conecta ella desde el chat (OAuth, o una clave que pega) y Puentes la guarda cifrada por persona; un conector puede trabajar con la de cada uno (`person`), con una por chat (`chat`) o con la de su creador (`owner`).

Con OAuth, cada llamada de una persona lleva su token en `Authorization: Bearer …` y la clave del servidor en `X-Api-Key`. **Sin nadie detrás** (el «Probar» de quien carga el conector, o un chat donde todavía nadie conectó su cuenta) llega **solo** `X-Api-Key`: con eso tu servidor debería dejar hacer `initialize` y `tools/list`, y nunca abrir la cuenta de alguien, nombre el chat que nombre `_meta.puentes`. Cuando Puentes quiere saber cómo entra cada persona, sondea con la clave del servidor *como* bearer y sin token: contestá `401` con `WWW-Authenticate: Bearer resource_metadata="…"` y lo lee como OAuth.

**Herramientas que no necesitan cuenta.** Buscar un precio o listar las ofertas del día no son de nadie. Marcalas en su definición con `_meta: { puentes: { account: false } }`: Puentes las llama igual en un chat sin cuenta conectada (con la clave del servidor sola), y solo cuando la IA va a usar una de las *otras* le muestra a la persona el botón para conectar. Sin la marca, toda herramienta de un conector sin cuenta pide conectarla primero. Solo cuenta `false` explícito.

**El nombre de una cuenta (`puentes_account`).** Una persona puede tener varias cuentas en tu servicio (la del trabajo y la personal; o, si para vos «la cuenta» es un archivo, una por archivo). Para que en «Mis cuentas» no se llamen todas igual, sumá una herramienta **reservada** `puentes_account`, sin argumentos, que responda `_meta: { puentes: { account: { "label": "Recetas · d…@gmail.com" } } }` (hasta 60 caracteres): Puentes la llama una vez, apenas la cuenta se conecta, y usa ese nombre. Toda herramienta cuyo nombre empieza con `puentes_` es de Puentes: **nunca se le ofrece al modelo ni se le lista a una persona**. Primer uso: el conector «Planilla de Google» (`puentesai-mcp-google`, `/planilla/mcp`), donde conectar es elegir *un* archivo del Drive (scope `drive.file` + el selector de Google) y ese archivo es la cuenta.

<a id="mcp-marcas"></a>
### Herramientas que leen, que cambian algo y que piden confirmación

Puentes lee de tu `tools/list` una sola anotación: `annotations.readOnlyHint: true` dice que la herramienta solo lee (buscar, listar). Toda herramienta sin esa marca se trata como que **cambia algo** (`changes`: agrega al carrito, manda, borra), y así se la muestra a la persona. `destructiveHint`, `idempotentHint` y `openWorldHint` se ignoran.

Quien registra el conector puede, para cada herramienta (`toolLabels` en [`POST /api/connectors`](#ref-conectores)): ponerle un nombre para personas (`label`), marcarla como que cambia algo aunque el servidor diga que solo lee (`changes: true`, nunca al revés), esconderla del todo (`hidden: true`) y hacer que **pida confirmación** antes de correr (`confirm: true`). Esa es la única forma de que una herramienta pregunte: las anotaciones nunca lo hacen. En la ficha pública de un conector cada herramienta viaja como `{ name, label, changes?, confirm? }`.

<a id="mcp-post"></a>
### Para quien escribe un servidor MCP: hablar por tu cuenta (`postUrl`)

Tu servidor solo contesta cuando lo llaman. Para decir algo **cuando a vos te pasa algo** («salió tu pedido», una alarma, un carrito que cambió del lado de la tienda) no hace falta un segundo bot ni un token: cada `tools/call` trae `_meta.puentes.postUrl`, una dirección **de ese bot en ese chat**. Guardala por `groupId` y, cuando quieras, hacé `POST postUrl` con JSON `{ "text"?, "card"?, "pin"? }`: `text` y `card` salen como un mensaje **del bot** (una burbuja, con su notificación; la tarjeta es cualquiera de las de abajo, con la misma limpieza), y `pin` pone, actualiza o saca un [fijado](#mcp-pin). Responde `202`; `400` si no trae nada, `404` si la dirección ya no vale, `429` pasados los 30 mensajes por minuto del bot.

No vence por tiempo: vale mientras el bot siga en ese chat (si lo sacan, muere, y volver a sumarlo da una dirección nueva en la próxima llamada). Viene también cuando habló otra AI. **No despierta a la IA ni le cobra a nadie**: es el bot hablando. La diferencia con `callbackUrl`: aquella es de una persona, dura 24 horas, publica una línea de sistema y **hace que la IA retome** (un login que terminó); `postUrl` es del chat, dura lo que dure el bot ahí y solo publica. Las dos son capacidades: quien las tenga hace hablar al bot en ese chat, así que guardalas cifradas y nunca las loguees.

<a id="mcp-send"></a>
### Para quien escribe un servidor MCP: escribirle a alguien en nombre de la persona (`send`)

«El lunes escribile a Flor que llevo el postre»: tu herramienta no manda nada, **se lo pide a Puentes**. Solo se atiende cuando la herramienta la usa **el asistente «Puentes»**: con cualquier otra IA el pedido se ignora, porque nadie más escribe en nombre de una persona. En el resultado de `tools/call` sumá `_meta.puentes.send = { "to": "Flor", "text": "Llevo el postre", "when"?: "lunes 9:00" }`. `to` es un nombre en palabras (un contacto o un grupo): Puentes lo busca **entre los chats de quien estaba hablando**, y tu servidor nunca ve esa lista; si hay varias coincidencias o ninguna, el chat le pregunta a la persona. `when` usa la gramática de los programados (una vez, todos los días, días de semana, día del mes) y sin él sale ya. Opcionales: `via` (`"whatsapp"` | `"telegram"`) cuando la persona nombró el canal, y `ref` (el número con código de país, o el usuario de Telegram): entonces no sale por Puentes; a esa hora la persona recibe un botón que abre ese chat con el texto listo y lo manda ella. Por WhatsApp sin número, Puentes le contesta a tu herramienta que lo pida. El mensaje sale **como un mensaje de la persona**, con una marca de «enviado por un asistente», así que un `@Bot` adentro despierta a ese bot.

Las reglas: solo vale cuando **la persona misma** lo pidió, y siempre a su nombre (no desde un `callbackUrl`, un programado u otra IA: ahí se descarta en silencio). En su **chat directo con el bot** se programa; en un **grupo**, Puentes le muestra a esa persona sola una tarjeta «¿Lo mando en tu nombre?» y no sale nada hasta que toca «Mandar» (en un grupo no hay contactos de afuera ni lista de chats). No hay un permiso aparte que conceder: lo que lo mantiene honesto es que Puentes lo dice al programarlo, lo dice en el chat al salir, y la persona lo ve y lo cancela desde Ajustes › Mensajes programados. Topes: texto de hasta 2000, 5 envíos programados a la vez y 10 mensajes por día por persona, nada más seguido que una vez por día. El modelo recibe una nota con lo que pasó (programado, esperando la tarjeta, no encontré a quién) para que se lo cuente a la persona.

<a id="mcp-cards"></a>
### Para quien escribe un servidor MCP: una lista para elegir (tarjeta `options`)

Cuando la respuesta es "¿cuál de estos?" (varios productos, varios horarios), la herramienta puede mostrarla como tarjeta en vez de texto: en el resultado de `tools/call`, además del `content` de siempre (que es lo que lee el modelo), mandá `_meta.puentes.card`:

```
{ "type": "options", "title": "Yerba Playadito", "multi": false,
  "items": [ { "id": "00186014", "title": "Yerba Playadito 500 g", "subtitle": "Con palo", "detail": "$3.186",
               "was": "$4.000", "badge": "20% OFF",
               "image": "https://…/foto.jpg", "url": "https://…/producto" } ] }
```

Hasta 50 ítems (el chat muestra 10 y un botón «Ver más» despliega el resto sin pasar por el modelo); `id` (64) y `title` (120) son obligatorios, `subtitle` (160), `detail` (40, va a la derecha: un precio, una hora), `was` (40, el precio anterior, que se dibuja tachado arriba de `detail`; sin `detail` se ignora), `badge` (24, una etiqueta debajo: "20% OFF", "3x2"), `image` y `url` (solo `https`) opcionales. `was` y `badge` son para una oferta: poné en `detail` el precio que la persona paga hoy y en `was` el de antes, y no uses el tachado para un precio que exige una condición (llevar tres, una tarjeta de socio) — eso va en el `subtitle` dicho con todas las letras. Puentes copia cada imagen a su propio bucket (JPEG, PNG, WebP o GIF de hasta 1,5 MB; la que no se puede copiar se omite) y pega la tarjeta debajo de la respuesta del bot, con `botId`. Hasta 3 tarjetas por respuesta. La persona toca un ítem (con `multi: true`, tilda varios y aprieta "Elegir") y eso sale como un mensaje suyo, "Elegí: …", con un adjunto `{ "type": "choice", "botId", "ids": ["00186014"], "labels": ["Yerba Playadito 500 g"] }` que despierta a ese bot aunque en el grupo nadie lo haya mencionado; el modelo lo lee como `[eligió: Yerba Playadito 500 g (id 00186014)]` y vuelve a llamar a tu herramienta con ese id. Contá en la descripción de la herramienta qué hacer cuando llega una elección.

**Una tira de carteles** (2026-09-15): con `"layout": "banners"` los ítems se dibujan como imágenes anchas que se corren de costado — para cuando la imagen *es* el mensaje y ya trae su propio texto (los carteles de campaña de un súper, las portadas de algo). Ahí el `title` es solo la etiqueta que lee un lector de pantalla, y no hace falta `detail`. La letra chica va en `subtitle` y el link a las bases en `url`: el chat los pone detrás de una **(i)** en la esquina del cartel: tocarla abre un globo con el texto y un "Ver legales" que va a tu `url` en otra pestaña (con mouse también aparece al pasar por encima). Así no tapa el arte, se puede leer en el celular, y nadie termina en otro sitio por tocar un botón de 22 píxeles. No repitas "ver legales" en el `subtitle`: el link ya lo dice. La altura la fija el chat y el ancho lo pone cada imagen, así que no se recorta el arte de nadie. Tocar uno manda la misma elección que cualquier lista: tu herramienta recibe el id y contesta con lo que haya detrás (por ejemplo, los productos de esa campaña como `carousel`).

**Una góndola** (2026-09-15): con `"layout": "carousel"` la misma lista se dibuja como tarjetas que se corren de costado, con la foto grande, la etiqueta de descuento encima y el precio anterior tachado debajo del nombre. Es para cuando la foto y el precio son el mensaje (una fila de ofertas) y nadie va a leer diez nombres. En escritorio aparecen flechas al pasar el mouse, porque una rueda de mouse no se mueve de costado. No cambia nada más: los mismos `items`, la misma elección de vuelta; si los ítems piden cantidad, cada tarjeta suma un botón `+` que agrega sin abrir nada. En una lista normal el chat muestra 10 y pagina; un carrusel los muestra todos, porque se corre de costado.

**Una cantidad** (2026-09-15): si lo que sigue a elegir es "cuántos", la tarjeta puede pedir un control en vez de que la persona escriba otro mensaje. Va `input` en la tarjeta, y un ítem puede traer el suyo para pisarlo:

```
{ "type": "options", "input": { "label": "Agregar", "min": 1, "max": 30, "step": 1, "default": 1, "unit": "u." },
  "items": [ { "id": "…", "title": "Queso rallado 150 g" },
             { "id": "…", "title": "Queso cremoso x kg",
               "input": { "label": "Agregar", "min": 0.25, "max": 20, "step": 0.25, "default": 1, "unit": "kg" } } ] }
```

`label` (24) es el botón y es obligatorio; `unit` (8) la palabra al lado del número ("kg"), y `min` / `max` / `step` / `default` el rango (por default 1, 100, 1 y 1; lo que no entra en el rango se acomoda solo). El `input` del ítem existe para las listas mezcladas: una búsqueda de "queso" trae el rallado en paquete y el cremoso cortado al momento, y el stepper de uno no sirve para el otro. Con `input`, tocar una fila abre debajo un `− [2] +` con el botón en vez de mandar la elección en el acto, y la elección sale con la cantidad: `{ "type": "choice", "botId", "ids": ["00186014"], "labels": […], "values": [2] }`, que el modelo lee como `[eligió: Yerba Playadito 500 g × 2 (id 00186014)]`. Una tarjeta con `input` (o con un ítem que lo traiga) es siempre de a una fila: `multi` se apaga, porque una cantidad por fila es un formulario y no una lista de tildes. Decí en la descripción de la herramienta que cuando la elección viene con cantidad no hay que volver a preguntar cuántos.

<a id="mcp-picks"></a>
### Cuando resolvés varias cosas de una (tarjeta `picks`)

"Necesito leche, pan, yerba y tomates" no se contesta con cuatro listas: son cuatro listas para recorrer, y una respuesta admite tres tarjetas. La tarjeta `picks` es lo contrario de `options`: en vez de una pregunta con muchas respuestas, son **muchas respuestas ya dadas**, cada una con sus alternativas a un toque. Resolvé vos (agregar al carrito, reservar los horarios) y mostrá qué hiciste:

**Forma nueva** (2026-09-24): también se escribe `{ "type": "options", "layout": "picks", "title", "rows" }`, con las mismas `rows`; las dos formas valen y significan lo mismo. **Desde 2026-09-25 Puentes la guarda en la forma nueva**: si leés el historial o recibís el webhook, una tarjeta nueva llega como `options` con `layout` (las de antes, con su tipo de siempre). Leé las dos.

```
{ "type": "picks", "title": "Puse 4 de 5 · carrito $21.840",
  "rows": [
    { "id": "leche", "label": "leche", "picked": "00253104", "qty": 1,
      "input": { "label": "Usar", "unit": "u." },
      "items": [ { "id": "00253104", "title": "Leche La Serenisima 1 L", "detail": "$3.060" },
                 { "id": "00251289", "title": "Leche Ilolay 1 L", "detail": "$2.890" } ] },
    { "id": "fideos", "label": "fideos", "note": "No encontré fideos en Coto", "items": [ … ] }
  ] }
```

Cada renglón es una cosa que te pidieron: `id` (64) lo identifica, `label` (60) es cómo lo dijo la persona, `picked` el `id` de lo que elegiste **entre los `items` de ese mismo renglón** (si apunta a otro lado se ignora), `qty` la cantidad, `input` el control para cambiarla y `note` (120) por qué no pudiste resolverlo. Los `items` son exactamente los de `options` (con `was`, `badge`, `input`): es una lista de listas con un elegido, no un formato nuevo. Hasta 10 renglones, 10 alternativas cada uno y 60 en total (cada foto es una copia a nuestro bucket).

Un renglón **sin `picked` se abre solo**, con su `note`: algo que falta en silencio es peor que algo que pide que lo elijan. La persona toca "Cambiar" en los que no le convencen, **los cambios se acumulan** y sale un solo mensaje con un `choice` que dice a qué renglón pertenece cada id: `{ "type": "choice", "botId", "ids": ["00251289"], "rows": ["leche"], "values": [1], "labels": […] }`, que el modelo lee como `[eligió: leche: Leche Ilolay 1 L (id 00251289)]`. La tarjeta entera la lee como `[puse "…": leche → Leche La Serenisima 1 L × 1 $3.060 (id …) · fideos → sin resolver]`. Decí en la descripción de la herramienta que **no repita la lista en el texto**: la tarjeta ya la muestra.

**Un botón** (tarjeta `link`): cuando la persona tiene que ir a una página tuya (ingresar en tu servicio, autorizar algo), en vez de pegar la URL en el texto mandá `_meta.puentes.card = { "type": "link", "label": "Conectar Google", "url": "https://…/conectar?t=…", "title": "Diego, conectá tu cuenta de Google" }`: `label` (40, el botón) y `url` (`https`) obligatorios, `title` (120) opcional, una línea arriba. Se pega debajo de la respuesta del bot y el modelo lo ve como `[botón "Conectar Google"]`, así no repite la URL. Hasta 3 tarjetas por respuesta, contando `options` y `link`.

<a id="mcp-buttons"></a>
### Botones de respuesta rápida, y una lista con secciones

**Botones** (2026-09-17, como los *reply buttons* de WhatsApp): con `"layout": "buttons"` la tarjeta `options` se dibuja como hasta 4 pastillas debajo de la burbuja, con el título de cada una (40 caracteres). Tocar una manda ese título como mensaje de la persona con el `choice` de siempre (`{ "ids": ["18"], "labels": ["Sí, a las 18"], "cardId": "…" }`). Cada persona la contesta una vez (2026-10-09): `cardId` es el mensaje de la tarjeta, Puentes guarda en ella lo que eligió cada uno (`answers: { userId: { ids, msgId, at } }`, avisa con `message:updated`) y quien ya contestó ve su botón marcado y los otros apagados; un segundo toque suyo no se guarda (vuelve su primera respuesta). El mensaje del toque cita la tarjeta (2026-10-12: `replyTo` es el mensaje de la tarjeta, como una respuesta citada), así con varias tarjetas parecidas se sabe a cuál contestó. En un grupo cada uno contesta lo suyo. Un fijado de bot se puede tocar siempre. Un ítem puede hacer otra cosa en vez de responder, una por botón: `url` (abre el link, el *CTA URL* de WhatsApp), `copy` (64: copia ese texto al portapapeles y avisa, sin mandar nada: un cupón, un código), o `ask` con `"location"`, `"photo"`, `"file"` o `"contact"` (le pide a la persona su ubicación, una foto con la cámara, un archivo o un contacto de su agenda: la respuesta es su mensaje con ese adjunto más el `choice` del botón, así sabés a cuál contesta; un contacto llega como palabras, «Nombre: número», y donde el navegador no abre la agenda la persona lo escribe en una hojita). El modelo lee la tarjeta como `[botones: "Sí" (id 18) · "Pagar" (abre un link) · "Foto" (pide una foto)]`.

```
{ "type": "options", "layout": "buttons", "items": [
  { "id": "18", "title": "Sí, a las 18" },
  { "id": "pay", "title": "Pagar la seña", "url": "https://…" },
  { "id": "cup", "title": "Copiar código", "copy": "BIENVENIDO20" },
  { "id": "ubi", "title": "Compartir mi ubicación", "ask": "location" } ] }
```

**Una IA también los escribe**, con o sin herramientas: si su respuesta termina en una pregunta cerrada puede cerrar con un bloque ````buttons`, una respuesta por línea (hasta 4), que Puentes convierte en la tarjeta y saca del texto; una línea `Pagar | https://…` es un botón que abre ese link, solo si la URL la devolvió una herramienta en esa misma respuesta.

**Encuesta** (2026-09-24): con `"poll": true` (y `"multi"` si se eligen varias) un toque es el voto de esa persona, no un mensaje; todos ven cuántos votos tiene cada fila y quién votó. Los votos los escribe Puentes (`votes` en el adjunto, por `message:updated`); no se mandan desde afuera. Cuando votaron todos, una IA que mandó la encuesta recibe el resultado.

**Marcar varias** (2026-09-24): con `"select": { "label": "Quitar" }` una lista de filas muestra «Seleccionar»; la persona marca varias y un botón con tu palabra las manda juntas: `choice` con `ids` y `"action": "Quitar"`, y el texto «Quitar: Leche, Pan». Sirve para sacar cosas de un carrito, archivar, marcar pagados.

**Secciones y lista plegada**: la misma `options` admite `"sections": [{ "id", "title", "items": […] }]` (hasta 10 encabezados sobre las filas de siempre; se guardan planas con `section` en cada ítem) y `"collapsed": "Elegir horario"` (24): con eso la lista no ocupa la burbuja sino que se abre en una hoja al tocar ese botón, como el *list message* de WhatsApp. Ni un carrusel ni los botones se pliegan.

<a id="mcp-catalog"></a>
### Un catálogo con carrito (tarjeta `catalog`)

**Forma nueva** (2026-09-24): también se escribe `{ "type": "options", "layout": "catalog", … }` con los mismos campos (las `sections` anidadas como abajo, o planas como en cualquier `options`); las dos formas valen y significan lo mismo. Puentes la guarda plana, como `options` con `layout: "catalog"` (ítems con `section` y `sections` como títulos): así llega al historial y al webhook.

Como el mensaje de catálogo de WhatsApp, pero los productos viajan en el mensaje: una burbuja chica (miniatura, título, una línea, un pie y el botón) que abre el catálogo entero para recorrer por secciones, con la ficha de cada producto, una cantidad y un **carrito que se mantiene** en el dispositivo por chat y bot: cualquier tarjeta de catálogo tuya en ese chat lo abre con lo que ya tenía, y se vacía cuando la persona lo manda. Todo sale en **un** mensaje: `{ "type": "choice", "ids": ["l1", "q1"], "values": [2, 0.5], "labels": […], "items": […] }` (hasta 30 renglones), que el modelo lee como `[eligió: Leche 1 L × 2 (id l1), Queso × 0,5 (id q1)]`.

```
{ "type": "catalog", "title": "Ofertas de la semana", "body": "Lácteos, almacén y limpieza…", "footer": "42 productos",
  "thumbnail": "https://…", "label": "Ver catálogo", "basket": "Carrito", "send": "Enviar pedido", "sent": "Pedido",
  "input": { "label": "Agregar", "min": 1, "max": 30 },
  "sections": [ { "id": "lacteos", "title": "Lácteos", "items": [ { "id": "l1", "title": "Leche 1 L", "detail": "$1.750", "image": "https://…" } ] } ] }
```

Los `items` son los de siempre (con `was`, `badge`, `url` y su propio `input` para lo que va por kilo); hasta 10 secciones y 60 productos por tarjeta (cada foto se copia al bucket; sin `thumbnail` se usa la primera foto). Es genérico a propósito: las cuatro palabras (`label`, `basket`, `send`, `sent`, 24 cada una) son tuyas, así que el mismo esquema sirve para un conteo de inventario (`detail` «12 en stock», el `input` de cada ítem arrancando en el stock actual, `send` «Guardar conteo») o para pedir repuestos.

<a id="mcp-form"></a>
### Una dirección, o cualquier formulario corto (tarjeta `form`)

Un botón en la burbuja que abre una hoja con respuestas listas arriba (`saved`: las direcciones que ya tenés de esa persona, como filas) y hasta 8 campos debajo. Tocar una fila guardada manda su id; completar los campos manda `form: { id: valor }`. Las dos como un `choice`, así te despierta a vos.

```
{ "type": "form", "label": "Elegir dirección", "title": "¿A dónde va el pedido?", "send": "Usar esta dirección",
  "saved": [ { "id": "casa", "title": "Casa · Av. Cabildo 2040", "subtitle": "Belgrano" } ],
  "fields": [ { "id": "calle", "label": "Calle y número", "required": true, "placeholder": "Av. Cabildo 2040" },
              { "id": "cp", "label": "Código postal", "type": "number" },
              { "id": "zona", "label": "Zona", "type": "select", "options": ["Norte", "Sur"] } ] }
```

Tipos: `text` (default), `number`, `tel`, `email`, `date`, `textarea`, `select` (con `options`). Los ids son `[a-zA-Z0-9_-]` de hasta 32. La vuelta se lee como `[eligió: Nueva (id form); calle: Av. Cabildo 2040, cp: 1428]` y el texto del mensaje repite «Calle y número: … · Código postal: …». No se valida nada por país: la validación es tuya.

<a id="mcp-pin"></a>
### Algo siempre a la vista arriba del chat (un fijado, `pin`)

Para lo que está **en curso** y conviene tener a un toque: un carrito, un pedido, un turno, un conteo. Un fijado es una pastilla arriba del chat (una etiqueta, un número, un detalle, un ícono) que al tocarla abre **cualquiera de las tarjetas de arriba** (`options`, `picks`, `catalog`, `form`, `link`) más hasta 4 botones. No es un mensaje: no hace ruido en el chat ni cuenta en el tope de 3 tarjetas por respuesta. Las palabras son tuyas; Puentes no sabe qué es un carrito.

```
// en el resultado de una herramienta, al lado de `content`
"_meta": { "puentes": { "pin": {
  "id": "cart", "label": "Carrito", "badge": "5", "detail": "$12.345", "icon": "basket",
  "scope": "chat",                       // o "person": solo de quien estaba hablando
  "card": { "type": "options", "title": "Carrito · total $12.345", "items": [ … ] },
  "buttons": ["Finalizar compra", "Actualizar"]
} } }
```

El último que mandás con un `id` reemplaza al anterior: devolvelo en cada herramienta que cambie ese estado. `{ "id": "cart", "remove": true }` lo saca. Un toque en la tarjeta o en un botón vuelve como cualquier otro: un mensaje de la persona con un `choice` para tu bot. Íconos: `basket, list, tasks, money, clock, store, food, home, plane, cake, ball, star, bell, file, location, people, form, key`. Hasta 4 fijados por bot y por chat. Un **bot externo**, que no tiene resultados de herramientas, publica lo mismo con su token: `PUT /api/groups/:id/pins/:pinId` (el mismo cuerpo sin `id`; `forUserId` lo hace de esa persona sola) y `DELETE` para sacarlo. Los clientes los leen con `GET /api/groups/:id/pins` y los eventos `pin:updated` / `pin:removed`.

<a id="meterlo"></a>
### Meterlo en un chat

- Desde la app: en el grupo, botón **Bot** (o en la info del grupo, "+ Agregar bot"). Por API: `POST /api/groups/:id/bots { "botId": "…" }` como miembro del grupo.
- Un bot nunca entra solo: con su token, `POST /api/invites/…/join` responde `403`. Siempre lo suma alguien que ya está en el chat.
- Chat 1 a 1: cualquiera que pueda usar el bot (builtin, público o suyo) hace `POST /api/direct/:botId`. Entre personas, hay que compartir algún grupo.

<a id="enviar"></a>
## Enviar mensajes

```
curl -X POST https://app.puentes.ai/api/groups/<chatId>/messages \
  -H "Authorization: Bearer pb_…" -H "Content-Type: application/json" \
  -d '{ "text": "🔴 @todos rack 4 sin energía", "replyTo": null }'
# -> el mensaje creado (ver Objetos)
```

- `text` hasta 20.000 caracteres. Markdown liviano: `**negrita**`, `_cursiva_`, ``código``, bloques `````, links.
- `@Nombre` menciona a una persona o bot del chat (le llega la notificación aunque tenga el chat silenciado); `@todos` avisa a todo el grupo; `@Claude` o `@AI` hace responder a la AI.
- `replyTo`: id de un mensaje del mismo chat para citarlo.
- `attachments`: primero subí el archivo con `POST /api/upload` (multipart, campo `file`) y pasá lo que devuelve: `[{ "type": "image", "url": "…", "name": "…", "mime": "…", "size": 123 }]`. Hasta 10 por mensaje: lo que pase de ahí no se guarda.
- **Tarjetas** (2026-09-17): un bot puede mandar en `attachments` las mismas tarjetas que devuelve un servidor MCP ([`options`](#mcp-cards) con cualquier `layout`, [botones](#mcp-buttons), `picks`, `link`, [`catalog`](#mcp-catalog), [`form`](#mcp-form)), hasta 3 por mensaje; Puentes las limpia igual, copia sus fotos al bucket y les pone tu id como `botId`: lo que la persona toque te llega por el webhook como su mensaje con un adjunto `choice` (ids, labels, values, form).
- **Menú fijo**: `PATCH /api/bots/:id { "menu": [{ "title": "Ofertas" }, { "title": "Carrito", "text": "Mostrame el carrito" }] }` (hasta 6, títulos de 24) pone esos botones arriba del campo de texto en el chat directo con tu bot; tocar uno manda `text` (o el título) como mensaje de la persona. Es un atajo para escribir: te llega como cualquier mensaje.
- `clientId` (hasta 60 caracteres): mandalo siempre. Si el request se corta y no sabés si entró, reintentá con el mismo: la respuesta es el mensaje ya guardado y nadie lo recibe dos veces. Reintentá ante un timeout o un 502/503/504; un 4xx es un no.
- `linkPreview: false`: no armar la ficha del link (la de un mensaje de bot nunca se arma).
- `POST /api/groups/:id/typing` muestra "está escribiendo…" unos segundos.
- Listas de tareas: `POST /api/groups/:id/tasklists { "name", "items": ["…"] }`, y sobre ellas `POST /api/tasklists/:id/tasks { "text" }`, `PATCH /api/tasks/:id { "text" }`, `POST /api/tasks/:id/toggle { "done" }`, `POST /api/tasks/:id/due { "dueAt" }`, `POST /api/tasks/:id/move { "before" | "after": taskId }`, `DELETE /api/tasks/:id`; la lista entera: `PATCH /api/tasklists/:id { "name" }` y `DELETE /api/tasklists/:id`. Cada cambio responde con la lista completa (el borrado, con el mensaje que la llevaba) y llega por socket como `tasklist:updated` (o `tasklist:deleted`). Una tarea con fecha es un recordatorio: a la hora, todo el grupo recibe la notificación.

<a id="recibir"></a>
## Recibir mensajes (polling)

Cada mensaje tiene un `seq` global creciente. `GET /api/me/updates?after=<seq>` devuelve lo nuevo en **todos** los chats del bot (menos lo que escribió él mismo), hasta 100 por llamada. Con `timeout=<segundos>` (máximo 25) la respuesta espera hasta que haya algo: long-poll, sin gastar llamadas.

```
# primera vez, sin `after`: solo el cursor actual
curl "https://app.puentes.ai/api/me/updates" -H "Authorization: Bearer pb_…"
# { "messages": [], "next": 1234 }

# después, en loop
curl "https://app.puentes.ai/api/me/updates?after=1234&timeout=25" -H "Authorization: Bearer pb_…"
# { "messages": [ { "seq": 1235, "groupId": "…", "senderType": "user", "senderName": "Ana", "text": "hola bot", … } ], "next": 1235 }
```

Guardá `next` y volvé a pedir con ese valor. Para saber si un mensaje viene de un chat directo o de un grupo, y quiénes están, `GET /api/groups/:id` (cacheable). El historial de un chat: `GET /api/groups/:id/messages`; con un token de bot y sin parámetros trae los últimos 500, y con `?limit`, `?before` o `?since` va por páginas como para una persona (ver [la referencia](#ref-mensajes)).

<a id="webhook"></a>
## Recibir mensajes (webhook)

Si el bot tiene `webhookUrl`, Puentes hace `POST` a esa URL por cada mensaje nuevo en sus chats (menos los propios y los del sistema):

```
POST https://mi-servidor.com/puentes
Content-Type: application/json
X-Puentes-Event: message
X-Puentes-Signature: sha256=<HMAC-SHA256 hex del body, con el webhookSecret del bot>

{ "event": "message", "ts": 1788924608778,
  "group": { "id": "…", "name": "Soporte", "kind": "group" },
  "message": { "id": "…", "seq": 1235, "groupId": "…", "senderType": "user", "senderId": "…", "senderName": "Ana", "text": "…", "attachments": [], "createdAt": 1788924608700, "replyTo": null } }
```

Verificá la firma con el `webhookSecret` (lo ves en la sección IAs, editando el bot, o en la respuesta de `POST /api/bots`):

```
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
app.post('/puentes', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET).update(req.body).digest('hex');
  const got = req.get('x-puentes-signature') || '';
  if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return res.status(401).end();
  const { group, message } = JSON.parse(req.body);
  console.log(`[${group.name}] ${message.senderName}: ${message.text}`);
  res.status(204).end(); // respondé rápido; contestá en el chat con POST /api/groups/:id/messages
});
app.listen(8080);
```

- Timeout de 5 segundos, sin reintentos. Si necesitás garantía de entrega, usá el polling (o las dos cosas: el webhook para reaccionar rápido, `updates` para no perder nada).
- Solo `https://` y pública: se rechaza `http://` y cualquier host privado o local, y una redirección nunca baja a `http`. Para desarrollar, un túnel (ngrok, cloudflared) te da una `https`, o [webhook.site](https://webhook.site) para ver el payload.
- Un audio llega al webhook cuando ya está transcripto, con el texto en `attachments[].transcript` (si la transcripción falla, `transcript` vacío y `transcriptFailed: true`); por eso ese POST puede tardar unos segundos más. Por `updates` puede llegar antes, sin texto: si te importa, releé `GET /api/groups/:id/messages`.

<a id="modo"></a>
## Modo: siempre o solo cuando lo mencionan

Como una IA, un bot externo tiene en cada grupo un `mode` (el `mode` de su fila en `members` de `GET /api/groups/:id`). Lo elige quien lo suma (`POST /api/groups/:id/bots { botId, mode }`) y lo cambia después quien puede (`PATCH /api/groups/:id/bots/:botId { mode }`, o el ⋮ del bot en la info del grupo). Para un bot externo el modo dice **qué mensajes le llegan**; qué contesta lo sigue decidiendo tu servidor.

- `always` (el default, y el de todo bot que ya estaba en un grupo): le llegan todos, como siempre.
- `mention`: le llegan solo los que son para él, con la misma regla que usa una IA: `@Nombre` o `@alias` de ese grupo, `@IA`/`@AI`, su nombre al principio de un audio, una respuesta que cita un mensaje suyo y el toque de una de sus tarjetas (el `choice` con su `botId`). Un chat directo le llega entero siempre.
- Lo que filtra: el [webhook](#webhook) y [`GET /api/me/updates`](#recibir). En `updates` el cursor `next` pasa los mensajes que no le llegan (no vuelven después), y un audio que se está transcribiendo en un grupo `mention` se espera hasta tener texto (como mucho un minuto) para saber si lo nombra: hasta entonces `updates` no avanza más allá de él.
- Lo que no filtra: leer. `GET /api/groups/:id/messages` trae el historial completo a cualquier miembro (con un token de bot y sin parámetros, los últimos 500): cuando te nombran, leé ahí el contexto. Tampoco el [socket](#socket): `message:new` va a todo el grupo, y los cambios de un mensaje (`message:updated`, reacciones, votos) llegan igual.

<a id="socket"></a>
## Tiempo real con Socket.io

La app usa Socket.io; un bot puede hacer lo mismo con su token en `auth.token`. Recibe los mismos eventos que el navegador. Los sockets se reparten entre varios dominios: antes de conectar, pedí `GET /api/sockets` con tu token y conectá al `host` que devuelve (`null` = `https://app.puentes.ai`); la respuesta trae también la lista completa en `hosts`. Conectá solo por WebSocket (`transports: ['websocket']`): el long-polling no funciona detrás del balanceador.

```
import { io } from 'socket.io-client';
const { host } = await (await fetch('https://app.puentes.ai/api/sockets', { headers: { authorization: `Bearer ${process.env.PUENTES_BOT_TOKEN}` } })).json();
const socket = io(host || 'https://app.puentes.ai', { auth: { token: process.env.PUENTES_BOT_TOKEN }, transports: ['websocket'] });
socket.on('message:new', (m) => console.log(m.groupId, m.senderName, m.text));
socket.on('typing', ({ groupId, name }) => {});
socket.on('group:updated', (g) => {});   // miembros, nombre
socket.on('group:new', (g) => {});       // te sumaron a un chat / alguien abrió un directo con vos
socket.on('ai:tool', (e) => {});         // un bot con herramientas (MCP) está llamando a una: { tool, label, phase, ok }
// Para ESCRIBIR usá HTTP, no el socket: POST /api/groups/:id/messages con un clientId (ver abajo).
```

Todos los eventos (los de la app que no son de la API no están):

| Evento | Payload | Cuándo |
| --- | --- | --- |
| `message:new` | `Message (+ clientId)` | Un mensaje nuevo en uno de mis chats. |
| `message:updated` | `Message` | Un mensaje cambió: su transcripción, la ficha del link, una dirección, votos, una tarjeta contestada, borrado para todos. |
| `message:hidden` | `{ groupId, id, lastMessage }` | Lo borré «para mí» (solo a mis dispositivos). |
| `message:status` | `{ groupId, statuses: { id: tick } }` | Los tildes de mis mensajes cambiaron (solo los que cambiaron). |
| `typing` | `{ groupId, userId, name }` | Alguien está escribiendo. |
| `ai:typing` | `{ groupId, aiId, tempId, senderName }` | Una IA empezó a responder (todavía sin texto). |
| `ai:chunk` | `{ groupId, aiId, tempId, text }` | Un pedazo nuevo de la respuesta de una IA (streaming): `text` es lo que se agregó desde el anterior, no el total. Juntalos por `tempId`. |
| `ai:tool` | `{ groupId, aiId, tempId, tool, label, phase, ok }` | Una IA con herramientas está llamando a una (`phase`: `start` \| `end` \| `cards`). |
| `ai:done` | `{ groupId, tempId }` | La IA terminó (el mensaje llega por `message:new`). |
| `group:new` | `Chat` | Me sumaron a un chat, o alguien abrió un directo conmigo. |
| `group:updated` | `Chat` | Cambió un chat: miembros, nombre, permisos, una IA. |
| `group:removed` | `{ id, name?, kicked? }` | Ya no estoy en ese chat: me sacaron (`kicked`), se borró, o me fui. |
| `group:cleared` | `{ id, by }` | Alguien vació el chat. |
| `group:requests` | `{ id, pending }` | A un admin: cambió la cola de pedidos para entrar. |
| `tasklist:updated` | `{ groupId, list }` | Cambió una lista de tareas. |
| `tasklist:deleted` | `{ groupId, listId }` | Se borró una lista (también al vaciar el chat con `lists: true`). |
| `table:updated` | `{ groupId, table }` | Cambió una tabla. |
| `table:deleted` | `{ groupId, tableId }` | Se borró una tabla (también al vaciar el chat con `lists: true`). |
| `schedules:updated` | `{ groupId, schedules }` | Cambiaron los programados del chat. |
| `pinned:changed` | `{ groupId }` | Se fijó o desfijó un mensaje (releé `GET …/pinned`). |
| `pin:updated` | `{ groupId, pin }` | Un fijado de un bot. |
| `pin:removed` | `{ groupId, botId, id }` | Un bot sacó su fijado. |
| `me:updated` | `—` | Cambió mi cuenta (releé `GET /api/me`). |
| `me:bots` | `—` | Cambió un bot que veo (releé lo que muestres). |
| `me:keys` | `—` | Una clave de modelo mía se acerca o llegó a su tope del mes. |
| `me:connections` | `{}` | Cambiaron mis cuentas conectadas. |
| `session:ended` | `—` | Cerraron esta sesión desde otro dispositivo: el socket se corta enseguida. |

**El socket es para recibir.** Una conexión larga puede morirse sin avisar (un cambio de red, un proxy que la corta) y seguir diciendo `connected` unos segundos: lo que se emite en ese rato no llega a nadie. Un request HTTP contesta o falla. Por eso las apps de Puentes mandan todo por HTTP desde septiembre de 2026; `socket.emit('message:send', …)` sigue funcionando, pero no lo recomendamos. Si tu bot depende de no perderse nada, combiná el socket con `GET /api/me/updates` al reconectar.

<a id="rutas"></a>
## Referencia de rutas

Todas bajo `https://app.puentes.ai`. **Quién** sale del código de cada ruta: *todos* = una sesión, una API key `pk_` o un token de bot `pb_`; *persona* = sesión o API key, nunca un bot; *persona (sesión)* = solo la app; *miembro* = del chat de la ruta; *permiso* = lo que el grupo deja hacer a cada uno ([Admins y permisos](#admins)). Lo que la ruta chequea además (el dueño de un bot, un admin, una cuenta verificada) va al final. **Límite** es el de esa ruta, además de los generales ([Límites](#limites)).

<a id="ref-base"></a>
### Quién soy, config y lo nuevo

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/me` | todos | Quién soy. Un bot ve `bot: true` y `ownerId`. | — |
| `GET /api/config` | público | Lo que este server tiene prendido: proveedores y niveles, cuota gratis, `emailEnabled`, `mcpEnabled`, `connectors`… Ver [/api/config](#config). | — |
| `GET /api/sockets` | público | A qué host conectar el socket: `{ host, hosts }` (`host: null` = `https://app.puentes.ai`). | — |
| `GET /api/me/groups` | todos | Mis chats (grupos y directos) con miembros y último mensaje. | — |
| `GET /api/me/updates` | todos | `?after=<seq>&timeout=<0-25>`: los mensajes nuevos de todos mis chats desde ese cursor (menos los míos y los borrados), hasta 100, con long-poll. Sin `after`: `{ messages: [], next }` con el cursor actual. Responde `{ messages, next }`. A un bot externo, de un grupo donde responde «solo cuando lo mencionan», le llegan solo los que lo mencionan (ver [Modo](#modo)); `next` pasa los demás. | — |
| `GET /api/me/usage` | todos | La cuota: `{ dayLimit, dayUsed, dayLeft, credits, left, day, lifetime }` (más los nombres viejos `freeUsed`, `freeLimit`, `freeLeft`). Un bot ve la de su dueño. | — |
| `GET /api/developers/:id` | todos | El perfil público de un desarrollador (`kind: "person"`) o de una organización (`kind: "org"`) y sus bots visibles para quien pregunta. | — |

<a id="ref-mensajes"></a>
### Mensajes

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id/messages` | todos · miembro | El historial. **Un bot (`pb_`) sin parámetros**: un array con los últimos 500, del más viejo al más nuevo. **Una persona o una API key**: una página, `{ messages, more, next, clearedAt }` con los últimos 40 (`?limit=1…100`); `?before=<id>` los anteriores (20 por default); `?before=<id>&to=<id>` salta hasta un mensaje (`{ tooFar: true }` a más de 500); `?since=<next>` lo que cambió desde el cursor `next` de la respuesta anterior: `{ messages, hidden, statuses, next }`, o `{ reset: true, … }` con la primera página si cambiaron más de 200. Un bot puede usar los mismos parámetros. | — |
| `POST /api/groups/:id/messages` | todos · miembro | Enviar `{ text?, attachments?, replyTo?, clientId?, linkPreview? }` (ver [Enviar mensajes](#enviar)). `clientId` (hasta 60) lo hace **idempotente** por chat y remitente: si vuelve a llegar, la respuesta es el mensaje ya guardado y nadie lo recibe dos veces. `linkPreview: false` pide no armar la ficha del link. | 30 por minuto por bot, 60 por persona (`Retry-After: 60`) |
| `POST /api/groups/:id/read` | todos · miembro | Leí el chat hasta `{ upTo? }` (id de mensaje; sin él, hasta el último): tildes azules para los demás y el contador en cero en mis dispositivos. Un bot nunca «lee»: los tildes solo cuentan personas. | — |
| `POST /api/groups/:id/unread` | todos · miembro | «Marcar como no leído» en todos mis dispositivos. | — |
| `POST /api/me/delivered` | todos | Al reconectar: todo lo escrito en mis chats desde `{ since? }` (epoch ms, a lo sumo 7 días) llegó a este dispositivo. Devuelve `{ ok, groups }`. | — |
| `DELETE /api/groups/:id/messages/:mid` | todos · miembro | Borrar, como en WhatsApp. `?scope=me` (default): solo desaparece de mi historial (`message:hidden` a mis dispositivos). `?scope=all`: un mensaje mío de los últimos 2 días (o cualquiera, si soy admin) queda como `{ deleted: true, deletedBy, text: "", attachments: [] }` para todos (`message:updated`), sus archivos se borran y las citas traen `replyTo.deleted`. | 120 por hora por cuenta |
| `POST /api/groups/:id/typing` | todos · miembro | «Está escribiendo…» unos segundos (`typing` en el socket). | — |
| `POST /api/upload` | todos | Subir un archivo (multipart, campo `file`, hasta 25 MB; `duration` opcional para un audio). Devuelve el adjunto para `attachments`: `{ type: "image" \| "audio" \| "video" \| "file", url, name, mime, size, duration? }`. | 60 por hora por cuenta |
| `GET /api/groups/:id/polls` | todos · miembro | Las encuestas abiertas del chat (hasta 20, de los últimos 30 días): `[{ message }]`. | — |
| `POST /api/groups/:id/polls` | persona · miembro | Crear una encuesta `{ title (≤ 80), options: [2 a 12, ≤ 100 cada una], multi?, hours? (1-168), clientId? }`. Devuelve `{ message }`; no despierta a ninguna IA. | 30 por hora por cuenta |
| `POST /api/groups/:id/messages/:mid/vote` | persona · miembro | Votar `{ ids: [...] }` (vacío retira el voto). `409` si ya cerró. Devuelve `{ ok, card }`; los votos los escribe solo el server. | 120 por hora por cuenta |
| `PUT /api/groups/:id/messages/:mid/reaction` | persona · miembro | `{ emoji }`: un emoji por persona y mensaje; `null` o `""` saca el mío. `400` si no es un emoji. Devuelve `{ ok, reactions }`. | 300 por hora por cuenta |
| `POST /api/groups/:id/messages/:mid/forward` | persona · miembro | Reenviar `{ to: groupId, clientId? }`: la copia es un mensaje mío con el texto y los archivos (nunca una tarjeta) y la marca `forward`. Devuelve `{ message, groupId }`. | 60 por hora por cuenta |
| `GET /api/search` | persona | Buscar en todos mis chats: `?q=` (las palabras, en cualquier orden, sin importar mayúsculas ni tildes; también en la transcripción de los audios). Devuelve `{ results: [{ id, groupId, seq, senderId, senderName, senderType, createdAt, snippet: { text, ranges, audio? } }], more }`, 30 por página del más nuevo al más viejo; la siguiente con `?before=<createdAt>&beforeSeq=<seq>` del último. Solo lo que veo en el chat: nada borrado, nada de antes de vaciarlo, ninguna línea del sistema. `503` si tardó demasiado (con más palabras va más rápido). Solo personas. | 600 por hora por cuenta |
| `GET /api/groups/:id/search` | persona · miembro | Lo mismo en un solo chat (el «Buscar» del chat): `?q=`, `?before=` y `?beforeSeq=`; el mensaje se abre con `GET /api/groups/:id/messages?before=…&to=<id>`. | 600 por hora por cuenta |
| `GET /api/link-preview` | persona | `?url=`: la ficha de un link (`{ url, title, site, description?, image?, imageW?, imageH?, icon? }`) o `204` si no hay. | 600 por hora por cuenta |

<a id="ref-chats"></a>
### Chats y miembros

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id` | todos · miembro | Un chat: `kind`, miembros (personas y bots), `peer` si es directo, `perms`. Ver [Objetos](#objetos). | — |
| `POST /api/groups` | persona | Crear un grupo `{ name, members?: [userId \| { userId, email \| phone }], assistant?, puentes? }`. Los miembros iniciales tienen que ser contactos (comparten un chat) o bots que podés usar. Con `assistant: { name, systemPrompt?, mode?, opening? }` nace con ese asistente (un bot tuyo) en vez de Claude; `puentes: false` sin ninguna IA. | 30 por día por cuenta |
| `PATCH /api/groups/:id` | persona · miembro | `{ name }` o `{ avatar }` (permiso `editInfo`), `{ linkOff }` («solo por invitación») y `{ perms: { editInfo?, send?, addMembers?, shareLink?: "all" \| "admins", approve? } }` (admins; ver [Admins y permisos](#admins)). | — |
| `POST /api/groups/:id/link/reset` | persona · miembro · permiso «admins» | Link de invitación nuevo; el anterior muere. | 10 por hora por cuenta |
| `PUT /api/groups/:id/disappear` | persona · miembro | Mensajes temporales `{ ms }`: los mensajes nuevos del chat (también los de las IAs y del sistema) viven 24 horas, 7 días o 90 días; `null` los desactiva. Lo que ya estaba no cambia. Permiso `editInfo` (en un directo, cualquiera de los dos); el chat con «Puentes» no lo tiene (`400`, `code: "assistant"`). Deja una línea de sistema y avisa con `group:updated`. Devuelve el chat. | 30 por hora por cuenta |
| `POST /api/groups/:id/leave` | persona · miembro | Salir del grupo (sin personas, se borra; sin admins, la persona más antigua pasa a admin). En un directo lo oculta hasta que el otro escribe. | — |
| `DELETE /api/groups/:id` | persona · miembro · permiso «admins» | Eliminar el grupo para todos. | — |
| `POST /api/groups/:id/clear` | persona · miembro · en un grupo, admin | Vaciar el chat para todos (mensajes, lo que recuerda la IA y los fijados de los bots, que salen con `pin:removed`; listas, tablas y miembros quedan). Con `{ lists: true }` se borran también, para todos, las listas y las tablas del chat: cada una sale con `tasklist:deleted` o `table:deleted`, como al borrar una, y la respuesta trae sus ids. `group:cleared { id, by }`. | 20 por hora por cuenta |
| `POST /api/groups/:id/members` | persona · miembro · permiso «agregar gente» | Agregar una persona `{ userId, email?, phone? }`: entra al instante si conocés su email o teléfono verificado, o si es un contacto; si no, `404` como si no existiera. | 60 por hora por cuenta |
| `PATCH /api/groups/:id/members/:userId` | persona · miembro · admin | `{ role: "admin" \| "member" }`. A quien creó el grupo no se le quita. | 60 por hora por cuenta |
| `DELETE /api/groups/:id/members/:userId` | persona · miembro · admin | Sacar a una persona: queda vetada (no vuelve por el link) hasta que un admin la deje volver. | 60 por hora por cuenta |
| `GET /api/groups/:id/bans` | persona · miembro · permiso «admins» | Las personas sacadas del grupo. | — |
| `DELETE /api/groups/:id/bans/:userId` | persona · miembro · permiso «admins» | Levantar un veto. | — |
| `GET /api/groups/:id/requests` | persona · miembro · permiso «admins» | Con `perms.approve`: quién pidió entrar (`{ id, user, via, addedByName, createdAt }`). | — |
| `POST /api/groups/:id/requests/:rid/approve` | persona · miembro · permiso «admins» | Dejar entrar. | 120 por hora por cuenta |
| `POST /api/groups/:id/requests/:rid/reject` | persona · miembro · permiso «admins» | Rechazar (no se avisa). | 120 por hora por cuenta |
| `GET /api/invites/:token` | público | Vista previa de una invitación (`?i=`: el token de una invitación por email). | — |
| `POST /api/invites/:token/join` | persona | Entrar por el link. Un token de bot recibe `403`: a un bot siempre lo suma alguien. Con `perms.approve` responde `202 { pending: true }`. | 30 por hora por IP |
| `DELETE /api/invites/:token/request` | persona | Cancelar mi pedido de entrada. | — |
| `GET /api/groups/:id/invites` | persona · miembro | Las invitaciones por email pendientes. | — |
| `POST /api/groups/:id/invites` | persona · miembro · permiso «agregar gente» · verificada | `{ email }`: si ya usa Puentes y comparte un chat con vos entra directo (`status: "added"`); si no, invitación de 7 días (`"invited"`). | 20 por hora y 50 por día (10 el primer día de la cuenta) |
| `POST /api/groups/:id/invites/:inviteId/resend` | persona · miembro | Reenviar una invitación. | — |
| `DELETE /api/groups/:id/invites/:inviteId` | persona · miembro | Cancelarla. | — |
| `POST /api/groups/:id/invite-link` | persona · miembro · permiso «agregar gente» | Un link personal a este grupo para alguien que todavía no está en Puentes (`{ email \| phone }`); el server no manda nada, devuelve la `url`. Una cuenta sin nada verificado también lo pide, con topes más bajos, y siempre recibe el link. | 60 por hora por cuenta |
| `POST /api/direct/:userId` | persona | Abrir (o encontrar) el chat directo con una persona o un bot. Con una persona hace falta compartir un chat, que los hayan presentado, o su `{ email }` / `{ phone }` verificado. | 60 por hora por cuenta |
| `POST /api/direct-invite` | persona | `{ email }` o `{ phone, country }` (+ `name`): directo con alguien que no está en Puentes; devuelve su link personal (`url`) para mandárselo vos. Una cuenta sin nada verificado también, con topes más bajos: a ella siempre le devuelve el link, nunca el chat de alguien que ya está. | — |
| `POST /api/groups/:id/share` | persona · miembro | Compartir en el chat `{ kind: "contact" \| "bot", id, by? }`: publica la tarjeta. Un contacto = presentación; un bot tuyo = permiso de uso para los del chat. | 30 por hora por cuenta |

<a id="ref-listas"></a>
### Listas de tareas

Un bot también las arma y edita. Cada cambio responde con la lista entera y llega por el socket como `tasklist:updated` (o `tasklist:deleted`). Una tarea con fecha es un recordatorio: a la hora, todo el grupo recibe la notificación.

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id/tasklists` | todos · miembro | Las listas del chat: `[{ id, groupId, messageId, name, items: [Task], done, total }]`. | — |
| `POST /api/groups/:id/tasklists` | todos · miembro · permiso «escribir» | `{ name (≤ 80), items: ["…"] (hasta 50, ≤ 200 cada una) }`. Una fecha en castellano al final de una tarea («… mañana 18hs») la vuelve recordatorio. Devuelve el mensaje con la tarjeta `tasklist`. | — |
| `PATCH /api/tasklists/:id` | todos · miembro del chat de la lista | Renombrar `{ name }`. | — |
| `DELETE /api/tasklists/:id` | todos · miembro del chat de la lista | Borrarla: `{ ok, groupId, listId, message }` (el mensaje queda con `list: null`). | — |
| `POST /api/tasklists/:id/tasks` | todos · miembro del chat de la lista | Agregar `{ text }`. | — |
| `PATCH /api/tasks/:id` | todos · miembro del chat de la lista | Cambiar el texto `{ text }` (una fecha nueva en el texto reemplaza la anterior). | — |
| `POST /api/tasks/:id/toggle` | todos · miembro del chat de la lista | Tildar `{ done: true \| false }`. | — |
| `POST /api/tasks/:id/due` | todos · miembro del chat de la lista | Fecha `{ dueAt }` (epoch ms; vacío la saca). | — |
| `POST /api/tasks/:id/move` | todos · miembro del chat de la lista | Moverla dentro de su lista: `{ before: taskId }` o `{ after: taskId }`, otra tarea de la misma lista (arriba de todo es antes de la primera; abajo de todo, después de la última). La lista queda en ese orden para todos. | 600 por hora por cuenta |
| `DELETE /api/tasks/:id` | todos · miembro del chat de la lista | Borrar la tarea. | — |

<a id="ref-tablas"></a>
### Tablas

Una tabla es una tarjeta del chat con columnas tipadas (hasta 8: `status`, `select`, `person`, `number`, `date`, `text`, `check`) y hasta 200 filas; el server calcula el resumen (cuántas en cada estado, sumas, por persona). Un bot crea filas y las edita; cambiar las columnas, renombrar o borrar la tabla es de quien la armó o de un admin, nunca de un bot. Los cambios llegan como `table:updated { groupId, table }` y `table:deleted`.

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id/tables` | todos · miembro | Las tablas del chat: `[{ id, groupId, messageId, name, createdBy, columns, rows: [{ id, title, cells }], summary }]`. | — |
| `POST /api/groups/:id/tables` | todos · miembro · permiso «escribir» | `{ name (≤ 80), columns?: [{ name, type, options? }], rows?: [{ title, cells? }] }`. Las ids de las columnas las arma el server con el nombre (minúsculas, sin acentos): para llenar celdas al crear, creá la tabla y usá las ids de la respuesta. Devuelve el mensaje con la tarjeta `table`. | — |
| `PATCH /api/tables/:id` | todos · miembro del chat de la tabla; quien la armó o un admin; nunca un bot | `{ name?, columns? }`: `columns` reemplaza todas (las que no mandás se borran, con sus celdas). | — |
| `DELETE /api/tables/:id` | todos · miembro del chat de la tabla; quien la armó o un admin; nunca un bot | Borrarla: `{ ok, groupId, tableId, message }`. | — |
| `POST /api/tables/:id/rows` | todos · miembro del chat de la tabla | Una fila `{ title (≤ 120), cells?: { columnId: valor } }`. Devuelve la tabla. | — |
| `PATCH /api/tables/:id/rows/:rid` | todos · miembro del chat de la tabla | `{ title?, cells? }`: las celdas que mandás cambian (`null` la vacía), las demás quedan. | — |
| `DELETE /api/tables/:id/rows/:rid` | todos · miembro del chat de la tabla | Borrar la fila. | — |

<a id="ref-programados"></a>
### Programados

Un programado lo crea una IA con un bloque ````schedule` (decir algo a una hora, despertarse, o mandar algo en nombre de la persona): por la API se leen, se pausan y se cancelan. Objeto: `{ id, groupId, botId, botName, createdBy, action: "say" | "wake" | "send", text, rule, tz, nextAt, lastFiredAt, runs, paused, held, onlyFor, targetLabel, createdAt }`; `rule` es `{ once: "YYYY-MM-DD HH:MM" }`, `{ freq: "daily", time }`, `{ freq: "weekly", days: [0-6], time }` o `{ freq: "monthly", monthDay, time }`, a la hora del lugar (`tz`). Los cambios llegan como `schedules:updated { groupId, schedules }`.

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id/schedules` | todos · miembro | Los programados vivos del chat que puedo ver. | — |
| `PATCH /api/groups/:id/schedules/:sid` | todos · miembro · quien lo pidió o un admin; nunca un bot | `{ paused: true \| false }`. Devuelve la lista del chat. | — |
| `DELETE /api/groups/:id/schedules/:sid` | todos · miembro · quien lo pidió o un admin; nunca un bot | Cancelarlo. Devuelve la lista del chat. | — |
| `GET /api/me/schedules` | persona | Lo que se manda en mi nombre en cualquier chat: `{ sends, grants }` (Ajustes › Mensajes programados). | — |
| `POST /api/schedules/:sid/confirm` | persona | Contestar la tarjeta «¿Lo mando en tu nombre?»: `{ yes: true \| false }` → `{ ok, result: "sent" \| "scheduled" \| "dropped" \| "expired" }`. | — |
| `POST /api/bots/:id/send-grant` | persona | Dejar que ese bot mande en mi nombre lo que yo le pida. | — |
| `DELETE /api/bots/:id/send-grant` | persona | Retirarlo (se borran sus envíos programados en mi nombre). | — |

<a id="ref-fijados"></a>
### Fijados

Dos cosas distintas: los **mensajes fijados** de un chat (hasta 3, como en WhatsApp; los fija una persona) y los **fijados de un bot** (una pastilla arriba del chat con una tarjeta, [ver arriba](#mcp-pin)).

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/groups/:id/pinned` | todos · miembro | Los mensajes fijados: `[{ message, pinnedBy, pinnedByName, pinnedAt }]`. | — |
| `POST /api/groups/:id/messages/:mid/pin` | persona · miembro · permiso «fijar» | Fijar un mensaje (el cuarto saca al más viejo). `pinned:changed { groupId }`. | 60 por hora por cuenta |
| `DELETE /api/groups/:id/messages/:mid/pin` | persona · miembro · permiso «fijar» | Desfijarlo. | — |
| `GET /api/groups/:id/pins` | todos · miembro | Los fijados de los bots del chat (los del chat y los míos). | — |
| `PUT /api/groups/:id/pins/:pinId` | bot · miembro | Poner o actualizar un fijado del bot: `{ label (≤ 24), badge?, detail?, icon?, card?, buttons?, forUserId? }`. Hasta 4 por bot y chat. `pin:updated`. | 120 por hora por cuenta |
| `DELETE /api/groups/:id/pins/:pinId` | bot · miembro | Sacarlo. `pin:removed`. | — |

<a id="ref-bots"></a>
### Bots

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `POST /api/bots` | persona · verificada; hasta 50 bots | Crear un bot (ver [Crear un bot](#crear)). Devuelve `{ bot, token?, group }`. | 10 por hora por cuenta |
| `GET /api/bots` | persona | Mis bots (con el prefijo del token, el webhook y su secreto). | — |
| `GET /api/bots/catalog` | persona | Los bots que puedo agregar: de Puentes, públicos, míos y compartidos conmigo. | — |
| `GET /api/bots/browse` | persona | La sección IAs: `?q=&chip=` → `{ mine, team, catalog, next }`. | — |
| `GET /api/bots/:id` | todos | La ficha de un bot que puedo usar: más `kind`, `shortDesc`, `tags`, `developer`, `mcp.tools`, `canUse`; si puedo pedir acceso, una recortada con `canRequest`. | — |
| `PATCH /api/bots/:id` | persona · quien lo puede editar | Cambiar cualquiera de los campos de [Crear un bot](#crear). Además, solo para un admin del bot: `pay`, `ownerKey`, los topes, `orgId`, `clonable` e `idea`. | — |
| `DELETE /api/bots/:id` | persona · dueño | Borrarlo: sale de todos los chats y sus directos desaparecen. | — |
| `POST /api/bots/:id/token` | persona · dueño | Token nuevo (el anterior muere al instante). | — |
| `POST /api/bots/:id/clone` | persona · verificada | «Hacer mi copia» de un bot `clonable`: `{ name?, avatar? }` → `{ bot, group }`. `403` con `code: "not_clonable"` si no se puede. | 10 por hora por cuenta |
| `GET /api/bots/:id/usage` | persona · dueño | Cuánto gastó un bot que paga sus respuestas: `{ monthUsd, replies, people, since }`. | — |
| `DELETE /api/bots/:id/owner-key` | persona · admin del bot | Olvidar la clave de modelo con la que el bot paga. | — |
| `POST /api/bots/:id/verify` | persona · dueño | Pedir el check verde (en el catálogo y con el desarrollador verificado): `{ verification: "pending" }`, o `409` con `missing`. | 10 por hora por cuenta |
| `PUT /api/bots/:id/domains` | persona · admin del bot | `{ domains: ["acme.com"] }` de un bot `authorized` (hace falta un email verificado tuyo en cada dominio). | 30 por hora por cuenta |
| `POST /api/bots/:id/access-requests` | persona | Pedir acceso a un bot `authorized` con pedidos abiertos. | 20 por hora por cuenta |
| `GET /api/bots/:id/access-requests` | persona · admin del bot | Los pedidos pendientes. | — |
| `POST /api/bots/:id/access-requests/:rid/:decision` | persona · admin del bot | `:decision` = `approve` \| `reject`. | 120 por hora por cuenta |
| `GET /api/bots/:id/access` | persona · dueño | Quién puede usar el bot además de su dueño (`via: "share" \| "chat" \| "link" \| "approved"`). | — |
| `POST /api/bots/:id/access` | persona · dueño | Dar acceso a un contacto `{ userId }`. | 60 por hora por cuenta |
| `DELETE /api/bots/:id/access/:userId` | persona · dueño | Quitarlo (su directo con el bot se borra). | — |
| `POST /api/bots/:id/share-link` | persona · dueño | Link `/bot/<token>` para compartirlo (7 días, uno vivo por bot). | 10 por hora por cuenta |
| `DELETE /api/bots/:id/share-link` | persona · dueño | Revocarlo. | — |
| `GET /api/bots/shared/:token` | persona | Quién comparte qué. | — |
| `POST /api/bots/shared/:token/accept` | persona | Aceptar: acceso para la cuenta logueada. | — |
| `POST /api/groups/:id/bots` | persona · miembro · permiso «agregar gente» | Sumar `{ botId, mode? }` (de Puentes, público, mío o compartido conmigo) al grupo. `mode` sin decir: `always`; en un bot externo dice qué mensajes le llegan (ver [Modo](#modo)). | 60 por hora por cuenta |
| `PATCH /api/groups/:id/bots/:botId` | persona · miembro · permiso «cómo responde un bot» | Cómo responde ese bot en ese grupo: `{ mode?: "mention" \| "always", alias?, connections? }`. También para un bot externo (ver [Modo](#modo)). | — |
| `POST /api/groups/:id/bots/:botId/mode-ask` | persona · miembro · permiso «cómo responde un bot» | Contestar la tarjeta privada «¿cómo responde?» de una IA nueva: `{ keep: true \| false }`. | 60 por hora por cuenta |
| `DELETE /api/groups/:id/bots/:botId` | persona · miembro · permiso «agregar gente» | Sacar el bot del grupo. | — |
| `GET /api/me/developer` | persona | Mi perfil de desarrollador, con `complete` y `missing`. | — |
| `PUT /api/me/developer` | persona | `{ name?, company?, bio?, website?, contactEmail?, contactable?, country? }`. Con `{}` activa Creadores con el nombre de la cuenta (solo pide una cuenta verificada); completo (nombre, bio y una forma de contacto), un bot o un conector puede ir al catálogo. | 30 por hora por cuenta |
| `POST /api/me/developer/verify` | persona | Pedir el check verde para el perfil. | 5 por hora por cuenta |
| `GET /api/me/organizations` | persona | Mis organizaciones. | — |
| `POST /api/organizations` | persona | Crear una `{ name, bio, website?, contactEmail?, contactable?, country?, avatar? }`: quien la crea es `admin`. Un bot pasa a ella con `orgId`. | 10 por hora por cuenta |
| `PATCH /api/organizations/:id` | persona · admin de la organización | Editarla. | 60 por hora por cuenta |
| `DELETE /api/organizations/:id` | persona · admin de la organización | Borrarla. | — |
| `GET /api/organizations/:id/members` | persona | Sus miembros. | — |
| `POST /api/organizations/:id/members` | persona · admin de la organización | Invitar `{ userId \| email, role: "admin" \| "dev" }`. | 60 por hora por cuenta |
| `PATCH /api/organizations/:id/members/:uid` | persona · admin de la organización | Cambiar el rol. | 60 por hora por cuenta |
| `DELETE /api/organizations/:id/members/:uid` | persona | Sacar a alguien (el último admin no se va). | — |
| `POST /api/organizations/:id/accept` | persona | Aceptar una invitación a la organización. | 30 por hora por cuenta |
| `POST /api/organizations/:id/decline` | persona | Rechazarla. | 30 por hora por cuenta |
| `POST /api/organizations/:id/verify` | persona · admin de la organización | Pedir el check verde para la organización. | 5 por hora por cuenta |

<a id="ref-mcp"></a>
### Bots con herramientas (MCP)

Lo que hace falta para escribir un servidor MCP está [arriba](#mcp); estas son las rutas.

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `POST /api/bots/:id/mcp/connect` | persona (sesión) · dueño | El camino viejo, sobre el servidor de la IA (el que tenía adentro, o el conector de quien la hizo en que se convirtió) y con la cuenta de quien la hizo. Servidor OAuth: `{ url }` para autorizar (vuelve a `/api/mcp/callback`); si ya hay tokens, `{ connected: true, tools }`. Lo nuevo: `POST /api/me/connections`. | 10 por hora por cuenta |
| `POST /api/bots/:id/mcp/test` | persona · dueño | Probar el servidor de la IA con la cuenta de quien la hizo: `{ status: "connected", transport, tools }` o `400` con el motivo (también si no tiene). Lo nuevo: `POST /api/connectors/:id/test`. | 30 por hora por cuenta |
| `DELETE /api/bots/:id/mcp/tokens` | persona · dueño | Desconectar: borra los tokens OAuth de quien la hizo en el servidor de la IA. | — |
| `POST /api/mcp/post/:token` | público · el token es la credencial | `{ text?, card?, pin? }`: el bot publica por su cuenta en ese chat (el [`postUrl`](#mcp-post) de `_meta.puentes`). `202`; `404` si ya no vale. El texto, hasta 4000. | 240 por hora por IP; 30 mensajes por minuto del bot |
| `POST /api/mcp/event/:token` | público · el token es la credencial | `{ text (≤ 500) }`: línea de sistema en el chat y la IA retoma (el [`callbackUrl`](#mcp-meta), 24 horas). `202`; `404` vencido. | 120 por hora por IP |
| `GET /api/mcp/callback` | público | La vuelta de OAuth (la abre el navegador, no tu código). | 30 por hora por IP |
| `POST /api/mcp/callback` | público | La confirmación de esa vuelta cuando el navegador no es el que pidió la URL. | 30 por hora por IP |

<a id="ref-conectores"></a>
### Conectores y cuentas

Un **conector** es un servidor MCP registrado (por una persona o una organización) que varias IAs pueden usar; una **cuenta** es la credencial de una persona en ese servicio. Cada IA dice con qué cuenta trabaja cada conector: la de cada persona (`person`), una por chat (`chat`) o la de quien la hizo (`owner`). Solo con `connectors: true` en [/api/config](#config). Nunca vuelven la clave del servidor, los tokens ni la URL de un conector ajeno.

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `GET /api/connectors/browse` | persona | Los conectores que puedo usar: `[{ id, name, description, avatar, category, verified, by, auth, accountModes, exclusive, connectHelp, tools: [{ name, label, changes?, confirm? }], bots }]`. | — |
| `GET /api/connectors/:id` | persona | Uno (`?botId=` si lo veo por una IA que lo usa). | — |
| `GET /api/connectors` | persona | Los míos y los de mis organizaciones, con `url`, `visibility`, `status`, `toolLabels` y `hasServerKey`. | — |
| `POST /api/connectors` | persona (sesión) · con un email, teléfono, Google o Apple | Registrar uno `{ name, url (https pública), orgId?, description?, category?, connectHelp?, avatar?, accountModes?: ["person" \| "chat" \| "owner"], exclusive?, visibility?, auth?: "none" \| "bearer" \| "oauth", serverKey?, toolLabels? }`. Hasta 30 por dueño. Privado (el default) pide solo una cuenta verificada (un invitado recibe `403` con `code: "guest"`); `visibility: "catalog"` pide además el perfil completo de quien lo firma, la organización o la persona (`409` con `{ missing, org }`). | 20 por hora por cuenta |
| `PATCH /api/connectors/:id` | persona (sesión) | Los mismos campos. Cambiar `url` o `auth` lo reinicia. Pasarlo al catálogo (o moverlo de dueño estando ahí) pide el perfil completo de quien lo firma (`409`). | — |
| `POST /api/connectors/:id/test` | persona | Volver a probarlo (detecta el acceso y lista las herramientas). | 30 por hora por cuenta |
| `DELETE /api/connectors/:id` | persona | Borrarlo. | — |
| `GET /api/me/connections` | persona | «Mis cuentas»: `[{ id, connectorId, connector, label, status: "ok" \| "reconnect" \| "error", createdAt, lastUsedAt?, bots }]`. | — |
| `GET /api/me/connections/:id` | persona | Una cuenta, con las IAs y los chats que la usan. | — |
| `POST /api/me/connections` | persona (sesión) · con un email, teléfono, Google o Apple | Conectar `{ connectorId, secret?, botId?, groupId? }`: con clave, `{ connection }`; con OAuth, `{ url }` para autorizar. Con `botId` y `groupId` se la presta a esa IA en ese chat y la despierta. | 20 por hora por cuenta |
| `POST /api/me/connections/:id/reconnect` | persona (sesión) | Volver a entrar (clave nueva u OAuth). | 20 por hora por cuenta |
| `POST /api/me/connections/:id/use` | persona (sesión) | Prestarle esa cuenta a una IA `{ botId, groupId? }`. | 30 por hora por cuenta |
| `PATCH /api/me/connections/:id` | persona | Nombre de la cuenta `{ label }`. | — |
| `DELETE /api/me/connections/:id` | persona | Desconectarla. | — |
| `DELETE /api/me/connections/:id/bots/:botId` | persona | Dejar de prestársela a esa IA. | — |
| `DELETE /api/me/connections/:id/chats/:groupId` | persona | Sacarla de ese chat. | — |
| `PUT /api/bots/:id/connectors` | persona (sesión) · quien la puede editar | Los conectores de una IA `{ connectors: [{ connectorId, account?: "person" \| "chat" \| "owner" }] }` (reemplaza la lista; hasta 8). Uno nuevo tiene que ser uno que podés usar (tuyo, de tu organización o del catálogo; si no, `404`) y, si es exclusivo, de quien es la IA (`403`); uno que la IA ya tiene se conserva aunque lo reenvíe otra persona. `account: "owner"` (la cuenta de quien hizo la IA, para todos) lo pone solo esa persona en un servicio con cuentas: otra recibe `403` con `code: "owner_account"`, y puede dejarlo como está o pasarlo a `person` o `chat`. | — |

<a id="ref-cuenta"></a>
### La cuenta de una persona

Lo que la app hace con su propia sesión. Una API key no administra sesiones, claves ni formas de entrar (`403`).

| Ruta | Quién | Qué hace | Límite |
| --- | --- | --- | --- |
| `PATCH /api/me` | persona | `{ locale?, avatar?, country?, tz?, linkPreviews? }` (al menos uno). El nombre no se cambia acá. | — |
| `DELETE /api/me` | persona (sesión) | Borrar la cuenta entera, al instante: membresías, directos, IAs propias, emails, teléfonos, sesiones, API keys. Los mensajes quedan para los demás como «Cuenta eliminada». No se puede deshacer. | 5 por hora por cuenta |
| `GET /api/me/keys` | persona (sesión) | Mis API keys. | — |
| `POST /api/me/keys` | persona (sesión) | Crear una `{ name }`: devuelve `pk_…` una sola vez. Hasta 10. | 10 por hora por cuenta |
| `DELETE /api/me/keys/:id` | persona (sesión) | Revocarla. | — |
| `GET /api/me/emails` | persona (sesión) | Mis emails. | — |
| `POST /api/me/emails` | persona (sesión) | `{ email }`: manda un código de 6 dígitos. | — |
| `POST /api/me/emails/verify` | persona (sesión) | `{ email, code }`: lo confirma. | — |
| `DELETE /api/me/emails/:email` | persona (sesión) | Sacarlo. | — |
| `GET /api/me/phones` | persona (sesión) | Mis teléfonos. | — |
| `POST /api/me/phones` | persona (sesión) | `{ phone, channel: "sms" \| "whatsapp" }`: manda un código (si `smsEnabled`). | — |
| `POST /api/me/phones/verify` | persona (sesión) | `{ phone, code }`. Un número es de una sola cuenta. | — |
| `DELETE /api/me/phones/:phone` | persona (sesión) | Sacarlo. | — |
| `POST /api/contacts/lookup` | persona · verificada | `{ email }` o `{ phone }` → `{ status: "found", user } \| { status: "none" } \| { status: "self" }`. Nunca devuelve el email ni el número. | 30 por hora |
| `GET /api/me/blocks` | persona | A quiénes bloqueé. | — |
| `POST /api/users/:id/block` | persona | Bloquear (ver [bloqueo](#contactos)). | 60 por hora por cuenta |
| `DELETE /api/users/:id/block` | persona | Desbloquear. | — |
| `POST /api/users/:id/report` | persona | `{ reason?, groupId?, block? }`: reporte al equipo de Puentes. | 5 por día |
| `POST /api/reports` | persona | Reportar un bot, un desarrollador o una organización `{ about: "bot" \| "developer" \| "org", id, reason? }`. | — |

<a id="solo-personas"></a>
### Solo personas (no para bots)

Las rutas de esta página que un token de bot no puede llamar (`403`): reaccionar, votar, reenviar, fijar mensajes, crear grupos y bots, invitar, compartir, bloquear, y todo lo de la cuenta.

| Ruta | Con | Sección |
| --- | --- | --- |
| `POST /api/groups/:id/polls` | sesión o API key | [Mensajes](#ref-mensajes) |
| `POST /api/groups/:id/messages/:mid/vote` | sesión o API key | [Mensajes](#ref-mensajes) |
| `PUT /api/groups/:id/messages/:mid/reaction` | sesión o API key | [Mensajes](#ref-mensajes) |
| `POST /api/groups/:id/messages/:mid/forward` | sesión o API key | [Mensajes](#ref-mensajes) |
| `GET /api/search` | sesión o API key | [Mensajes](#ref-mensajes) |
| `GET /api/groups/:id/search` | sesión o API key | [Mensajes](#ref-mensajes) |
| `GET /api/link-preview` | sesión o API key | [Mensajes](#ref-mensajes) |
| `POST /api/groups` | sesión o API key | [Chats y miembros](#ref-chats) |
| `PATCH /api/groups/:id` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/link/reset` | sesión o API key | [Chats y miembros](#ref-chats) |
| `PUT /api/groups/:id/disappear` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/leave` | sesión o API key | [Chats y miembros](#ref-chats) |
| `DELETE /api/groups/:id` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/clear` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/members` | sesión o API key | [Chats y miembros](#ref-chats) |
| `PATCH /api/groups/:id/members/:userId` | sesión o API key | [Chats y miembros](#ref-chats) |
| `DELETE /api/groups/:id/members/:userId` | sesión o API key | [Chats y miembros](#ref-chats) |
| `GET /api/groups/:id/bans` | sesión o API key | [Chats y miembros](#ref-chats) |
| `DELETE /api/groups/:id/bans/:userId` | sesión o API key | [Chats y miembros](#ref-chats) |
| `GET /api/groups/:id/requests` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/requests/:rid/approve` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/requests/:rid/reject` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/invites/:token/join` | sesión o API key | [Chats y miembros](#ref-chats) |
| `DELETE /api/invites/:token/request` | sesión o API key | [Chats y miembros](#ref-chats) |
| `GET /api/groups/:id/invites` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/invites` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/invites/:inviteId/resend` | sesión o API key | [Chats y miembros](#ref-chats) |
| `DELETE /api/groups/:id/invites/:inviteId` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/invite-link` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/direct/:userId` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/direct-invite` | sesión o API key | [Chats y miembros](#ref-chats) |
| `POST /api/groups/:id/share` | sesión o API key | [Chats y miembros](#ref-chats) |
| `GET /api/me/schedules` | sesión o API key | [Programados](#ref-programados) |
| `POST /api/schedules/:sid/confirm` | sesión o API key | [Programados](#ref-programados) |
| `POST /api/bots/:id/send-grant` | sesión o API key | [Programados](#ref-programados) |
| `DELETE /api/bots/:id/send-grant` | sesión o API key | [Programados](#ref-programados) |
| `POST /api/groups/:id/messages/:mid/pin` | sesión o API key | [Fijados](#ref-fijados) |
| `DELETE /api/groups/:id/messages/:mid/pin` | sesión o API key | [Fijados](#ref-fijados) |
| `POST /api/bots` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/catalog` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/browse` | sesión o API key | [Bots](#ref-bots) |
| `PATCH /api/bots/:id` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/bots/:id` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/token` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/clone` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/:id/usage` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/bots/:id/owner-key` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/verify` | sesión o API key | [Bots](#ref-bots) |
| `PUT /api/bots/:id/domains` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/access-requests` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/:id/access-requests` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/access-requests/:rid/:decision` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/:id/access` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/access` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/bots/:id/access/:userId` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/share-link` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/bots/:id/share-link` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/bots/shared/:token` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/shared/:token/accept` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/groups/:id/bots` | sesión o API key | [Bots](#ref-bots) |
| `PATCH /api/groups/:id/bots/:botId` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/groups/:id/bots/:botId/mode-ask` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/groups/:id/bots/:botId` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/me/developer` | sesión o API key | [Bots](#ref-bots) |
| `PUT /api/me/developer` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/me/developer/verify` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/me/organizations` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/organizations` | sesión o API key | [Bots](#ref-bots) |
| `PATCH /api/organizations/:id` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/organizations/:id` | sesión o API key | [Bots](#ref-bots) |
| `GET /api/organizations/:id/members` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/organizations/:id/members` | sesión o API key | [Bots](#ref-bots) |
| `PATCH /api/organizations/:id/members/:uid` | sesión o API key | [Bots](#ref-bots) |
| `DELETE /api/organizations/:id/members/:uid` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/organizations/:id/accept` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/organizations/:id/decline` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/organizations/:id/verify` | sesión o API key | [Bots](#ref-bots) |
| `POST /api/bots/:id/mcp/connect` | solo la sesión de la app | [Bots con herramientas (MCP)](#ref-mcp) |
| `POST /api/bots/:id/mcp/test` | sesión o API key | [Bots con herramientas (MCP)](#ref-mcp) |
| `DELETE /api/bots/:id/mcp/tokens` | sesión o API key | [Bots con herramientas (MCP)](#ref-mcp) |
| `GET /api/connectors/browse` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `GET /api/connectors/:id` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `GET /api/connectors` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `POST /api/connectors` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `PATCH /api/connectors/:id` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `POST /api/connectors/:id/test` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `DELETE /api/connectors/:id` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `GET /api/me/connections` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `GET /api/me/connections/:id` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `POST /api/me/connections` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `POST /api/me/connections/:id/reconnect` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `POST /api/me/connections/:id/use` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `PATCH /api/me/connections/:id` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `DELETE /api/me/connections/:id` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `DELETE /api/me/connections/:id/bots/:botId` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `DELETE /api/me/connections/:id/chats/:groupId` | sesión o API key | [Conectores y cuentas](#ref-conectores) |
| `PUT /api/bots/:id/connectors` | solo la sesión de la app | [Conectores y cuentas](#ref-conectores) |
| `PATCH /api/me` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `DELETE /api/me` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `GET /api/me/keys` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/me/keys` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `DELETE /api/me/keys/:id` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `GET /api/me/emails` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/me/emails` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/me/emails/verify` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `DELETE /api/me/emails/:email` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `GET /api/me/phones` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/me/phones` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/me/phones/verify` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `DELETE /api/me/phones/:phone` | solo la sesión de la app | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/contacts/lookup` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `GET /api/me/blocks` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/users/:id/block` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `DELETE /api/users/:id/block` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/users/:id/report` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |
| `POST /api/reports` | sesión o API key | [La cuenta de una persona](#ref-cuenta) |

<a id="contactos"></a>
## Contactos, compartir y bloqueo

- **No hay libreta de contactos.** Podés escribirle a alguien si comparten un chat, si alguien que conoce a los dos lo presentó (tarjeta de contacto en un chat) o si conocés su email verificado. Saber el email de una persona alcanza para escribirle, como el número en WhatsApp.
- **Compartir es siempre autenticado.** Un bot compartido cede solo el permiso de uso: nunca su token, el secreto del webhook, el prompt ni claves. Una tarjeta de contacto lleva nombre y avatar, nunca email ni teléfono. Las tarjetas (`attachments[].type = 'bot' | 'contact' | 'mcp_connect' | 'tools' | 'invite' | 'confirm' | 'forward' | 'cap'`) las construye el server: un cliente no puede mandarlas; las de un bot (`options`, `picks`, `link`, `catalog`, `form`) llevan siempre el `botId` del bot que las mandó.
- **Un bot con herramientas usa la cuenta de su dueño.** Compartirlo es dejar que esa gente ejecute sus herramientas (con lo que el servidor MCP permita a esa credencial). El rastro queda en cada respuesta; el dueño puede desconectarlo, quitar accesos o borrarlo.
- **Bloqueo.** Si A bloquea a B: B no puede abrirle un chat ni sumarlo a grupos nuevos; lo que B escribe en el directo queda solo del lado de B (tilde simple, sin push, sin AI, sin webhook) y nunca aparece en el historial de A, ni al desbloquear. A tampoco puede escribirle hasta desbloquear. B no recibe ningún aviso.
- **Un bot y las personas.** Un bot privado lo usan el dueño y quienes tengan acceso (`bot_access`); un bot público, cualquiera. Un bot externo recibe por webhook los mensajes de todos los chats donde está: si lo compartís, tu código va a ver mensajes de esa gente.

<a id="admins"></a>
## Admins y permisos de grupo

- **Admins.** Quien crea un grupo es su primer admin (`members[].role = 'admin'`); cualquier admin nombra o quita admins y saca gente. A quien creó el grupo nadie lo saca ni le quita admin mientras esté. Si el último admin se va, la persona más antigua pasa a admin. **Un bot nunca es admin**: lo agrega y lo saca alguien con permiso.
- **Permisos** (`perms` en el chat): `editInfo`, `send`, `addMembers`, `shareLink`, cada uno `'all'` o `'admins'`, y `approve`. Un grupo nuevo está abierto. Con `send: 'admins'` un bot externo recibe 403 al escribir (una AI del grupo sigue contestando a los admins). Con `shareLink: 'admins'` el chat llega sin `inviteToken` a quien no puede compartirlo.
- **Aprobar nuevos miembros** (`approve: true`): entrar por el link responde `202 { pending: true }`; lo que agrega o invita un admin entra directo. Un rechazo no avisa nada.
- **Sacar a alguien lo veta**: no vuelve por el link ni pidiendo entrar; solo un admin lo deja volver.

<a id="objetos"></a>
## Objetos

Los que más se usan, campo por campo. Salen de los mismos schemas que [/openapi.json](https://app.puentes.ai/openapi.json) (`components.schemas`), donde están todos los demás (listas, tablas, programados, conectores…). Un campo *opcional* viene solo cuando aplica; la descripción dice cuándo.

<a id="schema-Message"></a>
### Message

| Campo | Tipo | Qué es |
| --- | --- | --- |
| `id` | string |  |
| `seq` | integer | Cursor global creciente (GET /api/me/updates). |
| `groupId` | string |  |
| `senderType` | `"user"` \| `"bot"` \| `"system"` |  |
| `senderId` | string \| null | null en una línea de sistema. |
| `senderName` | string |  |
| `text` | string ≤ 20000 | "" en un mensaje borrado para todos. |
| `attachments` | array de [Attachment](#schema-Attachment) | [] en un mensaje borrado; las tarjetas de lista y tabla vienen con su estado actual. |
| `createdAt` | integer | Epoch ms. |
| `replyTo` | `MessageQuote` \| object \| null |  |
| `reactions` | `Reactions` |  |
| `via` opcional | object | Solo si lo mandó un asistente en nombre de la persona. |
| `onlyFor` opcional | string | Solo en un mensaje privado: la única persona que lo ve. |
| `suggest` opcional | array de `MenuItem` | Solo en una respuesta de bot con acciones sugeridas. |
| `link` opcional | `LinkPreview` | Solo cuando el server armó la ficha del primer link (llega después, por message:updated). Nunca en uno borrado ni en uno de bot. |
| `deleted` opcional | `true` | Solo si se borró para todos. |
| `deletedAt` opcional | integer | Solo si se borró. |
| `deletedBy` opcional | string \| null | Solo si se borró. |
| `status` opcional | `"sent"` \| `"delivered"` \| `"read"` | Solo en mis mensajes, en el historial de una persona: los tildes. |
| `clientId` opcional | string ≤ 60 | Solo en la respuesta al envío y en su message:new. |
| `expiresAt` opcional | integer | Solo en un chat con mensajes temporales: cuándo deja de existir (epoch ms). Pasada esa hora no se muestra ni se guarda. |
| `expired` opcional | `true` | Solo si venció (mensajes temporales): no se muestra nada en su lugar, ni «eliminado». Llega como cambio (message:updated, ?since) para sacarlo de la copia local. |

<a id="schema-Chat"></a>
### Chat

| Campo | Tipo | Qué es |
| --- | --- | --- |
| `id` | string |  |
| `name` | string |  |
| `avatar` | string \| null |  |
| `kind` | `"group"` \| `"direct"` |  |
| `direct` | boolean |  |
| `inviteToken` opcional | string \| null | Solo para quien puede compartir el link. |
| `linkOff` | boolean | «Solo por invitación». |
| `createdBy` | string \| null |  |
| `perms` opcional | `ChatPerms` | Solo en grupos. |
| `members` | array de `ChatMember` |  |
| `lastMessage` | [Message](#schema-Message) \| object \| null |  |
| `isMember` opcional | boolean | Solo en las respuestas a vos. |
| `myRole` opcional | `"admin"` \| `"member"` | Solo grupos, solo en las respuestas a vos. |
| `pendingRequests` opcional | integer | Para un admin de un grupo que aprueba a los nuevos. |
| `mutedUntil` opcional | number | 0 sin silencio, < 0 para siempre, si no epoch ms. Solo a vos. |
| `favorite` opcional | boolean | Solo a vos. |
| `peer` opcional | `ChatMember` \| object \| null | Solo en un directo: el otro. |
| `blocked` opcional | boolean | Directo con alguien que bloqueaste. |
| `pendingEmail` opcional | string \| null | Directo que todavía nadie aceptó. |
| `pendingPhone` opcional | string \| null |  |
| `referral` opcional | `ChatReferral` \| object \| null | Solo grupos: el premio por invitar. |
| `disappearMs` opcional | `86400000` \| `604800000` \| `7776000000` \| `null` | Mensajes temporales: cuánto viven los mensajes nuevos (24 h, 7 días o 90 días); null si están desactivados. |

<a id="schema-ChatBotMember"></a>
### ChatBotMember

| Campo | Tipo | Qué es |
| --- | --- | --- |
| `id` | string |  |
| `name` | string |  |
| `color` opcional | string |  |
| `avatar` opcional | string \| null |  |
| `kind` | `"bot"` |  |
| `runtime` | `"ai"` \| `"api"` |  |
| `builtin` opcional | boolean |  |
| `public` opcional | boolean |  |
| `verified` opcional | `"official"` \| `"trusted"` \| `null` | El check: dorado (de Puentes) o verde (verificado). Solo lectura. |
| `ownerId` opcional | string \| null | null en los de Puentes. |
| `description` opcional | string |  |
| `provider` opcional | string \| null |  |
| `model` opcional | string \| null |  |
| `tier` opcional | integer \| null | 1, 2 o 3 (solo ai). |
| `mode` | `"mention"` \| `"always"` | Cómo responde en ESTE chat (un bot externo: qué mensajes le llegan). |
| `mcp` opcional | boolean | Tiene herramientas. |
| `menu` opcional | array de `MenuItem` |  |
| `alias` opcional | string \| null | Su alias en ESTE chat. |
| `by` opcional | string \| null | De quién es (la organización, o quien lo hizo). |

<a id="schema-Bot"></a>
### Bot

Lo que dice «Solo para el dueño» llega solo a quien lo puede editar. Nunca vuelven la clave, los tokens ni la URL de un MCP ajeno.

| Campo | Tipo | Qué es |
| --- | --- | --- |
| `id` | string |  |
| `name` | string |  |
| `color` opcional | string |  |
| `avatar` opcional | string \| null |  |
| `runtime` | `"ai"` \| `"api"` |  |
| `builtin` | boolean | De Puentes. |
| `public` opcional | boolean |  |
| `verified` | `"official"` \| `"trusted"` \| `null` | El check: dorado (de Puentes) o verde (verificado). Solo lectura. |
| `ownerId` opcional | string \| null |  |
| `description` opcional | string |  |
| `createdAt` opcional | integer |  |
| `provider` opcional | string | Solo ai. |
| `model` opcional | string | Solo ai. |
| `tier` opcional | integer | Solo ai. |
| `mode` opcional | string \| null | Solo ai: el default. |
| `menu` opcional | array de `MenuItem` |  |
| `alias` opcional | string \| null |  |
| `kind` | `"ai"` \| `"external"` \| `"tools"` | tools: una IA con su servidor adentro; desde la migración del 2026-10-13 ninguna, son ai con connectors. |
| `shortDesc` opcional | string |  |
| `category` opcional | string \| null |  |
| `tags` opcional | array de string |  |
| `language` opcional | string \| null |  |
| `countries` opcional | array de string |  |
| `visibility` | `"private"` \| `"link"` \| `"authorized"` \| `"catalog"` |  |
| `pays` opcional | `"owner"` \| `"user"` |  |
| `developer` opcional | object \| null |  |
| `org` opcional | object \| null |  |
| `chats` opcional | integer | En cuántos chats está. |
| `template` opcional | string \| null |  |
| `clonable` opcional | boolean |  |
| `source` opcional | object \| null | Una copia: su original. |
| `following` opcional | boolean | Una copia que sigue a su original. |
| `copyBlock` opcional | `"tools"` \| `"external"` \| `"following"` \| `null` | Solo para quien lo edita: por qué no se puede ofrecer para copiar (un servicio que viene conectado o es exclusivo, un programa externo, una copia que sigue a su original), o null. |
| `idea` opcional | object \| null |  |
| `by` opcional | string \| null |  |
| `mcp` opcional | object | Solo una IA que todavía tiene su servidor adentro (antes de la migración del 2026-10-13). Después, nunca: ese servidor es uno de sus connectors. |
| `connectors` opcional | array de object | Solo si usa conectores. |
| `requestsOpen` opcional | boolean | Solo para el dueño. |
| `pay` opcional | `"owner"` \| `"user"` | Solo para el dueño. |
| `capMonthUsd` opcional | number \| null |  |
| `perUserDailyReplies` opcional | integer \| null |  |
| `perUserMonthUsd` opcional | number \| null |  |
| `verification` opcional | `"trusted"` \| `"pending"` \| `"none"` | Solo para el dueño. |
| `verificationRequestedAt` opcional | integer \| null |  |
| `actions` opcional | array de `MenuItem` | Solo para el dueño. |
| `systemPrompt` opcional | string | Solo para el dueño. |
| `tokenPrefix` opcional | string \| null | Solo para el dueño. |
| `webhookUrl` opcional | string | Solo para el dueño. |
| `webhookSecret` opcional | string \| null | Solo para el dueño. |
| `lastUsedAt` opcional | integer \| null | Solo para el dueño. |

<a id="schema-User"></a>
### User

| Campo | Tipo | Qué es |
| --- | --- | --- |
| `id` | string |  |
| `name` | string |  |
| `color` | string |  |
| `avatar` | string \| null |  |
| `bot` | boolean |  |

<a id="schema-Attachment"></a>
### Adjuntos y tarjetas (`attachments[]`)

Se distinguen por type (y las tarjetas options por layout). picks, catalog y confirm llegan con su tipo viejo o como options con ese layout: leé las dos formas. Cada forma, campo por campo, está en [/openapi.json](https://app.puentes.ai/openapi.json) (`components.schemas`).

| type | Schema | Qué es |
| --- | --- | --- |
| `image` \| `audio` \| `video` \| `file` | `FileAttachment` | Una foto, un audio (con su transcripción), un video o un archivo, subidos con POST /api/upload. |
| `location` | `LocationAttachment` | Una ubicación; la dirección la agrega el server después. |
| `choice` | `ChoiceAttachment` | Lo que una persona tocó en una tarjeta: despierta al bot que la mandó. Es la única tarjeta que manda un cliente. |
| `options` | `OptionsCard` | Una lista para elegir (filas, carrusel, carteles o botones), o una encuesta (poll). |
| `options` · layout `picks` | `OptionsPicksCard` | picks, en la forma nueva. |
| `options` · layout `catalog` | `OptionsCatalogCard` | catalog, en la forma nueva (ítems con section). |
| `options` · layout `confirm` | `OptionsConfirmCard` | confirm, en la forma nueva. |
| `picks` | `PicksCard` | Lo que una herramienta resolvió sola, con alternativas por renglón (forma vieja). |
| `catalog` | `CatalogCard` | Un catálogo con carrito (forma vieja). |
| `form` | `FormCard` | Un formulario corto, con respuestas guardadas para tocar. |
| `link` | `LinkCard` | Un botón que abre una página. |
| `tasklist` | `TasklistCard` | Una lista de tareas, siempre con su estado actual. |
| `table` | `TableCard` | Una tabla, siempre con su estado actual. |
| `bot` | `BotCard` | Un bot compartido en el chat: da permiso de uso. |
| `contact` | `ContactCard` | Una persona compartida en el chat. |
| `tools` | `ToolsAttachment` | Qué herramientas usó un bot en esta respuesta. |
| `mcp_connect` | `McpConnectAttachment` | El bot necesita que alguien conecte su cuenta. |
| `confirm` | `ConfirmCard` | Una pregunta de sí o no para una sola persona. Solo la crea el server. |
| `forward` | `ForwardAttachment` | La marca de «Reenviado». |
| `cap` | `CapAttachment` | En una línea de sistema: se llegó a un tope de gasto. |
| `task` | `TaskAttachment` | En la línea «⏰ Recordatorio»: su tarea, para los botones «Hecho» y «Posponer». |
| `schedule` | `ScheduleAttachment` | En la línea «⏰ …» de un aviso que se repite (un `say`): su programado, para «No recordar más». |
| `invite` | `InviteAttachment` | El botón «Invitar gente al grupo» del saludo de una IA. |

<a id="ejemplo"></a>
## Ejemplos: un bot eco

Responde citando en sus chats directos y cuando lo mencionan en un grupo. Node 18+, sin dependencias:

```
const BASE = 'https://app.puentes.ai';
const TOKEN = process.env.PUENTES_BOT_TOKEN;
const api = async (path, opts = {}) => {
  const res = await fetch(BASE + path, { ...opts, headers: { authorization: `Bearer ${TOKEN}`, 'content-type': 'application/json' } });
  if (!res.ok) throw new Error(`${path} ${res.status}`);
  return res.json();
};
const me = await api('/api/me');
const mention = new RegExp(`(^|\\s)@${me.name}(?=$|[\\s.,;:!?])`, 'i');
const chats = new Map();
const chatOf = async (id) => chats.get(id) || (chats.set(id, await api(`/api/groups/${id}`)), chats.get(id));

let { next } = await api('/api/me/updates');
for (;;) {
  const r = await api(`/api/me/updates?after=${next}&timeout=25`).catch(() => ({ messages: [], next }));
  next = r.next;
  for (const m of r.messages) {
    if (m.senderType === 'system') continue;
    const chat = await chatOf(m.groupId);
    if (!chat.direct && !mention.test(m.text || '')) continue;
    await api(`/api/groups/${m.groupId}/messages`, { method: 'POST', body: JSON.stringify({ text: `Eco: ${m.text}`, replyTo: m.id }) });
  }
}
```

El mismo en Python 3, sin dependencias:

```
import json, os, re, uuid, urllib.request

BASE, TOKEN = 'https://app.puentes.ai', os.environ['PUENTES_BOT_TOKEN']

def api(path, body=None):
    req = urllib.request.Request(BASE + path, data=json.dumps(body).encode() if body is not None else None,
                                 headers={'Authorization': f'Bearer {TOKEN}', 'Content-Type': 'application/json'},
                                 method='POST' if body is not None else 'GET')
    with urllib.request.urlopen(req, timeout=35) as res:
        return json.load(res)

me = api('/api/me')
mention = re.compile(rf"(^|s)@{re.escape(me['name'])}(?=$|[s.,;:!?])", re.I)
chats = {}
nxt = api('/api/me/updates')['next']
while True:
    try:
        r = api(f'/api/me/updates?after={nxt}&timeout=25')
    except Exception:
        continue
    nxt = r['next']
    for m in r['messages']:
        if m['senderType'] == 'system':
            continue
        chat = chats.setdefault(m['groupId'], api(f"/api/groups/{m['groupId']}"))
        if not chat['direct'] and not mention.search(m.get('text') or ''):
            continue
        api(f"/api/groups/{m['groupId']}/messages", {'text': f"Eco: {m.get('text')}", 'replyTo': m['id'], 'clientId': str(uuid.uuid4())})
```

El de Node, listo para correr: `node scripts/example-bot.mjs` en el [repo](https://github.com/a1500llc/puentesai-pwa).

<a id="limites"></a>
## Límites y buenas prácticas

- Los generales y los de cada ruta están en la tabla de abajo; lo que pasa de un límite recibe `429`, con `Retry-After` (segundos) cuando se sabe cuánto esperar.
- Además, por cuenta y por hora: 60 chats directos, 60 altas de bots en grupos, 30 tarjetas compartidas, 10 bots nuevos, 10 links de bot, 10 restablecimientos de link; por día: 30 grupos, 20/50 invitaciones por email (10 el primer día), 5 reportes. Por IP: 20 links de acceso o handoff cada 10 minutos, 30 entradas por link por hora.
- Bots con herramientas: cada respuesta descuenta de la cuota de quien le habla lo que cuesta su nivel (1, 2 o 3), una vez, use las rondas que use.
- Un bot no ve sus propios mensajes en `updates` ni por webhook. Sí ve los de otros bots: si respondés a todo, cuidá los loops entre bots.
- Nombres: hasta 40 caracteres; en un grupo no puede haber dos participantes con el mismo nombre.
- Los tildes de entregado/leído solo cuentan personas: un bot nunca "lee".
- Tratá el token como una contraseña. Si se filtró, regeneralo desde la sección IAs, editando el bot, o con `POST /api/bots/:id/token`.

| Ruta | Límite |
| --- | --- |
| Todo `/api` | 600 pedidos por minuto por IP |
| Mensajes | 30 por minuto por bot, 60 por persona; hasta 20.000 caracteres y 10 adjuntos |
| Cuentas nuevas | 20 por hora y 50 por día por IP (más para una IP compartida conocida) |
| Cuenta | hasta 50 bots, 10 API keys, 30 conectores, 40 cuentas conectadas |
| Bots con herramientas | 6 rondas por respuesta, 30 s por herramienta, 90 s por respuesta, resultados recortados a 8.000 caracteres |
| `POST /api/groups/:id/messages` | 30 por minuto por bot, 60 por persona (`Retry-After: 60`) |
| `DELETE /api/groups/:id/messages/:mid` | 120 por hora por cuenta |
| `POST /api/upload` | 60 por hora por cuenta |
| `POST /api/groups/:id/polls` | 30 por hora por cuenta |
| `POST /api/groups/:id/messages/:mid/vote` | 120 por hora por cuenta |
| `PUT /api/groups/:id/messages/:mid/reaction` | 300 por hora por cuenta |
| `POST /api/groups/:id/messages/:mid/forward` | 60 por hora por cuenta |
| `GET /api/search` | 600 por hora por cuenta |
| `GET /api/groups/:id/search` | 600 por hora por cuenta |
| `GET /api/link-preview` | 600 por hora por cuenta |
| `POST /api/groups` | 30 por día por cuenta |
| `POST /api/groups/:id/link/reset` | 10 por hora por cuenta |
| `PUT /api/groups/:id/disappear` | 30 por hora por cuenta |
| `POST /api/groups/:id/clear` | 20 por hora por cuenta |
| `POST /api/groups/:id/members` | 60 por hora por cuenta |
| `PATCH /api/groups/:id/members/:userId` | 60 por hora por cuenta |
| `DELETE /api/groups/:id/members/:userId` | 60 por hora por cuenta |
| `POST /api/groups/:id/requests/:rid/approve` | 120 por hora por cuenta |
| `POST /api/groups/:id/requests/:rid/reject` | 120 por hora por cuenta |
| `POST /api/invites/:token/join` | 30 por hora por IP |
| `POST /api/groups/:id/invites` | 20 por hora y 50 por día (10 el primer día de la cuenta) |
| `POST /api/groups/:id/invite-link` | 60 por hora por cuenta |
| `POST /api/direct/:userId` | 60 por hora por cuenta |
| `POST /api/groups/:id/share` | 30 por hora por cuenta |
| `POST /api/tasks/:id/move` | 600 por hora por cuenta |
| `POST /api/groups/:id/messages/:mid/pin` | 60 por hora por cuenta |
| `PUT /api/groups/:id/pins/:pinId` | 120 por hora por cuenta |
| `POST /api/bots` | 10 por hora por cuenta |
| `POST /api/bots/:id/clone` | 10 por hora por cuenta |
| `POST /api/bots/:id/verify` | 10 por hora por cuenta |
| `PUT /api/bots/:id/domains` | 30 por hora por cuenta |
| `POST /api/bots/:id/access-requests` | 20 por hora por cuenta |
| `POST /api/bots/:id/access-requests/:rid/:decision` | 120 por hora por cuenta |
| `POST /api/bots/:id/access` | 60 por hora por cuenta |
| `POST /api/bots/:id/share-link` | 10 por hora por cuenta |
| `POST /api/groups/:id/bots` | 60 por hora por cuenta |
| `POST /api/groups/:id/bots/:botId/mode-ask` | 60 por hora por cuenta |
| `PUT /api/me/developer` | 30 por hora por cuenta |
| `POST /api/me/developer/verify` | 5 por hora por cuenta |
| `POST /api/organizations` | 10 por hora por cuenta |
| `PATCH /api/organizations/:id` | 60 por hora por cuenta |
| `POST /api/organizations/:id/members` | 60 por hora por cuenta |
| `PATCH /api/organizations/:id/members/:uid` | 60 por hora por cuenta |
| `POST /api/organizations/:id/accept` | 30 por hora por cuenta |
| `POST /api/organizations/:id/decline` | 30 por hora por cuenta |
| `POST /api/organizations/:id/verify` | 5 por hora por cuenta |
| `POST /api/bots/:id/mcp/connect` | 10 por hora por cuenta |
| `POST /api/bots/:id/mcp/test` | 30 por hora por cuenta |
| `POST /api/mcp/post/:token` | 240 por hora por IP; 30 mensajes por minuto del bot |
| `POST /api/mcp/event/:token` | 120 por hora por IP |
| `GET /api/mcp/callback` | 30 por hora por IP |
| `POST /api/mcp/callback` | 30 por hora por IP |
| `POST /api/connectors` | 20 por hora por cuenta |
| `POST /api/connectors/:id/test` | 30 por hora por cuenta |
| `POST /api/me/connections` | 20 por hora por cuenta |
| `POST /api/me/connections/:id/reconnect` | 20 por hora por cuenta |
| `POST /api/me/connections/:id/use` | 30 por hora por cuenta |
| `DELETE /api/me` | 5 por hora por cuenta |
| `POST /api/me/keys` | 10 por hora por cuenta |
| `POST /api/contacts/lookup` | 30 por hora |
| `POST /api/users/:id/block` | 60 por hora por cuenta |
| `POST /api/users/:id/report` | 5 por día |

<a id="errores"></a>
## Errores

Siempre `{ "error": "una frase para mostrar", "code"?: "estable" }`. Decidí por el status y, cuando viene, por `code`; nunca por el texto de `error`, que puede cambiar.

| Status | `code` | Cuándo | Qué hacer |
| --- | --- | --- | --- |
| 400 | — | El pedido está mal (falta un campo, un valor fuera de rango). `error` dice qué, en castellano. | No reintentes igual. |
| 401 | `no_session` | La credencial no vale (vencida, revocada o mal escrita). | Pedí una nueva; un 401 sin este `code` no es «se cerró la sesión». |
| 403 | — | No tenés permiso: un bot en una ruta solo para personas, alguien que no es admin, un grupo donde solo escriben los admins, un bloqueo. | No reintentes. |
| 403 | `guest` | Una cuenta sin nada verificado (un invitado) intentó algo que lo pide: invitar por email, buscar gente o cruzar la agenda, compartir un contacto de la agenda, crear o copiar un bot, una organización o un perfil de desarrollador, pedir acceso a un bot, conectar un servicio, guardar una clave o reenviar a un email. | Verificá un email, un teléfono, Google o Apple. El texto de `error` dice para qué hace falta. |
| 403 | `not_clonable` | «Hacer mi copia» de un bot que no se puede copiar. | — |
| 404 | — | No existe, o no lo podés ver (un chat del que no sos miembro, un contacto que no te conoce: la respuesta es la misma a propósito). | — |
| 409 | — | Choca con algo: ir al catálogo sin el perfil de desarrollador completo (`missing`), una encuesta cerrada, un teléfono de otra cuenta. | Leé `missing` si viene. |
| 400 | `alias_format` | Alias de 2 a 20 letras, números, `-` o `_`, sin `@`. | Corregilo. |
| 400 | `alias_reserved` | `ai`, `ia` y `todos` están reservados. | Elegí otro. |
| 400 | `alias_taken` | Ya hay alguien con ese alias en el grupo. | Elegí otro. |
| 400 | `clone_tools` | Un bot que usa un servicio con la cuenta de quien lo hizo no se puede ofrecer para copiar. | — |
| 400 | `clone_external` | Un bot externo no se puede copiar. | — |
| 400 | `clone_following` | Una copia que sigue a su original no se puede ofrecer para copiar. | — |
| 400 | `idea_needs_catalog` | Una «idea» para copiar necesita el bot en el catálogo y copiable. | — |
| 400 | `cap` | Un tope de gasto del bot. | — |
| 429 | — | Pasaste un límite (ver [Límites](#limites)). | Esperá lo que dice `Retry-After` (segundos) si viene. |
| 429 | `guest_limit` | Demasiadas cuentas nuevas desde esta conexión. | Esperá. |
| 500 | — | «Error interno»: un error nuestro; el detalle queda en el log. | Reintentá con el mismo `clientId` si estabas enviando. |
| 502 | — | O 503, 504: el server se está actualizando o no llegó. | Reintentá con backoff (y el mismo `clientId`). |

<a id="config"></a>
## Lo que dice `/api/config`

`GET /api/config` es público y dice qué tiene prendido este server. Una función que depende de una bandera no existe sin ella: preguntá antes de ofrecerla.

| Campo | Qué dice |
| --- | --- |
| `providers` | Por proveedor (`anthropic`, `openai`): `label`, `models`, `tiers` (`{ modelo: 1 \| 2 \| 3 }`), `prices`, `defaultModel`, `freeModel`, `freeAvailable`. |
| `byokProviders` | Los proveedores que hoy responden solo con la clave de la persona (`gemini` o `xai` cuando el server no tiene clave de ellos): la misma forma que `providers` más `byokOnly: true`, sin mensajes gratis. Con clave del server van en `providers`. |
| `newBotProvider` | El proveedor con que nace una IA nueva. |
| `freeMessagesPerDay` | Mensajes gratis por día y por persona (`freeMessagesPerUser` es el nombre viejo). |
| `freeMessagesPerUser` | Igual que `freeMessagesPerDay` (la app nativa lo sigue leyendo). |
| `publicUrl` | De dónde salen los links. |
| `transcribeAudio` | Los audios llegan transcriptos (`attachments[].transcript`, por `message:updated`). |
| `googleClientId` | Entrar con Google (de la app). |
| `appleClientId` | Entrar con Apple (de la app). |
| `appleWebClientId` | Entrar con Apple en la web. |
| `androidAppUrl` | La app en Play. |
| `minAppVersion` | La versión mínima de la app nativa por plataforma. |
| `emailEnabled` | El server manda emails (códigos, invitaciones). |
| `smsEnabled` | Códigos por teléfono. |
| `smsChannels` | Por dónde: `sms`, `whatsapp`. |
| `mcpEnabled` | Bots con herramientas (MCP). |
| `connectors` | Conectores y cuentas ([ver](#ref-conectores)). |
| `chatLists` | Favoritos y listas de chats. |
| `notifyPrefs` | Interruptores de notificaciones. |
| `pending` | La pestaña «Pendientes» (`GET /api/me/pending`). |
| `reactions` | Reacciones (un mensaje sin `reactions` se lee como `{}`). |
| `linkPreviews` | Fichas de links (`message.link`). |
| `searchEnabled` | Buscar mensajes (`GET /api/search` y `GET /api/groups/:id/search`). |
| `disappearing` | Mensajes temporales (`PUT /api/groups/:id/disappear`, `disappearMs` del chat, `expiresAt` y `expired` del mensaje). |
| `campaignSkipEscape` | De la web: no mostrar la pantalla de salida en una visita de campaña. |
| `campaignAutoEscape` | De la web: en Android, una visita de anuncio dentro del navegador de una app sale sola a Chrome. |
| `ias` | La sección IAs (`/bots/browse`, fichas, `members[].mode`). |
| `botCategories` | Las categorías del catálogo (ids). |
| `ownerPayEnabled` | Un desarrollador puede pagar las respuestas de su bot con su clave. |
| `vapidPublicKey` | Web Push (de la web). |
| `socketHosts` | Los hosts del socket (mejor `GET /api/sockets`). |
| `assistantBotId` | Qué bot es «Puentes». |
| `places` | Avisos por lugar (de la app). |
| `calendar` | El server entiende el calendario de los chats (en prueba): si un chat lo tiene lo dice su `calendar`. |

La API y esta página están en castellano. En inglés: todavía no (lo decide Mauro).

¿Dudas o ideas? Escribile a Claude en Puentes 😉.
