Agent SDK · Tools and sessions

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

text
┌─────────────────────────────────────────────────────────┐
│  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

TypeScript
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

TypeScript
type ToolHandler = (
  args: Record<string, unknown>,
  ctx: ToolExecutionContext,
) => Promise<ToolResult>;

ToolExecutionContext

TypeScript
interface ToolExecutionContext {
  toolCallId?: string;
  signal?: AbortSignal;  // The run's AbortSignal, if provided
}

ToolResult

TypeScript
interface ToolResult {
  content: string;
  isError?: boolean;
}

ToolHandlers

TypeScript
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.

TypeScript
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:

text
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.

TypeScript
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 },
);