# Building a Chat App with the Vercel AI SDK

The Agent Gateway speaks the [Vercel AI SDK](https://ai-sdk.dev/) UI Message Stream protocol, so you drive a Treasure AI Studio chat with an unmodified open-source client — `useChat` from `@ai-sdk/react`, exactly as its own documentation describes. What Treasure AI adds is a small REST surface for creating a chat, and one tool your application has to implement. There is no Treasure AI client library to install, and none is needed.

This page is the integration guide for the credentials an [SDK App](/products/ai-studio/agent-channels/sdk-apps) hands you: what your server does, what your client does, and the handful of places where getting it wrong fails quietly.

## Prerequisites

- An [SDK App](/products/ai-studio/agent-channels/sdk-apps) and its **Gateway Token** (`tasgwt_…`), plus your account's Agent Gateway URL.
- A server you control. The Gateway Token must never reach a browser, so there is always a server in this design, even if it does nothing but create chats.
- The Vercel AI SDK packages `ai` v7 and `@ai-sdk/react` v4 or later. The gateway implements UI Message Stream **v1**, which those versions speak.


## How the Pieces Fit

Your application talks to two APIs, with a different credential each:

| API  | Called By  | Credential  | Purpose  |
|  --- | --- | --- | --- |
| **Control API** (`/v1/chats/…`) | Your server only | Gateway Token | Create a chat, reissue or revoke its token, check or archive it |
| **Chat Messaging API** (`/v1/messaging/…`) | Your server, or your client | Chat Access Token | Run turns, fetch history, stop generation |


Creating a chat returns both the Chat Access Token and a set of **endpoint URLs** for that chat. Store those URLs as given and pass them through unchanged — they are full, opaque URLs. Never build a messaging URL by string concatenation; what is not in `endpoints` is not published, and building URLs by hand is how an integration breaks when the messaging surface moves.

## Choose Where the Token Lives

Before any code, decide whether your server stays in the streaming path.

| Aspect  | Proxy through your server (recommended)  | Browser calls the gateway directly  |
|  --- | --- | --- |
| **Chat Access Token** | Stays on your server | Held by the browser |
| **CORS** | Nothing to configure — the browser stays on your origin | Requires **Browser access** on for the SDK App |
| **What you keep** | Your authentication, audit logging, rate limits, and any prompt checks run on every turn | Nothing — the gateway sees the browser, not you |
| **Cost** | Your server carries the streaming connections | Your server is out of the hot path |


**Proxying is the recommended shape.** A script running on your page — one a compromised dependency brought in, or an XSS — can read a Chat Access Token the browser holds and call the gateway itself, bypassing everything your server does. With **Browser access** off, that call is refused outright: no browser can suppress its `Origin` header, so nothing running in one reaches the gateway at all. Turning it on is a decision to trust the code in your page. See [Browser access](/products/ai-studio/agent-channels/tokens#browser-access).

The rest of this page assumes the proxy shape.

## Create a Chat

This is the only place the Gateway Token is used.

```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://…" }
// }
```

Store `chat_id`, `token.secret`, and the whole `endpoints` object against your own end user, in a database. Your client only ever needs to learn `chat_id`.

Keep the chat rather than creating a fresh one on every page load. A chat is what history and resume attach to, so an application that starts a new one each time has neither.

When the token expires, the chat does not
A Chat Access Token lasts 24 hours idle, up to 30 days absolute. When it runs out, `POST /v1/chats/{chat_id}/access_tokens` issues a new one for the same chat and the conversation continues. This is why you store the chat id, not only the token.

## Proxy the Messaging API

Your server forwards three routes to the stored `endpoints` and pipes the response straight back. Three things are yours to get right, and each one fails in a way that looks like something else:

- **Propagate the disconnect.** Abort the upstream request when the browser goes away. Watch the **response** object for `close`, not the request — the request closes as soon as its body has been read, which is before the answer has started, so aborting on that kills every turn immediately. Note what this does *not* do: it does not stop generation. Only the stop endpoint does that.
- **Use `pipeline`, not `pipe`.** The abort makes the upstream stream emit `error`; `pipe` does not forward it, and an unhandled `error` event ends the process. A browser leaving mid-turn is an ordinary event, so this crashes the server in normal use.
- **Do not buffer.** Compression middleware and reverse proxies will happily hold a server-sent-events stream until it ends, turning streaming into one late delivery. Set `x-accel-buffering: no` and flush headers immediately.


```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();
  });

  // Build our own headers. Do NOT replay the browser's — see the warning below.
  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();
  }
}
```

Do not forward the browser's Origin header
Build the upstream request's headers yourself. Replaying the browser's headers sends an `Origin` the gateway reads as "this call came from a browser", and with **Browser access** off it answers `403` with *"This chat cannot be called from a browser."* This is the most common wiring mistake.

### Defend the Proxy Against CSRF

Adding a login is what creates this exposure, and it is easy to miss, so it gets its own section. Once your `/api/chats/:id/ai-sdk` route is authorized by a session cookie, a page on another origin can make your user's browser post to it, and your server will faithfully forward the prompt with the Chat Access Token attached. The gateway cannot prevent this — what it sees is your server, correctly credentialled.

The defenses are the ordinary ones:

- `SameSite=Lax` or `Strict` on the session cookie. Current browsers do not send cookies on a cross-site POST, which stops this on its own.
- Reject anything whose `Sec-Fetch-Site` is not `same-origin`. Browsers set it; scripts cannot forge it.
- A CSRF token, if you need to cover older browsers.
- Require `Content-Type: application/json` and parse nothing else, so the request is preflighted.


None of this applies to the Gateway Token, which never leaves your process.

## Wire Up the Client

On the browser side there is nothing Treasure AI-specific about the transport:

```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,              // the turns that are over, fetched before render
  resume: true,                   // attach to a turn still running
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
```

Rendering is the SDK's job from here. Two part types are worth knowing:

- **`dynamic-tool`** — every tool the agent runs. It arrives already executed, with the tool's name in `part.toolName`. The gateway marks these chunks `providerExecuted`, so the SDK suppresses `onToolCall` and you wire nothing: these parts are display only. A worker's toolset is not a list you could declare up front, which is why they are dynamic.
- **`tool-AskUserQuestion`** — sent by name, because it is the one tool your application must answer.


Everything after the connection opens — streaming text, ordering, tool-call progress — is the SDK's, and [its documentation](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot) is your documentation.

## The One Tool You Have to Implement

When the agent needs a decision from a person, it stops and asks. That answer can only come from your application, so it arrives as an ordinary tool call named `AskUserQuestion` and stays at `input-available` until you record an output against it.

The turn that asked ends with `finishReason: "tool-calls"` rather than `"stop"`. Answering starts a new turn whose output joins the same assistant message, so the conversation reads as one answer with a question in the middle.

Each question in `input.questions` carries:

| Field  | Meaning  |
|  --- | --- |
| `question` | The question text. **This is the identity of the question and the key of its answer** |
| `header` | A short label for the card. Never an answer key |
| `multiSelect` | Whether more than one option may be chosen |
| `options[].label` | The choices. The chosen label is the answer value |


Record the answer with `addToolOutput`:

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

answers is keyed by question, not header
Keying by `header` fails silently: the request is accepted, the turn streams and finishes, and the agent, seeing no answer, says so. No error is raised. Build the `answers` object from `questions[].question`.

For the same reason, here is the value to send in every other case:

| Case  | Value  |
|  --- | --- |
| Single choice | The chosen `options[].label` |
| `multiSelect` | The chosen labels joined with `", "` |
| Unanswered | An empty string |


`sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` is what sends the answer. Without it the answer sits in the browser and the agent waits until its five minutes run out. If you do not implement this tool at all, any question stops the conversation until it times out — and the gateway cannot tell whether you have.

## Showing a Conversation the Client Does Not Have

A client that has just loaded holds none of the chat. Two routes give it back, and they split at a message boundary, so asking for both shows nothing twice:

| Route  | Returns  |
|  --- | --- |
| `endpoints.ai_sdk_history` | The turns that are over, as `useChat`'s `messages` |
| `${endpoints.ai_sdk}/{chat_id}/stream` | The turn that is not — replayed from its start, then live. `204` means nothing is running |


Fetch the first before rendering and hand it to `useChat({ messages })`; leave the second to `useChat({ resume: true })`, which asks once on mount.

Resume is the one place you build a URL rather than using a stored one: `useChat` derives it from its own `api` as `${api}/${chatId}/stream`, because the AI SDK's protocol fixes that shape. Your proxy route for it must be built off `ai_sdk` the same way. Build it from the chat id **your session holds**, not from the one in the browser's request — nothing the browser sends should be pasted into a URL your server calls.

A disconnect does not stop a turn, which is what leaves one to attach to. If you want a turn stopped, post to `endpoints.stop`.

## Control API Reference

Base URL is your account's Agent Gateway URL. Every call takes `Authorization: Bearer tasgwt_…`.

| Method and Path  | Returns  |
|  --- | --- |
| `POST /v1/chats` | `201` with `chat_id`, `token`, and `endpoints`. Creates a chat in the channel's folder |
| `POST /v1/chats/{chat_id}/access_tokens` | `201` with a new `token` and `endpoints`. Use when the previous one expired |
| `DELETE /v1/chats/{chat_id}/access_tokens` | `204`. Revokes every Chat Access Token of that chat — a sign-out, without tracking token ids |
| `DELETE /v1/chats/{chat_id}/access_tokens/{token_id}` | `204`. Revokes one |
| `GET /v1/chats/{chat_id}` | `200` with `chat_id`, `archived`, `created_at`, and `endpoints` |
| `DELETE /v1/chats/{chat_id}` | `204`. Archives the chat; its tokens stop with it |


### Messaging API

Called with `Authorization: Bearer tascat_…`, at the URLs in `endpoints`.

| Endpoint  | Returns  |
|  --- | --- |
| `endpoints.ai_sdk` | Server-sent events, `x-vercel-ai-ui-message-stream: v1`. One POST is one turn |
| `endpoints.ai_sdk_history` | A JSON array of completed messages |
| `endpoints.stop` | `204`. Ends the running turn |


### Errors

Every error is `{"errors":[{"message":"…"}]}` with a fixed, non-revealing message. The AI SDK surfaces a non-2xx body verbatim as the client-side exception, so these strings are what your developers will see.

| Status  | Meaning  |
|  --- | --- |
| `401` | The credential is missing, revoked, expired, or the channel is not accepting connections. A Chat Access Token used against a different chat also answers `401`, so chat existence cannot be probed |
| `403` | The call carried an `Origin` header and browser access is off |
| `404` | No such chat |
| `413` | Request body too large |
| `422` | The request did not parse |
| `429` | Rate limited. Honor the `Retry-After` header |
| `502` | Temporarily unavailable. Retry with backoff |


Failures never say why. Build error handling around the status, not the message.

## What Is Not Supported

- **Editing, branching, or re-running history.** The AI SDK re-sends the whole conversation on each turn, but the agent holds the session; only the trailing user message is acted on. Earlier messages you change are ignored.
- **Choosing a model, or changing session options.** Those verbs are not in the protocol the gateway speaks.
- **WebSocket transport.** Server-sent events only.
- **AG-UI and A2A.** The UI Message Stream is the only protocol published today.
- **Tool output in the client.** Your client sees that a tool ran and with what arguments, not what it returned. See [What an Agent Channel Chat Can Reach](/products/ai-studio/agent-channels/channel-chats#what-your-application-receives).


## Next Steps

- [Gateway Tokens and Chat Access Tokens](/products/ai-studio/agent-channels/tokens) — lifetimes, rotation, and revocation.
- [What an Agent Channel Chat Can Reach](/products/ai-studio/agent-channels/channel-chats) — the boundary your application is working inside, and the limits it will hit.
- [Creating and Managing an SDK App](/products/ai-studio/agent-channels/sdk-apps) — issuing a replacement token.