AI Connect/How to create/Multi-user consumer chatbot
Multi-user consumer chatbot
Build one chatbot route that serves thousands of humans, each with their own GitHub, Slack and Gmail connected and isolated, from a single Application key, and wire it to OpenAI with the AI Connect SDK. No platform has ever shipped this: every user brings their own accounts, and you never store a token.
This is the build most teams attempt first, and the one the industry has never managed to make cheap: a single chatbot route where thousands of humans each connect their own GitHub, Slack and Gmail, all isolated, all from one Application key. Every user of your product walks in with the entire Vinkius catalog behind them: thousands of AI connections from day one, zero integrations built by you, zero tokens stored by you, zero identity exposed to anyone. That is the future this page hands you, and it takes about eighty lines of backend.
The alternative, the one your competitors are still living, is the industry's standard answer: an army of OAuth flows, an encrypted token store with per-user key isolation, a refresh scheduler with distributed locks, and a security questionnaire you fail in front of enterprise deals. Analyses of that homegrown path price it at roughly $200,000 to $250,000 over three years, and 640+ engineering hours before a single tool call works. Your assistant creates the issue in the user's repo, summarizes their unread Slack, books against their calendar. The model was never the hard part. The connectivity was, and on the AI Connect SDK it is already done.
Here the "user" is the literal thing: a human with an account in your product. Their external_id is whatever your login already hands you.
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
...Press Run above to watch one user turn end to end: capabilities load for the signed-in user, convert to tools, the model picks github__create_issue, the SDK executes on that user's own connection. Swap the user id and every pane changes, that isolation is the whole product.
What you end up with
A single POST /chat endpoint. Given userId and a message, it:
- returns only the capabilities that user has connected,
- feeds them to OpenAI as tools,
- executes whichever tool the model chooses,
- and does all of it on your server so no credential ever leaves it.
1. One client, for the whole app
You create exactly one Vinkius instance. It holds application configuration, not a current user. Import it from a single module.
// 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_… (server only)
timeoutMs: 20_000,
});Construction makes no request; it only validates the credential prefixes. Reusing one instance across every request is the intended pattern.
2. Authenticate, then identify the actor
Never trust a userId from the request body. Resolve it from your session, then pass it to the SDK. The SDK never needs an email or name, the id is opaque and Vinkius learns nothing about who your customers are.
import { vinkius } from './vinkius';
// your auth returns the stable id you created users with
function requireUser(req: Request): string {
const userId = req.headers.get('x-user-id');
if (!userId) throw new Error('not authenticated');
return userId; // e.g. "alice_123"
}const user = vinkius.user(requireUser(req)); // lazy: zero network calls3. Connect an account when the user clicks "Connect GitHub"
Give your product a thin provisioning route. connect() is idempotent (get-or-create), then credentials are stored write-only: the response reports which fields are configured and never returns values.
// POST /connect/github { token }
async function connectGithub(userId: string, githubToken: string) {
const github = vinkius.user(userId).connector('github');
await github.connect(); // provision the connection
const schema = await github.credentials.schema(); // what this connector needs
await github.credentials.set({ GITHUB_TOKEN: githubToken });
return { status: await github.status(), requires: Object.keys(schema) };
}credentials.schema() reads the catalog and does not require a connection, so you can render the right form fields before the user ever connects. For OAuth connectors there is nothing to set, connect() returns after the provider consent and the status becomes ready.
4. Load only this user's capabilities
One call aggregates every ready connector for the actor. It fans out concurrently and is failure-tolerant: a flaky connector degrades, it does not fail the turn.
const capabilities = await user.capabilities({
include: ['github', 'slack', 'gmail'], // scope to what this product uses
onConnectorError: (slug, error) => {
console.warn('connector skipped', slug, (error as Error).message);
},
});
if (capabilities.length === 0) {
// nothing connected yet — prompt the user to connect an account
}Two users can connect the same GitHub integration and get completely separate connections, credentials and capabilities. Nothing crosses the boundary, and you wrote none of that isolation logic.
5. Hand the capabilities to the model
This is where the "works with any model" promise is delivered. The /openai adapter converts a capability set into the tools array OpenAI expects, and dispatches a returned tool call back onto the user-scoped connection.
// 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 ?? '' };
}
// executes on THIS user's connection; errors come back as data
const result = await runOpenAIToolCall(capabilities, call);
return { tool: call.function.name, result };
}For Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Cloudflare Workers AI or a neutral JSON-Schema bridge for any other runtime, see Framework adapters. The conversion line is the only thing that changes.
6. Loop the agent until it is done
A real conversation calls several tools. Feed results back and let the model finish:
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 on a result is a connector outcome (the action failed), not a crash. Passing the failed content back to the model is exactly what lets it recover, retry, choose a different tool, or tell the user. Reserve your try/catch for thrown VinkiusError subclasses: auth, quota, and transport. See Error handling.
7. Handle "not connected yet" as a product moment
user.capabilities() only lists connectors whose status is ready, so the normal signal that an account is missing is simply an empty or partial set, findCapability returns undefined. Detect it up front and prompt the user, rather than failing later. A thrown ConnectorNotConnectedError only comes from a connector operation on a handle you never connected (e.g. user.connector(slug).capabilities() before connect()).
const createIssue = capabilities.findCapability('github__create_issue');
if (!createIssue) {
const status = await vinkius.user(userId).connector('github').status();
// status: 'not_connected' | 'needs_credentials' | 'disabled'
return { needsConnection: 'github', status }; // UI → your /connect/github route
}When you do execute, branch on the typed errors, each carries stable fields (note that QuotaError exposes upgradeUrl, while ConnectorNotConnectedError does not name the connector, so pass the slug you already know):
import { ConnectorNotConnectedError, AuthError, QuotaError } from '@vinkius/connect';
try {
const result = await createIssue.execute(args, { idempotencyKey: `chat:${id}` });
if (result.isError) {
// the connector reported a failed action — feed result.content back to the model
}
} catch (error) {
if (error instanceof ConnectorNotConnectedError) return { needsConnection: 'github' };
if (error instanceof QuotaError) return { upgrade: error.upgradeUrl }; // server plan limit
if (error instanceof AuthError) return { reauth: true }; // 401/403
throw error;
}Advanced patterns that turn the prototype into a product
The basics work. Four capabilities the SDK already ships separate an early prototype from production.
Make every write idempotent, bounded and cancellable
The dispatch helpers (runOpenAIToolCall) execute the tool the model chose, but they do not attach an idempotency key, a per-call timeout or an abort signal. For anything that changes the world, resolve the capability yourself and pass those controls straight to execute():
const capability = capabilities.findCapability(call.function.name);
const result = await capability?.execute(
JSON.parse(call.function.arguments || '{}'),
{
idempotencyKey: `chat:${messageId}`, // a retried turn never opens a duplicate issue
timeoutMs: 15_000, // a slow tool gets its own deadline
signal, // the incoming request's AbortSignal, if any
},
);Declaring idempotencyKey is precisely what makes a non-idempotent POST retry-safe: it turns the SDK's transport retries on for that call and the server dedupes replays. Without it, transient 429/502/503/504 are never retried for a write.
Treat runtime_url as a secret
connect() returns a Connection whose runtime_url embeds this user's vk_live_* data-plane token. It is handed back once, and it authenticates every call, never log it, persist it to the browser, or inline it into a model prompt. For visibility you don't need to: register hooks, and the SDK scrubs Authorization, credential-shaped fields and the vk_live_* path before your callback ever runs.
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),
},
});Keep tool names legal for your model
toOpenAITools throws a ConfigError up front when a namespaced name like github__create_issue violates OpenAI's 64-character [A-Za-z0-9_-] rule, instead of a cryptic provider 400 mid-conversation. Shrink the namespace at construction if your connectors are verbose:
new Vinkius({
appId,
apiKey,
namespaceCapability: (connector, name) =>
`${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});Read typed output when you have it
Some capabilities return structured data alongside their text. result.structuredContent surfaces it verbatim (the SDK never parses it), so a "summarize my unread Slack" tool can hand your UI a clean object instead of a string you re-parse:
const result = await capability!.execute(args);
const data = result.structuredContent; // typed object when the connector provides itProduction checklist
- [ ] The SDK only ever runs on your server; your browser/mobile hits your routes, never Vinkius.
- [ ]
external_idcomes from your authenticated session, never from client input. - [ ] Derive stable ids (a DB key), and keep them under 255 chars with no
/,\or whitespace. - [ ] Pass
includetocapabilities()so the model only sees the tools this product should use. - [ ] Give every mutating call a stable
idempotencyKeyso retries do not execute the same call twice. - [ ] Attach non-secret metadata with
vinkius.user(id).ensure({ plan })if you segment by plan.
You now have one chatbot route serving every user, each with their own isolated connectors and capabilities, wired to your model of choice. The six-figure integration layer your competitors are still building by hand, you replaced with a key, an SDK and one afternoon. You compete on your product. The plumbing is done.
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.
