The first version of the table-booking assistant I built had a nasty bug: the createBooking tool returned a validation error because the party size was missing, yet the model cheerfully replied "I've booked a table for you at 7pm!". The user believed it, showed up at the restaurant, and there was no table. The lesson wasn't "write a better prompt". It was that the UI has to be built from the real state of the tool call, not from the model's account of it. This post is how I structure tool calling on both server and client since that incident: the loop, the schemas, typed messages, and components in place of paragraphs.

Tool calling is really a loop#

The model can't run code. When you send a list of tools along, the only thing the model does is return a proposal: "call the tool named X with arguments that are the JSON string Y", plus an id. The rest is up to you:

  1. Send the conversation history and the tool list (name, description, JSON Schema of the arguments) to the model.
  2. The model returns text, or one or more tool calls.
  3. Your code validates the arguments, runs the tool, and appends the result to the history as a message with the tool role, tagged with the call's id.
  4. Call the model again with the new history. Repeat until the model answers with text and calls no tools.

A weather question looks like this when written out as neutral, provider-agnostic messages:

JSON
[
  { "role": "user", "content": "Will it rain in Da Lat this weekend?" },
  {
    "role": "assistant",
    "content": "",
    "toolCalls": [{ "id": "call_1", "name": "getWeather", "argumentsJson": "{\"city\":\"Da Lat\",\"days\":2}" }]
  },
  {
    "role": "tool",
    "toolCallId": "call_1",
    "isError": false,
    "content": "{\"city\":\"Da Lat\",\"days\":[{\"date\":\"2026-07-04\",\"minC\":16,\"maxC\":23,\"summary\":\"Afternoon showers\"}]}"
  },
  { "role": "assistant", "content": "Expect showers on Saturday afternoon, so bring a jacket.", "toolCalls": [] }
]

Two important consequences. First, the arguments are text generated by the model: they can have the wrong types, missing fields, or not even be valid JSON. Some providers have a mode that forces arguments to match the schema, but the server still has to validate, because that's your trust boundary. Second, you control the loop: how many steps to run, which tools may run on their own, which must ask the user — all of that is decided by code, not by the model.

Defining tools: the schema is a contract with the model#

I define each tool once with Zod: the schema generates the JSON Schema sent to the model, it's the same schema that validates arguments when the model calls the tool, and it's the source of types for the client too. The file ai/tools.ts:

TypeScript
import { z } from "zod";

export type ToolContext = { userId: string; toolCallId: string; signal: AbortSignal };

type ToolDefinition<Input extends z.ZodType, Output> = {
  description: string;
  input: Input;
  requiresApproval: boolean;
  execute(input: z.infer<Input>, context: ToolContext): Promise<Output>;
};

function defineTool<Input extends z.ZodType, Output>(definition: ToolDefinition<Input, Output>) {
  return definition;
}

declare function fetchForecast(city: string, days: number, signal: AbortSignal): Promise<{ city: string; days: { date: string; minC: number; maxC: number; summary: string }[] }>;
declare function insertBooking(input: { restaurantId: string; partySize: number; startsAt: string; idempotencyKey: string; userId: string }): Promise<{ bookingId: string; status: "confirmed" | "waitlisted" }>;

export const tools = {
  getWeather: defineTool({
    description: "Get the weather forecast for a city. Use when the user asks about weather or what to wear.",
    input: z.object({
      city: z.string().min(1).describe("City name, for example: Da Lat"),
      days: z.number().int().min(1).max(7).default(1),
    }),
    requiresApproval: false,
    execute: ({ city, days }, { signal }) => fetchForecast(city, days, signal),
  }),
  createBooking: defineTool({
    description: "Book a table. Only call after the user has chosen a restaurant, a time and a party size.",
    input: z.object({
      restaurantId: z.string(),
      partySize: z.number().int().min(1).max(20),
      startsAt: z.iso.datetime({ offset: true }),
    }),
    requiresApproval: true,
    execute: (input, { userId, toolCallId }) => insertBooking({ ...input, userId, idempotencyKey: toolCallId }),
  }),
};

export type Tools = typeof tools;
export type ToolName = keyof Tools;
export type ToolInput<Name extends ToolName> = z.infer<Tools[Name]["input"]>;
export type ToolOutput<Name extends ToolName> = Awaited<ReturnType<Tools[Name]["execute"]>>;

export const providerToolSpecs = Object.entries(tools).map(([name, tool]) => ({
  name,
  description: tool.description,
  parameters: z.toJSONSchema(tool.input, { io: "input" }),
}));

A few things I've learned:

  • description is a prompt. The model picks tools based on their descriptions, so spell out when to call it, not just what it does. The sentence "only call after the user has chosen a restaurant, a time and a party size" sharply cut the number of times the model booked a table after hearing half a request.
  • Use io: "input" when exporting JSON Schema. By default Zod 4 generates the schema for the output type, so a field with .default(1) gets marked as required. For tool arguments, what you're describing is the input the model is allowed to send.
  • requiresApproval is a property of the tool, not of the prompt. Any tool that writes data, costs money or sends something to the outside world gets flagged right in its definition. Each provider puts the JSON Schema under a differently named field, so providerToolSpecs is just an intermediate shape that each provider adapter maps over.
  • toolCallId doubles as the idempotency key. If the loop is replayed, the same tool call can't create two bookings.

An agent loop with a step limit#

The loop lives on the server, in ai/agent.ts. streamModelTurn is the adapter that calls the provider in streaming mode: it emits events to the client as it goes and returns the text and tool calls once the turn is over.

TypeScript
import { z } from "zod";
import { tools, providerToolSpecs, type ToolContext, type ToolName } from "./tools";

export type ToolCall = { id: string; name: string; argumentsJson: string };

export type ModelMessage =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls: ToolCall[] }
  | { role: "tool"; toolCallId: string; content: string; isError: boolean };

export type AgentEvent =
  | { type: "text-delta"; text: string }
  | { type: "tool-input-start"; toolCallId: string; toolName: ToolName }
  | { type: "tool-input-delta"; toolCallId: string; inputTextDelta: string }
  | { type: "tool-input-available"; toolCallId: string; input: unknown }
  | { type: "tool-approval-requested"; toolCallId: string }
  | { type: "tool-output-available"; toolCallId: string; output: unknown }
  | { type: "tool-output-error"; toolCallId: string; errorText: string };

type Emit = (event: AgentEvent) => void;
type RunContext = Omit<ToolContext, "toolCallId">;
type ToolMessage = Extract<ModelMessage, { role: "tool" }>;
type ToolStepResult =
  | { kind: "result"; message: ToolMessage }
  | { kind: "pending-approval"; toolCallId: string; toolName: ToolName; input: unknown };

declare function streamModelTurn(request: {
  messages: ModelMessage[];
  tools: typeof providerToolSpecs;
  signal: AbortSignal;
  emit: Emit;
}): Promise<{ text: string; toolCalls: ToolCall[] }>;

const maxSteps = 6;
const maxToolErrors = 3;

export async function runAgent(history: ModelMessage[], context: RunContext, emit: Emit) {
  const messages = [...history];
  let toolErrors = 0;

  for (let step = 0; step < maxSteps; step++) {
    const turn = await streamModelTurn({ messages, tools: providerToolSpecs, signal: context.signal, emit });
    messages.push({ role: "assistant", content: turn.text, toolCalls: turn.toolCalls });
    if (turn.toolCalls.length === 0) return { status: "done" as const, messages };

    const results = await Promise.all(turn.toolCalls.map((call) => runToolCall(call, context, emit)));
    const pending = results.filter((result) => result.kind === "pending-approval");
    for (const result of results) {
      if (result.kind === "result") messages.push(result.message);
    }
    if (pending.length > 0) return { status: "awaiting-approval" as const, messages, pending };

    toolErrors += results.filter((result) => result.kind === "result" && result.message.isError).length;
    if (toolErrors >= maxToolErrors) return { status: "too-many-tool-errors" as const, messages };
  }
  return { status: "step-limit" as const, messages };
}

The decisions in this part:

  • There is always a maxSteps. Without a limit, a "confused" model can call the same tool over and over, and every step costs money. When the limit is hit, I return a step-limit status so the UI can be honest with the user instead of silently cutting off.
  • A single turn can contain several tool calls. Models often propose calls in parallel, such as weather for two cities at once. Read-only tools run concurrently with Promise.all; each result is matched to its call by id.
  • A tool that needs confirmation pauses the loop. Results of tools that already ran are still written to the history, while the pending call is returned to be stored; the loop resumes once the user decides.

When the model gets it wrong: invalid arguments and failing tools#

The rest of the file handles everything that can go wrong:

TypeScript
function isToolName(name: string): name is ToolName {
  return Object.hasOwn(tools, name);
}

function parseJsonOrUndefined(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return undefined;
  }
}

function toolError(toolCallId: string, errorText: string, emit: Emit): ToolStepResult {
  emit({ type: "tool-output-error", toolCallId, errorText });
  return { kind: "result", message: { role: "tool", toolCallId, content: errorText, isError: true } };
}

async function runToolCall(call: ToolCall, context: RunContext, emit: Emit): Promise<ToolStepResult> {
  if (!isToolName(call.name)) {
    return toolError(call.id, `Unknown tool "${call.name}". Available tools: ${Object.keys(tools).join(", ")}`, emit);
  }
  const tool = tools[call.name];
  const parsed = tool.input.safeParse(parseJsonOrUndefined(call.argumentsJson));
  if (!parsed.success) {
    return toolError(call.id, `Invalid arguments for ${call.name}:\n${z.prettifyError(parsed.error)}`, emit);
  }
  emit({ type: "tool-input-available", toolCallId: call.id, input: parsed.data });

  if (tool.requiresApproval) {
    emit({ type: "tool-approval-requested", toolCallId: call.id });
    return { kind: "pending-approval", toolCallId: call.id, toolName: call.name, input: parsed.data };
  }

  try {
    const output = await executeTool(call.name, parsed.data, { ...context, toolCallId: call.id });
    emit({ type: "tool-output-available", toolCallId: call.id, output });
    return { kind: "result", message: { role: "tool", toolCallId: call.id, content: JSON.stringify(output), isError: false } };
  } catch (error) {
    console.error("tool failed", call.name, error);
    return toolError(call.id, `${call.name} is temporarily unavailable. Tell the user and do not retry.`, emit);
  }
}

type AnyTool = { execute(input: unknown, context: ToolContext): Promise<unknown> };

function executeTool(name: ToolName, input: unknown, context: ToolContext) {
  const tool: AnyTool = tools[name];
  return tool.execute(input, context);
}

There are three kinds of errors, and I handle them differently:

ErrorExampleHandling
Tool doesn't existThe model invents searchRestaurantsReturn an error listing the valid tools
Invalid argumentsBroken JSON, partySize: "four", missing startsAtReturn a short validation error so the model can fix it next step
Tool execution failsThe weather API times outLog everything on the server, give the model one short sentence and tell it not to retry

For invalid arguments, z.prettifyError produces messages like "expected number, received string → at partySize", just enough for the model to fix things on the next step. But I count errors and stop at maxToolErrors, because sometimes the model just keeps getting it wrong. For execution failures, don't put stack traces or internal details in the tool result: that content is sent to the provider and the model may repeat it verbatim to the user.

executeTool has to widen the type because tools[name] is a union, and TypeScript can't correlate one tool's input with that same tool's execute. The widening only happens after the schema has validated the input, so I accept it.

A message list with typed parts#

On the client, an assistant message is no longer a string. It's a list of parts: some text, then a tool call, then more text. Each tool part has its own lifecycle, and I model it as a discriminated union over both the tool name and the state. The file ai/messages.ts:

TypeScript
import type { ToolInput, ToolName, ToolOutput } from "./tools";

type DeepPartial<T> = T extends object ? { [Key in keyof T]?: DeepPartial<T[Key]> } : T;

type ToolPartState<Name extends ToolName> =
  | { state: "input-streaming"; inputText: string; partialInput?: DeepPartial<ToolInput<Name>> }
  | { state: "input-available"; input: ToolInput<Name> }
  | { state: "approval-requested"; input: ToolInput<Name> }
  | { state: "approval-responded"; input: ToolInput<Name>; approved: boolean }
  | { state: "output-available"; input: ToolInput<Name>; output: ToolOutput<Name> }
  | { state: "output-error"; errorText: string };

export type ToolPart = {
  [Name in ToolName]: { type: `tool-${Name}`; toolCallId: string } & ToolPartState<Name>;
}[ToolName];

export type MessagePart = { type: "text"; text: string } | ToolPart;
export type UIMessage = { id: string; role: "user" | "assistant"; parts: MessagePart[] };

Thanks to the mapped type, part.type === "tool-getWeather" narrows input and output to exactly the weather tool's types. The client only uses import type from the tools file, so server code never leaks into the bundle. This is also the shape the AI SDK uses for UI messages: parts with a type of the form tool-<name> and states like input-streaming, input-available, output-available, output-error, plus a group of approval states.

Streaming partial arguments#

While the model generates arguments, many providers stream them as fragments of a JSON string. I accumulate them into inputText and parse them "partially" with the partial-json library so the UI has something to show early. The reducer that applies stream events to a message lives in the same file:

TypeScript
import { parse, ALL } from "partial-json";
import type { AgentEvent } from "./agent";

function parsePartialInput(inputText: string) {
  try {
    return parse(inputText, ALL);
  } catch {
    return undefined;
  }
}

function patchToolPart(message: UIMessage, toolCallId: string, patch: (part: ToolPart) => object): UIMessage {
  return {
    ...message,
    parts: message.parts.map((part) =>
      part.type !== "text" && part.toolCallId === toolCallId
        ? ({ type: part.type, toolCallId, ...patch(part) } as ToolPart)
        : part,
    ),
  };
}

export function applyAgentEvent(message: UIMessage, event: AgentEvent): UIMessage {
  switch (event.type) {
    case "text-delta": {
      const last = message.parts.at(-1);
      const parts: MessagePart[] =
        last?.type === "text"
          ? [...message.parts.slice(0, -1), { type: "text", text: last.text + event.text }]
          : [...message.parts, { type: "text", text: event.text }];
      return { ...message, parts };
    }
    case "tool-input-start": {
      const part = { type: `tool-${event.toolName}`, toolCallId: event.toolCallId, state: "input-streaming", inputText: "" };
      return { ...message, parts: [...message.parts, part as ToolPart] };
    }
    case "tool-input-delta":
      return patchToolPart(message, event.toolCallId, (part) => {
        if (part.state !== "input-streaming") return part;
        const inputText = part.inputText + event.inputTextDelta;
        return { state: "input-streaming", inputText, partialInput: parsePartialInput(inputText) };
      });
    case "tool-input-available":
      return patchToolPart(message, event.toolCallId, () => ({ state: "input-available", input: event.input }));
    case "tool-approval-requested":
      return patchToolPart(message, event.toolCallId, (part) => ({ state: "approval-requested", input: "input" in part ? part.input : undefined }));
    case "tool-output-available":
      return patchToolPart(message, event.toolCallId, (part) => ({ state: "output-available", input: "input" in part ? part.input : undefined, output: event.output }));
    case "tool-output-error":
      return patchToolPart(message, event.toolCallId, () => ({ state: "output-error", errorText: event.errorText }));
  }
}

The rule for partial arguments: for display only, never for action. {"city": "Ha could become "Ha Noi" or "Ha Giang"; don't fetch, don't validate, don't enable any button based on it. Only when tool-input-available arrives — arguments the server has validated — does the part get a real input. The type assertions in the reducer are where data from the network comes in; if you want to be stricter, validate events against a schema right here.

Generative UI: rendering tool parts as components#

Generative UI sounds grand, but the essence is simple: the model chooses which component by choosing a tool, and chooses the props through the arguments; the components, how they render and the real data belong to your code. WeatherCard, BookingDraft and BookingReceipt below are ordinary presentational components that take props.

TSX
import { useState } from "react";
import type { ToolPart } from "./messages";
import type { ToolInput } from "./tools";

export type ApprovalDecision = { toolCallId: string; approved: boolean; editedInput?: unknown };

export function ToolPartView({ part, onDecide }: { part: ToolPart; onDecide: (decision: ApprovalDecision) => void }) {
  switch (part.type) {
    case "tool-getWeather":
      switch (part.state) {
        case "input-streaming":
          return <WeatherCardSkeleton city={part.partialInput?.city} />;
        case "output-available":
          return <WeatherCard forecast={part.output} />;
        case "output-error":
          return <ToolErrorNotice text="Couldn't load the weather right now." />;
        default:
          return <WeatherCardSkeleton city={part.input.city} />;
      }
    case "tool-createBooking":
      switch (part.state) {
        case "input-streaming":
          return <BookingDraft draft={part.partialInput} />;
        case "input-available":
        case "approval-responded":
          return <BookingDraft draft={part.input} />;
        case "approval-requested":
          return <BookingConfirmation toolCallId={part.toolCallId} input={part.input} onDecide={onDecide} />;
        case "output-available":
          return <BookingReceipt booking={part.output} />;
        case "output-error":
          return <ToolErrorNotice text="The table has not been booked." />;
      }
  }
}

This is the fix for the bug from the intro. The booking receipt only appears when the part reaches output-available, with data taken from the tool's result. The model can say whatever it likes in its text, but the user looks at the card to know what actually happened. I also tell the model in the system prompt not to repeat figures already shown in the card, which keeps the answer short and keeps it from contradicting the card.

Nesting the switch by type and then state gives TypeScript an exhaustiveness check: add a new tool or a new state and forget to render it, and the compiler tells you. The AI SDK once offered a way to stream React Server Components straight from the model (AI SDK RSC), but its docs still label it experimental and recommend AI SDK UI for production — that is, the same "typed data, client renders it" model as above.

Generative UI does not mean letting the model generate JSX or HTML. The model only picks from a set of components you've already written and tested. Anything the model puts into props is still untrusted input that needs escaping and validating, just like user input.

Human-in-the-loop for tools with side effects#

Read-only tools can run on the model's say-so. Tools that write data need a human to press the button. When a part is in approval-requested, instead of asking "are you sure?" in text, I show a form prefilled from the arguments so the user can review and edit them directly:

TSX
function BookingConfirmation({
  toolCallId,
  input,
  onDecide,
}: {
  toolCallId: string;
  input: ToolInput<"createBooking">;
  onDecide: (decision: ApprovalDecision) => void;
}) {
  const [partySize, setPartySize] = useState(input.partySize);
  const startsAtLabel = new Date(input.startsAt).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });

  return (
    <form
      aria-label="Confirm booking"
      onSubmit={(event) => {
        event.preventDefault();
        onDecide({ toolCallId, approved: true, editedInput: { ...input, partySize } });
      }}
    >
      <p>Table at {startsAtLabel}</p>
      <label>
        Party size
        <input type="number" min={1} max={20} value={partySize} onChange={(event) => setPartySize(event.target.valueAsNumber)} />
      </label>
      <button type="submit">Confirm booking</button>
      <button type="button" onClick={() => onDecide({ toolCallId, approved: false })}>
        Don't book
      </button>
    </form>
  );
}

As soon as the user clicks, the client moves the part to approval-responded to lock the form and prevent double submits. The decision goes to the server, where everything is checked again from scratch, in ai/approval.ts:

TypeScript
import { z } from "zod";
import { tools, type ToolContext, type ToolName } from "./tools";
import type { ModelMessage } from "./agent";

export const approvalDecisionSchema = z.object({
  toolCallId: z.string(),
  approved: z.boolean(),
  editedInput: z.unknown().optional(),
});

type PendingCall = { toolCallId: string; toolName: ToolName; input: unknown };
declare const pendingCalls: { take(conversationId: string, toolCallId: string): Promise<PendingCall | null> };

type AnyTool = { input: z.ZodType; execute(input: unknown, context: ToolContext): Promise<unknown> };

export async function resolveApproval(
  conversationId: string,
  decision: z.infer<typeof approvalDecisionSchema>,
  context: Omit<ToolContext, "toolCallId">,
): Promise<Extract<ModelMessage, { role: "tool" }>> {
  const pending = await pendingCalls.take(conversationId, decision.toolCallId);
  if (!pending) throw new Error("No pending call with this id, or it was already resolved");

  const toolCallId = pending.toolCallId;
  if (!decision.approved) {
    return { role: "tool", toolCallId, isError: false, content: "The user declined this action. Ask what they would like to change." };
  }

  const tool: AnyTool = tools[pending.toolName];
  const parsed = tool.input.safeParse(decision.editedInput ?? pending.input);
  if (!parsed.success) {
    return { role: "tool", toolCallId, isError: true, content: z.prettifyError(parsed.error) };
  }
  const output = await tool.execute(parsed.data, { ...context, toolCallId });
  return { role: "tool", toolCallId, isError: false, content: JSON.stringify(output) };
}

Three layers of protection here:

  • The server only trusts what it stored itself. The pending call is saved when the loop pauses, including the tool name and the validated arguments. The client only sends an id and a decision; a forged request with an unknown id finds nothing.
  • take is atomic: fetch and delete in one step. If two confirmations arrive at the same time, only one can run, and the idempotency key is a second safety net.
  • Arguments the user edited are re-validated with the same schema. The client form is only a suggestion; the trust boundary is still the server.

Declining is a tool result too: the model receives "the user declined" and can ask what to change instead of hanging. Once there's a result, the server appends it to the history and calls runAgent again so the model can write its final answer.

The AI SDK has this mechanism built in as well. Version 7, released at the end of June, declares approval through a toolApproval option (replacing the per-tool needsApproval of the previous version), the client responds with addToolApprovalResponse, and there's an experimental option that lets the server verify an approval was actually issued by it. Whether you use the SDK or write it yourself, the model is the same.

The confirmation step doesn't only guard against the model misunderstanding. Results from tools that read external data — web pages, emails, restaurant reviews — can contain planted instructions meant to trick the model into calling another tool. For every tool with side effects, a user pressing a button is the last line of defence.

Summary#

  • The model only proposes tool calls; your code validates, executes, sends results back and decides when to stop.
  • Define each tool once with a schema: it feeds the JSON Schema sent to the model, server-side validation, and client types.
  • Write description like a prompt, export JSON Schema for the input type, and flag tools that need confirmation in their definition.
  • Always have maxSteps and an error budget; when you hit a limit, tell the user honestly.
  • For invalid arguments, return a short error so the model can fix it; for execution failures, log on the server and keep internal details out of tool results.
  • Model messages as parts typed by tool name and state; partial arguments are for display only.
  • Render the UI from tool part state, not from the model's account of it.
  • Tools with side effects must pause the loop for the user; the server only trusts pending calls it stored, takes them atomically and re-validates arguments.