-
-
Notifications
You must be signed in to change notification settings - Fork 114
Description
TanStack AI version
0.2.2
Framework/Library version
"@tanstack/react-start": "^1.149.4",
Describe the bug and the steps to reproduce it
I am following the documentation for implementing a client-side tool with approval using @tanstack/ai.
I have a tool delete_local_data configured with needsApproval: true. When the AI triggers the tool call, the UI correctly renders my approval component. However, when I call addToolApprovalResponse({ id, approved: true }):
- The tool implementation does not execute (the
localStorageitem is not deleted). - The AI does not respond back after the approval.
- The UI state seems stuck (likely
isLoadingremains active), preventing further input.
Reproduction Code
1. api.chat.ts (Server Handler) I am using OpenAITextAdapter via OpenRouter. Note: I am testing with xiaomi/mimo-v2-flash:free specifically because it is a popular free model that supports tool calling, strictly for testing purposes.
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { OpenAITextAdapter } from "@tanstack/ai-openai";
import { createFileRoute } from "@tanstack/react-router";
import { deleteLocalDataDef } from "@/lib/tools/definitions";
export const Route = createFileRoute("/api/chat")({
server: {
handlers: {
POST: async ({ request }) => {
if (!process.env.OPENAI_API_KEY) {
return new Response(JSON.stringify({ error: "No API Key" }), { status: 500 });
}
const { messages, conversationId } = await request.json();
try {
const adapter = new OpenAITextAdapter(
{
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENAI_API_KEY,
},
"xiaomi/mimo-v2-flash:free" as any,
);
const stream = chat({
adapter,
messages,
conversationId,
tools: [deleteLocalDataDef],
});
return toServerSentEventsResponse(stream);
} catch (error) {
return new Response(JSON.stringify({ error: "Error" }), { status: 500 });
}
},
},
},
});2. Definitions.ts (Tool Definition)
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";
export const deleteLocalDataDef = toolDefinition({
name: "delete_local_data",
description: "Delete data from local storage",
inputSchema: z.object({
key: z.string(),
}),
outputSchema: z.object({
deleted: z.boolean(),
}),
needsApproval: true, // Requires approval
});3. Chat.tsx (Client Component)
// app/routes/chat.tsx (or wherever your Chat component is)
import { fetchServerSentEvents, useChat } from "@tanstack/ai-react";
import { useState } from "react";
import { ApprovalPrompt } from "@/components/ApprovalPrompt";
import { deleteLocalDataDef } from "@/lib/tools/definitions";
// 1. Import your shared definitions
function Chat() {
// Client: Create implementation
const deleteLocalData = deleteLocalDataDef.client((input) => {
// This will only execute after approval
localStorage.removeItem(input.key);
return { deleted: true };
});
// 4. Pass options to useChat
// The hook now handles the streaming AND the tool execution automatically
const { messages, sendMessage, isLoading, addToolApprovalResponse } = useChat(
{
connection: fetchServerSentEvents("/api/chat"),
tools: [deleteLocalData], // Automatic execution after approval
},
);
const [input, setInput] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim() && !isLoading) {
sendMessage(input);
setInput("");
}
};
return (
<div className="flex flex-col h-screen">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4">
{messages.map((message) => (
<div
key={message.id}
className={`mb-4 ${
message.role === "assistant" ? "text-blue-600" : "text-gray-800"
}`}
>
<div className="font-semibold mb-1">
{message.role === "assistant" ? "Assistant" : "You"}
</div>
<div>
{message.parts.map((part, idx) => {
// Handle text
if (part.type === "text")
return <div key={idx}>{part.content}</div>;
// Handle "Thinking" (if you use it)
if (part.type === "thinking")
return (
<div key={idx} className="italic text-gray-400">
Thinking...
</div>
);
// Handle Tool Calls (Optional visualization)
// You don't HAVE to render this, but it helps debugging
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<ApprovalPrompt
key={part.id}
part={part}
onApprove={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: true,
})
}
onDeny={() =>
addToolApprovalResponse({
id: part.approval!.id,
approved: false,
})
}
/>
);
}
return null;
})}
</div>
</div>
))}
</div>
{/* Input Area */}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded-lg"
disabled={isLoading}
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
Send
</button>
</div>
</form>
</div>
);
}
export default Chat;Expected Behavior After calling addToolApprovalResponse with approved: true:
- The client-side tool implementation should execute (
localStorage.removeItem). - The result should be sent back to the LLM.
- The LLM should continue streaming a response acknowledging the action.
Actual Behavior The application simply hangs after the approval click. No network activity follows, and the client function is never triggered.
Additional Context
- Model Provider: OpenRouter
- Model:
xiaomi/mimo-v2-flash:free - Stack: TanStack Start, TanStack AI, React
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
None
Screenshots or Videos (Optional)
Do you intend to try to help solve this bug with your own PR?
None
Terms & Code of Conduct
- I agree to follow this project's Code of Conduct
- I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.