branch:
ToolsDemo.tsx
16743 bytesRaw
import {
Suspense,
useCallback,
useEffect,
useRef,
useState,
type ReactNode
} from "react";
import { useAgent } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { isToolUIPart, getToolName } from "ai";
import {
Button,
Surface,
Text,
InputArea,
Empty,
Badge
} from "@cloudflare/kumo";
import {
PaperPlaneRightIcon,
TrashIcon,
GearIcon,
WrenchIcon,
GlobeIcon,
MonitorIcon,
CheckCircleIcon,
XCircleIcon,
LightningIcon,
ShieldCheckIcon,
BrowserIcon
} from "@phosphor-icons/react";
import { Streamdown } from "streamdown";
import { DemoWrapper } from "../../layout";
import {
ConnectionStatus,
CodeExplanation,
type CodeSection
} from "../../components";
import { useUserId } from "../../hooks";
const codeSections: CodeSection[] = [
{
title: "Define tools for the AI to call",
description:
"Use the AI SDK's tool() function to define tools with Zod schemas. Tools can run server-side (in the agent) or client-side (in the browser) via executions.",
code: `import { tool } from "ai";
import { z } from "zod";
const tools = {
getWeather: tool({
description: "Get the current weather for a location",
parameters: z.object({
city: z.string().describe("City name"),
}),
execute: async ({ city }) => {
return { temperature: 72, condition: "sunny", city };
},
}),
};`
},
{
title: "Client-side tool execution",
description:
"Some tools need to run in the browser — accessing the DOM, camera, or user interactions. Mark them with executions and handle them on the client with useAgentChat.",
code: `const { messages, addToolResult } = useAgentChat(agent, {
// Handle tool calls that need client-side execution
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === "getUserLocation") {
const position = await navigator.geolocation.getCurrentPosition();
return { lat: position.coords.latitude, lng: position.coords.longitude };
}
},
});`
}
];
const TOOL_META: Record<
string,
{ icon: ReactNode; label: string; type: string }
> = {
getWeather: {
icon: <GlobeIcon size={14} />,
label: "getWeather",
type: "Server"
},
rollDice: {
icon: <LightningIcon size={14} />,
label: "rollDice",
type: "Server"
},
getUserTimezone: {
icon: <BrowserIcon size={14} />,
label: "getUserTimezone",
type: "Client"
},
getScreenSize: {
icon: <MonitorIcon size={14} />,
label: "getScreenSize",
type: "Client"
},
calculate: {
icon: <ShieldCheckIcon size={14} />,
label: "calculate",
type: "Approval"
},
deleteFile: {
icon: <ShieldCheckIcon size={14} />,
label: "deleteFile",
type: "Approval"
}
};
function typeBadgeVariant(
type: string
): "secondary" | "primary" | "destructive" {
if (type === "Server") return "secondary";
if (type === "Client") return "primary";
return "destructive";
}
function MessageBubble({
align,
variant,
children
}: {
align: "left" | "right";
variant: "user" | "assistant";
children: ReactNode;
}) {
const base = "max-w-[80%] rounded-2xl overflow-hidden";
const userStyle = `${base} rounded-br-md bg-kumo-contrast text-kumo-inverse`;
const assistantStyle = `${base} rounded-bl-md ring ring-kumo-line`;
return (
<div
className={`flex ${align === "right" ? "justify-end" : "justify-start"}`}
>
{variant === "user" ? (
<div className={userStyle}>{children}</div>
) : (
<Surface className={assistantStyle}>{children}</Surface>
)}
</div>
);
}
function ToolCard({
toolName,
state,
input: toolInput,
output,
approvalId,
onApprove,
onReject
}: {
toolName: string;
state: string;
input?: unknown;
output?: unknown;
approvalId?: string;
onApprove?: (id: string) => void;
onReject?: (id: string) => void;
}) {
const meta = TOOL_META[toolName] ?? {
icon: <GearIcon size={14} />,
label: toolName,
type: "Unknown"
};
const isApproval = state === "approval-requested";
const isDone = state === "output-available";
const isDenied = state === "output-denied";
const isError = state === "output-error";
const isRunning = state === "input-available" || state === "input-streaming";
return (
<div className="flex justify-start">
<Surface
className={`max-w-[80%] px-3 py-2.5 rounded-xl ring ${
isApproval ? "ring-2 ring-kumo-warning" : "ring-kumo-line"
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-kumo-inactive">{meta.icon}</span>
<Text size="xs" bold>
{meta.label}
</Text>
<Badge variant={typeBadgeVariant(meta.type)}>{meta.type}</Badge>
{isDone && (
<Badge variant="secondary">
<CheckCircleIcon size={10} className="mr-0.5" />
Done
</Badge>
)}
{isRunning && (
<Badge variant="secondary">
<GearIcon size={10} className="mr-0.5 animate-spin" />
Running
</Badge>
)}
{isApproval && <Badge variant="destructive">Needs Approval</Badge>}
{isDenied && (
<Badge variant="secondary">
<XCircleIcon size={10} className="mr-0.5" />
Denied
</Badge>
)}
{isError && (
<Badge variant="secondary">
<XCircleIcon size={10} className="mr-0.5" />
Error
</Badge>
)}
</div>
{toolInput != null && (
<pre className="font-mono text-xs text-kumo-subtle overflow-x-auto mb-1.5 bg-kumo-elevated rounded p-2">
{JSON.stringify(toolInput, null, 2)}
</pre>
)}
{isDone && output != null && (
<pre className="font-mono text-xs text-kumo-subtle overflow-x-auto bg-green-500/5 rounded p-2 border border-green-500/20">
{JSON.stringify(output, null, 2)}
</pre>
)}
{isApproval && approvalId && onApprove && onReject && (
<div className="flex gap-2 mt-2">
<Button
variant="primary"
size="sm"
icon={<CheckCircleIcon size={14} />}
onClick={() => onApprove(approvalId)}
>
Approve
</Button>
<Button
variant="secondary"
size="sm"
icon={<XCircleIcon size={14} />}
onClick={() => onReject(approvalId)}
>
Reject
</Button>
</div>
)}
</Surface>
</div>
);
}
function ToolsUI() {
const userId = useUserId();
const [connectionStatus, setConnectionStatus] = useState<
"connected" | "connecting" | "disconnected"
>("connecting");
const [input, setInput] = useState("");
const messagesContainerRef = useRef<HTMLDivElement>(null);
const agent = useAgent({
agent: "ToolsAgent",
name: `tools-demo-${userId}`,
onOpen: useCallback(() => setConnectionStatus("connected"), []),
onClose: useCallback(() => setConnectionStatus("disconnected"), []),
onError: useCallback(() => setConnectionStatus("disconnected"), [])
});
const {
messages,
sendMessage,
clearHistory,
addToolApprovalResponse,
status
} = useAgentChat({
agent,
onToolCall: async ({ toolCall, addToolOutput }) => {
if (toolCall.toolName === "getUserTimezone") {
addToolOutput({
toolCallId: toolCall.toolCallId,
output: {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
localTime: new Date().toLocaleTimeString()
}
});
}
if (toolCall.toolName === "getScreenSize") {
addToolOutput({
toolCallId: toolCall.toolCallId,
output: {
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio
}
});
}
}
});
const isStreaming = status === "streaming";
const isConnected = connectionStatus === "connected";
useEffect(() => {
const el = messagesContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages]);
const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
sendMessage({ role: "user", parts: [{ type: "text", text }] });
}, [input, isStreaming, sendMessage]);
const handleApprove = useCallback(
(id: string) => addToolApprovalResponse({ id, approved: true }),
[addToolApprovalResponse]
);
const handleReject = useCallback(
(id: string) => addToolApprovalResponse({ id, approved: false }),
[addToolApprovalResponse]
);
return (
<DemoWrapper
title="Tools"
description={
<>
AI agents can use tools — functions the model calls during a
conversation. Tools can run server-side (inside the agent),
client-side (in the browser, e.g. geolocation), or require human
approval before executing. Define them with Zod schemas for type-safe
argument validation.
</>
}
statusIndicator={<ConnectionStatus status={connectionStatus} />}
>
<div className="flex flex-col h-full max-w-3xl">
{/* Tool legend */}
<div className="flex flex-wrap gap-3 mb-4 px-1">
<div className="flex items-center gap-1.5">
<Badge variant="secondary">Server</Badge>
<Text size="xs" variant="secondary">
Auto-executed on server
</Text>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="primary">Client</Badge>
<Text size="xs" variant="secondary">
Runs in your browser
</Text>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="destructive">Approval</Badge>
<Text size="xs" variant="secondary">
Needs your confirmation
</Text>
</div>
</div>
{/* Messages area */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto mb-4 space-y-4"
>
{messages.length === 0 && (
<Empty
icon={<WrenchIcon size={32} />}
title="Try the tools"
description={
'Try "What\'s the weather in Tokyo?", "Roll 3d20", ' +
'"What timezone am I in?", "What\'s my screen size?", ' +
'"What is 42 * 38?", "What is 5000 + 3000?", or "Delete /tmp/old.log"'
}
/>
)}
{messages.map((message, index) => {
const isUser = message.role === "user";
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
return (
<div key={message.id} className="space-y-2">
{message.parts.map((part, partIdx) => {
if (part.type === "text") {
if (!part.text || part.text.trim() === "") return null;
if (isUser) {
return (
<MessageBubble
key={partIdx}
align="right"
variant="user"
>
<Streamdown
className="sd-theme px-4 py-2.5 text-sm leading-relaxed **:text-kumo-inverse"
controls={false}
>
{part.text}
</Streamdown>
</MessageBubble>
);
}
return (
<MessageBubble
key={partIdx}
align="left"
variant="assistant"
>
<Streamdown
className="sd-theme px-4 py-2.5 text-sm leading-relaxed"
controls={false}
isAnimating={isLastAssistant && isStreaming}
>
{part.text}
</Streamdown>
</MessageBubble>
);
}
if (part.type === "reasoning") {
if (!part.text || part.text.trim() === "") return null;
return (
<div key={partIdx} className="flex justify-start">
<Surface className="max-w-[80%] px-3 py-2 rounded-xl bg-purple-500/10 border border-purple-500/20">
<Text size="xs" variant="secondary">
<em>Thinking: {part.text}</em>
</Text>
</Surface>
</div>
);
}
if (isToolUIPart(part)) {
const toolName = getToolName(part);
const approvalId =
"approval" in part
? (part.approval as { id?: string })?.id
: undefined;
return (
<ToolCard
key={part.toolCallId}
toolName={toolName}
state={part.state}
input={part.input}
output={
part.state === "output-available"
? part.output
: undefined
}
approvalId={approvalId}
onApprove={handleApprove}
onReject={handleReject}
/>
);
}
return null;
})}
</div>
);
})}
<div />
</div>
{/* Input area */}
<div className="border-t border-kumo-line pt-4">
<form
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<div className="flex items-end gap-3 rounded-xl border border-kumo-line bg-kumo-base p-3 shadow-sm focus-within:ring-2 focus-within:ring-kumo-ring focus-within:border-transparent transition-shadow">
<InputArea
value={input}
onValueChange={setInput}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}}
placeholder={
isConnected
? 'Try "What\'s the weather in Tokyo?" or "What is 5000 + 3000?"'
: "Connecting to agent..."
}
disabled={!isConnected || isStreaming}
rows={2}
className="flex-1 ring-0! focus:ring-0! shadow-none! bg-transparent! outline-none!"
/>
<div className="flex items-center gap-2 mb-0.5">
<Button
type="button"
variant="ghost"
shape="square"
size="sm"
aria-label="Clear history"
onClick={clearHistory}
disabled={messages.length === 0}
icon={<TrashIcon size={16} />}
/>
<Button
type="submit"
variant="primary"
shape="square"
aria-label="Send message"
disabled={!input.trim() || !isConnected || isStreaming}
icon={<PaperPlaneRightIcon size={18} />}
loading={isStreaming}
/>
</div>
</div>
</form>
</div>
</div>
<CodeExplanation sections={codeSections} />
</DemoWrapper>
);
}
export function ToolsDemo() {
return (
<Suspense
fallback={
<DemoWrapper
title="Tools"
description="Server-side, client-side, and approval-required tools in action."
>
<div className="flex items-center justify-center h-64 text-kumo-inactive">
Loading tools demo...
</div>
</DemoWrapper>
}
>
<ToolsUI />
</Suspense>
);
}