AI Connect/How to create/Chatbot grand public multi-utilisateurs
Chatbot grand public multi-utilisateurs
Créez une seule route de chatbot qui sert des milliers de personnes, chacune avec son propre GitHub, Slack et Gmail connectés et isolés, à partir d'une seule Application key, et reliez-la à OpenAI avec l'AI Connect SDK. Aucune plateforme n'a jamais livré cela : chaque utilisateur apporte ses propres comptes et vous ne stockez jamais un jeton.
C'est le build que presque toutes les équipes tentent en premier, et celui que l'industrie n'a jamais réussi à rendre bon marché : une seule route de chatbot où des milliers de personnes connectent leur propre GitHub, Slack et Gmail, toutes isolées, toutes à partir d'une seule Application key. Chaque utilisateur de votre produit entre avec le catalogue Vinkius entier derrière lui : des milliers de connexions IA dès le premier jour, zéro intégration construite par vous, zéro jeton stocké par vous, zéro identité exposée à quiconque. C'est le futur que cette page vous remet, et il tient en une quatre-vingtaine de lignes de backend.
L'alternative, celle que vos concurrents vivent encore, est la réponse standard de l'industrie : une armée de flux OAuth, un coffre de jetons chiffré avec isolation de clé par utilisateur, un planificateur de rafraîchissement avec des verrous distribués, et un questionnaire de sécurité que vous échouez devant des contrats enterprise. Les analyses de ce chemin maison l'évaluent entre 200 000 et 250 000 dollars sur trois ans, et plus de 640 heures d'ingénierie avant que le premier appel d'outil ne fonctionne. Votre assistant crée l'issue dans le dépôt de l'utilisateur, résume ses messages Slack non lus, réserve un créneau dans son agenda. Le modèle n'a jamais été la partie difficile. La connectivité l'était, et sur l'AI Connect SDK elle est déjà faite.
Ici, l'« utilisateur » est pris au sens littéral : une personne ayant un compte dans votre produit. Son external_id est simplement ce que votre système d'authentification vous fournit déjà.
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
...Cliquez sur Run ci-dessus pour suivre un tour d'utilisateur de bout en bout : les capacités se chargent pour l'utilisateur connecté, se convertissent en tools, le modèle choisit github__create_issue, le SDK s'exécute sur la connexion propre à cet utilisateur. Changez l'id utilisateur et chaque panneau change, cette isolation est le produit tout entier.
Ce que vous obtenez au final
Un seul endpoint POST /chat. Étant donné un userId et un message, il :
- ne renvoie que les capacités que cet utilisateur a connectées,
- les transmet à OpenAI comme tools,
- exécute la tool que le modèle choisit,
- et fait tout cela sur votre serveur, pour qu'aucun identifiant ne le quitte.
1. Un client, pour toute l'application
Vous créez exactement une instance de Vinkius. Elle porte la configuration de l'application, pas un utilisateur courant. Importez-la depuis un module unique.
// 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_… (serveur uniquement)
timeoutMs: 20_000,
});La construction n'émet aucune requête ; elle valide seulement les préfixes des identifiants. Réutiliser une seule instance pour toutes les requêtes est le schéma prévu.
2. S'authentifier, puis identifier l'acteur
Ne faites jamais confiance à un userId venant du corps de la requête. Résolvez-le depuis votre session, puis transmettez-le au SDK. Le SDK n'a jamais besoin d'un e-mail ni d'un nom, l'id est opaque et Vinkius n'apprend rien sur l'identité de vos clients.
import { vinkius } from './vinkius';
// votre authentification renvoie l'id stable avec lequel vous avez créé les utilisateurs
function requireUser(req: Request): string {
const userId = req.headers.get('x-user-id');
if (!userId) throw new Error('not authenticated');
return userId; // p. ex. "alice_123"
}const user = vinkius.user(requireUser(req)); // paresseux : zéro appel réseau3. Connecter un compte quand l'utilisateur clique sur « Connecter GitHub »
Donnez à votre produit une route de provisionnement minimale. connect() est idempotent (get-or-create), et les identifiants sont stockés en écriture seule : la réponse indique quels champs sont configurés et ne renvoie jamais de valeurs.
// POST /connect/github { token }
async function connectGithub(userId: string, githubToken: string) {
const github = vinkius.user(userId).connector('github');
await github.connect(); // approvisionne la connexion
const schema = await github.credentials.schema(); // ce dont ce connecteur a besoin
await github.credentials.set({ GITHUB_TOKEN: githubToken });
return { status: await github.status(), requires: Object.keys(schema) };
}credentials.schema() lit le catalogue et n'exige pas de connexion ; vous pouvez donc afficher les bons champs de formulaire avant même que l'utilisateur se connecte. Pour les connecteurs OAuth, il n'y a rien à définir, connect() revient après le consentement du fournisseur et le statut passe à ready.
4. Charger uniquement les capacités de cet utilisateur
Un seul appel agrège tous les connecteurs prêts de l'acteur. Il se ventile en concurrence et tolère les pannes : un connecteur instable se dégrade, il ne fait pas échouer le tour.
const capabilities = await user.capabilities({
include: ['github', 'slack', 'gmail'], // limité à ce que ce produit utilise
onConnectorError: (slug, error) => {
console.warn('connector skipped', slug, (error as Error).message);
},
});
if (capabilities.length === 0) {
// rien n'est connecté pour l'instant — invitez l'utilisateur à connecter un compte
}Deux utilisateurs peuvent connecter la même intégration GitHub et obtenir des connexions, des identifiants et des capacités totalement séparés. Rien ne franchit la frontière, et vous n'avez écrit aucune ligne de cette logique d'isolation.
5. Transmettre les capacités au modèle
C'est ici que la promesse « fonctionne avec n'importe quel modèle » se concrétise. L'adaptateur /openai convertit un jeu de capacités dans le tableau tools attendu par OpenAI, et redirige l'appel d'outil renvoyé vers la connexion à portée de l'utilisateur.
// 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 ?? '' };
}
// s'exécute sur la connexion de CET utilisateur ; les erreurs reviennent comme des données
const result = await runOpenAIToolCall(capabilities, call);
return { tool: call.function.name, result };
}Pour Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Cloudflare Workers AI ou un pont JSON-Schema neutre pour tout autre runtime, voir Adaptateurs de frameworks. La ligne de conversion est la seule chose qui change.
6. Faire boucler l'agent jusqu'à la fin
Une conversation réelle appelle plusieurs tools. Renvoyez les résultats et laissez le modèle conclure :
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 sur un résultat est un dénouement du connecteur (l'action a échoué), pas un plantage. Renvoyer le contenu d'échec au modèle est précisément ce qui lui permet de récupérer, réessayer, choisir une autre tool ou prévenir l'utilisateur. Réservez votre try/catch aux sous-classes de VinkiusError levées : authentification, quota et transport. Voir Gestion des erreurs.
7. Se remettre d'un « pas encore connecté »
Quand un utilisateur n'a pas connecté de compte, findCapability ne renvoie rien ou l'exécution lève ConnectorNotConnectedError. Transformez cela en moment produit, pas en erreur 500 :
import { ConnectorNotConnectedError } from '@vinkius/connect';
try {
const result = await runOpenAIToolCall(capabilities, call);
} catch (error) {
if (error instanceof ConnectorNotConnectedError) {
return { needsConnection: error.message };
// UI : « Connectez GitHub pour faire cela » → votre route /connect/github
}
throw error;
}Motifs avancés qui transforment le prototype en produit
L'essentiel fonctionne. Quatre capacités déjà livrées par le SDK séparent un prototype préliminaire d'un système de production.
Rendre toute écriture idempotente, bornée et annulable
Les helpers de dispatch (runOpenAIToolCall) exécutent la tool choisie par le modèle, mais n'attachent pas de clé d'idempotence, de délai d'expiration par appel ni de signal d'annulation. Pour tout ce qui modifie le monde, résolvez vous-même la capacité et passez ces contrôles directement à execute() :
const capability = capabilities.findCapability(call.function.name);
const result = await capability?.execute(
JSON.parse(call.function.arguments || '{}'),
{
idempotencyKey: `chat:${messageId}`, // un tour réessayé ne crée jamais de doublon d'issue
timeoutMs: 15_000, // une tool lente obtient son propre délai
signal, // l'utilisateur a fermé l'onglet : annulez et cessez d'engendrer des coûts
},
);Déclarer idempotencyKey est précisément ce qui rend un POST non idempotent sûr à réessayer : cela active les retries de transport du SDK pour cet appel et le serveur déduplique les rejouages. Sans elle, les 429/502/503/504 transitoires ne sont jamais relancés pour une écriture.
Traiter runtime_url comme un secret
connect() renvoie un Connection dont le runtime_url intègre le jeton de data-plane vk_live_* de cet utilisateur. Il est remis une seule fois, et authentifie chaque appel, ne le journalisez jamais, ne le persistez pas dans le navigateur, ne l'insérez jamais dans une invite du modèle. Pour la visibilité, vous n'en avez pas besoin : enregistrez des hooks, et le SDK nettoie Authorization, les champs de forme d'identifiants et le chemin vk_live_* avant même l'exécution de votre callback.
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),
},
});Garder des noms de tools valides pour votre modèle
toOpenAITools lève un ConfigError en amont lorsqu'un nom à espace de noms comme github__create_issue enfreint la règle OpenAI de 64 caractères [A-Za-z0-9_-], plutôt qu'un 400 obscur du fournisseur en pleine conversation. Réduisez l'espace de noms à la construction si vos connecteurs sont verbeux :
new Vinkius({
appId,
apiKey,
namespaceCapability: (connector, name) =>
`${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});Lire la sortie typée quand elle existe
Certaines capacités renvoient des données structurées à côté de leur texte. result.structuredContent les restitue littéralement (le SDK ne les analyse jamais), ainsi une tool « résumez mes Slack non lus » peut remettre à votre UI un objet propre au lieu d'une chaîne que vous devez re-analyser :
const result = await capability!.execute(args);
const data = result.structuredContent; // objet typé quand le connecteur le fournitListe de contrôle de production
- [ ] Le SDK ne tourne que sur votre serveur ; votre navigateur/mobile appelle vos routes, jamais Vinkius.
- [ ]
external_idprovient de votre session authentifiée, jamais d'une entrée client. - [ ] Dérivez des ids stables (une clé de base de données) et maintenez-les sous 255 caractères, sans
/,\ni espace. - [ ] Passez
includeàcapabilities()pour que le modèle ne voie que les tools que ce produit doit utiliser. - [ ] Donnez à chaque appel mutatif un
idempotencyKeystable, pour que les retries ne génèrent pas d'exécutions dupliquées. - [ ] Attachez des métadonnées non sensibles avec
vinkius.user(id).ensure({ plan })si vous segmentez par offre.
Vous disposez désormais d'une seule route de chatbot qui sert tous les utilisateurs, chacun avec ses connecteurs et capacités isolés, reliée au modèle de votre choix. La couche d'intégration à six chiffres que vos concurrents construisent encore à la main, vous l'avez remplacée par une clé, un SDK et un après-midi. Vous concourez sur votre produit. La plomberie est faite.
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.
