The Vercel AI SDK is a TypeScript toolkit for building AI-powered applications. It provides streaming responses, framework-agnostic support for React, Next.js, Vue, and more, plus straightforward switching between AI providers. This guide uses Vercel AI SDK v6.
In this guide, you’ll build a browser-based chatbot that uses Arcade’s Gmail and Slack . Your can read emails, send messages, and interact with Slack through a conversational interface with built-in authentication.
Outcomes
Build a Next.js chatbot that integrates Arcade with the Vercel AI SDK
You will Learn
How to retrieve Arcade and convert them to Vercel AI SDK format
How to build a streaming chatbot with calling
How to handle Arcade’s authorization flow in a web app
How to combine tools from different Arcade servers
Before diving into the code, here are the key Vercel AI SDK concepts you’ll use:
streamText Streams AI responses with support for calling. Perfect for chat interfaces where you want responses to appear progressively.
useChat A React hook that manages chat state, handles streaming, and renders results. It connects your frontend to your API route automatically.
Tools Functions the AI can call to perform actions. Vercel AI SDK uses Zod schemas for type-safe definitions. Arcade’s toZodToolSet handles this conversion for you.
The ARCADE_USER_ID is your app’s internal identifier for the (often the email you signed up with, a UUID, etc.). Arcade uses this to track authorizations per user.
Create the API route
Create app/api/chat/route.ts. Start with the imports:
TypeScript
app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";import { streamText, convertToModelMessages, stepCountIs } from "ai";import { Arcade } from "@arcadeai/arcadejs";import { toZodToolSet, executeOrAuthorizeZodTool,} from "@arcadeai/arcadejs/lib";
What these imports do:
streamText: Streams AI responses with calling support
convertToModelMessages: Converts chat messages to the format the AI model expects
stepCountIs: Controls how many -calling steps the AI can take
Arcade: The for fetching and executing
toZodToolSet: Converts Arcade to Zod schemas (required by Vercel AI SDK)
executeOrAuthorizeZodTool: Handles and returns authorization URLs when needed
Configure which tools to use
Define which MCP servers and individual tools your chatbot can access:
TypeScript
app/api/chat/route.ts
const config = { // Get all tools from these MCP servers mcpServers: ["Slack"], // Add specific individual tools individualTools: ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"], // Maximum tools to fetch per MCP server toolLimit: 30, // System prompt defining the assistant's behavior systemPrompt: `You are a helpful assistant that can access Gmail and Slack.Always use the available tools to fulfill user requests. Do not tell users to authorize manually - just call the tool and the system will handle authorization if needed.For Gmail:- To find sent emails, use the query parameter with "in:sent"- To find received emails, use "in:inbox" or no queryAfter completing any action (sending emails, Slack messages, etc.), always confirm what you did with specific details.IMPORTANT: When calling tools, if an argument is optional, do not set it. Never pass null for optional parameters.`,};
You can mix MCP servers (which give you all their tools) with individual
tools. Browse the complete MCP server catalog to
see what’s available.
Write the tool fetching logic
This function retrieves tools from Arcade and converts them to Vercel AI SDK format. The toVercelTools adapter converts Arcade’s tool format to match what the Vercel AI SDK expects, and stripNullValues prevents issues with optional parameters:
TypeScript
app/api/chat/route.ts
// Strip null and undefined values from tool inputs// Some LLMs send null for optional params, which can cause tool failuresfunction stripNullValues( obj: Record<string, unknown>): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj)) { if (value !== null && value !== undefined) { result[key] = value; } } return result;}// Adapter to convert Arcade tools to Vercel AI SDK v6 formatfunction toVercelTools(arcadeTools: Record<string, unknown>): Record<string, unknown> { const vercelTools: Record<string, unknown> = {}; for (const [name, tool] of Object.entries(arcadeTools)) { const t = tool as { description: string; parameters: unknown; execute: Function }; vercelTools[name] = { description: t.description, inputSchema: t.parameters, // AI SDK v6 uses inputSchema, not parameters execute: async (input: Record<string, unknown>) => { const cleanedInput = stripNullValues(input); return t.execute(cleanedInput); }, }; } return vercelTools;}async function getArcadeTools(userId: string) { const arcade = new Arcade(); // Fetch tools from MCP servers const mcpServerTools = await Promise.all( config.mcpServers.map(async (serverName) => { const response = await arcade.tools.list({ toolkit: serverName, limit: config.toolLimit, }); return response.items; }) ); // Fetch individual tools const individualToolDefs = await Promise.all( config.individualTools.map((toolName) => arcade.tools.get(toolName)) ); // Combine and deduplicate const allTools = [...mcpServerTools.flat(), ...individualToolDefs]; const uniqueTools = Array.from( new Map(allTools.map((tool) => [tool.qualified_name, tool])).values() ); // Convert to Arcade's Zod format, then adapt for Vercel AI SDK const arcadeTools = toZodToolSet({ tools: uniqueTools, client: arcade, userId, executeFactory: executeOrAuthorizeZodTool, }); return toVercelTools(arcadeTools);}
The executeOrAuthorizeZodTool factory is key here. It automatically handles authorization. When a tool needs the user to authorize access (like connecting their Gmail), it returns an object with authorization_required: true and the URL they need to visit.
Create the POST handler
Handle incoming chat requests by streaming AI responses with tools:
This endpoint lets the frontend poll for authorization completion, creating a seamless experience where the chatbot automatically retries after the user authorizes.
Build the chat interface
AI Elements provides pre-built components for conversations, messages, and input—we just need to add custom handling for Arcade’s OAuth flow. Replace the contents of app/page.tsx with the following code:
The AuthPendingUI component polls for OAuth completion and calls onAuthComplete when the user finishes authorizing.
Create the Chat component
The Conversation component handles auto-scrolling, Message handles role-based styling, and MessageResponse renders markdown automatically. We just need to check for Arcade’s authorization_required flag in tool results:
TSX
app/page.tsx
export default function Chat() { const { messages, sendMessage, regenerate, status } = useChat(); const isLoading = status === "submitted" || status === "streaming"; const inputRef = useRef<HTMLTextAreaElement>(null); // Refocus input after response completes useEffect(() => { if (!isLoading && inputRef.current) { inputRef.current.focus(); } }, [isLoading]); return ( <div className="flex flex-col h-screen max-w-2xl mx-auto"> {/* Conversation handles auto-scrolling */} <Conversation className="flex-1"> <ConversationContent> {messages.map((message) => { // Check if any tool part requires authorization const authPart = message.parts?.find((part) => { if (part.type.startsWith("tool-")) { const toolPart = part as { state?: string; output?: unknown }; if (toolPart.state === "output-available") { const result = toolPart.output as Record<string, unknown>; return result?.authorization_required; } } return false; }); // Get text content from message parts const textContent = message.parts ?.filter((part) => part.type === "text") .map((part) => part.text) .join(""); // Skip empty messages without auth prompts if (!textContent && !authPart && !(message.role === "assistant" && isLoading)) { return null; } return ( <Message key={message.id} from={message.role}> <MessageContent> {/* Show loader while assistant is thinking */} {message.role === "assistant" && !textContent && !authPart && isLoading ? ( <Loader /> ) : authPart ? ( // Show auth UI when Arcade needs authorization (() => { const toolPart = authPart as { toolName?: string; output?: unknown }; const result = toolPart.output as Record<string, unknown>; const authResponse = result?.authorization_response as { url?: string }; // In Vercel AI SDK v6, toolName is a property on the part, not derived from type const toolName = toolPart.toolName || ""; return ( <AuthPendingUI authUrl={authResponse?.url || ""} toolName={toolName} onAuthComplete={() => regenerate()} /> ); })() ) : ( <MessageResponse>{textContent}</MessageResponse> )} </MessageContent> </Message> ); })} </ConversationContent> </Conversation> {/* PromptInput handles the form with auto-resize textarea */} <div className="p-4"> <PromptInput onSubmit={({ text }) => { if (text.trim()) { sendMessage({ text }); } }} > <PromptInputTextarea ref={inputRef} placeholder="Ask about your emails or Slack..." disabled={isLoading} /> <PromptInputFooter> <div /> {/* Spacer */} <PromptInputSubmit status={status} disabled={isLoading} /> </PromptInputFooter> </PromptInput> </div> </div> );}
The full page.tsx file is available in the Complete code
section below.
“Email me a summary of this slack channel’s activity since yesterday…”
On first use, you’ll see an authorization button. Click it to connect your Gmail or Slack account—Arcade remembers this for future requests.
Key takeaways
Arcade tools work seamlessly with Vercel AI SDK: Use toZodToolSet with the toVercelTools adapter to convert Arcade tools to the format Vercel AI SDK expects.
Authorization is automatic: The executeOrAuthorizeZodTool factory handles auth flows. Check for authorization_required in tool results and display the authorization UI.
Handle null parameters: LLMs sometimes send null for optional parameters. The stripNullValues wrapper prevents tool failures.
Mix MCP servers and individual tools: Combine entire MCP servers with specific tools to give your agent exactly the capabilities it needs.
Next steps
Add more tools: Browse the MCP server catalog and add tools for GitHub, Notion, Linear, and more.
Add user authentication: In production, get userId from your auth system instead of environment variables. See Security for best practices.
Deploy to Vercel: Push your chatbot to GitHub and deploy to Vercel with one click. Add your environment variables in the Vercel dashboard.
Complete code
app/api/chat/route.ts (full file)
TypeScript
app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";import { streamText, convertToModelMessages, stepCountIs } from "ai";import { Arcade } from "@arcadeai/arcadejs";import { toZodToolSet, executeOrAuthorizeZodTool,} from "@arcadeai/arcadejs/lib";const config = { mcpServers: ["Slack"], individualTools: ["Gmail_ListEmails", "Gmail_SendEmail", "Gmail_WhoAmI"], toolLimit: 30, systemPrompt: `You are a helpful assistant that can access Gmail and Slack.Always use the available tools to fulfill user requests. Do not tell users to authorize manually - just call the tool and the system will handle authorization if needed.For Gmail:- To find sent emails, use the query parameter with "in:sent"- To find received emails, use "in:inbox" or no queryAfter completing any action (sending emails, Slack messages, etc.), always confirm what you did with specific details.IMPORTANT: When calling tools, if an argument is optional, do not set it. Never pass null for optional parameters.`,};function stripNullValues( obj: Record<string, unknown>): Record<string, unknown> { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj)) { if (value !== null && value !== undefined) { result[key] = value; } } return result;}function toVercelTools(arcadeTools: Record<string, unknown>): Record<string, unknown> { const vercelTools: Record<string, unknown> = {}; for (const [name, tool] of Object.entries(arcadeTools)) { const t = tool as { description: string; parameters: unknown; execute: Function }; vercelTools[name] = { description: t.description, inputSchema: t.parameters, // AI SDK v6 uses inputSchema, not parameters execute: async (input: Record<string, unknown>) => { const cleanedInput = stripNullValues(input); return t.execute(cleanedInput); }, }; } return vercelTools;}async function getArcadeTools(userId: string) { const arcade = new Arcade(); const mcpServerTools = await Promise.all( config.mcpServers.map(async (serverName) => { const response = await arcade.tools.list({ toolkit: serverName, limit: config.toolLimit, }); return response.items; }) ); const individualToolDefs = await Promise.all( config.individualTools.map((toolName) => arcade.tools.get(toolName)) ); const allTools = [...mcpServerTools.flat(), ...individualToolDefs]; const uniqueTools = Array.from( new Map(allTools.map((tool) => [tool.qualified_name, tool])).values() ); const arcadeTools = toZodToolSet({ tools: uniqueTools, client: arcade, userId, executeFactory: executeOrAuthorizeZodTool, }); return toVercelTools(arcadeTools);}export async function POST(req: Request) { try { const { messages } = await req.json(); const userId = process.env.ARCADE_USER_ID || "default-user"; const tools = await getArcadeTools(userId); const result = streamText({ model: openai("gpt-4o-mini"), system: config.systemPrompt, messages: await convertToModelMessages(messages), tools, stopWhen: stepCountIs(5), }); return result.toUIMessageStreamResponse(); } catch (error) { console.error("Chat API error:", error); return Response.json( { error: "Failed to process chat request" }, { status: 500 } ); }}