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 easy 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—all 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 (an email, UUID, etc.). Arcade uses this to track authorizations per user.
Create the API route
Create or replace 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.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 formatfunction toVercelTools(arcadeTools: Record<string, any>): Record<string, any> { const vercelTools: Record<string, unknown> = {}; for (const [name, tool] of Object.entries(arcadeTools)) { const originalExecute = tool.execute; vercelTools[name] = { description: tool.description, inputSchema: tool.parameters, // Wrap execute to strip null values before calling execute: async (input: Record<string, unknown>) => { const cleanedInput = stripNullValues(input); return originalExecute(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
Create app/page.tsx. We’ll build it step by step, starting with the imports and setup.
Connect to the chat API
The useChat hook from @ai-sdk/react automatically connects to your /api/chat route and manages all chat state:
TSX
app/page.tsx
"use client"; // Required for React hooks in Next.js App Router// useChat connects to /api/chat and manages conversation stateimport { useChat } from "@ai-sdk/react";import { useState, useRef, useEffect } from "react";// For rendering AI responses with formatting (bold, lists, code blocks)import ReactMarkdown, { Components } from "react-markdown";// Style overrides for markdown elements in chat bubblesconst markdownComponents: Components = { p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>, ul: ({ children }) => <ul className="list-disc ml-4 mb-2">{children}</ul>, ol: ({ children }) => <ol className="list-decimal ml-4 mb-2">{children}</ol>, li: ({ children }) => <li className="mb-1">{children}</li>, strong: ({ children }) => <strong className="font-bold">{children}</strong>, code: ({ children }) => ( <code className="bg-gray-700 px-1 py-0.5 rounded text-sm">{children}</code> ), pre: ({ children }) => ( <pre className="bg-gray-700 p-2 rounded overflow-x-auto my-2">{children}</pre> ),};
Handle the OAuth flow
When Arcade returns authorization_required, you need to show the user an auth button and poll for completion. This component handles that flow and calls onAuthComplete when done:
TSX
app/page.tsx
// Displays authorization button and polls for completion// Props come from Arcade's authorization_required responsefunction AuthPendingUI({ authUrl, // URL to open for OAuth flow toolName, // e.g., "Gmail_ListEmails" - used to check auth status onAuthComplete, // Called when auth succeeds (triggers message retry)}: { authUrl: string; toolName: string; onAuthComplete: () => void;}) { // Track UI state: initial -> waiting (polling) -> completed const [status, setStatus] = useState<"initial" | "waiting" | "completed">("initial"); const pollingRef = useRef<NodeJS.Timeout | null>(null); const hasCompletedRef = useRef(false); // Prevent duplicate completions // Poll /api/auth/status every 2 seconds after user clicks authorize useEffect(() => { // Only poll when user has clicked authorize and we haven't completed if (status !== "waiting" || !toolName || hasCompletedRef.current) return; const pollStatus = async () => { try { // Check if user has completed OAuth in the other tab const res = await fetch("/api/auth/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ toolName }), }); const data = await res.json(); // Arcade returns "completed" when OAuth succeeds if (data.status === "completed" && !hasCompletedRef.current) { hasCompletedRef.current = true; if (pollingRef.current) clearInterval(pollingRef.current); setStatus("completed"); // Brief delay to show success message, then retry the original request setTimeout(() => onAuthComplete(), 1500); } } catch (error) { console.error("Polling error:", error); } }; pollingRef.current = setInterval(pollStatus, 2000); // Cleanup: stop polling when component unmounts return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, [status, toolName, onAuthComplete]); // Extract service name from tool name (e.g., "Gmail" from "Gmail_ListEmails") const displayName = toolName.split("_")[0] || toolName; const handleAuthClick = () => { window.open(authUrl, "_blank"); // Open OAuth in new tab setStatus("waiting"); // Start polling }; return ( <div> {status === "completed" ? ( <p className="text-green-400">✓ {displayName} authorized</p> ) : ( <> Give Arcade Chat access to {displayName}?{" "} <button onClick={handleAuthClick} className="ml-2 px-2 py-1 bg-teal-600 hover:bg-teal-500 rounded text-sm" > {status === "waiting" ? "Retry authorizing" : "Authorize now"} </button> </> )} </div> );}
Set up the main Chat component
The useChat hook returns everything you need: messages (the conversation history), sendMessage (to send new messages), regenerate (to retry the last request), and status (to show loading states):
TSX
app/page.tsx
export default function Chat() { const [input, setInput] = useState(""); // Controlled input for message field const messagesEndRef = useRef<HTMLDivElement>(null); // For auto-scrolling const inputRef = useRef<HTMLInputElement>(null); // For refocusing after send // useChat provides everything needed for the chat UI: // - messages: array of conversation messages with parts (text + tool results) // - sendMessage: sends user input to /api/chat // - regenerate: retries the last request (used after OAuth completes) // - status: "submitted" | "streaming" | "ready" | "error" const { messages, sendMessage, regenerate, status } = useChat(); const isLoading = status === "submitted" || status === "streaming"; // Auto-scroll to bottom when new messages arrive useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, status]); // Refocus input when AI finishes responding useEffect(() => { if (!isLoading) { inputRef.current?.focus(); } }, [isLoading]);
Render messages with tool results
Each message has parts—text content and tool results. Tool parts have a type like "tool-Gmail_ListEmails" and contain the tool’s output. When Arcade needs authorization, the tool output includes authorization_required: true and an auth URL:
TSX
app/page.tsx
return ( <div className="flex flex-col h-screen max-w-2xl mx-auto p-4"> <div className="flex-1 overflow-y-auto space-y-4"> {messages.map((message) => { // Check if any tool in this message needs authorization // Tool parts have type like "tool-Gmail_ListEmails" const hasAuthRequired = message.parts?.some((part) => { if (part.type.startsWith("tool-")) { const toolPart = part as { state?: string; output?: unknown }; // "output-available" means the tool has returned a result if (toolPart.state === "output-available") { const result = toolPart.output as Record<string, unknown>; // Arcade sets this flag when OAuth is needed return result?.authorization_required; } } return false; }); // Determine if this message has content worth showing // (skip empty messages and completed tool calls) const hasVisibleContent = message.parts?.some((part) => { if (part.type === "text" && part.text.trim()) return true; 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>; // Only show tool results that need auth (hide successful ones) return result?.authorization_required; } } return false; }); // Show loading animation for assistant messages still being generated const showLoading = message.role === "assistant" && !hasVisibleContent && isLoading; // Skip rendering empty messages entirely if (!hasVisibleContent && !showLoading) return null;
Display text and authorization prompts
For text parts, render with markdown. For tool parts that need authorization, show the AuthPendingUI component. The key is passing regenerate as onAuthComplete—this retries the original request after the user authorizes:
TSX
app/page.tsx
return ( // Align user messages right, assistant messages left <div key={message.id} className={message.role === "user" ? "text-right" : ""}> <div className={`inline-block p-3 rounded-lg ${ message.role === "user" ? "bg-blue-600 text-white" : "bg-gray-800" }`}> {showLoading ? ( // Bouncing dots animation while AI is thinking <div className="flex gap-1"> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }}></span> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }}></span> <span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }}></span> </div> ) : ( // Render each part of the message message.parts?.map((part, i) => { // Text parts: render AI response with markdown formatting if (part.type === "text" && !hasAuthRequired) { return ( <div key={i}> <ReactMarkdown components={markdownComponents}> {part.text} </ReactMarkdown> </div> ); } // Tool parts: check if authorization is needed 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>; // Show auth UI when Arcade needs user to connect their account if (result?.authorization_required) { const authResponse = result.authorization_response as { url?: string }; // Extract tool name from part type (e.g., "tool-Gmail_ListEmails" -> "Gmail_ListEmails") const toolName = part.type.replace("tool-", ""); return ( <AuthPendingUI key={i} authUrl={authResponse?.url || ""} toolName={toolName} // regenerate() retries the original request after auth succeeds onAuthComplete={() => regenerate()} /> ); } } } // Hide successful tool results (user just sees the AI's text response) return null; }) )} </div> </div> ); })} {/* Invisible element at bottom for auto-scroll target */} <div ref={messagesEndRef} /> </div>
Add the input form
The form calls sendMessage with the user’s input. The hook automatically sends it to /api/chat and streams the response:
TSX
app/page.tsx
{/* Message input form */} <form onSubmit={(e) => { e.preventDefault(); if (!input.trim()) return; // Don't send empty messages // sendMessage sends to /api/chat and streams the response sendMessage({ text: input }); setInput(""); // Clear input after sending inputRef.current?.focus(); // Keep focus on input }} className="flex gap-2 mt-4" > <input ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about your emails or Slack..." className="flex-1 p-2 border rounded" disabled={isLoading} // Prevent sending while AI is responding /> <button type="submit" disabled={isLoading || !input.trim()} className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50" > Send </button> </form> </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 Identifying users 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.