# Client tools

> Tools that execute locally, wired into the agent loop.

Source: https://maincode.com/docs/agent-sdk-client-tools
Section: Agent SDK · Matilda documentation

---

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

```ts
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`

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

## `ToolExecutionContext`

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

## `ToolResult`

```ts
interface ToolResult {
  content: string;
  isError?: boolean;
}
```

## `ToolHandlers`

```ts
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.

```ts
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.

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