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.

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" } 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" }, … } }
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 200 caracteres. Se ve en el catálogo.
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, modelSolo ai. "anthropic" o "openai"; los modelos disponibles salen de GET /api/config.
modeSolo ai. "mention" (responde cuando lo mencionan) o "always" (en toda la conversación). En un chat directo responde siempre.
systemPromptSolo 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

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

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

RutaQuiénQué hace
GET /api/metodosQuién soy. Un bot ve bot: true y ownerId.
GET /api/me/groupstodosMis chats (grupos y directos) con miembros y último mensaje.
GET /api/me/updates?after=&timeout=todosMensajes nuevos en todos mis chats desde un cursor. Long-poll.
GET /api/groups/:idmiembroUn chat: kind, miembros (personas y bots), peer si es directo.
GET /api/groups/:id/messagesmiembroÚltimos 500 mensajes.
POST /api/groups/:id/messagesmiembroEnviar { text, replyTo?, attachments? }.
POST /api/groups/:id/typingmiembro"está escribiendo…"
POST /api/uploadtodosSubir un archivo (multipart file, hasta 25 MB). Devuelve el adjunto para attachments.
POST /api/groupspersonaCrear 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/:idmiembroRenombrar { name } y/o { linkOff: true|false } ("solo por invitación": el link público deja de servir).
POST /api/groups/:id/link/resetmiembroLink de invitación nuevo; el anterior muere.
POST /api/groups/:id/leavepersonaSalir del grupo (si no queda ninguna persona, se borra). En un directo: lo oculta para vos; vuelve cuando el otro escribe.
DELETE /api/groups/:idcreadorEliminar el grupo para todos.
GET /api/invites/:token[?i=]públicoVista 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/jointodosEntrar 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/resendpersona verificadaInvitar 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/:userIdpersonaAbrir (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/lookuppersona verificada{ email }{ status: 'found', user } | { status: 'none' } | { status: 'self' }. Solo emails verificados; 30 por hora.
POST /api/direct-invitepersona 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/sharepersonaCompartir 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/blockpersonaBloquear / desbloquear a una persona (ver "Contactos, compartir y bloqueo").
POST /api/users/:id/reportpersona{ reason?, groupId?, block? }: reporte al equipo de Puentes con los últimos mensajes de esa persona en ese chat. 5 por día.
GET /api/botspersonaMis bots (con prefijo del token, webhook y secreto).
GET /api/bots/catalogpersonaBots que puedo agregar: de Puentes, públicos y míos.
POST /api/botspersonaCrear un bot (ver arriba).
GET /api/bots/:idtodosUn bot que puedo usar.
PATCH /api/bots/:iddueñoCambiar nombre, descripción, public, webhookUrl; en AI, model, mode, systemPrompt.
POST /api/bots/:id/tokendueñoToken nuevo (el anterior muere).
DELETE /api/bots/:iddueñoBorrar el bot: sale de todos los chats, sus directos desaparecen.
GET /api/bots/:id/access · POST …/access { userId } · DELETE …/access/:userIddueñoQuié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-linkdueñoLink /bot/<token> para compartir (7 días, uno vivo por bot) / revocarlo.
GET /api/bots/shared/:token · POST …/acceptpersonaVer quién comparte qué / aceptar: acceso para la cuenta logueada.
POST /api/groups/:id/botsmiembroAgregar { botId } (builtin, público, mío o compartido conmigo) al grupo.
DELETE /api/groups/:id/bots/:botIdmiembroSacarlo 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 …/verifypersona (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/:tokenpúblicoLink 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/configpúblicoProveedores 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

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

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