AI Connect/How to create/A multi-tenant AI SaaS
A multi-tenant AI SaaS
Deliver AI with connectors to every customer of your B2B platform: scope each external_id by tenant and user, keep every organization isolated on one application key, and add per-tenant control without ever exposing a credential across the boundary. Your customers get a connectivity platform; you never hand them the plumbing.
Ship the AI SaaS your category has been waiting for: every customer (tenant) gets a team of users, every user gets their AI reaching their GitHub, Jira and Slack, every tenant isolated on your one Application key, and every user of every tenant backed by thousands of AI connections from day one. No integration built by you, no token stored by you, no identity leaving your database.
Every integration platform on the market will tell you that problem ends with a per-tenant contract, a per-tenant invoice, or months of your engineers building the isolation by hand. The AI Connect SDK's answer is the one no competitor can copy without re-architecting their product: deliver AI features, not integration projects. This guide shows you how to make a whole marketplace of tenants share one application key without ever crossing a boundary.
Here the "user" is a person inside your customer, so the external_id must carry both the tenant and the human. That single decision is the isolation model. When it works, your product does what normally takes a platform company years and a security team to promise: every organization is an island, every user is a citizen of exactly one island, and you run the whole archipelago from one key. Vinkius never meets your real users. Their emails, their names, their profiles never leave your database; the platform only ever sees the opaque id your backend hands it.
The isolation contract
- One Application = your product. All tenants normally share your single
appId. external_idencodes the address.cus_<tenant>_u_<user>is the boundary; capabilities resolve only within it.- Cross-tenant is a 404, not a 500. An exposed or misrouted id cannot read another tenant's connection, Vinkius rejects it out of scope.
- Your users stay yours. No email or profile reaches Vinkius; you pass it an opaque id. Your customer relationship remains under your control.
Isolation is derived from a well-formed external_id, so treat it as security-critical input. Always build it from authenticated tenant and user claims, never from raw request data, and never let one tenant supply another's id. For the deep guarantee model, see Authentication and scope and Security.
1. Address a user inside a tenant
Compose a deterministic, URL-safe id from the two ids your auth already trusts.
interface AuthedPrincipal { tenantId: string; userId: string } // from your JWT/session
const externalIdFor = (p: AuthedPrincipal) =>
`cus_${p.tenantId}_u_${p.userId}`; // "cus_acme_u_9f2c"2. One shared client
// server/vinkius.ts
import { Vinkius } from '@vinkius/connect';
export const vinkius = new Vinkius({
appId: process.env.VINKIUS_APP_ID!,
apiKey: process.env.VINKIUS_APP_KEY!,
maxRetries: 2,
});3. Resolve the actor from a verified session
Derive the principal from your token, then pass it to the SDK. Everything downstream is scoped because externalIdFor is.
import { vinkius } from './vinkius';
export async function actorFor(req: Request) {
const claims = await verifySession(req); // your authN/authZ
if (!claims) throw new Error('unauthenticated');
return vinkius.user(externalIdFor(claims));
}4. Let each user connect their own tools
Two people at two companies connect the same GitHub integration and receive two completely separate connections, credentials and capabilities, automatically.
// POST /connect { connector, values }
async function connectForUser(claims: AuthedPrincipal, connector: string, values: Record<string, string>) {
const handle = vinkius.user(externalIdFor(claims)).connector(connector);
await handle.connect();
await handle.credentials.set(values);
return handle.status();
}5. Per-tenant policies on a shared key
You will typically need to offer different connectors per plan, or per tenant. Because the actor is namespaced, tenant-level configuration composes with user-level connection without any special SDK concept: keep the allow-list per tenant in your database and pass it as include.
// server/policy.ts
export async function allowedConnectors(tenantId: string): Promise<string[]> {
// e.g. enterprise tenants get 'salesforce' and 'snowflake'
return await billing.planAllows(tenantId);
}
async function capabilitiesFor(claims: AuthedPrincipal) {
const allowed = await allowedConnectors(claims.tenantId);
return vinkius
.user(externalIdFor(claims))
.capabilities({ include: allowed }); // prunes the fan-out to allowed connectors only
}include prunes the fan-out before any runtime call: a connector the tenant's plan forbids is never queried, so the user simply never sees it, even if that connection exists. Policy and connectivity compose cleanly.
6. Use the right adapter for heterogeneous model runtimes
Different tenants (or different features) may run on different models. Because CapabilitySet is framework-agnostic, one code path serves all of them: convert at the last line.
import { toOpenAITools } from '@vinkius/connect/openai';
import { toAnthropicTools } from '@vinkius/connect/anthropic';
import { toGeminiTools } from '@vinkius/connect/gemini';
const capabilities = await capabilitiesFor(claims);
const toolSpec =
model === 'openai' ? toOpenAITools(capabilities)
: model === 'anthropic' ? toAnthropicTools(capabilities)
: model === 'gemini' ? toGeminiTools(capabilities)
: capabilities; // keep the raw set to execute onKeep the original capabilities for execution, only the converted definitions are handed to the model. The adapter dispatch helpers route the chosen tool back onto the correct user connection.
7. Enterprise tenants that require their own application
Some enterprise customers require a dedicated tenant key rather than sharing yours. That is simply another Vinkius instance, selected per request, your externalIdFor logic does not change.
import { Vinkius } from '@vinkius/connect';
const shared = new Vinkius({
appId: process.env.VINKIUS_APP_ID!,
apiKey: process.env.VINKIUS_APP_KEY!,
});
const dedicated = new Map<string, Vinkius>(); // tenantId -> its own app
function clientFor(tenantId: string): Vinkius {
return dedicated.get(tenantId) ?? shared;
}8. Handle the "wrong tenant" case explicitly
Defense in depth: if a request references an id you cannot authorize, treat a NotFoundError as a scoping failure, not a generic 404.
import { NotFoundError, AuthError } from '@vinkius/connect';
try {
await capabilitiesFor(claims);
} catch (error) {
if (error instanceof AuthError) return respond(401);
if (error instanceof NotFoundError) return respond(403, 'out of scope'); // cross-tenant
throw error;
}Two guarantees every tenant trusts
One exposed key cannot cross a boundary
Your platform holds a single vk_app_sk_* secret. Its blast radius is scoped by design: an exposed key compromises one application, and any attempt to access a resource outside that application returns 404, never another tenant's data, never a 500 that confirms a resource exists. A misrouted external_id therefore fails safe, which is exactly what an enterprise security review expects.
One namespaced name, many model rules
The default namespace connector__name is not valid for every runtime you might route a tenant to. toGeminiTools rejects hyphens immediately (a google-calendar connector slug violates Gemini's ^[a-zA-Z_][a-zA-Z0-9_]*$ rule) and toOpenAITools caps names at 64 chars, both throwing ConfigError at conversion time, not a provider 400 at inference time. Normalize once so a tenant that runs on Gemini works as cleanly as one on OpenAI:
new Vinkius({
appId,
apiKey,
// underscore-only, length-capped: valid for OpenAI, Anthropic and Gemini alike
namespaceCapability: (connector, name) =>
`${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});Production checklist
- [ ] Always compose
external_idfrom authenticated tenant + user claims, never raw input. - [ ] Keep one Application key for the platform; add dedicated
Vinkiusinstances only for tenants that require them. - [ ] Enforce per-tenant plans with
capabilities({ include }), backed by your billing table. - [ ] Rely on 404-as-out-of-scope: never try to synthesize another tenant's resources.
- [ ] Convert with adapters at the model call; execute on the retained
CapabilitySet. - [ ] Use per-operation
idempotencyKeyso tenant retries never cross or duplicate.
You now run one AI platform that serves every customer and every user inside them, each on isolated connections and credentials, with your own application key and a per-tenant policy layer, and your customers' identities never leave your side. Rivals must buy their way into this capability or build it for years. You got it the day you created your Application, and that head start compounds with every tenant you sign.
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.
One app key, every customer isolated by address; a cross-tenant attempt is a 404. A customer can even get their own Vinkius instance, same code.
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.
