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):
| Helper | Stops 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 itstool_callsandapprovalRequests) alongsideapprovalResponses. A bareBaleybotwithkeepHistoryonly records assistant text, so use@baleybots/chat(segment-based history) or supplyconversationHistoryexplicitly 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
onApprovalRequiredhandler, it's called inline and the run does not pause — use this for programmatic policies that don't need a human round-trip. NestedProcessabletools 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()callsdefineZodTool()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). Usetool()ordefineZodTool()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
| Need | Use |
|---|---|
| Fixed specialist + typed return | subagentTool({ bot, inputSchema }) |
| Parent invents name/goal at call time | examples spawnBaleybotTool (dynamic) |
| Production CLI / BAL pipelines | CLI spawn_agent / createBalTools |
API reference
tool(name, description, inputSchema, fn, options?)
Quick inline tool definition using Zod schema.
name-- tool namedescription-- what the tool doesinputSchema-- Zod schema for parametersfn-- 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 namebot-- specialistBaleybotinputSchema-- Zod schema for tool argumentsdescription?-- defaults tobot.config.goalmapInput?-- map args → childprocess()input (default:query/task/inputstring, else JSON)toModelOutput?-- transform the typed child result for the parent modelrequiresApproval?-- approval gate on the standard tool path
defineZodTool(definition)
Object-based tool definition with Zod schema.
name-- tool namedescription-- what the tool doesinputSchema-- Zod schema for parametersexecute-- the function to executerequiresApproval?-- boolean or function for approval gatingstrict?-- enable strict JSON schema modetoModelOutput?-- transform output before sending to model
defineTool(definition)
Tool definition with raw JSON Schema.
name-- tool namedescription-- what the tool doesinputSchema-- JSON Schema object for parametersexecute-- the function to execute
Baleybot config options
tools?-- record of tool definitionsoutput?-- structured output viaOutput.object(),Output.array(), orOutput.choice()(see Structured Outputs)stopWhen?-- when to stop the tool loop (default:stepCountIs(50))