TanStack AI is a type-safe, provider-agnostic SDK for building AI applications in JavaScript and TypeScript. It provides streaming responses, calling, and framework-agnostic primitives for React, Solid, and vanilla JavaScript. Provider adapters let you switch between OpenAI, Anthropic, Google Gemini, and Ollama without rewriting your code.
In this guide, you’ll build a browser-based chatbot using TanStack Start 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 TanStack Start chatbot that integrates Arcade with TanStack AI
You will Learn
How to retrieve Arcade and convert them to TanStack AI format
How to build a streaming chatbot with server functions
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 TanStack concepts you’ll use:
chat The server-side function that streams AI responses with support for calling. Returns an AsyncIterable stream for real-time responses.
useChat A React hook that manages chat state, handles streaming via Server-Sent Events, and renders results automatically.
Server routes TanStack Start’s server routes run exclusively on the server using the server.handlers property, keeping secure while handling streaming responses.
toolDefinition Defines with type-safe parameters. Tools can run on server, client, or both.
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 chat API route
Create the directory and file src/routes/api/chat.ts. This server route handles chat requests and streams AI responses:
TypeScript
src/routes/api/chat.ts
import { createFileRoute } from "@tanstack/react-router";import { toServerSentEventsResponse, chat, toolDefinition } from "@tanstack/ai";import type { JSONSchema } from "@tanstack/ai";import { openaiText } from "@tanstack/ai-openai";import { Arcade } from "@arcadeai/arcadejs";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 queryFor Slack:- Use Slack.ListConversations to see channels and DMs- Use Slack.ListMessages to read messages from a channel or DM- Use Slack.SendDmToUser to send a direct message- Use Slack.SendMessageToChannel to post in a channelAfter completing any action, 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.`,};// Empty JSON Schema for tools with no parametersconst emptySchema: JSONSchema = { type: "object", properties: {} };// Strip null values from tool inputsfunction 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;}// Maximum characters for any string field in tool outputconst MAX_STRING_CHARS = 300;/** * Recursively truncates all large strings in objects/arrays. * This prevents token overflow when tool results pass back to the LLM. */function truncateDeep(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { if (obj.length > MAX_STRING_CHARS) { return obj.slice(0, MAX_STRING_CHARS) + "..."; } return obj; } if (Array.isArray(obj)) { return obj.map(truncateDeep); } if (typeof obj === "object") { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { result[key] = truncateDeep(value); } return result; } return obj;}// Maximum number of recent messages to keep in contextconst MAX_MESSAGES = 10;/** * Truncates message content and limits message history to prevent context overflow. */function prepareMessages(messages: unknown[]): unknown[] { const recentMessages = messages.slice(-MAX_MESSAGES); return recentMessages.map((msg) => truncateDeep(msg));}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 TanStack AI tool format return uniqueTools.map((tool) => { // Use Arcade's JSON Schema directly - TanStack AI accepts it natively const params = tool.input?.parameters as JSONSchema | undefined; const hasValidSchema = params && params.type && params.type !== "None"; const inputSchema = hasValidSchema ? params : emptySchema; return toolDefinition({ name: tool.qualified_name.replace(".", "_"), description: tool.description || "", inputSchema, }).server(async (input: unknown) => { const typedInput = input as Record<string, unknown>; const cleanedInput = stripNullValues(typedInput); try { // Check authorization status first const authResponse = await arcade.tools.authorize({ tool_name: tool.qualified_name, user_id: userId, }); if (authResponse.status !== "completed") { return { authorization_required: true, authorization_response: { url: authResponse.url, }, tool_name: tool.qualified_name, }; } // Execute the tool const result = await arcade.tools.execute({ tool_name: tool.qualified_name, user_id: userId, input: cleanedInput, }); // Truncate large strings to prevent context window overflow const output = result.output?.value ?? result; return truncateDeep(output); } catch (error) { console.error(`Tool execution error for ${tool.qualified_name}:`, error); throw error; } }); });}// Server route with POST handler for chat streamingexport const Route = createFileRoute("/api/chat")({ server: { handlers: { POST: async ({ request }) => { const body = await request.json(); const userId = process.env.ARCADE_USER_ID || "default-user"; const tools = await getArcadeTools(userId); // Prepare messages: limit history and truncate large content const preparedMessages = prepareMessages(body.messages || []); const stream = chat({ adapter: openaiText("gpt-4o"), systemPrompts: [config.systemPrompt], messages: preparedMessages, tools, }); return toServerSentEventsResponse(stream); }, }, },});
You can mix servers (which give you all their ) with individual
tools. Browse the complete MCP server catalog to
see what’s available.
Handling large outputs: Tools like Gmail.ListEmails can return 200KB+ of email content. When the passes this data back to the LLM in the agentic loop, it may exceed token limits. The code above includes truncateDeep to limit all strings to 300 characters and prepareMessages to keep only the last 10 messages.
Create the auth status server function
Create src/functions/auth.ts to check authorization status:
This server function allows the frontend to poll for authorization completion, creating a seamless experience where the chatbot automatically retries after the authorizes.
Build the chat route
Update src/routes/index.tsx with the chat interface. TanStack AI’s useChat hook manages messages, streaming, and loading states:
The AuthPendingUI component polls for OAuth completion using the checkAuthStatus server function and calls onAuthComplete when the user finishes authorizing, triggering a reload to retry the call.