Build an AI Chatbot with Arcade and TanStack AI
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
Prerequisites
TanStack concepts
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
AsyncIterablestream 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.handlersproperty, keeping secure while handling streaming responses. - toolDefinition Defines with type-safe parameters. Tools can run on server, client, or both.
Build the chatbot
Create a new TanStack Start project
pnpm
pnpm create @tanstack/start@latest arcade-chatbot
cd arcade-chatbotFollow the prompts to configure your . Select the defaults or customize as needed.
Install the required dependencies:
pnpm
pnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai @arcadeai/arcadejs react-markdown
pnpm add -D @tailwindcss/typographyThen add the typography plugin to your src/styles.css:
@import "tailwindcss";
@plugin "@tailwindcss/typography";Set up environment variables
Create a .env file with your :
ARCADE_API_KEY={arcade_api_key}
ARCADE_USER_ID={arcade_user_id}
OPENAI_API_KEY=your_openai_api_keyThe 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:
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 query
For 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 channel
After 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 parameters
const emptySchema: JSONSchema = { type: "object", properties: {} };
// Strip null values from tool inputs
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;
}
// Maximum characters for any string field in tool output
const 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 context
const 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 streaming
export 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:
import { createServerFn } from "@tanstack/react-start";
import { Arcade } from "@arcadeai/arcadejs";
export const checkAuthStatus = createServerFn({ method: "POST" })
.inputValidator((data: { toolName: string }) => data)
.handler(async ({ data }) => {
if (!data.toolName) {
throw new Error("toolName required");
}
const arcade = new Arcade();
const userId = process.env.ARCADE_USER_ID || "default-user";
const authResponse = await arcade.tools.authorize({
tool_name: data.toolName,
user_id: userId,
});
return { status: authResponse.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:
import { createFileRoute } from "@tanstack/react-router";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { useState, useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { checkAuthStatus } from "../functions/auth";
// Component that handles Arcade's OAuth authorization flow
function AuthPendingUI({
authUrl,
toolName,
onAuthComplete,
}: {
authUrl: string;
toolName: string;
onAuthComplete: () => void;
}) {
const [status, setStatus] = useState<"initial" | "waiting" | "completed">(
"initial"
);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
const hasCompletedRef = useRef(false);
const onAuthCompleteRef = useRef(onAuthComplete);
useEffect(() => {
onAuthCompleteRef.current = onAuthComplete;
}, [onAuthComplete]);
// Poll for auth completion after user clicks authorize
useEffect(() => {
if (status !== "waiting" || !toolName || hasCompletedRef.current) return;
const pollStatus = async () => {
try {
const result = await checkAuthStatus({ data: { toolName } });
if (result.status === "completed" && !hasCompletedRef.current) {
hasCompletedRef.current = true;
if (pollingRef.current) clearInterval(pollingRef.current);
setStatus("completed");
setTimeout(() => onAuthCompleteRef.current(), 1500);
}
} catch (error) {
console.error("Polling error:", error);
}
};
pollingRef.current = setInterval(pollStatus, 2000);
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, [status, toolName]);
const displayName = toolName.split("_")[0] || toolName;
const handleAuthClick = () => {
if (!authUrl) return;
window.open(authUrl, "_blank");
setStatus("waiting");
};
return (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
{status === "completed" ? (
<p className="text-green-600 font-medium">
{displayName} authorized successfully
</p>
) : !authUrl ? (
<p className="text-red-600">Authorization URL not available</p>
) : (
<div className="flex items-center gap-3">
<span>Authorize access to {displayName}?</span>
<button
onClick={handleAuthClick}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium"
>
{status === "waiting" ? "Retry" : "Authorize"}
</button>
</div>
)}
</div>
);
}
function Chat() {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { messages, sendMessage, reload, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !isLoading) {
sendMessage(input);
setInput("");
}
};
// 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 p-4">
<h1 className="text-2xl font-bold mb-4">Arcade + TanStack AI Chat</h1>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages
.filter((message) => {
// Filter out empty assistant messages (tool calls without text)
if (message.role === "assistant") {
const textParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "text"
);
const textContent = textParts
.map((p: any) => p.content || "")
.join("")
.trim();
const toolResultParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "tool-result"
);
const authRequired = toolResultParts.find(
(result: any) => result.output?.authorization_required
);
return textContent.length > 0 || authRequired;
}
return true;
})
.map((message) => {
const textParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "text"
);
const toolResultParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "tool-result"
);
const textContent = textParts
.map((p: { content?: string }) => p.content || "")
.join("");
const authRequired = toolResultParts.find(
(result: { output?: { authorization_required?: boolean } }) =>
result.output?.authorization_required
);
return (
<div
key={message.id}
className={`p-4 rounded-lg ${
message.role === "assistant"
? "bg-gray-100"
: "bg-blue-100 ml-8"
}`}
>
<div className="font-semibold text-sm text-gray-500 mb-1">
{message.role === "assistant" ? "Assistant" : "You"}
</div>
{authRequired ? (
<AuthPendingUI
authUrl={
(
authRequired as {
output?: {
authorization_response?: { url?: string };
};
}
)?.output?.authorization_response?.url || ""
}
toolName={
(authRequired as { output?: { tool_name?: string } })
?.output?.tool_name || ""
}
onAuthComplete={() => reload()}
/>
) : (
<div className="prose prose-sm max-w-none">
<ReactMarkdown>{textContent}</ReactMarkdown>
</div>
)}
</div>
);
})}
{isLoading &&
(() => {
// Only show loading if there's no assistant message with content
const lastMessage = messages[messages.length - 1];
const hasAssistantContent =
lastMessage?.role === "assistant" &&
(lastMessage.parts || [])
.filter((p: { type: string }) => p.type === "text")
.some((p: any) => (p.content || "").trim().length > 0);
if (hasAssistantContent) return null;
return (
<div className="p-4 rounded-lg bg-gray-100">
<div className="font-semibold text-sm text-gray-500 mb-1">
Assistant
</div>
<div className="animate-pulse">Thinking...</div>
</div>
);
})()}
</div>
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about your emails or Slack..."
disabled={isLoading}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</form>
</div>
);
}
export const Route = createFileRoute("/")({
component: Chat,
});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.
Run the chatbot
pnpm
pnpm devOpen http://localhost:3000 and try prompts like:
- “Summarize my last 3 emails”
- “Send a Slack DM to myself saying hello”
- “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 (Arcade remembers this for future requests).
Key takeaways
- Full TanStack stack: TanStack Start + TanStack Router + TanStack AI work together seamlessly with server routes and file-based routing.
- Server routes keep secrets safe: The
server.handlersproperty ensures your never reach the client while handling streaming responses. - Authorization is automatic: Check
authorization_requiredin results and display the authorization UI. Poll for completion to retry automatically. - Truncate large outputs: Tools like Gmail can return 200KB+ of data. Wrap with truncation to prevent token overflow in the agentic loop.
- Provider flexibility: Switch between OpenAI, Anthropic, Gemini, or Ollama by changing the adapter. No code rewrites needed.
Next steps
- Add more : Browse the MCP server catalog and add tools for GitHub, Notion, Linear, and more.
- Try different providers: Swap
@tanstack/ai-openaifor@tanstack/ai-anthropicor@tanstack/ai-geminito use different AI models. - Add authentication: In production, get
userIdfrom your auth system instead of environment variables. See Secure your server for best practices. - Deploy your chatbot: TanStack Start supports deployment to Vercel, Netlify, Cloudflare, and Node.js servers.
Complete code
src/routes/api/chat.ts (full file)
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 = {
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 query
For 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 channel
After 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 parameters
const emptySchema: JSONSchema = { type: "object", properties: {} };
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;
}
const MAX_STRING_CHARS = 300;
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;
}
const MAX_MESSAGES = 10;
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();
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()
);
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 {
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,
};
}
const result = await arcade.tools.execute({
tool_name: tool.qualified_name,
user_id: userId,
input: cleanedInput,
});
const output = result.output?.value ?? result;
return truncateDeep(output);
} catch (error) {
console.error(`Tool execution error for ${tool.qualified_name}:`, error);
throw error;
}
});
});
}
export 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);
const preparedMessages = prepareMessages(body.messages || []);
const stream = chat({
adapter: openaiText("gpt-4o"),
systemPrompts: [config.systemPrompt],
messages: preparedMessages,
tools,
});
return toServerSentEventsResponse(stream);
},
},
},
});src/functions/auth.ts (full file)
import { createServerFn } from "@tanstack/react-start";
import { Arcade } from "@arcadeai/arcadejs";
export const checkAuthStatus = createServerFn({ method: "POST" })
.inputValidator((data: { toolName: string }) => data)
.handler(async ({ data }) => {
if (!data.toolName) {
throw new Error("toolName required");
}
const arcade = new Arcade();
const userId = process.env.ARCADE_USER_ID || "default-user";
const authResponse = await arcade.tools.authorize({
tool_name: data.toolName,
user_id: userId,
});
return { status: authResponse.status };
});src/routes/index.tsx (full file)
import { createFileRoute } from "@tanstack/react-router";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { useState, useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { checkAuthStatus } from "../functions/auth";
function AuthPendingUI({
authUrl,
toolName,
onAuthComplete,
}: {
authUrl: string;
toolName: string;
onAuthComplete: () => void;
}) {
const [status, setStatus] = useState<"initial" | "waiting" | "completed">(
"initial"
);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
const hasCompletedRef = useRef(false);
const onAuthCompleteRef = useRef(onAuthComplete);
useEffect(() => {
onAuthCompleteRef.current = onAuthComplete;
}, [onAuthComplete]);
useEffect(() => {
if (status !== "waiting" || !toolName || hasCompletedRef.current) return;
const pollStatus = async () => {
try {
const result = await checkAuthStatus({ data: { toolName } });
if (result.status === "completed" && !hasCompletedRef.current) {
hasCompletedRef.current = true;
if (pollingRef.current) clearInterval(pollingRef.current);
setStatus("completed");
setTimeout(() => onAuthCompleteRef.current(), 1500);
}
} catch (error) {
console.error("Polling error:", error);
}
};
pollingRef.current = setInterval(pollStatus, 2000);
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, [status, toolName]);
const displayName = toolName.split("_")[0] || toolName;
const handleAuthClick = () => {
if (!authUrl) return;
window.open(authUrl, "_blank");
setStatus("waiting");
};
return (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
{status === "completed" ? (
<p className="text-green-600 font-medium">
{displayName} authorized successfully
</p>
) : !authUrl ? (
<p className="text-red-600">Authorization URL not available</p>
) : (
<div className="flex items-center gap-3">
<span>Authorize access to {displayName}?</span>
<button
onClick={handleAuthClick}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium"
>
{status === "waiting" ? "Retry" : "Authorize"}
</button>
</div>
)}
</div>
);
}
function Chat() {
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { messages, sendMessage, reload, isLoading } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !isLoading) {
sendMessage(input);
setInput("");
}
};
useEffect(() => {
if (!isLoading && inputRef.current) {
inputRef.current.focus();
}
}, [isLoading]);
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Arcade + TanStack AI Chat</h1>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((message) => {
// Extract text content and tool results from parts
const textParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "text"
);
const toolResultParts = (message.parts || []).filter(
(p: { type: string }) => p.type === "tool-result"
);
const textContent = textParts
.map((p: { content?: string }) => p.content || "")
.join("");
const authRequired = toolResultParts.find(
(result: { output?: { authorization_required?: boolean } }) =>
result.output?.authorization_required
);
return (
<div
key={message.id}
className={`p-4 rounded-lg ${
message.role === "assistant"
? "bg-gray-100"
: "bg-blue-100 ml-8"
}`}
>
<div className="font-semibold text-sm text-gray-500 mb-1">
{message.role === "assistant" ? "Assistant" : "You"}
</div>
{authRequired ? (
<AuthPendingUI
authUrl={
(
authRequired as {
output?: { authorization_response?: { url?: string } };
}
)?.output?.authorization_response?.url || ""
}
toolName={
(authRequired as { output?: { tool_name?: string } })
?.output?.tool_name || ""
}
onAuthComplete={() => reload()}
/>
) : (
<div className="whitespace-pre-wrap">{textContent}</div>
)}
</div>
);
})}
{isLoading && (
<div className="p-4 rounded-lg bg-gray-100">
<div className="font-semibold text-sm text-gray-500 mb-1">
Assistant
</div>
<div className="animate-pulse">Thinking...</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about your emails or Slack..."
disabled={isLoading}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</form>
</div>
);
}
export const Route = createFileRoute("/")({
component: Chat,
});