# Vercel AI SDK でチャットアプリを作る

Agent Gateway は [Vercel AI SDK](https://ai-sdk.dev/) の UI Message Stream プロトコルに対応しているため、Treasure AI Studio のチャットは OSS のクライアント — `@ai-sdk/react` の `useChat` — に手を加えることなくそのまま扱えます。公式ドキュメントに書かれている使い方がそのまま通用します。Treasure AI 側が追加しているのは、チャットを作成するための小さな REST インターフェースと、アプリケーションが実装する必要のあるツール1つだけです。インストールすべき Treasure AI 製のクライアントライブラリはなく、必要もありません。

このページは、[SDK App](/ja/products/ai-studio/agent-channels/sdk-apps) から受け取った資格情報のための統合ガイドです。サーバーが何をするか、クライアントが何をするか、そして間違えると静かに壊れる数カ所を説明します。

## 前提条件

- [SDK App](/ja/products/ai-studio/agent-channels/sdk-apps) とその **Gateway Token**（`tasgwt_…`）、そしてアカウントの Agent Gateway URL。
- 自分で管理するサーバー。Gateway Token をブラウザに到達させてはいけないため、この構成には必ずサーバーが存在します。たとえチャットを作成するだけの役割でも必要です。
- Vercel AI SDK の npm パッケージ `ai` v7 以降と `@ai-sdk/react` v4 以降。ゲートウェイが実装しているのは UI Message Stream の **v1** で、これらのバージョンが対応するものです。


## 全体の構成

アプリケーションが呼ぶ API は2種類あり、それぞれ使う資格情報が異なります。

| API  | 呼び出し元  | 資格情報  | 用途  |
|  --- | --- | --- | --- |
| **コントロール API**（`/v1/chats/…`） | 自社サーバーのみ | Gateway Token | チャットの作成、トークンの再発行・無効化、状態の確認、アーカイブ |
| **チャットメッセージング API**（`/v1/messaging/…`） | 自社サーバー、またはクライアント | Chat Access Token | ターンの実行、履歴の取得、生成の停止 |


チャットを作成すると、Chat Access Token とそのチャット用の**接続先 URL** 一式が返ります。この URL は返ってきた値のまま保存して使ってください。完全な URL であり、中身を解釈する必要はありません。メッセージング用の URL を文字列連結で組み立てないでください。`endpoints` に無いものは公開されていないものであり、URL を手で組むと、メッセージングの提供先が将来変わったときに統合が壊れます。

## トークンをどこに置くか決める

コードを書く前に、サーバーをストリーミングの経路に置くかどうかを決めます。

| 観点  | サーバーで中継する（推奨）  | ブラウザがゲートウェイを直接呼ぶ  |
|  --- | --- | --- |
| **Chat Access Token** | サーバーに留まる | ブラウザが保持する |
| **CORS** | 設定するものがない — ブラウザは自分のオリジンに留まる | SDK App の**ブラウザアクセス**をオンにする必要がある |
| **自社の統制が効く範囲** | 自社の認証、監査ログ、レート制限、プロンプト検査がすべてのターンで効く | 何もない — ゲートウェイが見るのは自社サーバーではなくブラウザ |
| **コスト** | サーバーがストリーミング接続を保持し続ける | サーバーはストリーミングの経路から外れる |


**中継する構成が推奨です。** ページ上で動くスクリプト — 侵害された依存パッケージが持ち込んだものや XSS — は、ブラウザが保持している Chat Access Token を読み取ってゲートウェイを直接呼べるため、サーバーで行っていることすべてを迂回できます。**ブラウザアクセス**がオフなら、その呼び出しは拒否されます。ブラウザは `Origin` ヘッダーを抑制できないので、ブラウザ内で動くものはゲートウェイに一切到達しません。オンにすることは、ページ内のコードを信頼するという判断です。[ブラウザアクセス](/ja/products/ai-studio/agent-channels/tokens#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9)を参照してください。

以降は中継する構成を前提に説明します。

## チャットを作成する

Gateway Token を使うのはここだけです。

```ts
const created = await fetch(`${AGENT_GATEWAY_URL}/v1/chats`, {
  method: "POST",
  headers: { authorization: `Bearer ${GATEWAY_TOKEN}` },
});

const chat = await created.json();
// {
//   chat_id: "…",
//   token: { id: "…", secret: "tascat_…", expires_at: "2026-10-21T00:00:00Z" },
//   endpoints: { ai_sdk: "https://…", ai_sdk_history: "https://…", stop: "https://…" }
// }
```

`chat_id`、`token.secret`、そして `endpoints` オブジェクト全体を、自社のエンドユーザーに紐づけてデータベースに保存してください。クライアントが知る必要があるのは `chat_id` だけです。

ページを読み込むたびに新しいチャットを作るのではなく、チャットを保持してください。履歴と再開はチャットに紐づくため、毎回新規に作るアプリケーションはそのどちらも持てません。

トークンが期限切れになってもチャットは残ります
Chat Access Token は未使用のまま24時間、絶対上限は30日です。切れたときは `POST /v1/chats/{chat_id}/access_tokens` で同じチャットに対して新しいトークンを発行すれば、会話は続きます。トークンだけでなくチャット ID を保存しておくのはこのためです。

## メッセージング API を中継する

サーバーは3つのルートを保存済みの `endpoints` に転送し、レスポンスをそのまま返します。自分で正しく実装する必要があるのは3点で、いずれも別の問題のように見える壊れ方をします。

- **切断を伝播させる。** ブラウザが切断したら上流のリクエストを中断してください。`close` を監視する対象は**レスポンス**オブジェクトで、リクエストではありません — リクエストはボディを読み終えた時点で閉じ、それは回答が始まる前なので、そこで中断するとすべてのターンが直ちに打ち切られます。ただし、これは生成を止めるものではありません。ターンを止めるのは stop エンドポイントだけです。
- **`pipe` ではなく `pipeline` を使う。** 上記の中断で上流のストリームが `error` を発しますが、`pipe` はそれを転送せず、処理されない `error` イベントはプロセスを終了させます。ブラウザがターンの途中で離脱するのは普通のことなので、通常の運用中にクラッシュするということです。
- **バッファリングさせない。** 圧縮ミドルウェアやリバースプロキシは Server-Sent Events のストリームを終了まで保持してしまい、ストリーミングが「最後にまとめて1回」に変わります。`x-accel-buffering: no` を設定し、ヘッダーを直ちにフラッシュしてください。


```ts
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";

async function forward(req, res, session, url, body) {
  const controller = new AbortController();
  res.on("close", () => {
    if (!res.writableEnded) controller.abort();
  });

  // ヘッダーは明示的に指定する。ブラウザのヘッダーを再送してはいけない（下の警告を参照）
  const upstream = await fetch(url, {
    method: req.method,
    headers: {
      authorization: `Bearer ${session.accessToken}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
    signal: controller.signal,
  });

  res.status(upstream.status);
  for (const header of ["content-type", "cache-control", "x-vercel-ai-ui-message-stream"]) {
    const value = upstream.headers.get(header);
    if (value) res.setHeader(header, value);
  }
  res.setHeader("x-accel-buffering", "no");
  res.flushHeaders();

  if (!upstream.body) return void res.end();

  try {
    await pipeline(Readable.fromWeb(upstream.body), res);
  } catch {
    res.destroy();
  }
}
```

ブラウザの Origin ヘッダーを転送しないでください
上流リクエストのヘッダーは、ブラウザから受け取ったものを流用せず、明示的に指定してください。ブラウザのヘッダーを再送すると、ゲートウェイが「この呼び出しはブラウザから来た」と解釈する `Origin` が送られ、**ブラウザアクセス**がオフなら *「This chat cannot be called from a browser.」* という内容の `403` が返ります。実装のつまずきとして最も多いパターンです。

### 中継エンドポイントを CSRF から守る

この問題はログインを追加した時点で生まれます。見落としやすいため、個別に説明します。`/api/chats/:id/ai-sdk` をセッション Cookie で認可するようにすると、別オリジンのページからユーザーのブラウザに POST させることができ、サーバーは Chat Access Token を付けて忠実にプロンプトを転送します。ゲートウェイ側では防げません — ゲートウェイから見えるのは、正しい資格情報を持った自社サーバーです。

対策は一般的なものです。

- セッション Cookie に `SameSite=Lax` か `Strict` を付ける。現在のブラウザはクロスサイトの POST で Cookie を送らないため、これだけでも止まります。
- `Sec-Fetch-Site` が `same-origin` でないリクエストを拒否する。ブラウザが設定する値で、スクリプトから偽装できません。
- 古いブラウザまで守る必要があれば CSRF トークンを使う。
- `Content-Type: application/json` を必須にし、それ以外はパースしない。リクエストが preflight の対象になります。


これらはいずれも Gateway Token には関係しません。Gateway Token はプロセスの外に出ないためです。

## クライアントを実装する

ブラウザ側のトランスポートに Treasure AI 固有のものはありません。

```tsx
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";

const { messages, sendMessage, status, addToolOutput } = useChat({
  transport: new DefaultChatTransport({ api: `/api/chats/${chatId}/ai-sdk` }),
  messages: history,              // 終了済みのターン。描画前に取得しておく
  resume: true,                   // 実行中のターンに接続する
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
```

以降の描画は SDK の仕事です。知っておく価値があるパートの型は2つあります。

- **`dynamic-tool`** — エージェントが実行するすべてのツール。実行済みの状態で届き、ツール名は `part.toolName` に入っています。ゲートウェイはこれらのチャンクに `providerExecuted` を付けるので、SDK 側で `onToolCall` は抑制されます。実装は必要ありません。つまりこのパートは表示専用です。ワーカーのツールセットは事前に宣言できる一覧ではないので、dynamic になっています。
- **`tool-AskUserQuestion`** — 名前付きで送られます。アプリケーションが答える必要がある唯一のツールだからです。


接続が開いた後のこと — テキストのストリーミング、順序、ツール呼び出しの進行 — はすべて SDK 側の担当で、[SDK のドキュメント](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot)がそのまま参照先になります。

## 実装が必要な唯一のツール

エージェントは人間の判断が必要になると、処理をいったん止めて質問します。その答えはアプリケーションからしか来ないので、`AskUserQuestion` という名前の通常のツール呼び出しとして届き、出力を記録するまで `input-available` の状態に留まります。

質問して終わったターンは、`finishReason` が `"stop"` ではなく `"tool-calls"` になります。答えると新しいターンが始まり、その出力は同じアシスタントメッセージに合流するため、会話は「途中に質問が挟まった1つの回答」として読めます。

`input.questions` の各質問が持つフィールドは次のとおりです。

| フィールド  | 意味  |
|  --- | --- |
| `question` | 質問の文面。**これが質問の識別子であり、答えのキーです** |
| `header` | カード用の短いラベル。答えのキーには決してなりません |
| `multiSelect` | 複数選択できるかどうか |
| `options[].label` | 選択肢。選ばれた label が答えの値になります |


答えは `addToolOutput` で記録します。

```tsx
addToolOutput({
  tool: "AskUserQuestion",
  toolCallId: part.toolCallId,
  output: { answers: { "Which colour do you prefer?": "Blue" } },
});
```

answers のキーは question です。header ではありません
`header` をキーにすると静かに失敗します。リクエストは受理され、ターンはストリーミングされて完了し、エージェントには答えが見えないため、その旨を回答します。捕捉できるエラーは発生しません。`answers` オブジェクトは `questions[].question` から組み立ててください。

同様に、その他のケースの値の形式も示します。

| ケース  | 値  |
|  --- | --- |
| 単一選択 | 選ばれた `options[].label` |
| `multiSelect` | 選ばれた label を `", "` で連結した文字列 |
| 未回答 | 空文字列 |


答えを実際に送信するのは `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` です。これがないと答えはブラウザに留まり、エージェントは5分のタイムアウトまで待ちます。このツールをまったく実装しない場合、質問が出た時点で会話はタイムアウトまで止まります。実装されているかどうかをゲートウェイ側から知る手段はありません。

## クライアントが持っていない会話を表示する

開いたばかりのクライアントは、会話の内容を何も持っていません。これを復元する方法は2つあり、メッセージの境界で分かれているので、両方に問い合わせても二重に表示されることはありません。

| ルート  | 返るもの  |
|  --- | --- |
| `endpoints.ai_sdk_history` | 終了済みのターン。`useChat` の `messages` の形式 |
| `${endpoints.ai_sdk}/{chat_id}/stream` | 終了していないターン — 開始時点からリプレイし、その後ライブに繋がります。`204` は実行中のものがないことを意味します |


前者は描画前に取得して `useChat({ messages })` に渡し、後者は `useChat({ resume: true })` に任せます。後者は初回レンダリング時に一度だけ問い合わせます。

resume は、保存済みの URL ではなく自分で URL を組む唯一の箇所です。`useChat` は自身の `api` から `${api}/${chatId}/stream` という形で導出します。AI SDK のプロトコルがその形を固定しているためです。中継用のルートも同じ形で `ai_sdk` から組んでください。組み立てに使うチャット ID は**セッションが保持しているもの**で、ブラウザのリクエストに入っていたものではありません。ブラウザから送られてきた値を、サーバーが呼ぶ URL に埋め込むべきではありません。

切断してもターンは止まらないため、再接続できるターンが残ります。ターンを止めたい場合は `endpoints.stop` に POST してください。

## コントロール API リファレンス

ベース URL はアカウントの Agent Gateway URL です。どの呼び出しにも `Authorization: Bearer tasgwt_…` が必要です。

| メソッドとパス  | 返り値  |
|  --- | --- |
| `POST /v1/chats` | `201` と `chat_id`、`token`、`endpoints`。チャネルのフォルダにチャットを作成します |
| `POST /v1/chats/{chat_id}/access_tokens` | `201` と新しい `token`、`endpoints`。前のトークンが期限切れになったときに使います |
| `DELETE /v1/chats/{chat_id}/access_tokens` | `204`。そのチャットのすべての Chat Access Token を無効化します。トークン ID を管理せずにサインアウトを実装できます |
| `DELETE /v1/chats/{chat_id}/access_tokens/{token_id}` | `204`。1本だけ無効化します |
| `GET /v1/chats/{chat_id}` | `200` と `chat_id`、`archived`、`created_at`、`endpoints` |
| `DELETE /v1/chats/{chat_id}` | `204`。チャットをアーカイブします。配下のトークンも一緒に止まります |


### メッセージング API

`Authorization: Bearer tascat_…` を付けて、`endpoints` の URL に対して呼び出します。

| エンドポイント  | 返り値  |
|  --- | --- |
| `endpoints.ai_sdk` | Server-Sent Events、`x-vercel-ai-ui-message-stream: v1`。1回の POST が1回のターンです |
| `endpoints.ai_sdk_history` | 完了済みメッセージの JSON 配列 |
| `endpoints.stop` | `204`。実行中のターンを終了します |


### エラー

どのエラーも `{"errors":[{"message":"…"}]}` の形で、内容を明かさない固定の文面が入ります。AI SDK は 2xx 以外のボディをそのままクライアント側の例外のメッセージに使うため、開発者が目にするのはこの文字列です。

| ステータス  | 意味  |
|  --- | --- |
| `401` | 資格情報が無い、無効化済み、期限切れ、またはチャネルが接続を受け付けていません。別のチャットに対して Chat Access Token を使った場合も `401` になります。チャットの存在を探れないようにするためです |
| `403` | `Origin` ヘッダーが付いた呼び出しで、ブラウザアクセスがオフです |
| `404` | そのチャットは存在しません |
| `413` | リクエストボディが大きすぎます |
| `422` | リクエストをパースできませんでした |
| `429` | レート制限。`Retry-After` ヘッダーに従ってください |
| `502` | 一時的に利用できません。バックオフして再試行してください |


失敗の理由は決して返りません。エラー処理はステータスに対して組み、メッセージには依存しないでください。

## サポートされないもの

- **履歴の編集・分岐・再実行。** AI SDK は毎回の会話全体を送り直しますが、セッションを保持しているのはエージェント側なので、実際に処理されるのは末尾のユーザーメッセージだけです。それより前のメッセージを変更しても無視されます。
- **モデルの選択、セッションオプションの変更。** これらの操作は、ゲートウェイが対応するプロトコルに含まれていません。
- **WebSocket トランスポート。** Server-Sent Events のみです。
- **AG-UI と A2A。** 現在公開されているプロトコルは UI Message Stream だけです。
- **クライアント側でのツール出力。** クライアントにはツールが動いたことと引数が見えますが、何を返したかは見えません。[Agent Channel のチャットが到達できる範囲](/ja/products/ai-studio/agent-channels/channel-chats#%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%8C%E5%8F%97%E3%81%91%E5%8F%96%E3%82%8B%E3%82%82%E3%81%AE)を参照してください。


## 次のステップ

- [Gateway Token と Chat Access Token](/ja/products/ai-studio/agent-channels/tokens) — 有効期間、ローテーション、無効化
- [Agent Channel のチャットが到達できる範囲](/ja/products/ai-studio/agent-channels/channel-chats) — アプリケーションが動く境界と、ぶつかる制限値
- [SDK App の作成と管理](/ja/products/ai-studio/agent-channels/sdk-apps) — 代替トークンの発行