Level 3
Designing a tool
- user
- assistant
- tool_result
EventBus
The problem
When you write a function, you know what it does. The model doesn't: all it gets is the sign on the Workshop door. If the sign is vague, the model guesses. A wrong guess costs a turn, an error, and more tokens in the history. Worse, a vague tool can "succeed" with a useless result and nobody notices.
In the battle, a wild creature blocks the path and the trainer asks which partner to send in. The Oracle has four
tools, and the move menu shows exactly what the model gets for each one: a name, its parameters and a
description. That's enough to skip do_stuff, to skip heal_party because its
description says "never in battle", to know that the creature's type has to be found first, and to send
check_matchup its input in the right format on the first try.
Anatomy of a good tool
-
Name
Instead of
do_stuff, use identify_wild, check_matchupA specific verb and noun tell the model what the tool does before it reads anything else.
-
Description
Instead of
"Does stuff.", use What it does, when to use it, and what it returns.This is the only documentation the model gets. Write it for someone who can’t read your code.
-
Parameters
Instead of
x: string, use attack and defend, each with a description, and the format spelled out: one lowercase type, like water.Mark what’s required and forbid extras, so a wrong input fails fast instead of doing something odd.
-
Output
Instead of
The whole type chart, 18 types by 18, use One line: grass vs water: 2x, super effective.Everything a tool returns goes into the history, and the model reads it again every turn.
-
Errors
Instead of
"Invalid input", use "defend must be one lowercase type, like water. Call identify_wild to get it."An error the model can understand is an error it can fix on its next turn.
One more rule: fewer, sharper tools. Every tool you add is one more sign the model reads on every turn, and one more way to pick the wrong one.
The code
With astorlm: tool() takes a Zod schema, turns it into the JSON Schema the model reads, and checks the model's
input against it before your code runs. If the input doesn't match, the model gets a clear error instead of your
function crashing.
From scratch: A tool is a JSON Schema the model reads plus a function the model never sees. Here are Crooky's
do_stuff and the two tools the Oracle used in the battle, side by side. Only the schemas travel to the
model, in the tools field of each request from level 2.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const type = z
.string()
.regex(/^[a-z]+$/, 'a type is one lowercase word, like water; call identify_wild to get the wild one')
// Describe the input once with Zod: astorlm turns it into the JSON Schema the model
// reads, and checks the model's input against it before running your code.
const checkMatchup = tool({
name: 'check_matchup',
description:
'How hard one type hits another. Use it to choose which partner to send into a battle. ' +
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.',
schema: z.object({
attack: type.describe('The attacking type, one lowercase word, e.g. "grass".'),
defend: type.describe('The defending type, one lowercase word, e.g. "water".'),
}),
execute: async ({ attack, defend }) => {
const times = typeChart[attack]?.[defend] ?? 1 // your code; the model never sees it
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
},
})
const identifyWild = tool({
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
schema: z.object({}),
execute: async () => {
const wild = battle.opponent() // your code
return `${wild.name} · type: ${wild.type}`
},
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [checkMatchup, identifyWild],
})
await agent.run('A wild creature appeared! Should I send Emberpup (fire) or Sproutle (grass)?')
// A tool is two things: a description the model reads, and code the model never sees.
// ✗ Crooky's tool. The model has to guess what it does and what x means.
const doStuffSchema = {
type: 'function',
function: {
name: 'do_stuff',
description: 'Does stuff.',
parameters: { type: 'object', properties: { x: { type: 'string' } } },
},
}
// ✓ Named for what it does, described for someone who can't read the code.
const checkMatchupSchema = {
type: 'function',
function: {
name: 'check_matchup',
description:
'How hard one type hits another. ' + // what it does
'Use it to choose which partner to send into a battle. ' + // when to use it
'Returns one line: the multiplier (2x, 1x or 0.5x) and what it means.', // what comes back
parameters: {
type: 'object',
properties: {
attack: { type: 'string', description: 'The attacking type, one lowercase word, e.g. "grass".' },
defend: { type: 'string', description: 'The defending type, one lowercase word, e.g. "water".' },
},
required: ['attack', 'defend'],
additionalProperties: false,
},
},
}
// ✓ A small helper tool, so the model never has to guess what it is facing.
const identifyWildSchema = {
type: 'function',
function: {
name: 'identify_wild',
description: 'Identify the wild creature you are facing. Returns its name and type.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
}
// The code behind the names. In a real game, these read the battle state.
export function identifyWild(): string {
const wild = battle.opponent()
return `${wild.name} · type: ${wild.type}`
}
export function checkMatchup({ attack, defend }: { attack: string; defend: string }): string {
// An error the model can fix on its next turn: what went wrong, and what to send instead.
for (const value of [attack, defend]) {
if (!/^[a-z]+$/.test(value)) {
return `Error: a type is one lowercase word, like water. Got "${value}". Call identify_wild to get the wild one.`
}
}
const times = typeChart[attack]?.[defend] ?? 1
// Short, on-topic output: every character here goes into the history, and gets read every turn.
return `${attack} vs ${defend}: ${times}x${times > 1 ? ', super effective' : ''}`
}
// Only the schemas travel to the model, in the `tools` field of each request.
export const toolSchemas = [checkMatchupSchema, identifyWildSchema]
# A tool is two things: a description the model reads, and code the model never sees.
import re
# ✗ Crooky's tool. The model has to guess what it does and what x means.
DO_STUFF_SCHEMA = {
"type": "function",
"function": {
"name": "do_stuff",
"description": "Does stuff.",
"parameters": {"type": "object", "properties": {"x": {"type": "string"}}},
},
}
# ✓ Named for what it does, described for someone who can't read the code.
CHECK_MATCHUP_SCHEMA = {
"type": "function",
"function": {
"name": "check_matchup",
"description": (
"How hard one type hits another. " # what it does
"Use it to choose which partner to send into a battle. " # when to use it
"Returns one line: the multiplier (2x, 1x or 0.5x) and what it means." # what comes back
),
"parameters": {
"type": "object",
"properties": {
"attack": {"type": "string", "description": 'The attacking type, one lowercase word, e.g. "grass".'},
"defend": {"type": "string", "description": 'The defending type, one lowercase word, e.g. "water".'},
},
"required": ["attack", "defend"],
"additionalProperties": False,
},
},
}
# ✓ A small helper tool, so the model never has to guess what it is facing.
IDENTIFY_WILD_SCHEMA = {
"type": "function",
"function": {
"name": "identify_wild",
"description": "Identify the wild creature you are facing. Returns its name and type.",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
},
}
# The code behind the names. In a real game, these read the battle state.
def identify_wild():
wild = battle.opponent()
return f"{wild['name']} · type: {wild['type']}"
def check_matchup(attack, defend):
# An error the model can fix on its next turn: what went wrong, and what to send instead.
for value in (attack, defend):
if not re.fullmatch(r"[a-z]+", value):
return f'Error: a type is one lowercase word, like water. Got "{value}". Call identify_wild to get the wild one.'
times = TYPE_CHART.get(attack, {}).get(defend, 1)
# Short, on-topic output: every character here goes into the history, and gets read every turn.
return f"{attack} vs {defend}: {times}x" + (", super effective" if times > 1 else "")
# Only the schemas travel to the model, in the `tools` field of each request.
TOOL_SCHEMAS = [CHECK_MATCHUP_SCHEMA, IDENTIFY_WILD_SCHEMA]
Check your tools like the model would
- Read only the schema. Hide the code and ask yourself: would I know when to call this, and what to pass?
- Watch the first calls. If the model keeps sending the wrong input, the fix is almost always in the description, not the prompt.
- Measure the output. A tool that returns the whole type chart when one line would do fills the history fast.