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.
Conceptos
| Participante | Quién lo corre | Credencial |
|---|---|---|
| Persona | Un humano desde la app | Sesión del dispositivo, o una API key pk_… que actúa como esa persona desde un script |
| Bot AI AI | Puentes llama al modelo (Claude, GPT) con el historial del chat. Los preintegrados y los que creás con tus instrucciones | Ninguna: lo corre el server |
| Bot externo BOT | Tu programa, donde quieras | Token de bot pb_… que actúa como el bot |
Todo chat es un grupo (kind: "group", con link de invitación) o un chat directo (kind: "direct", exactamente dos participantes, sin invitación). Al crear un bot se crea solo su chat directo con vos. Los mensajes de cualquier bot llevan senderType: "bot"; los de personas, "user"; los avisos del sistema, "system".
@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>:
pk_…API key personal: se crea en la app, Ajustes → "Tus API keys de Puentes". Hace todo lo que hacés vos (crear bots, leer y escribir en tus chats) salvo administrar sesiones, Google y otras keys.pb_…token de bot: lo devuelvePOST /api/bots(o Ajustes → "+ Bot externo"). Actúa como el bot: lee y escribe en los chats donde está, se une por link de invitación. No puede tocar la cuenta del dueño.
Los dos se muestran una sola vez y se guardan hasheados. Si perdés el token de un bot, generá otro (POST /api/bots/:id/token); el anterior deja de funcionar al instante. Los errores vuelven como { "error": "mensaje" } con el status HTTP que corresponde (401 credencial inválida, 403 sin permiso, 404 no encontrado, 429 límite).
curl https://app.puentes.ai/api/me -H "Authorization: Bearer pk_…"
# { "id": "…", "name": "Mauro", "color": "#65aadd", "bot": false, … }
Crear un bot
POST /api/bots con tu API key. Devuelve el bot, su chat directo con vos y, si es externo, el token.
curl -X POST https://app.puentes.ai/api/bots \
-H "Authorization: Bearer pk_…" -H "Content-Type: application/json" \
-d '{
"runtime": "api",
"name": "Alertas",
"description": "Avisos del datacenter",
"webhookUrl": "https://mi-servidor.com/puentes",
"public": false
}'
# { "bot": { "id": "…", "name": "Alertas", "runtime": "api", "tokenPrefix": "pb_ab12cd…", "webhookSecret": "…", … },
# "token": "pb_…", <- guardalo: no se vuelve a mostrar
# "group": { "id": "…", "direct": true, "peer": { "name": "Alertas" }, … } }
| Campo | Descripción |
|---|---|
runtime | "api" (externo, con token) o "ai" (lo corre Puentes con un modelo). Default "ai". |
name | Hasta 40 caracteres, único entre tus bots. AI, IA y todos están reservados. |
description | Opcional, hasta 200 caracteres. Se ve en el catálogo. |
public | true lo publica en el catálogo: cualquiera puede agregarlo a sus grupos o hablarle 1 a 1. |
webhookUrl | Solo api. https:// pública (ver Webhook). Se puede setear después con PATCH. |
provider, model | Solo ai. "anthropic" o "openai"; los modelos disponibles salen de GET /api/config. |
mode | Solo ai. "mention" (responde cuando lo mencionan) o "always" (en toda la conversación). En un chat directo responde siempre. |
systemPrompt | Solo ai. Instrucciones extra, hasta 2000 caracteres. |
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.
Meterlo en un chat
- Desde la app: en el grupo, botón Bot (o en la info del grupo, "+ Agregar bot"). Por API:
POST /api/groups/:id/bots { "botId": "…" }como miembro del grupo. - El bot solo, con su token:
POST /api/invites/<token del link>/join. El link de invitación de un grupo eshttps://app.puentes.ai/join/<token>. - Chat 1 a 1: cualquiera que pueda usar el bot (builtin, público o suyo) hace
POST /api/direct/:botId. Entre personas, hay que compartir algún grupo.
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)
texthasta 20.000 caracteres. Markdown liviano:**negrita**,_cursiva_,`código`, bloques```, links.@Nombremenciona a una persona o bot del chat (le llega la notificación aunque tenga el chat silenciado);@todosavisa a todo el grupo;@Claudeo@AIhace responder a la AI.replyTo: id de un mensaje del mismo chat para citarlo.attachments: primero subí el archivo conPOST /api/upload(multipart, campofile) y pasá lo que devuelve:[{ "type": "image", "url": "…", "name": "…", "mime": "…", "size": 123 }].POST /api/groups/:id/typingmuestra "está escribiendo…" unos segundos.- Listas de tareas:
POST /api/groups/:id/tasklists { "name", "items": ["…"] }, y sobre ellasPOST /api/tasklists/:id/tasks,POST /api/tasks/:id/toggle,POST /api/tasks/:id/due. Una tarea con fecha es un recordatorio: a la hora, todo el grupo recibe la notificación.
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 completo de un chat: GET /api/groups/:id/messages (últimos 500).
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 Ajustes → Editar bot, o en la respuesta de POST /api/bots):
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
app.post('/puentes', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET).update(req.body).digest('hex');
const got = req.get('x-puentes-signature') || '';
if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return res.status(401).end();
const { group, message } = JSON.parse(req.body);
console.log(`[${group.name}] ${message.senderName}: ${message.text}`);
res.status(204).end(); // respondé rápido; contestá en el chat con POST /api/groups/:id/messages
});
app.listen(8080);
- Timeout de 5 segundos, sin reintentos. Si necesitás garantía de entrega, usá el polling (o las dos cosas: el webhook para reaccionar rápido,
updatespara no perder nada). - Solo
http(s); en producción se rechazan hosts privados o locales. Para desarrollar, un túnel (ngrok, cloudflared) o webhook.site para ver el payload. - Las transcripciones de audios llegan después, como actualización del mensaje: no viajan por webhook ni por
updates. Si te importan, releéGET /api/groups/:id/messages.
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.
import { io } from 'socket.io-client';
const socket = io('https://app.puentes.ai', { auth: { token: process.env.PUENTES_BOT_TOKEN } });
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.emit('message:send', { groupId, text: 'hola' }, (ack) => console.log(ack.ok, ack.message?.id));
Referencia de rutas
| Ruta | Quién | Qué hace |
|---|---|---|
GET /api/me | todos | Quién soy. Un bot ve bot: true y ownerId. |
GET /api/me/groups | todos | Mis chats (grupos y directos) con miembros y último mensaje. |
GET /api/me/updates?after=&timeout= | todos | Mensajes nuevos en todos mis chats desde un cursor. Long-poll. |
GET /api/groups/:id | miembro | Un chat: kind, miembros (personas y bots), peer si es directo. |
GET /api/groups/:id/messages | miembro | Últimos 500 mensajes. |
POST /api/groups/:id/messages | miembro | Enviar { text, replyTo?, attachments? }. |
POST /api/groups/:id/typing | miembro | "está escribiendo…" |
POST /api/upload | todos | Subir un archivo (multipart file, hasta 25 MB). Devuelve el adjunto para attachments. |
POST /api/groups | persona | Crear un grupo { name, members?: [userId] }. Los miembros iniciales tienen que ser contactos (comparten un chat con quien crea) o bots que puede usar. |
PATCH /api/groups/:id | miembro | Renombrar { name } y/o { linkOff: true|false } ("solo por invitación": el link público deja de servir). |
POST /api/groups/:id/link/reset | miembro | Link de invitación nuevo; el anterior muere. |
POST /api/groups/:id/leave | persona | Salir del grupo (si no queda ninguna persona, se borra). En un directo: lo oculta para vos; vuelve cuando el otro escribe. |
DELETE /api/groups/:id | creador | Eliminar el grupo para todos. |
GET /api/invites/:token[?i=] | público | Vista previa de una invitación. i = token personal de una invitación por email (obligatorio para chats directos y grupos con el link desactivado). |
POST /api/invites/:token/join | todos | Entrar por el link de invitación. { invite?: i } marca aceptada la invitación por email (nunca verifica el email). |
GET/POST/DELETE /api/groups/:id/invites[/:inviteId] · POST …/:inviteId/resend | persona verificada | Invitar por email { email }: si ya usa Puentes y comparte un chat con vos, entra directo (status: 'added'); si no, invitación pendiente ('invited', 7 días). |
POST /api/direct/:userId | persona | Abrir (o encontrar) el chat directo con una persona o un bot. Con una persona hace falta compartir un chat, que alguien los haya presentado, o mandar { email } verificado de esa persona. |
POST /api/contacts/lookup | persona verificada | { email } → { status: 'found', user } | { status: 'none' } | { status: 'self' }. Solo emails verificados; 30 por hora. |
POST /api/direct-invite | persona verificada | { email }: chat directo con alguien que no está en Puentes. Crea el chat con vos solo (pendingEmail) y le manda un link personal; si ya usa Puentes, devuelve el chat (status: 'chat'). |
POST /api/groups/:id/share | persona | Compartir en el chat { kind: 'contact' | 'bot', id }: publica una tarjeta (adjunto contact/bot). Un contacto = presentación (los del chat pueden escribirle); un bot tuyo = permiso de uso para los del chat. |
GET /api/me/blocks · POST/DELETE /api/users/:id/block | persona | Bloquear / desbloquear a una persona (ver "Contactos, compartir y bloqueo"). |
POST /api/users/:id/report | persona | { reason?, groupId?, block? }: reporte al equipo de Puentes con los últimos mensajes de esa persona en ese chat. 5 por día. |
GET /api/bots | persona | Mis bots (con prefijo del token, webhook y secreto). |
GET /api/bots/catalog | persona | Bots que puedo agregar: de Puentes, públicos y míos. |
POST /api/bots | persona | Crear un bot (ver arriba). |
GET /api/bots/:id | todos | Un bot que puedo usar. |
PATCH /api/bots/:id | dueño | Cambiar nombre, descripción, public, webhookUrl; en AI, model, mode, systemPrompt. |
POST /api/bots/:id/token | dueño | Token nuevo (el anterior muere). |
DELETE /api/bots/:id | dueño | Borrar el bot: sale de todos los chats, sus directos desaparecen. |
GET /api/bots/:id/access · POST …/access { userId } · DELETE …/access/:userId | dueño | Quién puede usar un bot privado además del dueño (via: 'share' | 'chat' | 'link'), dar acceso a un contacto, quitarlo (su chat directo con el bot se borra). |
POST/DELETE /api/bots/:id/share-link | dueño | Link /bot/<token> para compartir (7 días, uno vivo por bot) / revocarlo. |
GET /api/bots/shared/:token · POST …/accept | persona | Ver quién comparte qué / aceptar: acceso para la cuenta logueada. |
POST /api/groups/:id/bots | miembro | Agregar { botId } (builtin, público, mío o compartido conmigo) al grupo. |
DELETE /api/groups/:id/bots/:botId | miembro | Sacarlo del grupo. |
GET/POST/DELETE /api/me/keys[/:id] | persona (sesión) | API keys personales. No se pueden administrar con otra key. |
GET/POST/DELETE /api/me/emails[/:email] · POST …/verify | persona (sesión) | Emails de la cuenta: { email } manda un código de 6 dígitos, verify { email, code } lo confirma. |
POST /api/auth/email · POST /api/auth/email/login/:token | público | Link de acceso por email (un solo uso, 10 min). El POST del token devuelve la sesión; { conflict } si el dispositivo ya tiene otra cuenta verificada (reintentar con { merge: true }). |
GET /api/config | público | Proveedores y modelos disponibles, cuota gratis, emailEnabled. |
"persona" = sesión o API key; "todos" incluye tokens de bot; "persona verificada" = con email confirmado o Google. Los bots no pueden crear grupos ni bots, ni tocar sesiones, notificaciones, silencios, bloqueos o compartir.
Contactos, compartir y bloqueo
- No hay libreta de contactos. Podés escribirle a alguien si comparten un chat, si alguien que conoce a los dos lo presentó (tarjeta de contacto en un chat) o si conocés su email verificado. Saber el email de una persona alcanza para escribirle, como el número en WhatsApp.
- Compartir es siempre autenticado. Un bot compartido cede solo el permiso de uso: nunca su token, el secreto del webhook, el prompt ni claves. Una tarjeta de contacto lleva nombre y avatar, nunca email ni teléfono. Las tarjetas (
attachments[].type = 'bot' | 'contact') las construye el server: un cliente no puede mandarlas. - Bloqueo. Si A bloquea a B: B no puede abrirle un chat ni sumarlo a grupos nuevos; lo que B escribe en el directo queda solo del lado de B (tilde simple, sin push, sin AI, sin webhook) y nunca aparece en el historial de A, ni al desbloquear. A tampoco puede escribirle hasta desbloquear. B no recibe ningún aviso.
- Un bot y las personas. Un bot privado lo usan el dueño y quienes tengan acceso (
bot_access); un bot público, cualquiera. Un bot externo recibe por webhook los mensajes de todos los chats donde está: si lo compartís, tu código va a ver mensajes de esa gente.
Objetos
Mensaje
{ "id": "uuid", "seq": 1235, "groupId": "uuid",
"senderType": "user" | "bot" | "system", "senderId": "uuid" | null, "senderName": "Ana",
"text": "…", "createdAt": 1788924608700,
"attachments": [ { "type": "image" | "audio" | "video" | "file" | "tasklist", "url": "…", "name": "…", "mime": "…", "size": 0, "duration"?: 3.2, "transcript"?: "…", "list"?: { … } },
{ "type": "bot" | "contact", "id": "userId", "name": "…", "color": "#…", "runtime"?: "ai" | "api", "description"?: "…" } ], // tarjetas compartidas (solo las crea el server)
"replyTo": { "id": "…", "senderName": "…", "text": "…", "image": null } | null }
Chat
{ "id": "uuid", "name": "Soporte", "kind": "group" | "direct", "direct": false, "inviteToken": "…" | null,
"linkOff": false, "createdBy": "userId" | null,
"members": [
{ "id": "…", "name": "Ana", "color": "#65aadd", "kind": "person" },
{ "id": "builtin-anthropic", "name": "Claude", "kind": "bot", "runtime": "ai", "builtin": true, "provider": "anthropic", "model": "claude-haiku-4-5", "mode": "mention" },
{ "id": "…", "name": "Alertas", "kind": "bot", "runtime": "api", "ownerId": "…", "description": "…" } ],
"lastMessage": { … } | null,
"peer": { … }, // solo en directos: el otro participante
"pendingEmail"?: "…", // directo abierto por email que todavía nadie aceptó
"blocked"?: true, // directo con alguien que bloqueaste (el bloqueado nunca lo ve)
"mutedUntil": 0 | -1 | epochMs, "isMember": true // campos por usuario (solo en respuestas a vos)
}
Bot
{ "id": "uuid", "name": "Alertas", "color": "#…", "runtime": "api" | "ai", "builtin": false, "public": false, "ownerId": "…", "description": "…", "createdAt": 0,
"provider"?, "model"?, "mode"?, // runtime ai
"systemPrompt"?, "tokenPrefix"?, "webhookUrl"?, "webhookSecret"?, "lastUsedAt"? // solo para el dueño
}
Ejemplo: un bot eco en 30 líneas
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 }) });
}
}
Este mismo bot, listo para correr: node scripts/example-bot.mjs en el repo.
Límites y buenas prácticas
- 30 mensajes por minuto por bot, 60 por persona (
429conRetry-Aftersi te pasás). Mensajes de hasta 20.000 caracteres, 10 adjuntos. - Techo general: 600 pedidos por minuto por IP a
/api. Por cuenta y hora: 60 archivos, 60 chats directos, 60 altas de bots en grupos, 30 tarjetas compartidas, 10 bots nuevos, 10 links de bot, 10 restablecimientos de link. Por día: 30 grupos, 20/50 invitaciones por email (10 el primer día), 5 reportes. Por IP: 60 cuentas nuevas por hora, 20 links de acceso o handoff cada 10 minutos, 30 entradas por link por hora. Búsquedas por email: 30 por hora. - Máximo 10 bots y 10 API keys por cuenta.
- Un bot no ve sus propios mensajes en
updatesni por webhook. Sí ve los de otros bots: si respondés a todo, cuidá los loops entre bots. - Nombres: hasta 40 caracteres; en un grupo no puede haber dos participantes con el mismo nombre.
- Los tildes de entregado/leído solo cuentan personas: un bot nunca "lee".
- Tratá el token como una contraseña. Si se filtró, regeneralo desde Ajustes o con
POST /api/bots/:id/token.
¿Dudas o ideas? Escribile a Claude en Puentes 😉.