Skip to content
astorlm
← Map

Level 3

Designing a tool

The model never sees your tool's code. It sees a name, a description and a schema, and from that alone it decides whether to use the tool and what to pass. For the model, that's the whole tool.
1/16 Bandoneón folds:
  • user
  • assistant
  • tool_result
A wild creature blocks the path. Its type is still ???. The Oracle has four tools, and all it will ever know about them is the move list: names, parameters and descriptions.

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_matchup

    A 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)?')

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.