Skip to main content

Vercel chat client

A chat screen built on the Vercel AI SDK's useChat talks to the gateway through dyna-ai-gateway/vercel-ui: a server-only module that takes your AIGateway instance and a configuration per chat use case, and speaks the SDK's UI message protocol. Each endpoint of your application becomes one call. The gateway itself knows nothing of the chat client (D40).

1. One instance per use case​

import {AIGateway} from "dyna-ai-gateway";
import {AIGatewayVercelUi} from "dyna-ai-gateway/vercel-ui";

const gateway = new AIGateway(config);

const supportChat = new AIGatewayVercelUi({
mode: "run", // "ask": one grounded answer; "run": an agent run with the profile's tools
userContext: async ({request}) => sessionOf(request), // your authentication; throw a 401 dynaError for nobody
call: {
profileId: "support",
kbFilters: {libraryIds: ["help-center"], bookIds: []},
instructions: "Answer as the support desk of the shop.",
},
}, {gateway});

userContext is the trust boundary of the endpoint: the request body never names the tenant or the user (D118). call holds what every call of the use case shares (the same fields as run(), minus what each request brings); prepareCall overrides it per request, for example the context of the record the user is looking at, carried in the request's body. A second use case, an invoice assistant on another profile and another knowledge base, is a second instance.

2. The endpoints​

// POST /api/chat — what useChat posts
export const POST = (request: Request): Promise<Response> => supportChat.chat({request});

// GET /api/chat/:threadId — the messages to draw when a thread is opened
export const GET = async (request: Request, {params}: {params: {threadId: string}}): Promise<Response> =>
Response.json(await supportChat.history({request, threadId: params.threadId}));

chat reads the last message of the request only: its text parts are the prompt, its file parts the files. The gateway holds the history; the client's copy is never the truth. It returns the SDK's SSE response, so useChat needs no transport option. The thread: the first message of a chat carries no threadId, the gateway creates one, and the stream's metadata names it (message.metadata.threadId); from then on the client sends it in the body:

const {messages, sendMessage} = useChat({
messages: history, // from GET /api/chat/:threadId
transport: new DefaultChatTransport({
api: "/api/chat",
body: {threadId}, // the thread of this screen, once known
}),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});

An error before the first chunk (a 400 of the input, a 404 of another user's thread, a 409 of a thread that waits for a human, the 401 of your hook) is a JSON response with that status; an error once the stream runs is an error chunk. A 5xx never carries its message to the browser.

3. What the client receives​

In the streamMeaning
text-start / text-delta / text-endThe answer's text, one text per model step
tool-input-available, tool-output-available, tool-output-errorA tool call of the run and its result; dynamic: true, render it by toolName
tool-approval-requestA tool call that waits for a human: the SDK's approval card, see § 4
message-metadataThe gateway's results on the message: threadId, usage, citations, issues, structured (a use case with an outputSchema), endedByTool, withdrawn, pause
errorThe call failed: the reason

metadata.withdrawn says the text that streamed was not kept (an output rule failed; the issues are next to it): grey it or drop it, the protocol cannot take text back. In history, one answer is one assistant message with a step-start part per model step (D34); a call the human rejected is a dynamic-tool part in state output-denied with the reason, a call that failed is output-error.

The gateway's own tools are parts like any other, named dyna_aigw_<name> (D125). A client needs no name to find a user question: the envelope of kind USER_QUESTION in metadata.pause carries the callId of its part (toolCallId). A client that does match a built-in by toolName (to show a badge for dyna_aigw_remember_user_fact, to hide dyna_aigw_load_skill) checks its literals against the package's types. dyna-ai-gateway is server-only, so the browser imports the constant as a type, and the import leaves nothing in the bundle:

import type {BUILT_IN_TOOL_NAMES} from "dyna-ai-gateway";

type TBuiltInToolName = (typeof BUILT_IN_TOOL_NAMES)[keyof typeof BUILT_IN_TOOL_NAMES];

// A renamed built-in fails to compile here instead of silently matching nothing
const REMEMBER_USER_FACT: TBuiltInToolName = "dyna_aigw_remember_user_fact";

4. Approvals and the other decisions​

A run that pauses for a tool streams a tool-approval-request and metadata.pause; the message's tool part is in state approval-requested. The SDK's card does the rest: addToolApprovalResponse({id, approved, reason}) and, with sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, useChat posts the messages to the same chat endpoint, which resumes the run and streams the continuation (D35). Every card of the pause is answered in that one request.

What the card cannot express goes through resume: the answers of a user question (the dyna_aigw_ask_user_question call is a dynamic-tool part in state input-available, its input the form to render), an EDIT of the tool's input, a REJECT with the user's own text. The application builds the decisions from metadata.pause (the runId, the pauseId, the envelopes with their allowed actions) and calls:

// POST /api/chat/resume
export const POST = async (request: Request): Promise<Response> => {
const {runId, pauseId, decisions} = await request.clone().json();
return supportChat.resume({request, runId, pauseId, decisions});
};

The pause must be one of the calling user's own paused runs; anything else is a 404. A review of the answer before the user sees it (D99) streams no text and sets metadata.pause with the kind RESULT_REVIEW; the reviewer's screen is AIGateway.getPausedRun, not this module.

5. Files​

Uploads are your own route on AIGateway.uploadFile; the client then names the file in a file part of its message. The files hooks of the config translate both ways: fromUi turns the part into the stored file's pointer, toUrl gives history the url the client loads a stored file from (AIGateway.getFileUrl, a signed url of the storage, or your own download route). Without the hooks, a file part in a request is a 400 and the file parts of a stored thread are left out of history.

6. What stays the gateway's​

The thread list of the sidebar, renaming and deleting (listThreads, renameThread, see the FAQ), the uploads and the reviewer's screen are the gateway's own methods: a chat screen calls both. On another transport than HTTP (a WebSocket, a resumable stream after a reload), compose UiMapper, the pure translation the module is built on, with the gateway's events yourself.