Puentes · API para developersAbrir la app →

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 12 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
Para una IA o un programa: esta misma página en Markdown, la referencia en OpenAPI 3.1 (con quién puede llamar cada ruta en x-puentes-who y su límite en x-puentes-limits) y el índice llms.txt. Las tres se generan del mismo origen que esta página: lo que dice una lo dicen las otras.

Conceptos

ParticipanteQuién lo correCredencial
PersonaUn humano desde la appSesión del dispositivo, o una API key pk_… que actúa como esa persona desde un script
Bot AI AIPuentes llama al modelo (Claude, GPT) con el historial del chat. Los preintegrados y los que creás con tus instruccionesNinguna: lo corre el server
Bot externo BOTTu programa, donde quierasToken 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.

Autenticación

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

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

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

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" }, … } }
CampoDescripción
runtime"api" (externo, con token) o "ai" (lo corre Puentes con un modelo). Default "ai".
nameHasta 40 caracteres, único entre tus bots. AI, IA y todos están reservados.
descriptionOpcional, hasta 2000 caracteres. Se ve en la ficha.
avatarOpcional: 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.
publictrue lo publica en el catálogo: cualquiera puede agregarlo a sus grupos o hablarle 1 a 1.
webhookUrlSolo api. https:// pública (ver Webhook). Se puede setear después con PATCH.
provider, tierSolo 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.
modeSolo 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, countriesLa 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".
systemPromptSolo ai. Instrucciones extra, hasta 4000 caracteres.
menu, actionsBotones 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.
aliasEl 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_*.
orgIdCrear el bot a nombre de una organización tuya (sus miembros lo editan).
requestsOpenCon visibility: "authorized": quien llega por el link puede pedir acceso.
pay, ownerKey, capMonthUsd, perUserDailyReplies, perUserMonthUsdSolo 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, ideaSolo 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).
mcpSolo ai. { url, label?, secret?, auth? }: un servidor MCP 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. 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.

Bots con herramientas (MCP)

Un bot ai con mcp recibe las herramientas del servidor en cada respuesta 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). La credencial es siempre la del dueño: si el servidor autentica por OAuth, el dueño lo conecta desde el chat (el bot publica una tarjeta mcp_connect en vez de responder) o con POST /api/bots/:id/mcp/connect, que devuelve la url de autorización; al volver, el server guarda los tokens cifrados. Quien pueda usar el bot ejecuta las herramientas con la cuenta del dueño: 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.

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? }. 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.

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.

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 (Creadores › Conectores) 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.

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): 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? }.

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

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.

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.

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.

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.

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.

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.

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.

Meterlo en un chat

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)

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

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

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

EventoPayloadCuándo
message:newMessage (+ clientId)Un mensaje nuevo en uno de mis chats.
message:updatedMessageUn 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:newChatMe sumaron a un chat, o alguien abrió un directo conmigo.
group:updatedChatCambió 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.
table:updated{ groupId, table }Cambió una tabla.
table:deleted{ groupId, tableId }Se borró una tabla.
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.

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

Quién soy, config y lo nuevo

RutaQuiénQué haceLímite
GET /api/metodosQuién soy. Un bot ve bot: true y ownerId.—
GET /api/configpúblicoLo que este server tiene prendido: proveedores y niveles, cuota gratis, emailEnabled, mcpEnabled, connectors… Ver /api/config.—
GET /api/socketspúblicoA qué host conectar el socket: { host, hosts } (host: null = https://app.puentes.ai).—
GET /api/me/groupstodosMis chats (grupos y directos) con miembros y último mensaje.—
GET /api/me/updatestodos?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 }.—
GET /api/me/usagetodosLa 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/:idtodosEl perfil público de un desarrollador (kind: "person") o de una organización (kind: "org") y sus bots visibles para quien pregunta.—

Mensajes

RutaQuiénQué haceLímite
GET /api/groups/:id/messagestodos · miembroEl 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/messagestodos · miembroEnviar { text?, attachments?, replyTo?, clientId?, linkPreview? } (ver Enviar mensajes). 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/readtodos · miembroLeí 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/unreadtodos · miembro«Marcar como no leído» en todos mis dispositivos.—
POST /api/me/deliveredtodosAl 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/:midtodos · miembroBorrar, 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/typingtodos · miembro«Está escribiendo…» unos segundos (typing en el socket).—
POST /api/uploadtodosSubir 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/pollstodos · miembroLas encuestas abiertas del chat (hasta 20, de los últimos 30 días): [{ message }].—
POST /api/groups/:id/pollspersona · miembroCrear 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/votepersona · miembroVotar { 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/reactionpersona · 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/forwardpersona · miembroReenviar { 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/searchpersonaBuscar 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/searchpersona · miembroLo 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-previewpersona?url=: la ficha de un link ({ url, title, site, description?, image?, imageW?, imageH?, icon? }) o 204 si no hay.600 por hora por cuenta

Chats y miembros

RutaQuiénQué haceLímite
GET /api/groups/:idtodos · miembroUn chat: kind, miembros (personas y bots), peer si es directo, perms. Ver Objetos.—
POST /api/groupspersonaCrear 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/:idpersona · 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).—
POST /api/groups/:id/link/resetpersona · miembro · permiso «admins»Link de invitación nuevo; el anterior muere.10 por hora por cuenta
POST /api/groups/:id/leavepersona · miembroSalir 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/:idpersona · miembro · permiso «admins»Eliminar el grupo para todos.—
POST /api/groups/:id/clearpersona · miembro · en un grupo, adminVaciar el chat para todos (mensajes y lo que recuerda la IA; listas y miembros quedan). group:cleared { id, by }.20 por hora por cuenta
POST /api/groups/:id/memberspersona · 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/:userIdpersona · miembro · admin{ role: "admin" | "member" }. A quien creó el grupo no se le quita.60 por hora por cuenta
DELETE /api/groups/:id/members/:userIdpersona · miembro · adminSacar 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/banspersona · miembro · permiso «admins»Las personas sacadas del grupo.—
DELETE /api/groups/:id/bans/:userIdpersona · miembro · permiso «admins»Levantar un veto.—
GET /api/groups/:id/requestspersona · miembro · permiso «admins»Con perms.approve: quién pidió entrar ({ id, user, via, addedByName, createdAt }).—
POST /api/groups/:id/requests/:rid/approvepersona · miembro · permiso «admins»Dejar entrar.120 por hora por cuenta
POST /api/groups/:id/requests/:rid/rejectpersona · miembro · permiso «admins»Rechazar (no se avisa).120 por hora por cuenta
GET /api/invites/:tokenpúblicoVista previa de una invitación (?i=: el token de una invitación por email).—
POST /api/invites/:token/joinpersonaEntrar 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/requestpersonaCancelar mi pedido de entrada.—
GET /api/groups/:id/invitespersona · miembroLas invitaciones por email pendientes.—
POST /api/groups/:id/invitespersona · 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/resendpersona · miembroReenviar una invitación.—
DELETE /api/groups/:id/invites/:inviteIdpersona · miembroCancelarla.—
POST /api/groups/:id/invite-linkpersona · 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/:userIdpersonaAbrir (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-invitepersona{ 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/sharepersona · miembroCompartir 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

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.

RutaQuiénQué haceLímite
GET /api/groups/:id/taskliststodos · miembroLas listas del chat: [{ id, groupId, messageId, name, items: [Task], done, total }].—
POST /api/groups/:id/taskliststodos · 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/:idtodos · miembro del chat de la listaRenombrar { name }.—
DELETE /api/tasklists/:idtodos · miembro del chat de la listaBorrarla: { ok, groupId, listId, message } (el mensaje queda con list: null).—
POST /api/tasklists/:id/taskstodos · miembro del chat de la listaAgregar { text }.—
PATCH /api/tasks/:idtodos · miembro del chat de la listaCambiar el texto { text } (una fecha nueva en el texto reemplaza la anterior).—
POST /api/tasks/:id/toggletodos · miembro del chat de la listaTildar { done: true | false }.—
POST /api/tasks/:id/duetodos · miembro del chat de la listaFecha { dueAt } (epoch ms; vacío la saca).—
DELETE /api/tasks/:idtodos · miembro del chat de la listaBorrar la tarea.—

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.

RutaQuiénQué haceLímite
GET /api/groups/:id/tablestodos · miembroLas tablas del chat: [{ id, groupId, messageId, name, createdBy, columns, rows: [{ id, title, cells }], summary }].—
POST /api/groups/:id/tablestodos · 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/:idtodos · 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/:idtodos · miembro del chat de la tabla; quien la armó o un admin; nunca un botBorrarla: { ok, groupId, tableId, message }.—
POST /api/tables/:id/rowstodos · miembro del chat de la tablaUna fila { title (≤ 120), cells?: { columnId: valor } }. Devuelve la tabla.—
PATCH /api/tables/:id/rows/:ridtodos · 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/:ridtodos · miembro del chat de la tablaBorrar la fila.—

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

RutaQuiénQué haceLímite
GET /api/groups/:id/schedulestodos · miembroLos programados vivos del chat que puedo ver.—
PATCH /api/groups/:id/schedules/:sidtodos · miembro · quien lo pidió o un admin; nunca un bot{ paused: true | false }. Devuelve la lista del chat.—
DELETE /api/groups/:id/schedules/:sidtodos · miembro · quien lo pidió o un admin; nunca un botCancelarlo. Devuelve la lista del chat.—
GET /api/me/schedulespersonaLo que se manda en mi nombre en cualquier chat: { sends, grants } (Ajustes › Mensajes programados).—
POST /api/schedules/:sid/confirmpersonaContestar la tarjeta «¿Lo mando en tu nombre?»: { yes: true | false } → { ok, result: "sent" | "scheduled" | "dropped" | "expired" }.—
POST /api/bots/:id/send-grantpersonaDejar que ese bot mande en mi nombre lo que yo le pida.—
DELETE /api/bots/:id/send-grantpersonaRetirarlo (se borran sus envíos programados en mi nombre).—

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

RutaQuiénQué haceLímite
GET /api/groups/:id/pinnedtodos · miembroLos mensajes fijados: [{ message, pinnedBy, pinnedByName, pinnedAt }].—
POST /api/groups/:id/messages/:mid/pinpersona · 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/pinpersona · miembro · permiso «fijar»Desfijarlo.—
GET /api/groups/:id/pinstodos · miembroLos fijados de los bots del chat (los del chat y los míos).—
PUT /api/groups/:id/pins/:pinIdbot · miembroPoner 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/:pinIdbot · miembroSacarlo. pin:removed.—

Bots

RutaQuiénQué haceLímite
POST /api/botspersona · verificada; hasta 50 botsCrear un bot (ver Crear un bot). Devuelve { bot, token?, group }.10 por hora por cuenta
GET /api/botspersonaMis bots (con el prefijo del token, el webhook y su secreto).—
GET /api/bots/catalogpersonaLos bots que puedo agregar: de Puentes, públicos, míos y compartidos conmigo.—
GET /api/bots/browsepersonaLa sección IAs: ?q=&chip= → { mine, team, catalog, next }.—
GET /api/bots/:idtodosLa 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/:idpersona · quien lo puede editarCambiar cualquiera de los campos de Crear un bot. Además, solo para un admin del bot: pay, ownerKey, los topes, orgId, clonable e idea.—
DELETE /api/bots/:idpersona · dueñoBorrarlo: sale de todos los chats y sus directos desaparecen.—
POST /api/bots/:id/tokenpersona · dueñoToken nuevo (el anterior muere al instante).—
POST /api/bots/:id/clonepersona · 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/usagepersona · dueñoCuánto gastó un bot que paga sus respuestas: { monthUsd, replies, people, since }.—
DELETE /api/bots/:id/owner-keypersona · admin del botOlvidar la clave de modelo con la que el bot paga.—
POST /api/bots/:id/verifypersona · dueñoPedir 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/domainspersona · 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-requestspersonaPedir acceso a un bot authorized con pedidos abiertos.20 por hora por cuenta
GET /api/bots/:id/access-requestspersona · admin del botLos pedidos pendientes.—
POST /api/bots/:id/access-requests/:rid/:decisionpersona · admin del bot:decision = approve | reject.120 por hora por cuenta
GET /api/bots/:id/accesspersona · dueñoQuién puede usar el bot además de su dueño (via: "share" | "chat" | "link" | "approved").—
POST /api/bots/:id/accesspersona · dueñoDar acceso a un contacto { userId }.60 por hora por cuenta
DELETE /api/bots/:id/access/:userIdpersona · dueñoQuitarlo (su directo con el bot se borra).—
POST /api/bots/:id/share-linkpersona · dueñoLink /bot/<token> para compartirlo (7 días, uno vivo por bot).10 por hora por cuenta
DELETE /api/bots/:id/share-linkpersona · dueñoRevocarlo.—
GET /api/bots/shared/:tokenpersonaQuién comparte qué.—
POST /api/bots/shared/:token/acceptpersonaAceptar: acceso para la cuenta logueada.—
POST /api/groups/:id/botspersona · miembro · permiso «agregar gente»Sumar { botId, mode? } (de Puentes, público, mío o compartido conmigo) al grupo.60 por hora por cuenta
PATCH /api/groups/:id/bots/:botIdpersona · miembro · permiso «cómo responde un bot»Cómo responde ese bot en ese grupo: { mode?: "mention" | "always", alias?, connections? }.—
POST /api/groups/:id/bots/:botId/mode-askpersona · 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/:botIdpersona · miembro · permiso «agregar gente»Sacar el bot del grupo.—
GET /api/me/developerpersonaMi perfil de desarrollador, con complete y missing.—
PUT /api/me/developerpersona{ name, company?, bio, website?, contactEmail?, contactable?, country? }: completo, un bot puede ir al catálogo.30 por hora por cuenta
POST /api/me/developer/verifypersonaPedir el check verde para el perfil.5 por hora por cuenta
GET /api/me/organizationspersonaMis organizaciones.—
POST /api/organizationspersonaCrear 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/:idpersona · admin de la organizaciónEditarla.60 por hora por cuenta
DELETE /api/organizations/:idpersona · admin de la organizaciónBorrarla.—
GET /api/organizations/:id/memberspersonaSus miembros.—
POST /api/organizations/:id/memberspersona · admin de la organizaciónInvitar { userId | email, role: "admin" | "dev" }.60 por hora por cuenta
PATCH /api/organizations/:id/members/:uidpersona · admin de la organizaciónCambiar el rol.60 por hora por cuenta
DELETE /api/organizations/:id/members/:uidpersonaSacar a alguien (el último admin no se va).—
POST /api/organizations/:id/acceptpersonaAceptar una invitación a la organización.30 por hora por cuenta
POST /api/organizations/:id/declinepersonaRechazarla.30 por hora por cuenta
POST /api/organizations/:id/verifypersona · admin de la organizaciónPedir el check verde para la organización.5 por hora por cuenta

Bots con herramientas (MCP)

Lo que hace falta para escribir un servidor MCP está arriba; estas son las rutas.

RutaQuiénQué haceLímite
POST /api/bots/:id/mcp/connectpersona (sesión) · dueñoServidor OAuth: { url } para autorizar (vuelve a /api/mcp/callback); si ya hay tokens, { connected: true, tools }.10 por hora por cuenta
POST /api/bots/:id/mcp/testpersona · dueñoProbar la conexión: { status: "connected", transport, tools } o 400 con el motivo.30 por hora por cuenta
DELETE /api/bots/:id/mcp/tokenspersona · dueñoDesconectar: borra los tokens OAuth guardados.—
POST /api/mcp/post/:tokenpúblico · el token es la credencial{ text?, card?, pin? }: el bot publica por su cuenta en ese chat (el postUrl 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/:tokenpúblico · el token es la credencial{ text (≤ 500) }: línea de sistema en el chat y la IA retoma (el callbackUrl, 24 horas). 202; 404 vencido.120 por hora por IP
GET /api/mcp/callbackpúblicoLa vuelta de OAuth (la abre el navegador, no tu código).30 por hora por IP
POST /api/mcp/callbackpúblicoLa confirmación de esa vuelta cuando el navegador no es el que pidió la URL.30 por hora por IP

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. Nunca vuelven la clave del servidor, los tokens ni la URL de un conector ajeno.

RutaQuiénQué haceLímite
GET /api/connectors/browsepersonaLos conectores que puedo usar: [{ id, name, description, avatar, category, verified, by, auth, accountModes, exclusive, connectHelp, tools: [{ name, label, changes?, confirm? }], bots }].—
GET /api/connectors/:idpersonaUno (?botId= si lo veo por una IA que lo usa).—
GET /api/connectorspersonaLos míos y los de mis organizaciones, con url, visibility, status, toolLabels y hasServerKey.—
POST /api/connectorspersona (sesión)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; exige el perfil de desarrollador completo.20 por hora por cuenta
PATCH /api/connectors/:idpersona (sesión)Los mismos campos. Cambiar url o auth lo reinicia.—
POST /api/connectors/:id/testpersonaVolver a probarlo (detecta el acceso y lista las herramientas).30 por hora por cuenta
DELETE /api/connectors/:idpersonaBorrarlo.—
GET /api/me/connectionspersona«Mis cuentas»: [{ id, connectorId, connector, label, status: "ok" | "reconnect" | "error", createdAt, lastUsedAt?, bots }].—
GET /api/me/connections/:idpersonaUna cuenta, con las IAs y los chats que la usan.—
POST /api/me/connectionspersona (sesión) · con un email, teléfono, Google o AppleConectar { 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/reconnectpersona (sesión)Volver a entrar (clave nueva u OAuth).20 por hora por cuenta
POST /api/me/connections/:id/usepersona (sesión)Prestarle esa cuenta a una IA { botId, groupId? }.30 por hora por cuenta
PATCH /api/me/connections/:idpersonaNombre de la cuenta { label }.—
DELETE /api/me/connections/:idpersonaDesconectarla.—
DELETE /api/me/connections/:id/bots/:botIdpersonaDejar de prestársela a esa IA.—
DELETE /api/me/connections/:id/chats/:groupIdpersonaSacarla de ese chat.—
PUT /api/bots/:id/connectorspersona (sesión) · quien la puede editarLos conectores de una IA { connectors: [{ connectorId, account?: "person" | "chat" | "owner" }] } (reemplaza la lista; hasta 8).—

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

RutaQuiénQué haceLímite
PATCH /api/mepersona{ locale?, avatar?, country?, tz?, linkPreviews? } (al menos uno). El nombre no se cambia acá.—
DELETE /api/mepersona (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/keyspersona (sesión)Mis API keys.—
POST /api/me/keyspersona (sesión)Crear una { name }: devuelve pk_… una sola vez. Hasta 10.10 por hora por cuenta
DELETE /api/me/keys/:idpersona (sesión)Revocarla.—
GET /api/me/emailspersona (sesión)Mis emails.—
POST /api/me/emailspersona (sesión){ email }: manda un código de 6 dígitos.—
POST /api/me/emails/verifypersona (sesión){ email, code }: lo confirma.—
DELETE /api/me/emails/:emailpersona (sesión)Sacarlo.—
GET /api/me/phonespersona (sesión)Mis teléfonos.—
POST /api/me/phonespersona (sesión){ phone, channel: "sms" | "whatsapp" }: manda un código (si smsEnabled).—
POST /api/me/phones/verifypersona (sesión){ phone, code }. Un número es de una sola cuenta.—
DELETE /api/me/phones/:phonepersona (sesión)Sacarlo.—
POST /api/contacts/lookuppersona · 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/blockspersonaA quiénes bloqueé.—
POST /api/users/:id/blockpersonaBloquear (ver bloqueo).60 por hora por cuenta
DELETE /api/users/:id/blockpersonaDesbloquear.—
POST /api/users/:id/reportpersona{ reason?, groupId?, block? }: reporte al equipo de Puentes.5 por día
POST /api/reportspersonaReportar un bot, un desarrollador o una organización { about: "bot" | "developer" | "org", id, reason? }.—

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.

RutaConSección
POST /api/groups/:id/pollssesión o API keyMensajes
POST /api/groups/:id/messages/:mid/votesesión o API keyMensajes
PUT /api/groups/:id/messages/:mid/reactionsesión o API keyMensajes
POST /api/groups/:id/messages/:mid/forwardsesión o API keyMensajes
GET /api/searchsesión o API keyMensajes
GET /api/groups/:id/searchsesión o API keyMensajes
GET /api/link-previewsesión o API keyMensajes
POST /api/groupssesión o API keyChats y miembros
PATCH /api/groups/:idsesión o API keyChats y miembros
POST /api/groups/:id/link/resetsesión o API keyChats y miembros
POST /api/groups/:id/leavesesión o API keyChats y miembros
DELETE /api/groups/:idsesión o API keyChats y miembros
POST /api/groups/:id/clearsesión o API keyChats y miembros
POST /api/groups/:id/memberssesión o API keyChats y miembros
PATCH /api/groups/:id/members/:userIdsesión o API keyChats y miembros
DELETE /api/groups/:id/members/:userIdsesión o API keyChats y miembros
GET /api/groups/:id/banssesión o API keyChats y miembros
DELETE /api/groups/:id/bans/:userIdsesión o API keyChats y miembros
GET /api/groups/:id/requestssesión o API keyChats y miembros
POST /api/groups/:id/requests/:rid/approvesesión o API keyChats y miembros
POST /api/groups/:id/requests/:rid/rejectsesión o API keyChats y miembros
POST /api/invites/:token/joinsesión o API keyChats y miembros
DELETE /api/invites/:token/requestsesión o API keyChats y miembros
GET /api/groups/:id/invitessesión o API keyChats y miembros
POST /api/groups/:id/invitessesión o API keyChats y miembros
POST /api/groups/:id/invites/:inviteId/resendsesión o API keyChats y miembros
DELETE /api/groups/:id/invites/:inviteIdsesión o API keyChats y miembros
POST /api/groups/:id/invite-linksesión o API keyChats y miembros
POST /api/direct/:userIdsesión o API keyChats y miembros
POST /api/direct-invitesesión o API keyChats y miembros
POST /api/groups/:id/sharesesión o API keyChats y miembros
GET /api/me/schedulessesión o API keyProgramados
POST /api/schedules/:sid/confirmsesión o API keyProgramados
POST /api/bots/:id/send-grantsesión o API keyProgramados
DELETE /api/bots/:id/send-grantsesión o API keyProgramados
POST /api/groups/:id/messages/:mid/pinsesión o API keyFijados
DELETE /api/groups/:id/messages/:mid/pinsesión o API keyFijados
POST /api/botssesión o API keyBots
GET /api/botssesión o API keyBots
GET /api/bots/catalogsesión o API keyBots
GET /api/bots/browsesesión o API keyBots
PATCH /api/bots/:idsesión o API keyBots
DELETE /api/bots/:idsesión o API keyBots
POST /api/bots/:id/tokensesión o API keyBots
POST /api/bots/:id/clonesesión o API keyBots
GET /api/bots/:id/usagesesión o API keyBots
DELETE /api/bots/:id/owner-keysesión o API keyBots
POST /api/bots/:id/verifysesión o API keyBots
PUT /api/bots/:id/domainssesión o API keyBots
POST /api/bots/:id/access-requestssesión o API keyBots
GET /api/bots/:id/access-requestssesión o API keyBots
POST /api/bots/:id/access-requests/:rid/:decisionsesión o API keyBots
GET /api/bots/:id/accesssesión o API keyBots
POST /api/bots/:id/accesssesión o API keyBots
DELETE /api/bots/:id/access/:userIdsesión o API keyBots
POST /api/bots/:id/share-linksesión o API keyBots
DELETE /api/bots/:id/share-linksesión o API keyBots
GET /api/bots/shared/:tokensesión o API keyBots
POST /api/bots/shared/:token/acceptsesión o API keyBots
POST /api/groups/:id/botssesión o API keyBots
PATCH /api/groups/:id/bots/:botIdsesión o API keyBots
POST /api/groups/:id/bots/:botId/mode-asksesión o API keyBots
DELETE /api/groups/:id/bots/:botIdsesión o API keyBots
GET /api/me/developersesión o API keyBots
PUT /api/me/developersesión o API keyBots
POST /api/me/developer/verifysesión o API keyBots
GET /api/me/organizationssesión o API keyBots
POST /api/organizationssesión o API keyBots
PATCH /api/organizations/:idsesión o API keyBots
DELETE /api/organizations/:idsesión o API keyBots
GET /api/organizations/:id/memberssesión o API keyBots
POST /api/organizations/:id/memberssesión o API keyBots
PATCH /api/organizations/:id/members/:uidsesión o API keyBots
DELETE /api/organizations/:id/members/:uidsesión o API keyBots
POST /api/organizations/:id/acceptsesión o API keyBots
POST /api/organizations/:id/declinesesión o API keyBots
POST /api/organizations/:id/verifysesión o API keyBots
POST /api/bots/:id/mcp/connectsolo la sesión de la appBots con herramientas (MCP)
POST /api/bots/:id/mcp/testsesión o API keyBots con herramientas (MCP)
DELETE /api/bots/:id/mcp/tokenssesión o API keyBots con herramientas (MCP)
GET /api/connectors/browsesesión o API keyConectores y cuentas
GET /api/connectors/:idsesión o API keyConectores y cuentas
GET /api/connectorssesión o API keyConectores y cuentas
POST /api/connectorssolo la sesión de la appConectores y cuentas
PATCH /api/connectors/:idsolo la sesión de la appConectores y cuentas
POST /api/connectors/:id/testsesión o API keyConectores y cuentas
DELETE /api/connectors/:idsesión o API keyConectores y cuentas
GET /api/me/connectionssesión o API keyConectores y cuentas
GET /api/me/connections/:idsesión o API keyConectores y cuentas
POST /api/me/connectionssolo la sesión de la appConectores y cuentas
POST /api/me/connections/:id/reconnectsolo la sesión de la appConectores y cuentas
POST /api/me/connections/:id/usesolo la sesión de la appConectores y cuentas
PATCH /api/me/connections/:idsesión o API keyConectores y cuentas
DELETE /api/me/connections/:idsesión o API keyConectores y cuentas
DELETE /api/me/connections/:id/bots/:botIdsesión o API keyConectores y cuentas
DELETE /api/me/connections/:id/chats/:groupIdsesión o API keyConectores y cuentas
PUT /api/bots/:id/connectorssolo la sesión de la appConectores y cuentas
PATCH /api/mesesión o API keyLa cuenta de una persona
DELETE /api/mesolo la sesión de la appLa cuenta de una persona
GET /api/me/keyssolo la sesión de la appLa cuenta de una persona
POST /api/me/keyssolo la sesión de la appLa cuenta de una persona
DELETE /api/me/keys/:idsolo la sesión de la appLa cuenta de una persona
GET /api/me/emailssolo la sesión de la appLa cuenta de una persona
POST /api/me/emailssolo la sesión de la appLa cuenta de una persona
POST /api/me/emails/verifysolo la sesión de la appLa cuenta de una persona
DELETE /api/me/emails/:emailsolo la sesión de la appLa cuenta de una persona
GET /api/me/phonessolo la sesión de la appLa cuenta de una persona
POST /api/me/phonessolo la sesión de la appLa cuenta de una persona
POST /api/me/phones/verifysolo la sesión de la appLa cuenta de una persona
DELETE /api/me/phones/:phonesolo la sesión de la appLa cuenta de una persona
POST /api/contacts/lookupsesión o API keyLa cuenta de una persona
GET /api/me/blockssesión o API keyLa cuenta de una persona
POST /api/users/:id/blocksesión o API keyLa cuenta de una persona
DELETE /api/users/:id/blocksesión o API keyLa cuenta de una persona
POST /api/users/:id/reportsesión o API keyLa cuenta de una persona
POST /api/reportssesión o API keyLa cuenta de una persona

Contactos, compartir y bloqueo

Admins y permisos de grupo

Objetos

Los que más se usan, campo por campo. Salen de los mismos schemas que /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.

Message

CampoTipoQué es
idstring
seqintegerCursor global creciente (GET /api/me/updates).
groupIdstring
senderType"user" | "bot" | "system"
senderIdstring | nullnull en una línea de sistema.
senderNamestring
textstring ≤ 20000"" en un mensaje borrado para todos.
attachmentsarray de Attachment[] en un mensaje borrado; las tarjetas de lista y tabla vienen con su estado actual.
createdAtintegerEpoch ms.
replyToMessageQuote | object | null
reactionsReactions
via opcionalobjectSolo si lo mandó un asistente en nombre de la persona.
onlyFor opcionalstringSolo en un mensaje privado: la única persona que lo ve.
suggest opcionalarray de MenuItemSolo en una respuesta de bot con acciones sugeridas.
link opcionalLinkPreviewSolo 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 opcionaltrueSolo si se borró para todos.
deletedAt opcionalintegerSolo si se borró.
deletedBy opcionalstring | nullSolo si se borró.
status opcional"sent" | "delivered" | "read"Solo en mis mensajes, en el historial de una persona: los tildes.
clientId opcionalstring ≤ 60Solo en la respuesta al envío y en su message:new.

Chat

CampoTipoQué es
idstring
namestring
avatarstring | null
kind"group" | "direct"
directboolean
inviteToken opcionalstring | nullSolo para quien puede compartir el link.
linkOffboolean«Solo por invitación».
createdBystring | null
perms opcionalChatPermsSolo en grupos.
membersarray de ChatMember
lastMessageMessage | object | null
isMember opcionalbooleanSolo en las respuestas a vos.
myRole opcional"admin" | "member"Solo grupos, solo en las respuestas a vos.
pendingRequests opcionalintegerPara un admin de un grupo que aprueba a los nuevos.
mutedUntil opcionalnumber0 sin silencio, < 0 para siempre, si no epoch ms. Solo a vos.
favorite opcionalbooleanSolo a vos.
peer opcionalChatMember | object | nullSolo en un directo: el otro.
blocked opcionalbooleanDirecto con alguien que bloqueaste.
pendingEmail opcionalstring | nullDirecto que todavía nadie aceptó.
pendingPhone opcionalstring | null
referral opcionalChatReferral | object | nullSolo grupos: el premio por invitar.

ChatBotMember

CampoTipoQué es
idstring
namestring
color opcionalstring
avatar opcionalstring | null
kind"bot"
runtime"ai" | "api"
builtin opcionalboolean
public opcionalboolean
verified opcional"official" | "trusted" | nullEl check: dorado (de Puentes) o verde (verificado). Solo lectura.
ownerId opcionalstring | nullnull en los de Puentes.
description opcionalstring
provider opcionalstring | null
model opcionalstring | null
tier opcionalinteger | null1, 2 o 3 (solo ai).
mode"mention" | "always"Cómo responde en ESTE chat.
mcp opcionalbooleanTiene herramientas.
menu opcionalarray de MenuItem
alias opcionalstring | nullSu alias en ESTE chat.
by opcionalstring | nullDe quién es (la organización, o quien lo hizo).

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.

CampoTipoQué es
idstring
namestring
color opcionalstring
avatar opcionalstring | null
runtime"ai" | "api"
builtinbooleanDe Puentes.
public opcionalboolean
verified"official" | "trusted" | nullEl check: dorado (de Puentes) o verde (verificado). Solo lectura.
ownerId opcionalstring | null
description opcionalstring
createdAt opcionalinteger
provider opcionalstringSolo ai.
model opcionalstringSolo ai.
tier opcionalintegerSolo ai.
mode opcionalstring | nullSolo ai: el default.
menu opcionalarray de MenuItem
alias opcionalstring | null
kind"ai" | "external" | "tools"
shortDesc opcionalstring
category opcionalstring | null
tags opcionalarray de string
language opcionalstring | null
countries opcionalarray de string
visibility"private" | "link" | "authorized" | "catalog"
pays opcional"owner" | "user"
developer opcionalobject | null
org opcionalobject | null
chats opcionalintegerEn cuántos chats está.
template opcionalstring | null
clonable opcionalboolean
source opcionalobject | nullUna copia: su original.
following opcionalbooleanUna copia que sigue a su original.
idea opcionalobject | null
by opcionalstring | null
mcp opcionalobjectSolo si tiene un servidor propio.
connectors opcionalarray de objectSolo si usa conectores.
requestsOpen opcionalbooleanSolo para el dueño.
pay opcional"owner" | "user"Solo para el dueño.
capMonthUsd opcionalnumber | null
perUserDailyReplies opcionalinteger | null
perUserMonthUsd opcionalnumber | null
verification opcional"trusted" | "pending" | "none"Solo para el dueño.
verificationRequestedAt opcionalinteger | null
actions opcionalarray de MenuItemSolo para el dueño.
systemPrompt opcionalstringSolo para el dueño.
tokenPrefix opcionalstring | nullSolo para el dueño.
webhookUrl opcionalstringSolo para el dueño.
webhookSecret opcionalstring | nullSolo para el dueño.
lastUsedAt opcionalinteger | nullSolo para el dueño.

User

CampoTipoQué es
idstring
namestring
colorstring
avatarstring | null
botboolean

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 (components.schemas).

typeSchemaQué es
image | audio | video | fileFileAttachmentUna foto, un audio (con su transcripción), un video o un archivo, subidos con POST /api/upload.
locationLocationAttachmentUna ubicación; la dirección la agrega el server después.
choiceChoiceAttachmentLo que una persona tocó en una tarjeta: despierta al bot que la mandó. Es la única tarjeta que manda un cliente.
optionsOptionsCardUna lista para elegir (filas, carrusel, carteles o botones), o una encuesta (poll).
options · layout picksOptionsPicksCardpicks, en la forma nueva.
options · layout catalogOptionsCatalogCardcatalog, en la forma nueva (ítems con section).
options · layout confirmOptionsConfirmCardconfirm, en la forma nueva.
picksPicksCardLo que una herramienta resolvió sola, con alternativas por renglón (forma vieja).
catalogCatalogCardUn catálogo con carrito (forma vieja).
formFormCardUn formulario corto, con respuestas guardadas para tocar.
linkLinkCardUn botón que abre una página.
tasklistTasklistCardUna lista de tareas, siempre con su estado actual.
tableTableCardUna tabla, siempre con su estado actual.
botBotCardUn bot compartido en el chat: da permiso de uso.
contactContactCardUna persona compartida en el chat.
toolsToolsAttachmentQué herramientas usó un bot en esta respuesta.
mcp_connectMcpConnectAttachmentEl bot necesita que alguien conecte su cuenta.
confirmConfirmCardUna pregunta de sí o no para una sola persona. Solo la crea el server.
forwardForwardAttachmentLa marca de «Reenviado».
capCapAttachmentEn una línea de sistema: se llegó a un tope de gasto.
inviteInviteAttachmentEl botón «Invitar gente al grupo» del saludo de una IA.

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.

Límites y buenas prácticas

RutaLímite
Todo /api600 pedidos por minuto por IP
Mensajes30 por minuto por bot, 60 por persona; hasta 20.000 caracteres y 10 adjuntos
Cuentas nuevas20 por hora y 50 por día por IP (más para una IP compartida conocida)
Cuentahasta 50 bots, 10 API keys, 30 conectores, 40 cuentas conectadas
Bots con herramientas6 rondas por respuesta, 30 s por herramienta, 90 s por respuesta, resultados recortados a 8.000 caracteres
POST /api/groups/:id/messages30 por minuto por bot, 60 por persona (Retry-After: 60)
DELETE /api/groups/:id/messages/:mid120 por hora por cuenta
POST /api/upload60 por hora por cuenta
POST /api/groups/:id/polls30 por hora por cuenta
POST /api/groups/:id/messages/:mid/vote120 por hora por cuenta
PUT /api/groups/:id/messages/:mid/reaction300 por hora por cuenta
POST /api/groups/:id/messages/:mid/forward60 por hora por cuenta
GET /api/search600 por hora por cuenta
GET /api/groups/:id/search600 por hora por cuenta
GET /api/link-preview600 por hora por cuenta
POST /api/groups30 por día por cuenta
POST /api/groups/:id/link/reset10 por hora por cuenta
POST /api/groups/:id/clear20 por hora por cuenta
POST /api/groups/:id/members60 por hora por cuenta
PATCH /api/groups/:id/members/:userId60 por hora por cuenta
DELETE /api/groups/:id/members/:userId60 por hora por cuenta
POST /api/groups/:id/requests/:rid/approve120 por hora por cuenta
POST /api/groups/:id/requests/:rid/reject120 por hora por cuenta
POST /api/invites/:token/join30 por hora por IP
POST /api/groups/:id/invites20 por hora y 50 por día (10 el primer día de la cuenta)
POST /api/groups/:id/invite-link60 por hora por cuenta
POST /api/direct/:userId60 por hora por cuenta
POST /api/groups/:id/share30 por hora por cuenta
POST /api/groups/:id/messages/:mid/pin60 por hora por cuenta
PUT /api/groups/:id/pins/:pinId120 por hora por cuenta
POST /api/bots10 por hora por cuenta
POST /api/bots/:id/clone10 por hora por cuenta
POST /api/bots/:id/verify10 por hora por cuenta
PUT /api/bots/:id/domains30 por hora por cuenta
POST /api/bots/:id/access-requests20 por hora por cuenta
POST /api/bots/:id/access-requests/:rid/:decision120 por hora por cuenta
POST /api/bots/:id/access60 por hora por cuenta
POST /api/bots/:id/share-link10 por hora por cuenta
POST /api/groups/:id/bots60 por hora por cuenta
POST /api/groups/:id/bots/:botId/mode-ask60 por hora por cuenta
PUT /api/me/developer30 por hora por cuenta
POST /api/me/developer/verify5 por hora por cuenta
POST /api/organizations10 por hora por cuenta
PATCH /api/organizations/:id60 por hora por cuenta
POST /api/organizations/:id/members60 por hora por cuenta
PATCH /api/organizations/:id/members/:uid60 por hora por cuenta
POST /api/organizations/:id/accept30 por hora por cuenta
POST /api/organizations/:id/decline30 por hora por cuenta
POST /api/organizations/:id/verify5 por hora por cuenta
POST /api/bots/:id/mcp/connect10 por hora por cuenta
POST /api/bots/:id/mcp/test30 por hora por cuenta
POST /api/mcp/post/:token240 por hora por IP; 30 mensajes por minuto del bot
POST /api/mcp/event/:token120 por hora por IP
GET /api/mcp/callback30 por hora por IP
POST /api/mcp/callback30 por hora por IP
POST /api/connectors20 por hora por cuenta
POST /api/connectors/:id/test30 por hora por cuenta
POST /api/me/connections20 por hora por cuenta
POST /api/me/connections/:id/reconnect20 por hora por cuenta
POST /api/me/connections/:id/use30 por hora por cuenta
DELETE /api/me5 por hora por cuenta
POST /api/me/keys10 por hora por cuenta
POST /api/contacts/lookup30 por hora
POST /api/users/:id/block60 por hora por cuenta
POST /api/users/:id/report5 por dí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.

StatuscodeCuándoQué hacer
400—El pedido está mal (falta un campo, un valor fuera de rango). error dice qué, en castellano.No reintentes igual.
401no_sessionLa 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.
403guestUna 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.
403not_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.
400alias_formatAlias de 2 a 20 letras, números, - o _, sin @.Corregilo.
400alias_reservedai, ia y todos están reservados.Elegí otro.
400alias_takenYa hay alguien con ese alias en el grupo.Elegí otro.
400clone_toolsUn bot que usa un servicio con la cuenta de quien lo hizo no se puede ofrecer para copiar.—
400clone_externalUn bot externo no se puede copiar.—
400clone_followingUna copia que sigue a su original no se puede ofrecer para copiar.—
400idea_needs_catalogUna «idea» para copiar necesita el bot en el catálogo y copiable.—
400capUn tope de gasto del bot.—
429—Pasaste un límite (ver Límites).Esperá lo que dice Retry-After (segundos) si viene.
429guest_limitDemasiadas 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).

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.

CampoQué dice
providersPor proveedor (anthropic, openai): label, models, tiers ({ modelo: 1 | 2 | 3 }), prices, defaultModel, freeModel, freeAvailable.
newBotProviderEl proveedor con que nace una IA nueva.
freeMessagesPerDayMensajes gratis por día y por persona (freeMessagesPerUser es el nombre viejo).
freeMessagesPerUserIgual que freeMessagesPerDay (la app nativa lo sigue leyendo).
publicUrlDe dónde salen los links.
transcribeAudioLos audios llegan transcriptos (attachments[].transcript, por message:updated).
googleClientIdEntrar con Google (de la app).
appleClientIdEntrar con Apple (de la app).
appleWebClientIdEntrar con Apple en la web.
androidAppUrlLa app en Play.
minAppVersionLa versión mínima de la app nativa por plataforma.
emailEnabledEl server manda emails (códigos, invitaciones).
smsEnabledCódigos por teléfono.
smsChannelsPor dónde: sms, whatsapp.
mcpEnabledBots con herramientas (MCP).
connectorsConectores y cuentas (ver).
chatListsFavoritos y listas de chats.
notifyPrefsInterruptores de notificaciones.
reactionsReacciones (un mensaje sin reactions se lee como {}).
linkPreviewsFichas de links (message.link).
searchEnabledBuscar mensajes (GET /api/search y GET /api/groups/:id/search).
campaignSkipEscapeDe la web: no mostrar la pantalla de salida en una visita de campaña.
campaignAutoEscapeDe la web: en Android, una visita de anuncio dentro del navegador de una app sale sola a Chrome.
iasLa sección IAs (/bots/browse, fichas, members[].mode).
botCategoriesLas categorías del catálogo (ids).
ownerPayEnabledUn desarrollador puede pagar las respuestas de su bot con su clave.
vapidPublicKeyWeb Push (de la web).
socketHostsLos hosts del socket (mejor GET /api/sockets).
assistantBotIdQué bot es «Puentes».
placesAvisos por lugar (de la app).

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