Site
All posts

Published Sep 23, 20269 min read

The AI Connect SDK: Give Your AI Agents Each User's Connectors as Tools, One Line per Framework

The integration tax on AI products is the wiring, not the model. The Vinkius AI Connect SDK removes it: one zero dependency TypeScript client, user scoped handles, and nine framework adapters. Here is how it works, the minimal code, and a working multi user assistant you can ship.

Renato Marinho

By Renato Marinho

Founder · Vinkius

The Vinkius AI Connect SDK: your app passes an app id, a user's external id and an intent; the SDK provisions, resolves and executes that user's capabilities at the governed MCP data plane; nine framework adapters turn the result into LLM tools; zero runtime dependencies

The question I get most often about an AI product is not about the model. It is about the wiring. How does your assistant read the user's own GitHub? Post to their Slack? Pull a record from their CRM? In most products the answer is that a team spent months building and maintaining a shelf of integrations: the OAuth handshakes, the token rotation, the per provider error shapes, the retries, the silent schema drift the moment an upstream API changes. None of that makes the product more intelligent. It sits between the model and the thing the user actually wanted, and it keeps asking for a budget.

That tax is what the Vinkius AI Connect SDK removes. It is the part of Vinkius I talk about the least in sales calls and the most with engineers, because it is where the actual leverage lives: one small TypeScript client that turns a user's connected services into tools your model can call, without your team owning a single upstream credential.

The SDK in one line

@vinkius/connect is a zero runtime dependency TypeScript client for the Vinkius connectivity platform. Your application is identified by a pair of keys you create once in the Vinkius dashboard: a public app id and a secret application key. You then address your own end users by the id you already assign them in your system, and for each of them the SDK hands you a scoped handle to the connectors they connected. Vinkius provisions the connection, holds the credentials, and executes the capabilities behind a governed data plane. Your code carries no upstream API key, no OAuth flow, no retry loop for a third party API. You describe the work in your model's own tool dialect, and an adapter routes it to the right connector for that user.

That sentence is the whole value proposition. The rest of this post is proof.

The model: one app, many users, scoped per user

The design decision I would defend hardest is that the unit of scope is the user, not the tenant. A chat assistant that can act on someone's calendar is only safe when the calendar it can touch is the one that person connected, and nothing else. The SDK encodes that directly: vinkius.user("yourUserId") builds a lazy handle that makes zero network calls and never resolves or stores an internal user id. It addresses the platform by your external id, all the way down to execution.

Each user's connector is an isolated connection with its own token. Listing and executing capabilities happens at that connection's runtime, so one user's connected GitHub can never reach another user's. Each connection is metered and can be switched off independently, and a revoked token fails closed instead of silently re minting. When a user unlinks a service, the capability disappears from that user's tool set the moment the next request lists it. Isolation is not a promise in the docs. It is the shape of the data path.

The code, in three moves

Here is the connect and credential flow, straight from the SDK's own examples. You connect a user's GitHub connector, read the schema it expects, write the token, and check readiness:

import { Vinkius } from '@vinkius/connect';

const vinkius = new Vinkius({
  appId: process.env.VINKIUS_APP_ID!,
  apiKey: process.env.VINKIUS_APP_KEY!,
});

async function connectGithub(userId: string, githubToken: string) {
  const github = vinkius.user(userId).connector('github');

  await github.connect();
  const schema = await github.credentials.schema();
  await github.credentials.set({ GITHUB_TOKEN: githubToken });

  const status = await github.status();
  console.log(`github status=${status} requires=${Object.keys(schema).join(',')}`);
}

status comes back as one of four words: not_connected, needs_credentials, ready, or disabled. Credentials are write only. credentials.schema() and credentials.status() tell you which keys exist and which are set, never the values, and the only path a secret takes is in, once, into the vault.

The second move is aggregation. One call returns the executable capabilities across all of the user's ready connectors, already namespaced so two connectors that both ship a create tool do not collide:

const caps = await vinkius.user('alice_123').capabilities();
// caps is a CapabilitySet: an array of executable Capability objects

The third move is the adapter. This is the line that pays for the platform, because it is the same shape of code no matter which model you run:

import { toOpenAITools, runOpenAIToolCall } from '@vinkius/connect/openai';

const tools = toOpenAITools(caps);
// pass tools to your model, then dispatch each call back:
const result = await runOpenAIToolCall(caps, call, { idempotencyKey: `alice_123:${call.id}` });

Swap the model and you swap the import. There are nine subpath adapters for the major model frameworks, from the OpenAI and Anthropic SDKs to LangChain, LlamaIndex, the Vercel AI SDK, OpenAI Agents and Cloudflare Workers AI, plus a framework neutral JSON Schema dispatcher for the rest. Each one is a zero dependency structural type converter. It converts Vinkius capabilities into your model's tool dialect and back, and it deliberately does not import the vendor SDK, so adding Vinkius to your project pulls in nothing it does not already use.

A working multi user assistant

This is the implementation I would ship. It is a chat handler that answers for one user using that user's own connectors, with the full tool loop: the model asks for a tool, we execute it at the governed runtime, we hand the result back, and we cap the loop so a misbehaving model cannot bill you forever.

import OpenAI from 'openai';
import { Vinkius } from '@vinkius/connect';
import { toOpenAITools, runOpenAIToolCall } from '@vinkius/connect/openai';

const openai = new OpenAI();
const vinkius = new Vinkius({
  appId: process.env.VINKIUS_APP_ID!,
  apiKey: process.env.VINKIUS_APP_KEY!,
});

// Any model your chosen provider serves. The integration is model agnostic:
// nothing below depends on a specific model name.
const MODEL = process.env.OPENAI_MODEL ?? 'your-model-id';

export async function assistantFor(userId: string, question: string) {
  // 1. What can this user's connectors actually do, right now?
  const caps = await vinkius.user(userId).capabilities();
  if (caps.length === 0) {
    return 'You have not connected a service yet. Connect one in the app and I can act on it.';
  }

  // 2. Hand the model this user's capabilities as its own tools.
  const messages = [{ role: 'user', content: question }];
  const tools = toOpenAITools(caps);

  // 3. The tool loop. The model picks, we execute at the runtime, we feed the result back.
  let reply;
  for (let step = 0; step < 4; step++) {
    reply = await openai.chat.completions.create({
      model: MODEL,
      messages,
      tools,
    });

    const msg = reply.choices[0]?.message;
    if (!msg || !msg.tool_calls || msg.tool_calls.length === 0) break;

    messages.push(msg);
    for (const call of msg.tool_calls) {
      // Tool level failures come back as isError results, not throws.
      // Transport level failures throw the SDK's typed error classes.
      const result = await runOpenAIToolCall(caps, call, {
        idempotencyKey: `${userId}:${call.id}`,
      });
      const text = result.content.map((c) => c.text).join('\n');
      messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: result.isError ? `The tool reported an error: ${text}` : text,
      });
    }
  }

  return reply?.choices[0]?.message?.content ?? '';
}

Four things in that block are worth calling out, because they are where a naive version would quietly go wrong.

First, the capability list is fetched per request, per user, and only from connectors that are ready. A user who links a new service gets it on the very next turn without a deploy, and a user who revokes one loses the corresponding tools at the same time. Nothing is cached in a way that outlives the trust relationship.

Second, idempotencyKey on the execution call is what makes the retry safe. The SDK retries transient responses, but a POST that mints a side effect is only retried when you declare the key, because then a replay is deduplicated server side rather than doubled. The key above is namespaced by user and tool call id, so two users acting on the same connector never collide.

Third, the loop cap. An agent that reads a tool result, decides it is not enough, and calls the next tool is how the product does useful work. It is also how a confused model can walk for a very long time. Capping the steps bounds the bill and the blast radius, and the model's plain text answer on the final turn is the thing you return.

Fourth, the error split. A capability that fails at the tool level (the API it wraps returned an error) comes back as a result with isError: true. The model reads that, and can recover, retry a different tool, or explain the failure to the user. A transport or authentication failure throws one of the SDK's typed errors. Two failure shapes, two different handlers, and neither one takes the whole loop down.

Run it on a different framework and only the adapter line changes. For Anthropic it is toAnthropicTools and runAnthropicToolUse. For LangChain you inject the framework's tool factory so the SDK ships no peer dependency at all. The capability side is identical.

Why it is built this way

I keep coming back to a rule I hold the team to: the SDK may not make your app own a secret it cannot see and cannot revoke. Everything about the shape follows from that. Credentials are write only and encrypted at rest, and the platform does not let its own operators read them. Connection tokens are scoped to a single connector, HMAC authenticated, and the plaintext is never stored, so the worst case of a leak is a named, revocable, single connector asset, not a standing pass to the whole estate. Retries are bounded and jittered, and a per request timeout races the body read so a stalled stream cannot hang a worker. The same transport object drives both the control plane and the data plane, which is why the retry, timeout, and redaction semantics can never drift between the two.

The one place I will not soften the language: keep the application keys server side. This is a backend client. The public app id and the secret key belong in your environment, not in a bundle, and the design assumes the user scoping you pass is something your server can prove is the caller. If you are tempted to ship it to the browser to save a round trip, you have moved the problem from integration work to secret management, which is a worse deal.

What it ships as

npm install @vinkius/connect. It is dual ESM and CJS with full types, tree shakable, and it runs on Node 18 and up and on any runtime that has fetch. No runtime dependencies to audit, no lock file bloat, no transitive supply chain to argue with a security team about. The license is Apache 2.0, so the code is yours to keep and to run wherever your app runs.

The capability layer it drives is covered in the post on the capability layer and the catalog, and the execution guarantees it relies on are in the post on running untrusted MCP servers in V8 isolates. This post is the developer surface of both: the client your team writes against.

Ship the feature, not the integration project. That is the whole point, and it is why the SDK has one job: hand your model the tools your user connected, in the dialect your model already speaks, and get out of the way.

Topicsconnectorscapabilitiesmcpagentssdk