Adding tool calling
import Callout from "../../../components/docs/Callout.astro"; import StepList from "../../../components/docs/StepList.astro"; Give the AI access to your app's functions. ### What you'll build A chat app where the AI can call a documentation search function and a calculator. You'll learn both auto-execute mode (tools run automatically) and manual mode (you confirm before executing). <Callout type="info" title="Prerequisites"> This tutorial builds on the [First Chat App](/docs/tutorials/first-chat-app) and [Streaming Responses](/docs/tutorials/streaming-responses) tutorials. </Callout> <StepList steps={[ { title: "Define tools", body: "Create ToolDefinition objects with name, description, parameters, and optional handler.", }, { title: "Pass tools to useConversation", body: "The hook injects tool descriptions so the model knows what's available.", }, { title: "Auto-execute mode", body: "Tools with a handler run automatically when the model calls them.", }, { title: "Manual mode", body: "Omit the handler for tools that need user confirmation.", }, { title: "Show tool activity", body: "Subscribe to tool events and display call details in messages.", }, { title: "Set maxToolRounds", body: "Prevent infinite tool call loops." }, ]} /> ### Step 1 — Define tools A ToolDefinition has a name, description, JSON Schema parameters, and an optional handler function. The description tells the model when to use the tool. ```typescript title="tools.ts" import type { ToolDefinition } from "@arlopass/react"; const tools: ToolDefinition[] = [ { name: "search_docs", description: "Search the documentation for a given query", parameters: { type: "object", properties: { query: { type: "string", description: "The search query", }, }, required: ["query"], }, handler: async (args) => { // Auto-executed when the model calls this tool const results = await searchDocs(args.query as string); return JSON.stringify(results); }, }, { name: "calculate", description: "Evaluate a math expression", parameters: { type: "object", properties: { expression: { type: "string", description: "The math expression to evaluate, e.g. '2 + 2'", }, }, required: ["expression"], }, handler: async (args) => { const expr = args.expression as string; // Simple evaluation — use a math library in production const result = Function(`"use strict"; return (${expr})`)(); return String(result); }, }, ]; ``` ### Step 2 — Pass tools to useConversation Pass the tools array to useConversation. The hook automatically injects tool descriptions into the system prompt so the model knows what's available. ```tsx title="Chat.tsx" import { useConversation } from "@arlopass/react"; function Chat() { const { messages, streamingContent, isStreaming, stream } = useConversation({ systemPrompt: "You can search docs and do math. Use your tools.", tools, // pass the tools array }); // The hook handles tool execution automatically // when tools have a handler function } ``` ### Step 3 — Auto-execute mode When a tool has a handler, it runs automatically. The SDK parses the model's tool call, runs your handler, feeds the result back, and lets the model continue. ```typescript title="tools.ts" // When a tool has a handler, it runs automatically: const tools: ToolDefinition[] = [ { name: "get_weather", description: "Get current weather for a city", parameters: { type: "object", properties: { city: { type: "string", description: "City name" }, }, required: ["city"], }, // This runs automatically when the model calls get_weather handler: async (args) => { const weather = await fetchWeather(args.city as string); return JSON.stringify(weather); }, }, ]; // The conversation flow: // 1. User: "What's the weather in Paris?" // 2. Model calls get_weather({ city: "Paris" }) // 3. Handler runs automatically, returns result // 4. Model generates final response using the result ``` ### Step 4 — Manual mode Omit the handler for tools that need user confirmation. Subscribe to "tool_call" events and call submitToolResult when ready. ```tsx title="Chat.tsx" import { useConversation } from "@arlopass/react"; function Chat() { const { messages, stream, subscribe, submitToolResult } = useConversation({ tools: [ { name: "approve_purchase", description: "Submit a purchase for approval", parameters: { type: "object", properties: { item: { type: "string", description: "Item name" }, amount: { type: "number", description: "Amount in USD" }, }, required: ["item", "amount"], }, // No handler — manual mode }, ], }); // Listen for tool calls and handle them yourself subscribe("tool_call", (event) => { console.log("Tool called:", event.name, event.arguments); // Show a confirmation dialog, then submit the result if (confirm(`Approve purchase of ${event.arguments.item}?`)) { submitToolResult(event.toolCallId, "Purchase approved"); } else { submitToolResult(event.toolCallId, "Purchase denied by user"); } }); } ``` <Callout type="warning" title="User-facing actions"> Use manual mode for tools that have side effects — purchases, deletions, emails. Always confirm with the user first. </Callout> ### Step 5 — Show tool activity Subscribe to tool_call and tool_result events to show the user what's happening. Messages also include a toolCalls array with call details and results. ```tsx title="Chat.tsx" import { useConversation } from "@arlopass/react"; function Chat() { const { messages, stream, subscribe } = useConversation({ tools }); // Subscribe to tool events for UI updates subscribe("tool_call", (event) => { console.log(`🔧 Calling ${event.name}(${JSON.stringify(event.arguments)})`); }); subscribe("tool_result", (event) => { console.log(`✅ ${event.name} returned: ${event.result}`); }); // Show tool calls within messages return ( <div> {messages.map((msg) => ( <div key={msg.id}> <strong>{msg.role}:</strong> {msg.content} {msg.toolCalls?.map((tc) => ( <div key={tc.toolCallId} style={{ fontSize: "0.85em", color: "#666" }} > 🔧 {tc.name}({JSON.stringify(tc.arguments)}) {tc.status === "complete" && ` → ${tc.result}`} </div> ))} </div> ))} </div> ); } ``` ### Step 6 — Set maxToolRounds Prevent infinite tool call loops by setting maxToolRounds. The default is 5. After this many rounds, the model must produce a text response. ```tsx title="Chat.tsx" const { messages, stream } = useConversation({ tools, maxToolRounds: 3, // Stop after 3 tool call rounds (default: 5) }); // This prevents infinite loops where the model keeps calling tools. // After maxToolRounds, the model must produce a text response. ``` ### Complete example A full app with search_docs and calculate tools: ```tsx title="App.tsx" import { useState } from "react"; import { ArlopassProvider, ChatReadyGate, useConversation, } from "@arlopass/react"; import type { ToolDefinition } from "@arlopass/react"; const tools: ToolDefinition[] = [ { name: "search_docs", description: "Search the documentation for a given query", parameters: { type: "object", properties: { query: { type: "string", description: "The search query", }, }, required: ["query"], }, handler: async (args) => { // Simulate a search return JSON.stringify([ { title: "Getting Started", snippet: "Install with npm..." }, { title: "API Reference", snippet: "useConversation hook..." }, ]); }, }, { name: "calculate", description: "Evaluate a math expression", parameters: { type: "object", properties: { expression: { type: "string", description: "The math expression to evaluate", }, }, required: ["expression"], }, handler: async (args) => { const expr = args.expression as string; const result = Function(`"use strict"; return (${expr})`)(); return String(result); }, }, ]; function Chat() { const { messages, streamingContent, isStreaming, stream, stop } = useConversation({ systemPrompt: "You can search docs and do calculations. Use your tools when appropriate.", tools, maxToolRounds: 3, }); const [input, setInput] = useState(""); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!input.trim() || isStreaming) return; const text = input; setInput(""); await stream(text); } return ( <div style={{ maxWidth: 600, margin: "0 auto" }}> <div style={{ minHeight: 300, padding: 16 }}> {messages.map((msg) => ( <div key={msg.id} style={{ padding: "8px 0" }}> <strong>{msg.role === "user" ? "You" : "AI"}:</strong> {msg.content} {msg.toolCalls?.map((tc) => ( <div key={tc.toolCallId} style={{ fontSize: "0.85em", color: "#888", marginLeft: 16 }} > 🔧 {tc.name}({JSON.stringify(tc.arguments)}) {tc.status === "complete" && ( <span style={{ color: "#4a9" }}> → {tc.result}</span> )} </div> ))} </div> ))} {isStreaming && streamingContent && ( <div style={{ padding: "8px 0", opacity: 0.7 }}> <strong>AI:</strong> {streamingContent} </div> )} </div> <form onSubmit={handleSubmit} style={{ display: "flex", gap: 8 }}> <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Try: search for hooks, or what is 42 * 17?" disabled={isStreaming} style={{ flex: 1, padding: 8 }} /> {isStreaming ? ( <button type="button" onClick={() => stop()}> Stop </button> ) : ( <button type="submit" disabled={!input.trim()}> Send </button> )} </form> </div> ); } export default function App() { return ( <ArlopassProvider appId="tool-calling-demo"> <ChatReadyGate connecting={<p>Connecting...</p>} noProvider={<p>Select a provider in the Arlopass extension.</p>} error={(err) => <p>Error: {err.message}</p>} > <Chat /> </ChatReadyGate> </ArlopassProvider> ); } ``` ### React SDK vs Web SDK The React SDK wraps ConversationManager's tool handling in hooks. Here's the same tool calling in both: #### React SDK ```tsx title="Chat.tsx" import { useConversation } from "@arlopass/react"; import type { ToolDefinition } from "@arlopass/react"; const tools: ToolDefinition[] = [ { name: "calculate", description: "Evaluate a math expression", parameters: { type: "object", properties: { expression: { type: "string", description: "Math expression" }, }, required: ["expression"], }, handler: async (args) => String(eval(args.expression as string)), }, ]; const { messages, stream } = useConversation({ tools }); await stream("What is 42 * 17?"); ``` #### Web SDK ```typescript title="main.ts" import { ArlopassClient, ConversationManager } from "@arlopass/web-sdk"; const client = new ArlopassClient({ transport: window.arlopass }); await client.connect({ appId: "my-app" }); const convo = new ConversationManager({ client, tools: [ { name: "calculate", description: "Evaluate a math expression", parameters: { type: "object", properties: { expression: { type: "string", description: "Math expression" }, }, required: ["expression"], }, handler: async (args) => String(eval(args.expression)), }, ], maxToolRounds: 3, }); for await (const event of convo.stream("What is 42 * 17?")) { if (event.type === "delta") process.stdout.write(event.content); if (event.type === "tool_call") console.log("Tool:", event.name); if (event.type === "tool_result") console.log("Result:", event.result); } ``` <Callout type="tip" title="What's next"> Explore the [tool calling guide](/docs/guides/tool-calling) for advanced patterns like tool priming, or learn about [guard components](/docs/guides/guard-components) for production error handling. </Callout>Give the AI access to your app’s functions.
What you’ll build
A chat app where the AI can call a documentation search function and a calculator. You’ll learn both auto-execute mode (tools run automatically) and manual mode (you confirm before executing).
Define tools
Create ToolDefinition objects with name, description, parameters, and optional handler.
Pass tools to useConversation
The hook injects tool descriptions so the model knows what's available.
Auto-execute mode
Tools with a handler run automatically when the model calls them.
Manual mode
Omit the handler for tools that need user confirmation.
Show tool activity
Subscribe to tool events and display call details in messages.
Set maxToolRounds
Prevent infinite tool call loops.
Step 1 — Define tools
A ToolDefinition has a name, description, JSON Schema parameters, and an optional handler function. The description tells the model when to use the tool.
import type { ToolDefinition } from "@arlopass/react";
const tools: ToolDefinition[] = [
{
name: "search_docs",
description: "Search the documentation for a given query",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query",
},
},
required: ["query"],
},
handler: async (args) => {
// Auto-executed when the model calls this tool
const results = await searchDocs(args.query as string);
return JSON.stringify(results);
},
},
{
name: "calculate",
description: "Evaluate a math expression",
parameters: {
type: "object",
properties: {
expression: {
type: "string",
description: "The math expression to evaluate, e.g. '2 + 2'",
},
},
required: ["expression"],
},
handler: async (args) => {
const expr = args.expression as string;
// Simple evaluation — use a math library in production
const result = Function(`"use strict"; return (${expr})`)();
return String(result);
},
},
];
Step 2 — Pass tools to useConversation
Pass the tools array to useConversation. The hook automatically injects tool descriptions into the system prompt so the model knows what’s available.
import { useConversation } from "@arlopass/react";
function Chat() {
const { messages, streamingContent, isStreaming, stream } = useConversation({
systemPrompt: "You can search docs and do math. Use your tools.",
tools, // pass the tools array
});
// The hook handles tool execution automatically
// when tools have a handler function
}
Step 3 — Auto-execute mode
When a tool has a handler, it runs automatically. The SDK parses the model’s tool call, runs your handler, feeds the result back, and lets the model continue.
// When a tool has a handler, it runs automatically:
const tools: ToolDefinition[] = [
{
name: "get_weather",
description: "Get current weather for a city",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
},
required: ["city"],
},
// This runs automatically when the model calls get_weather
handler: async (args) => {
const weather = await fetchWeather(args.city as string);
return JSON.stringify(weather);
},
},
];
// The conversation flow:
// 1. User: "What's the weather in Paris?"
// 2. Model calls get_weather({ city: "Paris" })
// 3. Handler runs automatically, returns result
// 4. Model generates final response using the result
Step 4 — Manual mode
Omit the handler for tools that need user confirmation. Subscribe to “tool_call” events and call submitToolResult when ready.
import { useConversation } from "@arlopass/react";
function Chat() {
const { messages, stream, subscribe, submitToolResult } = useConversation({
tools: [
{
name: "approve_purchase",
description: "Submit a purchase for approval",
parameters: {
type: "object",
properties: {
item: { type: "string", description: "Item name" },
amount: { type: "number", description: "Amount in USD" },
},
required: ["item", "amount"],
},
// No handler — manual mode
},
],
});
// Listen for tool calls and handle them yourself
subscribe("tool_call", (event) => {
console.log("Tool called:", event.name, event.arguments);
// Show a confirmation dialog, then submit the result
if (confirm(`Approve purchase of ${event.arguments.item}?`)) {
submitToolResult(event.toolCallId, "Purchase approved");
} else {
submitToolResult(event.toolCallId, "Purchase denied by user");
}
});
}
Step 5 — Show tool activity
Subscribe to tool_call and tool_result events to show the user what’s happening. Messages also include a toolCalls array with call details and results.
import { useConversation } from "@arlopass/react";
function Chat() {
const { messages, stream, subscribe } = useConversation({ tools });
// Subscribe to tool events for UI updates
subscribe("tool_call", (event) => {
console.log(`🔧 Calling ${event.name}(${JSON.stringify(event.arguments)})`);
});
subscribe("tool_result", (event) => {
console.log(`✅ ${event.name} returned: ${event.result}`);
});
// Show tool calls within messages
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong> {msg.content}
{msg.toolCalls?.map((tc) => (
<div
key={tc.toolCallId}
style={{ fontSize: "0.85em", color: "#666" }}
>
🔧 {tc.name}({JSON.stringify(tc.arguments)})
{tc.status === "complete" && ` → ${tc.result}`}
</div>
))}
</div>
))}
</div>
);
}
Step 6 — Set maxToolRounds
Prevent infinite tool call loops by setting maxToolRounds. The default is 5. After this many rounds, the model must produce a text response.
const { messages, stream } = useConversation({
tools,
maxToolRounds: 3, // Stop after 3 tool call rounds (default: 5)
});
// This prevents infinite loops where the model keeps calling tools.
// After maxToolRounds, the model must produce a text response.
Complete example
A full app with search_docs and calculate tools:
import { useState } from "react";
import {
ArlopassProvider,
ChatReadyGate,
useConversation,
} from "@arlopass/react";
import type { ToolDefinition } from "@arlopass/react";
const tools: ToolDefinition[] = [
{
name: "search_docs",
description: "Search the documentation for a given query",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query",
},
},
required: ["query"],
},
handler: async (args) => {
// Simulate a search
return JSON.stringify([
{ title: "Getting Started", snippet: "Install with npm..." },
{ title: "API Reference", snippet: "useConversation hook..." },
]);
},
},
{
name: "calculate",
description: "Evaluate a math expression",
parameters: {
type: "object",
properties: {
expression: {
type: "string",
description: "The math expression to evaluate",
},
},
required: ["expression"],
},
handler: async (args) => {
const expr = args.expression as string;
const result = Function(`"use strict"; return (${expr})`)();
return String(result);
},
},
];
function Chat() {
const { messages, streamingContent, isStreaming, stream, stop } =
useConversation({
systemPrompt:
"You can search docs and do calculations. Use your tools when appropriate.",
tools,
maxToolRounds: 3,
});
const [input, setInput] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || isStreaming) return;
const text = input;
setInput("");
await stream(text);
}
return (
<div style={{ maxWidth: 600, margin: "0 auto" }}>
<div style={{ minHeight: 300, padding: 16 }}>
{messages.map((msg) => (
<div key={msg.id} style={{ padding: "8px 0" }}>
<strong>{msg.role === "user" ? "You" : "AI"}:</strong> {msg.content}
{msg.toolCalls?.map((tc) => (
<div
key={tc.toolCallId}
style={{ fontSize: "0.85em", color: "#888", marginLeft: 16 }}
>
🔧 {tc.name}({JSON.stringify(tc.arguments)})
{tc.status === "complete" && (
<span style={{ color: "#4a9" }}> → {tc.result}</span>
)}
</div>
))}
</div>
))}
{isStreaming && streamingContent && (
<div style={{ padding: "8px 0", opacity: 0.7 }}>
<strong>AI:</strong> {streamingContent}
</div>
)}
</div>
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 8 }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Try: search for hooks, or what is 42 * 17?"
disabled={isStreaming}
style={{ flex: 1, padding: 8 }}
/>
{isStreaming ? (
<button type="button" onClick={() => stop()}>
Stop
</button>
) : (
<button type="submit" disabled={!input.trim()}>
Send
</button>
)}
</form>
</div>
);
}
export default function App() {
return (
<ArlopassProvider appId="tool-calling-demo">
<ChatReadyGate
connecting={<p>Connecting...</p>}
noProvider={<p>Select a provider in the Arlopass extension.</p>}
error={(err) => <p>Error: {err.message}</p>}
>
<Chat />
</ChatReadyGate>
</ArlopassProvider>
);
}
React SDK vs Web SDK
The React SDK wraps ConversationManager’s tool handling in hooks. Here’s the same tool calling in both:
React SDK
import { useConversation } from "@arlopass/react";
import type { ToolDefinition } from "@arlopass/react";
const tools: ToolDefinition[] = [
{
name: "calculate",
description: "Evaluate a math expression",
parameters: {
type: "object",
properties: {
expression: { type: "string", description: "Math expression" },
},
required: ["expression"],
},
handler: async (args) => String(eval(args.expression as string)),
},
];
const { messages, stream } = useConversation({ tools });
await stream("What is 42 * 17?");
Web SDK
import { ArlopassClient, ConversationManager } from "@arlopass/web-sdk";
const client = new ArlopassClient({ transport: window.arlopass });
await client.connect({ appId: "my-app" });
const convo = new ConversationManager({
client,
tools: [
{
name: "calculate",
description: "Evaluate a math expression",
parameters: {
type: "object",
properties: {
expression: { type: "string", description: "Math expression" },
},
required: ["expression"],
},
handler: async (args) => String(eval(args.expression)),
},
],
maxToolRounds: 3,
});
for await (const event of convo.stream("What is 42 * 17?")) {
if (event.type === "delta") process.stdout.write(event.content);
if (event.type === "tool_call") console.log("Tool:", event.name);
if (event.type === "tool_result") console.log("Result:", event.result);
}