Client tools.
Tools that execute locally, wired into the agent loop.
Client tools are handlers you register that the agent can invoke mid-turn. The SDK handles the entire roundtrip loop: detecting tool calls, executing your handler, feeding the result back to the agent, and repeating until the agent stops calling tools or the roundtrip limit is reached.
Tool execution loop
┌─────────────────────────────────────────────────────────┐
│ Turn 0 │
│ 1. Send messages + clientTools to server │
│ 2. Stream events — agent responds, may call tools │
│ 3. If tool calls detected: │
│ a. client.tool.requested → client.tool.executing │
│ b. Execute handler from toolHandlers │
│ c. client.tool.result │
│ d. Append result to messages as a user message │
│ e. client.tool.roundtrip (turn + 1 / maxTurns) │
│ f. Go to Turn 1 │
│ 4. If no tool calls: run is done │
│ 5. If turn >= maxToolRoundtrips: truncated │
└─────────────────────────────────────────────────────────┘Declaring and handling tools
import { stream, type ToolHandlers } from '@maincode-ai/matilda-agent-sdk';
// 1. Declare the tools so the server knows they exist
const clientTools = [
{ name: 'get_weather', description: 'Get current weather for a city', parameters: { type: 'object' } },
{ name: 'calculate', description: 'Evaluate a math expression', parameters: { type: 'object' } },
];
// 2. Register handlers — the SDK calls these when the agent invokes a tool
const toolHandlers: ToolHandlers = {
get_weather: async (args) => {
const city = (args.city as string) ?? 'unknown';
return { content: JSON.stringify({ city, temp: 22, condition: 'sunny' }) };
},
calculate: async (args) => {
const expr = args.expression as string;
try {
const result = Function(`return (${expr})`)();
return { content: String(result) };
} catch {
return { content: 'Invalid expression', isError: true };
}
},
};
// 3. Pass both to stream() or run()
for await (const event of stream(
{ name: 'assistant', instructions: 'Use the available tools to answer questions.' },
'What is the weather in Sydney, and what is 15 * 23?',
{ toolHandlers, clientTools },
)) {
if (event.type === 'client.tool.requested') {
console.log(`→ Agent requested: ${event.name}(${JSON.stringify(event.args)})`);
}
if (event.type === 'client.tool.executing') {
console.log(`⚙ Executing: ${event.name}`);
}
if (event.type === 'client.tool.result') {
console.log(`← Result: ${event.result}${event.isError ? ' (error)' : ''}`);
}
if (event.type === 'client.tool.roundtrip') {
console.log(` Roundtrip ${event.turn}/${event.maxTurns}`);
}
if (event.type === 'message.delta') {
process.stdout.write(event.delta);
}
}ToolHandler
type ToolHandler = (
args: Record<string, unknown>,
ctx: ToolExecutionContext,
) => Promise<ToolResult>;ToolExecutionContext
interface ToolExecutionContext {
toolCallId?: string;
signal?: AbortSignal; // The run's AbortSignal, if provided
}ToolResult
interface ToolResult {
content: string;
isError?: boolean;
}ToolHandlers
type ToolHandlers = Record<string, ToolHandler>;maxToolRoundtrips
Controls how many back-and-forth tool cycles the SDK allows before stopping. Default is 25 (DEFAULT_MAX_TOOL_ROUNDTRIPS). Lower it to prevent infinite loops or control cost.
const result = await runner.run(
{ name: 'tool-heavy-agent', instructions: 'Use tools to gather information.' },
'Do a task that needs tools',
{
maxToolRoundtrips: 5,
toolHandlers: { search: async () => ({ content: 'search results...' }) },
clientTools: [{ name: 'search', description: 'Search', parameters: { type: 'object' } }],
},
);
const roundtrips = result.events.filter((e) => e.type === 'client.tool.roundtrip');
console.log(`Roundtrips used: ${roundtrips.length} (max was 5)`);Advertised-tool guard
The SDK enforces that the agent can only call tools that were advertised for the current turn. If the model calls a tool that wasn't in clientTools, the SDK returns an error result instead of executing a handler:
Tool not offered this turn: <name>This prevents a model from talking the runner into invoking a handler that was never offered — tool output is untrusted input.
DSML tool-call interception
Some models emit tool calls as text tokens wrapped in DSML markup (<|DSML|tool_call>{...}<|DSML|/tool_call>) instead of using native function calling. The SDK automatically intercepts these text tokens, parses the JSON payload, and surfaces them as native client.tool.requested events — the consumer never sees the raw markup.
This interception is fully automatic and applies to all streaming paths.
Human-in-the-loop
The tool execution loop makes human-in-the-loop trivial: a tool handler is just an async function, so it can block on stdin, a UI prompt, or any other input source.
import * as readline from 'node:readline/promises';
const toolHandlers: ToolHandlers = {
ask_user: async (args) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = await rl.question(`\n Agent asks: ${args.question}\n > `);
return { content: answer.trim() };
} finally {
rl.close;
}
},
};
const clientTools = [
{ name: 'ask_user', description: 'Ask the user a question', parameters: { type: 'object' } },
];
const result = await runner.run(
{ name: 'clarifier', instructions: 'Ask the user for clarification when needed.' },
'Help me plan a trip',
{ toolHandlers, clientTools },
);