DEV Community

Cover image for Tool Calling in TypeScript: Zod Schema to Working Function in 40 Lines
Gabriel Anhaia
Gabriel Anhaia

Posted on

Tool Calling in TypeScript: Zod Schema to Working Function in 40 Lines


Tool calling gets described as a framework feature, which makes it
sound like something you adopt rather than something you write. It is
about forty lines of TypeScript, and understanding those forty lines
is what lets you debug the framework later when it does something you
did not expect.

Here is the whole thing, then the three places real code needs more.

One schema, two consumers

Start with Zod, because it feeds both sides.

import { z } from "zod";

const GetWeather = z.object({
  city: z.string().describe("City name, e.g. 'Berlin'"),
  unit: z.enum(["c", "f"]).default("c"),
});

type GetWeather = z.infer<typeof GetWeather>;
Enter fullscreen mode Exit fullscreen mode

.describe() is not decoration. It becomes the field description in
the JSON Schema the model reads, and it is frequently the difference
between the model passing "Berlin" and passing
"Berlin, Germany, Europe".

Now define the tool as data:

type Tool<S extends z.ZodType> = {
  name: string;
  description: "string;"
  schema: S;
  run: (args: z.infer<S>) => Promise<unknown>;
};

function tool<S extends z.ZodType>(t: Tool<S>) {
  return t;
}
Enter fullscreen mode Exit fullscreen mode

The generic is what makes run typed. Inside the handler, args is
{ city: string; unit: "c" | "f" } — inferred from the schema, not
declared twice.

const getWeather = tool({
  name: "get_weather",
  description: ""
    "Current weather for a city. Use when the user asks about " +
    "weather, temperature, or conditions in a named place.",
  schema: GetWeather,
  async run({ city, unit }) {
    const r = await fetch(`${API}/current?city=${city}&unit=${unit}`);
    if (!r.ok) throw new WeatherUnavailable(city, r.status);
    return WeatherResult.parse(await r.json());
  },
});
Enter fullscreen mode Exit fullscreen mode

The description is written for a model, not a teammate. Say what the
tool does and when to reach for it — the "when" is what prevents the
model choosing it for a question about climate history.

Registering and calling

import zodToJsonSchema from "zod-to-json-schema";

const TOOLS = [getWeather, getForecast] as const;

const defs = TOOLS.map((t) => ({
  name: t.name,
  description: "t.description,"
  input_schema: zodToJsonSchema(t.schema, { target: "openApi3" }),
}));

const res = await client.messages.create({
  model: "claude-opus-5",
  max_tokens: 1024,
  tools: defs,
  messages,
});
Enter fullscreen mode Exit fullscreen mode

Generating the JSON Schema from the Zod object keeps one source of
truth. Hand-writing the JSON Schema alongside a Zod schema means two
definitions that drift, and the drift is invisible until the model
sends a field your validator rejects.

The loop

async function runTurn(messages: MessageParam[]) {
  const res = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 1024,
    tools: defs,
    messages,
  });

  if (res.stop_reason !== "tool_use") return res;

  const results: ToolResultBlockParam[] = [];

  for (const block of res.content) {
    if (block.type !== "tool_use") continue;

    const t = TOOLS.find((x) => x.name === block.name);
    if (!t) {
      results.push(errorResult(block.id, `unknown tool ${block.name}`));
      continue;
    }

    const parsed = t.schema.safeParse(block.input);
    if (!parsed.success) {
      results.push(errorResult(block.id, format(parsed.error)));
      continue;
    }

    try {
      const out = await t.run(parsed.data);
      results.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: JSON.stringify(out),
      });
    } catch (err) {
      results.push(errorResult(block.id, message(err)));
    }
  }

  messages.push({ role: "assistant", content: res.content });
  messages.push({ role: "user", content: results });
  return runTurn(messages);
}
Enter fullscreen mode Exit fullscreen mode

That is the loop. Four things in it are load-bearing.

stop_reason === "tool_use" is the condition, not the presence
of a tool block. A response can contain text and a tool call
together, and checking content types instead of the stop reason is
how you end up dropping the text.

Every tool_use gets exactly one tool_result, matched by
tool_use_id.
Skip one and the next request is malformed. Match by
position instead of id and you attach results to the wrong calls the
moment the model emits two.

Both message pushes. The assistant turn with the tool calls, then
the user turn with the results. Push only the results and the model
has no record of having asked.

Errors go back as results, not as throws. This is the part people
get wrong most often — see below.

One turn: model emits tool_use blocks, each resolves to a matched tool_result, loop repeats.

Gap one: validation failure is information

function errorResult(id: string, msg: string): ToolResultBlockParam {
  return {
    type: "tool_result",
    tool_use_id: id,
    content: msg,
    is_error: true,
  };
}
Enter fullscreen mode Exit fullscreen mode

A model that passed unit: "celsius" when the enum wants "c" can
correct itself if you tell it what was wrong. Throwing kills the
turn and loses the work already done.

Format the Zod error into something actionable:

const format = (e: z.ZodError) =>
  "Invalid arguments:\n" +
  e.issues.map((i) => `- ${i.path.join(".")}: ${i.message}`).join("\n");
Enter fullscreen mode Exit fullscreen mode

Path plus message. Not the whole serialised error, which is mostly
noise, and not "invalid input", which gives the model nothing to act
on.

Gap two: unknown tool names

A model can emit a tool name you did not register — a hallucinated
one, or a stale one after you removed a tool mid-conversation.

TOOLS.find returning undefined must produce an error result, not
a crash. The version that indexes a record directly —
TOOLS[block.name].run(...) — throws TypeError: Cannot read
properties of undefined
, and the stack trace tells you nothing about
which tool the model asked for.

Gap three: execution errors

t.run calls the network. It will fail.

The instinct is to let it propagate, which turns a recoverable
situation into a failed request. A weather API returning 503 is
something the model can work with: it can tell the user, or try a
different city, or use a cached forecast tool instead.

What it must not receive is your internal detail:

function message(err: unknown): string {
  if (err instanceof WeatherUnavailable) {
    return `Weather unavailable for ${err.city} (${err.status}).`;
  }
  logger.error("tool execution failed", { err });
  return "The tool failed. Do not retry this call.";
}
Enter fullscreen mode Exit fullscreen mode

Known failures get a specific, useful message. Unknown ones get a
generic string and a full log entry. A raw stack trace in a
tool_result goes into the model's context, gets summarised, and can
end up quoted to a user.

The guard the 40 lines are missing

runTurn recurses with no ceiling. A model that keeps calling tools
keeps looping, and every iteration is a paid request.

async function runTurn(messages: MessageParam[], depth = 0) {
  if (depth > 10) throw new ToolLoopExceeded(depth);
  // ...
  return runTurn(messages, depth + 1);
}
Enter fullscreen mode Exit fullscreen mode

Ten is arbitrary. Having a number is not. This is the single most
common way a first agent produces a surprising bill, and it is one
line.

A tool loop without a depth ceiling repeating the same call until stopped.

What a framework adds

Now that the loop is visible, the value of a framework is easier to
judge. It gives you persistence across process restarts, branching
between tool paths, human approval interrupts, streamed intermediate
state, and retries with policy.

If you need none of those, the forty lines are the better
engineering — fewer moving parts, and you can read the whole control
flow in one screen.


If this was useful

AI That Acts builds the tool
layer properly — schema design that models get right, the validation
boundary, error results as a channel back to the model, and the loop
guards that keep a first agent from surprising you.

AI That Acts — Tool Calling in TypeScript

Stateful agents and graphs are book four. The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)