AI Connect/How to create/Chatbot de consumo multiusuario
Chatbot de consumo multiusuario
Cree una única ruta de chatbot que atienda a miles de personas, cada una con su propio GitHub, Slack y Gmail conectados y aislados, desde una sola Application key, y conéctela a OpenAI con el AI Connect SDK. Ninguna plataforma ha entregado nunca esto: cada usuario trae sus propias cuentas y usted jamás almacena un token.
Esta es la construcción que casi todo equipo intenta primero, y la que la industria nunca logró abaratar: una única ruta de chatbot donde miles de personas conectan su propio GitHub, Slack y Gmail, todas aisladas, todas desde una sola Application key. Cada usuario de tu producto entra con el catálogo completo de Vinkius detrás: miles de conexiones de IA desde el primer día, cero integraciones construidas por ti, cero tokens almacenados por ti, cero identidad expuesta a nadie. Ese es el futuro que esta página te entrega, y cabe en unas ochenta líneas de backend.
La alternativa, la que tus competidores siguen viviendo, es la respuesta estándar de la industria: un ejército de flujos de OAuth, un almacén de tokens cifrado con aislamiento de claves por usuario, un planificador de refrescos con locks distribuidos y un cuestionario de seguridad que usted falla frente a los contratos enterprise. Los análisis de ese camino casero lo tasan en entre 200.000 y 250.000 dólares a lo largo de tres años, y más de 640 horas de ingeniería antes de que la primera llamada a una tool funcione. Su asistente crea la issue en el repositorio del usuario, resume su Slack sin leer, agenda en su calendario. El modelo nunca fue la parte difícil. La conectividad lo era, y en el AI Connect SDK ya está resuelta.
Aquí el «usuario» se toma en el sentido literal: una persona con una cuenta en su producto. Su external_id es simplemente lo que su inicio de sesión ya le entrega.
vinkius.user('alice_123').capabilities({ include: ['github'] })GET /apps/vk_app_xxx/users/alice_123/tools?connector=githubconst capabilities = await vinkius
.user('alice_123')
.capabilities({ include: ['github'] });CapabilitySet (6)
github__list_issues read-only
github__create_issue POST /repos/{owner}/{repo}/issues
github__list_pull_requests read-only
github__search_code read-only
...Pulse Run arriba para ver un turno de usuario de extremo a extremo: las capacidades se cargan para el usuario autenticado, se convierten en tools, el modelo elige github__create_issue, el SDK se ejecuta sobre la conexión propia de ese usuario. Cambie el id de usuario y cada panel cambia, ese aislamiento es todo el producto.
Qué obtiene al final
Un único endpoint POST /chat. Dados userId y un mensaje, este:
- devuelve solo las capacidades que ese usuario ha conectado,
- las entrega a OpenAI como tools,
- ejecuta la tool que el modelo elija,
- y hace todo eso en su servidor, para que ninguna credencial salga de él.
1. Un cliente, para toda la aplicación
Crea exactamente una instancia de Vinkius. Ella guarda la configuración de la aplicación, no un usuario actual. Impórtela desde un único módulo.
// server/vinkius.ts
import { Vinkius } from '@vinkius/connect';
export const vinkius = new Vinkius({
appId: process.env.VINKIUS_APP_ID!, // vk_app_…
apiKey: process.env.VINKIUS_APP_KEY!, // vk_app_sk_… (solo en el servidor)
timeoutMs: 20_000,
});La construcción no realiza ninguna petición; solo valida los prefijos de las credenciales. Reutilizar una única instancia en todas las peticiones es el patrón previsto.
2. Autentique y luego identifique al actor
Nunca confíe en un userId que venga del cuerpo de la petición. Resuélvalo a partir de su sesión y páselo al SDK. El SDK nunca necesita un correo ni un nombre, el id es opaco y Vinkius no sabe nada sobre quiénes son sus clientes.
import { vinkius } from './vinkius';
// su autenticación devuelve el id estable con el que creó los usuarios
function requireUser(req: Request): string {
const userId = req.headers.get('x-user-id');
if (!userId) throw new Error('not authenticated');
return userId; // p. ej. "alice_123"
}const user = vinkius.user(requireUser(req)); // perezoso: cero llamadas de red3. Conecte una cuenta cuando el usuario haga clic en «Conectar GitHub»
Proporcione a su producto una ruta ligera de aprovisionamiento. connect() es idempotente (get-or-create), y las credenciales se guardan solo para escritura: la respuesta informa qué campos están configurados y nunca devuelve valores.
// POST /connect/github { token }
async function connectGithub(userId: string, githubToken: string) {
const github = vinkius.user(userId).connector('github');
await github.connect(); // aprovisiona la conexión
const schema = await github.credentials.schema(); // lo que este conector necesita
await github.credentials.set({ GITHUB_TOKEN: githubToken });
return { status: await github.status(), requires: Object.keys(schema) };
}credentials.schema() lee el catálogo y no requiere una conexión, así que puede renderizar los campos correctos del formulario antes de que el usuario se conecte. Para conectores OAuth no hay nada que definir: connect() regresa tras el consentimiento del proveedor y el estado pasa a ready.
4. Cargue solo las capacidades de este usuario
Una sola llamada agrega todos los conectores listos del actor. Se distribuye de forma concurrente y es tolerante a fallos: un conector inestable degrada, no hace fallar el turno.
const capabilities = await user.capabilities({
include: ['github', 'slack', 'gmail'], // limita a lo que este producto usa
onConnectorError: (slug, error) => {
console.warn('connector skipped', slug, (error as Error).message);
},
});
if (capabilities.length === 0) {
// nada conectado aún — pida al usuario que conecte una cuenta
}Dos usuarios pueden conectar la misma integración de GitHub y obtener conexiones, credenciales y capacidades completamente separadas. Nada cruza la frontera, y usted no implementó ninguna parte de esa lógica de aislamiento.
5. Entregue las capacidades al modelo
Aquí es donde se materializa la promesa de «funciona con cualquier modelo». El adaptador /openai convierte un conjunto de capacidades en el array tools que OpenAI espera y despacha la llamada a tool devuelta de nuevo sobre la conexión con alcance de usuario.
// server/chat.ts
import OpenAI from 'openai';
import { vinkius } from './vinkius';
import { toOpenAITools, runOpenAIToolCall } from '@vinkius/connect/openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
export async function handleChat(userId: string, message: string) {
const capabilities = await vinkius.user(userId).capabilities({
include: ['github', 'slack', 'gmail'],
});
const completion = await openai.chat.completions.create({
model: '[MODEL_ID]',
messages: [{ role: 'user', content: message }],
tools: toOpenAITools(capabilities),
tool_choice: 'auto',
});
const call = completion.choices[0]?.message.tool_calls?.[0];
if (!call) {
return { text: completion.choices[0]?.message.content ?? '' };
}
// se ejecuta en la conexión DE ESTE usuario; los errores vuelven como datos
const result = await runOpenAIToolCall(capabilities, call);
return { tool: call.function.name, result };
}Para Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Cloudflare Workers AI o un puente neutro de JSON-Schema para cualquier otro runtime, consulte Adaptadores de frameworks. La línea de conversión es lo único que cambia.
6. Haga iterar al agente hasta que termine
Una conversación real invoca varias tools. Devuelva los resultados y deje que el modelo concluya:
export async function runTurn(userId: string, messages: object[]) {
const capabilities = await vinkius.user(userId).capabilities();
const tools = toOpenAITools(capabilities);
for (let step = 0; step < 6; step++) {
const completion = await openai.chat.completions.create({
model: '[MODEL_ID]',
messages: messages as never,
tools,
});
const msg = completion.choices[0].message;
messages.push(msg as object);
if (!msg.tool_calls?.length) return msg.content;
for (const call of msg.tool_calls) {
const result = await runOpenAIToolCall(capabilities, call);
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result.content),
});
}
}
return 'Stopped after too many steps.';
}isError: true en un resultado es un desenlace del conector (la acción falló), no un fallo del sistema. Pasar el contenido fallido de vuelta al modelo es exactamente lo que le permite recuperarse, reintentar, elegir otra tool o avisar al usuario. Reserve su try/catch para las subclases de VinkiusError que se lanzan: autenticación, cuota y transporte. Consulte Gestión de errores.
7. Recupérese de «aún no conectado»
Cuando un usuario no ha conectado una cuenta, findCapability no devuelve nada o la ejecución lanza ConnectorNotConnectedError. Convierta eso en un momento de producto, no en un 500:
import { ConnectorNotConnectedError } from '@vinkius/connect';
try {
const result = await runOpenAIToolCall(capabilities, call);
} catch (error) {
if (error instanceof ConnectorNotConnectedError) {
return { needsConnection: error.message };
// UI: «Conecte GitHub para hacer eso» → su ruta /connect/github
}
throw error;
}Patrones avanzados que convierten el prototipo en producto
Lo básico funciona. Cuatro capacidades que el SDK ya incluye separan un prototipo inicial de un sistema en producción.
Haga que toda escritura sea idempotente, acotada y cancelable
Los helpers de despacho (runOpenAIToolCall) ejecutan la tool que el modelo eligió, pero no adjuntan una clave de idempotencia, un timeout por llamada ni una señal de cancelación. Para cualquier cosa que modifique el mundo, resuelva la capacidad usted mismo y pase esos controles directamente a execute():
const capability = capabilities.findCapability(call.function.name);
const result = await capability?.execute(
JSON.parse(call.function.arguments || '{}'),
{
idempotencyKey: `chat:${messageId}`, // un turno reintentado nunca crea una issue duplicada
timeoutMs: 15_000, // una tool lenta obtiene su propio plazo
signal, // el usuario cerró la pestaña: cancele la ejecución y evite incurrir en costes
},
);Declarar idempotencyKey es precisamente lo que vuelve seguro reintentar un POST no idempotente: activa los reintentos de transporte del SDK para esa llamada y el servidor deduplica las repeticiones. Sin ella, los 429/502/503/504 transitorios nunca se reintentan en una escritura.
Trate runtime_url como un secreto
connect() devuelve un Connection cuyo runtime_url incrusta el token de data-plane vk_live_* de este usuario. Se entrega una única vez y autentiza cada llamada, nunca lo registre en logs, nunca lo persista en el navegador ni lo incruste en un prompt del modelo. Para tener visibilidad no lo necesita: registre hooks, y el SDK limpia Authorization, los campos con forma de credencial y la ruta vk_live_* antes de que su callback se ejecute.
const vinkius = new Vinkius({
appId: process.env.VINKIUS_APP_ID!,
apiKey: process.env.VINKIUS_APP_KEY!,
hooks: {
onRequest: ({ method, attempt }) => metrics.count(method, attempt),
onResponse: ({ status, requestId }) => trace.record(status, requestId),
},
});Mantenga nombres de tool válidos para su modelo
toOpenAITools lanza un ConfigError por adelantado cuando un nombre con espacio de nombres como github__create_issue incumple la regla de 64 caracteres [A-Za-z0-9_-] de OpenAI, en lugar de un 400 críptico del proveedor a mitad de la conversación. Reduzca el espacio de nombres en la construcción si sus conectores son verbosos:
new Vinkius({
appId,
apiKey,
namespaceCapability: (connector, name) =>
`${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});Lea la salida tipada cuando exista
Algunas capacidades devuelven datos estructurados junto con su texto. result.structuredContent los presenta literalmente (el SDK nunca los analiza), de modo que una tool «resuma mi Slack sin leer» puede entregar a su UI un objeto limpio en lugar de una cadena que usted deba volver a analizar:
const result = await capability!.execute(args);
const data = result.structuredContent; // objeto tipado cuando el conector lo proporcionaLista de verificación de producción
- [ ] El SDK solo se ejecuta en su servidor; su navegador/móvil llama a sus rutas, nunca a Vinkius.
- [ ]
external_idproviene de su sesión autenticada, nunca de entrada del cliente. - [ ] Derive ids estables (una clave de BD), y manténgalos por debajo de 255 caracteres sin
/,\ni espacios. - [ ] Pase
includeacapabilities()para que el modelo solo vea las tools que este producto debe usar. - [ ] Dé a cada llamada mutante un
idempotencyKeyestable, para que los reintentos no generen ejecuciones duplicadas. - [ ] Adjunte metadatos no secretos con
vinkius.user(id).ensure({ plan })si segmenta por plan.
Ya tiene una única ruta de chatbot que atiende a todos los usuarios, cada uno con sus conectores y capacidades aislados, conectada al modelo que elija. La capa de integración de seis cifras que sus competidores siguen construyendo a mano, usted la cambió por una clave, un SDK y una tarde. Usted compite en su producto. La infraestructura está lista.
What you just got
Not a pitch: the properties this build inherits automatically.
Connections and capabilities resolve only inside one external_id. No cross-actor leakage is possible, and you wrote none of that enforcement.
Your server stores secrets and can read back which fields are configured, never the values. Not your code, the model, or a dashboard can exfiltrate them.
Every connection owns a vk_live_* token, so cost and revocation are per connection. One call to disconnect() is a complete, auditable stop.
One CapabilitySet converts to OpenAI, Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Workers AI or neutral JSON Schema. Only the last line changes.
idempotencyKey, timeoutMs and AbortSignal per call; automatic full-jitter retries on transient failures; typed VinkiusError branches. No bespoke harness.
HandleChat(userId, ...) serves your whole base. Adding a user is one external_id, never a new integration.
Give it to your AI agent
An Agent Skill (SKILL.md) for this build. Preview the first lines below, then copy or download it into your repo under .claude/skills/: Claude Code, Cursor or any Agent-Skills-compatible agent follows it to implement this pattern correctly.
