AI Connect/How to create/マルチユーザー消費者向けチャットボット

マルチユーザー消費者向けチャットボット

VinkiusについてAIに質問

単一の Application key から、何千人ものユーザーを、それぞれが自身の GitHub、Slack、Gmail を分離された状態で接続した状態でサービスする 1 つのチャットボットルートを構築し、AI Connect SDK で OpenAI に接続します。これを届けたプラットフォームはかつてありません。すべてのユーザーが自分のアカウントを持ち込み、あなたはトークンを一切保存しません。

これはほとんどのチームが最初に挑む構成であり、業界が安価にすることに成功したことのない構成です。すなわち、何千人ものユーザーがそれぞれ自身の GitHub、Slack、Gmail を、すべて分離された状態で、単一の Application key から接続する、1 つのチャットボットルートです。あなたのプロダクトのすべてのユーザーは、Vinkius のカタログ全体を背負って入場します。初日から数千の AI 接続、あなたが構築する統合はゼロ、あなたが保存するトークンはゼロ、誰にも露出するアイデンティティはゼロ。これがこのページが手渡す未来であり、バックエンド約 80 行に収まります。

代替案、つまり競合が今も生きている道筋は、業界の標準回答です。OAuth フローの大群、ユーザーごとの鍵分離を備えた暗号化トークンストア、分散ロックつきのリフレッシュスケジューラ、そしてエンタープライズ契約の前で不合格になるセキュリティアンケート。その自社構築ルートの分析は、3年間で20万から25万ドル、最初のツールコールが動くまでに640時間以上のエンジニアリングと見積もっています。あなたのアシスタントは、ユーザーのリポジトリにイシューを作成し、その人の未読 Slack を要約し、その人のカレンダーに予定を予約します。モデルが難しかったのではありません。難しかったのは接続性であり、AI Connect SDK ではそれはすでに完成しています。

ここで「ユーザー」とは文字どおり、あなたのプロダクトにアカウントを持つ人間のことです。その人の external_id は、ログイン処理がすでに渡している値です。

Agent loop · one turn
One user turn of the quickstart, exactly as the console serves it. Click a step or press Run.
vinkius.user('alice_123').capabilities({ include: ['github'] })
HTTPGET /apps/vk_app_xxx/users/alice_123/tools?connector=github
const 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
  ...
Step 1 of 6
The agent loop, step by step. Press Run and follow one user turn: capabilities load, convert to tools, the model calls github__create_issue, the SDK executes on that user connection and the result feeds back. Every step shows the real SDK call and its HTTP request.

上の Run を押すと、1 ユーザー分のターンが最初から最後まで確認できます。接続済みユーザー向けに capabilities が読み込まれ、tools に変換され、モデルが github__create_issue を選び、SDK はそのユーザー自身の接続に対して実行されます。ユーザー id を入れ替えるとすべてのペインが変わります。この分離こそがプロダクト全体です。

The catalogthousands from day onePOST /chatone route, one keyAny modelOpenAI · Anthropicalice_123own connectionsbob_42own connectionscarol_7own connections+ thousandsof userstools
One route, every user isolated: each person connects their own accounts from the day-one catalog, and the model sees only that user's tools.

最終的に得られるもの

単一の POST /chat エンドポイント。userId とメッセージを与えられると、次のことを行います:

  • そのユーザーが接続した capabilities だけを返し、
  • それらを OpenAI に tools として渡し、
  • モデルが選んだツールを実行し、
  • そのすべてをサーバー上で行うため、認証情報が外部に漏れることはありません。

1. アプリ全体でクライアントを 1 つ

Vinkius インスタンスは正確に 1 つだけ作成します。これは現在のユーザーではなく、アプリケーション設定を保持します。単一のモジュールからインポートしてください。

typescript
// 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_…  (サーバー専用)
  timeoutMs: 20_000,
});

構築時にはリクエストは発生しません。認証情報のプレフィックスを検証するだけです。単一のインスタンスをすべてのリクエストで使い回すのが意図されたパターンです。

2. 認証してからアクターを識別する

リクエストボディの userId を決して信用しないでください。セッションから解決し、それを SDK に渡します。SDK にメールアドレスや名前は不要です、id はオペークであり、Vinkius は顧客が誰であるかについて何も知りません。

typescript
import { vinkius } from './vinkius';

// あなたの認証は、ユーザー作成時に使用した安定した id を返します
function requireUser(req: Request): string {
  const userId = req.headers.get('x-user-id');
  if (!userId) throw new Error('not authenticated');
  return userId; // 例: "alice_123"
}
typescript
const user = vinkius.user(requireUser(req)); // 遅延評価: ネットワーク呼び出しゼロ

3. ユーザーが「Connect GitHub」をクリックしたらアカウントを接続する

プロダクトには薄いプロビジョニング用ルートを用意します。connect() は冪等(get-or-create)で、認証情報は書き込み専用として保存されます。レスポンスは設定済みのフィールドを報告し、値を返すことは決してありません。

typescript
// POST /connect/github  { token }
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 });

  return { status: await github.status(), requires: Object.keys(schema) };
}

credentials.schema() はカタログを読み取り、接続を必要としないため、ユーザーが接続する前に正しいフォームフィールドを描画できます。OAuth コネクタには設定するものがありません、connect() はプロバイダーの同意後に戻り、ステータスが ready になります。

4. このユーザーの capabilities のみを読み込む

1 つの呼び出しで、アクターの準備ができたすべてのコネクタを集約します。並行的に展開され、フォールトトレラントです。不安定なコネクタは劣化しても、ターンを失敗させることはありません。

typescript
const capabilities = await user.capabilities({
  include: ['github', 'slack', 'gmail'],   // このプロダクトが使うものにスコープを限定
  onConnectorError: (slug, error) => {
    console.warn('connector skipped', slug, (error as Error).message);
  },
});

if (capabilities.length === 0) {
  // まだ何も接続されていません — ユーザーにアカウントの接続を促してください
}

2 人のユーザーが同じ GitHub 統合を接続しても、完全に別々の接続・認証情報・capabilities が得られます。何も境界を越えず、こうした分離ロジックは 1 行も書く必要がありません。

5. capabilities をモデルに引き渡す

「どのモデルでも動く」という約束が実現するのはここです。/openai アダプターは capability のセットを OpenAI が期待する tools 配列に変換し、返されたツールコールをユーザー単位のスコープ付き接続へディスパッチし直します。

typescript
// 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 ?? '' };
  }

  // このユーザーの接続に対して実行。エラーはデータとして返る
  const result = await runOpenAIToolCall(capabilities, call);
  return { tool: call.function.name, result };
}

Anthropic、Gemini、Vercel AI SDK、LangChain、LlamaIndex、Cloudflare Workers AI、または任意のランタイム向けの中立な JSON-Schema ブリッジについては、Framework アダプターを参照してください。変換の 1 行だけが異なります。

6. 完了するまでエージェントをループさせる

実際の会話では複数のツールが呼ばれます。結果をフィードバックに戻し、モデルに完了させましょう:

typescript
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コネクタの帰結(アクションが失敗した)であり、クラッシュではありません。失敗した内容をモデルに返すことが、まさにリトライ、別のツールの選択、ユーザーへの伝達といった回復を可能にします。スローされる VinkiusError サブクラス、認証、クォータ、トランスポート、用に try/catch をとっておいてください。エラーハンドリングを参照してください。

7. 「まだ未接続」からの回復

ユーザーがアカウントを接続していない場合、findCapability は何も返さないか、実行が ConnectorNotConnectedError をスローします。これを 500 エラーではなく、プロダクトの機会に変えてください:

typescript
import { ConnectorNotConnectedError } from '@vinkius/connect';

try {
  const result = await runOpenAIToolCall(capabilities, call);
} catch (error) {
  if (error instanceof ConnectorNotConnectedError) {
    return { needsConnection: error.message };
    // UI:「それには GitHub を接続してください」→ あなたの /connect/github ルート
  }
  throw error;
}

プロトタイプをプロダクトに変える高度なパターン

基本は動きます。SDK がすでに備えている 4 つの機能が、初期プロトタイプと本番システムを分けます。

すべての書き込みを冪等・範囲限定・キャンセル可能にする

ディスパッチ ヘルパー(runOpenAIToolCall)はモデルが選んだツールを実行しますが、冪等性キー、呼び出しごとのタイムアウト、中止シグナルは添付しません。世界を変えるものに対しては、capability を自分で解決し、これらの制御を直接 execute() に渡してください:

typescript
const capability = capabilities.findCapability(call.function.name);

const result = await capability?.execute(
  JSON.parse(call.function.arguments || '{}'),
  {
    idempotencyKey: `chat:${messageId}`, // リトライされたターンが重複したイシューを作成することはない
    timeoutMs: 15_000,                    // 遅いツールには独自の締切を与える
    signal,                   // ユーザーがタブを閉じた: 実行を中止して追加のコストの発生を止める
  },
);

idempotencyKey を宣言することは、非冪等な POST をリトライ安全にするまさにものです。その呼び出しに対する SDK のトランスポートリトライが有効になり、サーバー側で再生が重複排除されます。これがないと、一時的な 429/502/503/504 は書き込みでリトライされません。

runtime_url をシークレットとして扱う

connect() は、このユーザーのデータプレーン トークン vk_live_* を埋め込んだ Connection を返します。これは一度だけ渡され、すべての呼び出しを認証します。ログに残すことも、ブラウザに永続化することも、モデルのプロンプトにインライン化することも絶対にやめてください。可視性のために必要ありません:hooks を登録すると、SDK はコールバックが実行される前に Authorization、認証情報風のフィールド、vk_live_* パスを除去します。

typescript
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),
  },
});

モデルに適合するツール名を保つ

github__create_issue のような namespace 付きの名前が OpenAI の 64 文字 [A-Za-z0-9_-] 規則に違反する場合、toOpenAITools は会話途中の不可解なプロバイダー 400 ではなく、事前に ConfigError をスローします。コネクタの名前が長い場合は、構築時に namespace を縮小してください:

typescript
new Vinkius({
  appId,
  apiKey,
  namespaceCapability: (connector, name) =>
    `${connector}_${name}`.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64),
});

構造化出力があるときはそれを読む

一部の capability はテキストとともに構造化データを返します。result.structuredContent はそれをそのまま公開します(SDK は解析しません)。これにより「未読 Slack を要約して」というツールは、再解析が必要な文字列ではなく、クリーンなオブジェクトを UI に渡すことができます:

typescript
const result = await capability!.execute(args);
const data = result.structuredContent; // コネクタが提供する場合は型付きオブジェクト

本番チェックリスト

  • [ ] SDK はサーバー上でのみ実行します。ブラウザ/モバイルは Vinkius ではなくあなたのルートを呼び出します。
  • [ ] external_id は認証済みセッションから取得し、クライアント入力からは絶対に取得しません。
  • [ ] 安定した id(DB キー)を割り出し、/\、空白を含めず 255 文字以内に保ってください。
  • [ ] capabilities()include を渡します。モデルにはこのプロダクトが使うツールだけが見えます。
  • [ ] すべての変更を伴う呼び出しに安定した idempotencyKey を与え、リトライによる重複実行を防ぎます。
  • [ ] プランでセグメント化するなら、vinkius.user(id).ensure({ plan }) で非シークレットのメタデータを付与します。

これで、すべてのユーザーにサービスする単一のチャットボットルートができました。各ユーザーは分離された独自のコネクタとケイパビリティを持ち、任意のモデルに接続されます。競合が今も手作業で構築し続ける6桁の統合レイヤーを、あなたは鍵1つ、SDK 1つ、そしてある午後で置き換えました。あなたはプロダクトで競います。土管の工事は終わっています。

What you just got

Not a pitch: the properties this build inherits automatically.

Isolation by construction

Connections and capabilities resolve only inside one external_id. No cross-actor leakage is possible, and you wrote none of that enforcement.

Write-only credentials

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.

Metered, revocable spend

Every connection owns a vk_live_* token, so cost and revocation are per connection. One call to disconnect() is a complete, auditable stop.

Any model runtime

One CapabilitySet converts to OpenAI, Anthropic, Gemini, Vercel AI SDK, LangChain, LlamaIndex, Workers AI or neutral JSON Schema. Only the last line changes.

Production safety built in

idempotencyKey, timeoutMs and AbortSignal per call; automatic full-jitter retries on transient failures; typed VinkiusError branches. No bespoke harness.

One route, every customer

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.

Download SKILL.md6 · Available in your language
What this chatbot build inherits, plus the SKILL.md, in your language, for your coding agent.

次のステップ