Ask a language model for the temperature in Lisbon and it may well give you a number. That number is invented, because the model has no access to today's weather. Ask it for advice your page can render and you get a friendly paragraph, when your template wanted a temperature and a number of layers to wear. This tutorial fixes both in a Nuxt 4.5 app with AI SDK 7 (ai 7.0 and @ai-sdk/vue 4.0) and Zod 4.4.
You will build a small weather assistant. Tools are functions the model may ask your server to run, such as "get the weather for a city". Structured output makes the model's final answer a JSON object that must match a schema you wrote. Pages live in app/, and server/ stays at the project root.
Install the SDK and keep the key on the server
Install the SDK, the Vue bindings, a provider and Zod:
npm install ai @ai-sdk/vue @ai-sdk/openai zod
The provider key goes into runtime config, filled from NUXT_OPENAI_API_KEY in your .env. Runtime config keys outside public are only readable on the server, so the browser never sees the key:
export default defineNuxtConfig({
runtimeConfig: {
openaiApiKey: '',
},
})
Define the tools
A tool has three parts, created with the tool() helper: a description the model reads to decide when to call it, an inputSchema (a Zod schema the arguments must match), and an execute function that runs on your server. The weather comes from Open-Meteo, an API that needs no key and is free for non-commercial use.
import { tool, type InferUITools, type UIDataTypes, type UIMessage } from 'ai'
import { z } from 'zod'
type GeoResult = { results?: { name: string, country: string, latitude: number, longitude: number }[] }
type Forecast = { current: { temperature_2m: number, wind_speed_10m: number } }
export const weatherTools = {
getWeather: tool({
description: 'Get the current temperature in Celsius and the wind speed for a city',
inputSchema: z.object({
city: z.string().min(1).describe('City name, for example "Lisbon"'),
}),
execute: async ({ city }) => {
const geo = await $fetch<GeoResult>('https://geocoding-api.open-meteo.com/v1/search', {
query: { name: city, count: 1 },
})
const place = geo.results?.[0]
if (!place) throw new Error(`No city found for "${city}"`)
const { current } = await $fetch<Forecast>('https://api.open-meteo.com/v1/forecast', {
query: { latitude: place.latitude, longitude: place.longitude, current: 'temperature_2m,wind_speed_10m' },
})
return { city: `${place.name}, ${place.country}`, celsius: current.temperature_2m, windKmh: current.wind_speed_10m }
},
}),
convertTemperature: tool({
description: 'Convert a temperature between Celsius and Fahrenheit',
inputSchema: z.object({
value: z.number(),
to: z.enum(['celsius', 'fahrenheit']),
}),
execute: async ({ value, to }) => {
const result = to === 'fahrenheit' ? value * 9 / 5 + 32 : (value - 32) * 5 / 9
return { value: Math.round(result * 10) / 10, unit: to }
},
}),
}
export type WeatherMessage = UIMessage<never, UIDataTypes, InferUITools<typeof weatherTools>>
getWeather looks the city up, then fetches its current weather, and throws when no city matches (the last section covers that case). convertTemperature is plain arithmetic on purpose: models are unreliable at maths, and a tool turns a guess into a calculation. The last line builds a message type from the tools, so the Vue page later knows exactly what each tool returns.
The model never sees your execute code. It sees the tool's name, its description and its schema, and nothing else. So write the description as an instruction to a colleague: what the tool does, what it returns, and the unit. "Get the current temperature in Celsius" is better than "weather", because it tells the model it will need the second tool for Fahrenheit. The same goes for .describe() on each field: an example value such as "Lisbon" heads off arguments like "Lisbon, the capital of Portugal".
Let the model loop with stopWhen
The chat route passes the tools to streamText. By default streamText and generateText run a single step: the model answers, or calls a tool, and stops. A question like "How warm is Lisbon in Fahrenheit?" needs three steps: call getWeather, call convertTemperature with the result, then write the answer. stopWhen lets the SDK feed each tool result back to the model until a condition is met.
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, createUIMessageStreamResponse, isStepCount, streamText, toUIMessageStream } from 'ai'
import { weatherTools, type WeatherMessage } from '../utils/weather-tools'
export default defineEventHandler(async (event) => {
const { messages } = await readBody<{ messages: WeatherMessage[] }>(event)
const openai = createOpenAI({ apiKey: useRuntimeConfig(event).openaiApiKey })
const result = streamText({
model: openai('gpt-5.4-mini'),
instructions: 'You answer weather questions. Use the tools for every number. Never guess a temperature.',
messages: await convertToModelMessages(messages),
tools: weatherTools,
stopWhen: isStepCount(5),
})
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
})
})
convertToModelMessages turns the chat's messages, which arrive from the browser as lists of parts, into the messages the model reads; it returns a promise, hence the await. At the end, toUIMessageStream turns the result's stream into the chunks useChat reads, and createUIMessageStreamResponse streams them back.
isStepCount(5) is a ceiling, and every step is a paid model call, so keep it low. Another built-in condition, hasToolCall, stops as soon as a named tool has run. If the conversion result is all your UI needs, write stopWhen: [isStepCount(5), hasToolCall('convertTemperature')]; the loop ends at whichever condition comes first and skips the final model call.
Log each step of the loop
It helps to watch the loop once. Add an onStepEnd callback to the streamText call while you develop:
onStepEnd({ stepNumber, toolCalls, usage }) {
console.log(stepNumber, toolCalls.map(call => call.toolName), usage.totalTokens)
},
For "How warm is Lisbon in Fahrenheit?" the terminal shows three lines. Step 0 calls getWeather, step 1 calls convertTemperature with the Celsius value from step 0, and step 2 has no tool calls, because the model wrote its answer.
Each line also shows the tokens that step used, and that number grows with every step because the whole conversation, tool results included, is sent again each time. That is the real cost of a tool loop, and the reason the step ceiling exists.
Show tool calls in the chat
In @ai-sdk/vue 4, useChat is back as a composable. Its messages hold an ordered list of parts. Text arrives as a text part, and each tool call arrives as a part named tool- plus the tool's key, with a state that moves from input-streaming to input-available to output-available, or to output-error when execute threw.
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import type { WeatherMessage } from '~~/server/utils/weather-tools'
const input = ref('')
const { messages, sendMessage, status, error } = useChat<WeatherMessage>()
function send() {
if (!input.value.trim()) return
sendMessage({ text: input.value })
input.value = ''
}
</script>
<template>
<div v-for="message in messages" :key="message.id">
<template v-for="(part, index) in message.parts" :key="index">
<p v-if="part.type === 'text'">{{ part.text }}</p>
<template v-else-if="part.type === 'tool-getWeather'">
<p v-if="part.state === 'output-available'">
{{ part.output.city }}: {{ part.output.celsius }} °C, wind {{ part.output.windKmh }} km/h
</p>
<p v-else-if="part.state === 'output-error'" role="alert">Could not get the weather for that city.</p>
<p v-else>Looking up the weather…</p>
</template>
<p v-else-if="part.type === 'tool-convertTemperature' && part.state === 'output-available'">
Converted: {{ part.output.value }} {{ part.output.unit }}
</p>
</template>
</div>
<p v-if="error" role="alert">{{ error.message }}</p>
<form @submit.prevent="send">
<label>Ask about the weather <input v-model="input" type="text"></label>
<button type="submit" :disabled="status === 'submitted' || status === 'streaming'">Send</button>
</form>
</template>
useChat posts to /api/chat by default, which is the route above. The import from server/utils is import type, so it disappears at build time and no server code reaches the browser.
Thanks to WeatherMessage, part.output.celsius is typed as a number, and a typo in a tool name is a type error. Each branch checks state before reading output, which is only there once the tool has returned a result; skip the check and TypeScript flags part.output as possibly undefined.
Return a typed object with structured output
Chat is for people. When a page needs values, ask for an object. Output.object() takes a Zod schema, sends it to the model, and validates the answer before you get it. It combines with tools in the same call:
import { createOpenAI } from '@ai-sdk/openai'
import { generateText, isStepCount, NoObjectGeneratedError, NoOutputGeneratedError, Output } from 'ai'
import { z } from 'zod'
import { weatherTools } from '../utils/weather-tools'
const Advice = z.object({
city: z.string(),
celsius: z.number(),
fahrenheit: z.number(),
layers: z.number().int().min(1).max(4).describe('Layers of clothing to wear, from 1 to 4'),
summary: z.string().max(200).describe('One or two sentences'),
})
export default defineEventHandler(async (event) => {
const { city } = await readValidatedBody(event, z.object({ city: z.string().min(1).max(80) }).parse)
const openai = createOpenAI({ apiKey: useRuntimeConfig(event).openaiApiKey })
try {
const { output } = await generateText({
model: openai('gpt-5.4-mini'),
tools: weatherTools,
stopWhen: isStepCount(5),
output: Output.object({ schema: Advice }),
prompt: `What should I wear in ${city} right now?`,
})
return output
}
catch (error) {
if (NoObjectGeneratedError.isInstance(error) || NoOutputGeneratedError.isInstance(error)) {
console.warn('outfit: no valid object', error.message)
throw createError({ statusCode: 502, statusMessage: 'The assistant could not produce an answer. Try again.' })
}
throw error
}
})
output is fully typed from the schema, and Nuxt's typed $fetch passes that type on to the page that calls $fetch('/api/outfit', { method: 'POST', body: { city } }). The AI SDK docs point out that producing the object counts as a step, so the budget of five covers two tool calls and the answer with room to spare. The .describe() calls matter: they travel with the schema and tell the model what each field means.
What happens when validation fails
Three things can go wrong, and the SDK catches each at a different point.
- Bad tool input. Arguments that fail the
inputSchemanever reachexecute. The SDK sends the validation message back as a tool error, and if the loop has steps left, the model sees it and can correct the call. In the chat, the failed call arrives as adynamic-toolpart, which the page above skips. - A tool that throws. The
No city founderror above becomes atool-errorresult. The model reads it in the next step, so it can say the city was not found instead of inventing a forecast. In the chat the tool's part ends inoutput-error. - An answer that does not match the schema.
generateTextrejects withNoObjectGeneratedError, which carries the rawtext, thecauseand the tokenusage. If the step limit runs out before an answer exists, readingoutputthrowsNoOutputGeneratedErrorinstead.
By default the browser does not see a tool error's own text: toUIMessageStream replaces it with An error occurred., which keeps server details private. Pass your own onError to toUIMessageStream to send something else.
For bad tool input there is also a repairToolCall option on generateText and streamText, a function that receives the failed call and may return a fixed one. Reach for it only when one tool keeps failing in the same way; a clearer schema is usually the cheaper fix.
Log the cause on the server, return a short message to the user, and let them retry. Do not loop automatically until the model gets it right, because each attempt costs money and a schema the model keeps failing usually needs a clearer .describe() or looser bounds.