Skip to main content

Tools

Give your bots capabilities by defining tools. A tool is a function the LLM can call during processing, with typed parameters and automatic schema generation.

Defining tools

Use the tool() helper. It takes a name, description, Zod schema, and function:

import { Baleybot, tool } from '@baleybots/core';
import { z } from 'zod';

// 1. Define the input schema
const WeatherInput = z.object({
city: z.string().describe('City name'),
});

// 2. Define the tool
const weatherTool = tool(
'get_weather',
'Get current weather for a city',
WeatherInput,
async ({ city }) => {
// Your weather API call here
return { temp: 72, condition: 'sunny', city };
},
);

// 3. Create the processor
const weatherBot = Baleybot.create({
name: 'weather-lookup',
goal: 'Look up current weather for a city',
tools: { weather: weatherTool },
});

const answer = await weatherBot.process(
"What's the weather in Tokyo?"
);

Parameters are automatically typed from the Zod schema. No manual type annotations needed.

Complex parameters

Zod handles all parameter shapes naturally:

// Object parameters — extract nested schemas
const UserInput = z.object({
name: z.string().describe('User name'),
age: z.number().optional().describe('User age'),
});

const createUser = tool(
'create_user',
'Creates a new user',
z.object({ user: UserInput }),
async ({ user }) => `Created user ${user.name}`,
);

// Array parameters
const SumInput = z.object({
numbers: z.array(z.number()).describe('Numbers to sum'),
});

const sumArray = tool(
'sum_array',
'Sums an array of numbers',
SumInput,
async ({ numbers }) => numbers.reduce((a, b) => a + b, 0),
);

// Enum parameters
const Color = z.enum(['red', 'green', 'blue']);

const setColor = tool(
'set_color',
'Sets the color',
z.object({ color: Color.describe('The color to set') }),
async ({ color }) => `Color set to ${color}`,
);

Tool loop control (stopWhen)

When a processor has tools, Baleybots runs an AI SDK v7 tool loop. Control when it stops with stopWhen on Baleybot.create() (or per-call via process() options).

Default: stepCountIs(50) — up to 50 steps before the loop exits.

import {
Baleybot,
tool,
stepCountIs,
hasToolResult,
combineConditions,
} from '@baleybots/core';
import { z } from 'zod';

const submitAnswer = tool(
'submit_answer',
'Submit the final answer',
z.object({ answer: z.string() }),
async ({ answer }) => ({ answer }),
);

const bot = Baleybot.create({
name: 'researcher',
goal: 'Research and submit a final answer',
tools: { submit_answer: submitAnswer },
// Stop after 10 steps OR when submit_answer returns
stopWhen: combineConditions(
stepCountIs(10),
hasToolResult('submit_answer'),
),
});

Available stop helpers (from @baleybots/core):

HelperStops when…
stepCountIs(n)n steps have completed
hasToolResult(name, predicate?)Named tool produced a result on the latest step
hasToolCall(...names)Latest step called one of the named tools (AI SDK re-export)
isLoopFinished()AI SDK reports the loop is finished
noToolCalls()Latest step made no tool calls
totalToolCallsExceed(n)Total tool calls across all steps ≥ n
combineConditions(a, b, …)Any condition is met (OR)
allConditions(a, b, …)Every condition is met (AND)

combineConditions is for stop predicates only — not the same as multimodal combine().

AI SDK features (v7)

Tools support AI SDK v7 features via an optional fifth argument to tool():

Approval gating

Require user approval before a tool executes:

const deleteTool = tool(
'delete_file',
'Delete a file from the system',
z.object({ path: z.string() }),
async ({ path }) => {
// delete logic
return { deleted: true };
},
{ requiresApproval: true }
);

You can also gate conditionally:

const WriteFileInput = z.object({
path: z.string(),
content: z.string(),
});

const writeTool = tool(
'write_file',
'Write content to a file',
WriteFileInput,
async ({ path, content }) => {
// write logic
return { written: true };
},
{
requiresApproval: (params) =>
params.path.startsWith('/etc/') || params.path.includes('.env'),
},
);

How approval works: request → response → resume

When a requiresApproval tool comes up and there is no in-process approval handler, the run pauses instead of blocking. Baleybot emits a tool_approval_request stream event and returns — nothing is executed yet. Your app decides, then resumes the same turn by calling process() again with approvalResponses.

Resume needs the paused turn (the assistant message carrying the approval request) in the conversation history. A stateful chat records this for you, so the two calls just work:

import { ChatBot } from '@baleybots/chat';

const chat = ChatBot.forUser(bot); // segment-based history, records the paused turn

// 1. First call pauses on the gated tool.
let pending: { approvalId: string; toolName: string; arguments: unknown } | undefined;

await chat.process('Delete /tmp/report.txt', {
onToken: (_name, event) => {
if (event.type === 'tool_approval_request') {
pending = {
approvalId: event.approvalId,
toolName: event.toolName,
arguments: event.arguments,
};
}
},
});
// `pending` is now set; the tool has NOT executed. The paused turn (tool call +
// approval request) is now in `chat`'s history.

// 2. Ask your user (UI, CLI, policy check)… then resume the SAME turn.
if (pending) {
await chat.process('Delete /tmp/report.txt', {
approvalResponses: [{ approvalId: pending.approvalId, approved: true }],
});
// The tool executes now that it's approved.
}

Deny by passing approved: false (with an optional reason); the tool is skipped and the model is told it was rejected:

approvalResponses: [
{ approvalId: pending.approvalId, approved: false, reason: 'Not allowed in prod' },
]

Stateless / cold-worker resume: if you're not using a stateful chat, pass the prior turn back yourself via conversationHistory (it must include the assistant message with its tool_calls and approvalRequests) alongside approvalResponses. A bare Baleybot with keepHistory only records assistant text, so use @baleybots/chat (segment-based history) or supply conversationHistory explicitly to round-trip a pending approval.

The stream events are:

  • tool_approval_request{ approvalId, toolCallId, toolName, arguments }
  • tool_approval_response{ approvalId, approved, reason? }

The segment reducer marks the tool-call segment awaiting_approval (with its approvalId) while paused, so a UI can render the pending gate directly from segments.

Synchronous approval (no pause): if you supply an in-process onApprovalRequired handler, it's called inline and the run does not pause — use this for programmatic policies that don't need a human round-trip. Nested Processable tools that require approval without an in-process handler are denied with a clear error (v1 does not bubble nested approvals across requests).

In React (useChat)

useChat consumes these events for you. Awaiting-approval tool segments populate pendingApprovals and fire onApprovalRequest; call respondToToolApprovals(responses) with exactly one decision per pending id to resume — in client mode it re-runs process(), in server mode it POSTs approvalResponses to your existing chat route (no separate approval endpoint). Use shouldAskApproval(toolName) to auto-approve/deny specific tools without prompting; remaining manual approvals still require a batch response.

const { pendingApprovals, respondToToolApprovals } = useChat({ chat });

if (pendingApprovals.length > 0) {
return (
<div>
{pendingApprovals.map((p) => (
<div key={p.approvalId}>Approve <b>{p.toolCall.name}</b>?</div>
))}
<button
onClick={() =>
respondToToolApprovals(
pendingApprovals.map((p) => ({ approvalId: p.approvalId, approved: true })),
)
}
>
Approve all
</button>
<button
onClick={() =>
respondToToolApprovals(
pendingApprovals.map((p) => ({
approvalId: p.approvalId,
approved: false,
reason: 'No thanks',
})),
)
}
>
Reject all
</button>
</div>
);
}

Strict schema mode

Force the provider to enforce strict adherence to the schema, preventing hallucinated parameters:

const strictTool = tool(
'query_db',
'Query the database',
z.object({ sql: z.string() }),
async ({ sql }) => { /* ... */ },
{ strict: true }
);

Output transformation

Control what the model sees after a tool runs:

const searchTool = tool(
'search',
'Search the knowledge base',
z.object({ query: z.string() }),
async ({ query }) => {
const results = await search(query);
return results; // full results returned to your code
},
{
// model only sees a summary, not the full payload
toModelOutput: ({ output }) => ({
type: 'text',
value: `Found ${output.length} results. Top: ${output[0]?.title}`,
}),
}
);

You can also omit tool output from the model entirely:

{
toModelOutput: () => ({ type: 'omit' })
}

Streaming tool events

Baleybot handles tool execution internally. To observe tool calls in real-time, use onToken:

const result = await bot.process('What is 5 + 3?', {
onToken: (_name, event) => {
switch (event.type) {
case 'tool_call_stream_start':
console.log('Calling tool:', event.toolName);
break;
case 'tool_execution_output':
console.log('Tool result:', event.result);
break;
}
},
});

See the Streaming guide for all event types.

Alternative APIs

For most use cases, tool() is all you need. Two alternative APIs exist for specific situations:

defineZodTool() -- object-based syntax

Identical to tool() but uses an object instead of positional arguments. Use this if you prefer named properties or need to spread options:

import { defineZodTool } from '@baleybots/core';
import { z } from 'zod';

const AddInput = z.object({
a: z.number().describe('First number'),
b: z.number().describe('Second number'),
});

const addTool = defineZodTool({
name: 'add',
description: 'Adds two numbers',
inputSchema: AddInput,
execute: (params) => params.a + params.b,
requiresApproval: false,
strict: true,
});

Note: tool() calls defineZodTool() internally -- they produce identical results.

defineTool() -- raw JSON Schema (deprecated)

Deprecated -- will be removed in the next major version. Use tool() or defineZodTool() instead. Kept only for pre-existing JSON Schema you don't want to convert to Zod yet:

import { defineTool } from '@baleybots/core';

const addTool = defineTool({
name: 'add',
description: 'Adds two numbers',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' },
},
required: ['a', 'b'],
},
execute: ({ a, b }: { a: number; b: number }) => a + b,
});

Note: defineTool() does not support AI SDK v7 tool options (requiresApproval, strict, toModelOutput). Use tool() or defineZodTool() for those.

Typed subagents

Wrap a specialist Baleybot (usually with output: Output.object(...)) as a normal Zod tool. The parent calls it like any other tool; the tool result is the child's typed process() return — not a { botName, response } envelope. This matches the AI SDK agent-as-tool pattern.

Basic — typed specialist as a function block

import { Baleybot, Output, subagentTool } from '@baleybots/core';
import { z } from 'zod';

const Findings = z.object({
summary: z.string(),
sources: z.array(z.string()),
confidence: z.number().min(0).max(1),
});

const researcher = Baleybot.create({
name: 'researcher',
goal: 'Research a topic and return structured findings',
model: 'gpt-4.1-mini',
output: Output.object({ schema: Findings }),
});

const coordinator = Baleybot.create({
name: 'coordinator',
goal: 'Answer the user by delegating research when needed',
model: 'gpt-4.1-mini',
tools: {
research: subagentTool({
name: 'research',
bot: researcher,
inputSchema: z.object({
query: z.string().describe('What to research'),
}),
}),
},
});

const answer = await coordinator.process('What changed in AI SDK v7 agents?');

Parallel specialists — distinct Output schemas

const Sentiment = z.object({
label: z.enum(['positive', 'neutral', 'negative']),
score: z.number(),
});

const Entities = z.object({
people: z.array(z.string()),
orgs: z.array(z.string()),
});

const sentimentBot = Baleybot.create({
name: 'sentiment',
goal: 'Classify sentiment',
output: Output.object({ schema: Sentiment }),
});

const entityBot = Baleybot.create({
name: 'entities',
goal: 'Extract named entities',
output: Output.object({ schema: Entities }),
});

const analyzer = Baleybot.create({
name: 'analyzer',
goal: 'Analyze text with specialist subagents',
tools: {
sentiment: subagentTool({
name: 'sentiment',
bot: sentimentBot,
inputSchema: z.object({ text: z.string() }),
mapInput: ({ text }) => text,
}),
entities: subagentTool({
name: 'entities',
bot: entityBot,
inputSchema: z.object({ text: z.string() }),
mapInput: ({ text }) => text,
}),
},
});

Context offload — summary for the parent model

UI still receives nested tool_execution_stream events; toModelOutput controls what the parent model sees:

research: subagentTool({
name: 'research',
bot: researcher,
inputSchema: z.object({ query: z.string() }),
toModelOutput: ({ output }) => ({
type: 'text',
value: `${output.summary}\nSources: ${output.sources.join(', ')}`,
}),
}),

When not to use subagentTool

NeedUse
Fixed specialist + typed returnsubagentTool({ bot, inputSchema })
Parent invents name/goal at call timeexamples spawnBaleybotTool (dynamic)
Production CLI / BAL pipelinesCLI spawn_agent / createBalTools

API reference

tool(name, description, inputSchema, fn, options?)

Quick inline tool definition using Zod schema.

  • name -- tool name
  • description -- what the tool does
  • inputSchema -- Zod schema for parameters
  • fn -- the function to execute (params automatically typed)
  • options? -- AI SDK v7 features: requiresApproval, strict, toModelOutput

subagentTool(options)

Wrap a typed Baleybot (typically with output: Output.object(...)) as a Zod tool for a parent bot's tools map.

  • name -- tool name
  • bot -- specialist Baleybot
  • inputSchema -- Zod schema for tool arguments
  • description? -- defaults to bot.config.goal
  • mapInput? -- map args → child process() input (default: query / task / input string, else JSON)
  • toModelOutput? -- transform the typed child result for the parent model
  • requiresApproval? -- approval gate on the standard tool path

defineZodTool(definition)

Object-based tool definition with Zod schema.

  • name -- tool name
  • description -- what the tool does
  • inputSchema -- Zod schema for parameters
  • execute -- the function to execute
  • requiresApproval? -- boolean or function for approval gating
  • strict? -- enable strict JSON schema mode
  • toModelOutput? -- transform output before sending to model

defineTool(definition)

Tool definition with raw JSON Schema.

  • name -- tool name
  • description -- what the tool does
  • inputSchema -- JSON Schema object for parameters
  • execute -- the function to execute

Baleybot config options

  • tools? -- record of tool definitions
  • output? -- structured output via Output.object(), Output.array(), or Output.choice() (see Structured Outputs)
  • stopWhen? -- when to stop the tool loop (default: stepCountIs(50))