---
title: "Tools and structured output with the AI SDK 7 · vuejs.school"
description: "Give a model real data with tools, cap its loop with stopWhen, get typed JSON back with Output.object, and show every tool call in a Vue chat with useChat."
canonical_url: "https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7"
meta:
  "og:description": "Give a model real data with tools, cap its loop with stopWhen, get typed JSON back with Output.object, and show every tool call in a Vue chat with useChat."
  "og:image": "https://vuejs.school/_og/d/a_~VG9vbHMgYW5kIHN0cnVjdHVyZWQgb3V0cHV0IHdpdGggdGhlIEFJIFNESyA3IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Tools%2Band%2Bstructured%2Boutput%2Bwith%2Bthe%2BAI%2BSDK%2B7%2Cdescription_~R2l2ZSBhIG1vZGVsIHJlYWwgZGF0YSB3aXRoIHRvb2xzLCBjYXAgaXRzIGxvb3Agd2l0aCBzdG9wV2hlbiwgZ2V0IHR5cGVkIEpTT04gYmFjayB3aXRoIE91dHB1dC5vYmplY3QsIGFuZCBzaG93IGV2ZXJ5IHRvb2wgY2FsbCBpbiBhIFZ1ZSBjaGF0IHdpdGggdXNlQ2hhdC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy90b29scy1hbmQtc3RydWN0dXJlZC1vdXRwdXQtd2l0aC10aGUtYWktc2RrLTci%2Cs_fpyb-VuNgeC2lO-z.png"
  "og:image:alt": "Tools and structured output with the AI SDK 7 · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Tools and structured output with the AI SDK 7 · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VG9vbHMgYW5kIHN0cnVjdHVyZWQgb3V0cHV0IHdpdGggdGhlIEFJIFNESyA3IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Tools%2Band%2Bstructured%2Boutput%2Bwith%2Bthe%2BAI%2BSDK%2B7%2Cdescription_~R2l2ZSBhIG1vZGVsIHJlYWwgZGF0YSB3aXRoIHRvb2xzLCBjYXAgaXRzIGxvb3Agd2l0aCBzdG9wV2hlbiwgZ2V0IHR5cGVkIEpTT04gYmFjayB3aXRoIE91dHB1dC5vYmplY3QsIGFuZCBzaG93IGV2ZXJ5IHRvb2wgY2FsbCBpbiBhIFZ1ZSBjaGF0IHdpdGggdXNlQ2hhdC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy90b29scy1hbmQtc3RydWN0dXJlZC1vdXRwdXQtd2l0aC10aGUtYWktc2RrLTci%2Cs_fpyb-VuNgeC2lO-z.png"
  "twitter:image:alt": "Tools and structured output with the AI SDK 7 · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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:

*nuxt.config.ts*

```
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.

*server/utils/weather-tools.ts*

```
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.

*server/api/chat.post.ts*

```
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:

*server/api/chat.post.ts*

```
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.

*app/pages/weather.vue*

```
<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:

*server/api/outfit.post.ts*

```
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](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) 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 `inputSchema` never reach `execute`. 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 a `dynamic-tool` part, which the page above skips.
- **A tool that throws.** The `No city found` error above becomes a `tool-error` result. 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 in `output-error`.
- **An answer that does not match the schema.** `generateText` rejects with `NoObjectGeneratedError`, which carries the raw `text`, the `cause` and the token `usage`. If the step limit runs out before an answer exists, reading `output` throws `NoOutputGeneratedError` instead.

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.

**Check it stuck**3 questions from the Tools and structured output with the AI SDK quiz.

[**Practice now**](https://vuejs.school/learn/ai/tools-and-structured-output-with-the-ai-sdk?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**RAG in a Nuxt app: embeddings, retrieval and a chatbot that knows your docs**](https://vuejs.school/community/blog/rag-in-a-nuxt-app-a-chatbot-that-knows-your-docs) [Release notes · 6 min read**AI SDK 7: what changes for Vue apps**](https://vuejs.school/community/blog/ai-sdk-7-what-changes-for-vue-apps)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-08-04","description":"Give a model real data with tools, cap its loop with stopWhen, get typed JSON back with Output.object, and show every tool call in a Vue chat with useChat.","name":"Tools and structured output with the AI SDK 7","url":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7","breadcrumb":{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-08-04","description":"Give a model real data with tools, cap its loop with stopWhen, get typed JSON back with Output.object, and show every tool call in a Vue chat with useChat.","headline":"Tools and structured output with the AI SDK 7","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VG9vbHMgYW5kIHN0cnVjdHVyZWQgb3V0cHV0IHdpdGggdGhlIEFJIFNESyA3IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Tools%2Band%2Bstructured%2Boutput%2Bwith%2Bthe%2BAI%2BSDK%2B7%2Cdescription_~R2l2ZSBhIG1vZGVsIHJlYWwgZGF0YSB3aXRoIHRvb2xzLCBjYXAgaXRzIGxvb3Agd2l0aCBzdG9wV2hlbiwgZ2V0IHR5cGVkIEpTT04gYmFjayB3aXRoIE91dHB1dC5vYmplY3QsIGFuZCBzaG93IGV2ZXJ5IHRvb2wgY2FsbCBpbiBhIFZ1ZSBjaGF0IHdpdGggdXNlQ2hhdC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy90b29scy1hbmQtc3RydWN0dXJlZC1vdXRwdXQtd2l0aC10aGUtYWktc2RrLTci%2Cs_fpyb-VuNgeC2lO-z.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/tools-and-structured-output-with-the-ai-sdk-7#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Tools and structured output with the AI SDK 7","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VG9vbHMgYW5kIHN0cnVjdHVyZWQgb3V0cHV0IHdpdGggdGhlIEFJIFNESyA3IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Tools%2Band%2Bstructured%2Boutput%2Bwith%2Bthe%2BAI%2BSDK%2B7%2Cdescription_~R2l2ZSBhIG1vZGVsIHJlYWwgZGF0YSB3aXRoIHRvb2xzLCBjYXAgaXRzIGxvb3Agd2l0aCBzdG9wV2hlbiwgZ2V0IHR5cGVkIEpTT04gYmFjayB3aXRoIE91dHB1dC5vYmplY3QsIGFuZCBzaG93IGV2ZXJ5IHRvb2wgY2FsbCBpbiBhIFZ1ZSBjaGF0IHdpdGggdXNlQ2hhdC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy90b29scy1hbmQtc3RydWN0dXJlZC1vdXRwdXQtd2l0aC10aGUtYWktc2RrLTci%2Cs_fpyb-VuNgeC2lO-z.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VG9vbHMgYW5kIHN0cnVjdHVyZWQgb3V0cHV0IHdpdGggdGhlIEFJIFNESyA3IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Tools%2Band%2Bstructured%2Boutput%2Bwith%2Bthe%2BAI%2BSDK%2B7%2Cdescription_~R2l2ZSBhIG1vZGVsIHJlYWwgZGF0YSB3aXRoIHRvb2xzLCBjYXAgaXRzIGxvb3Agd2l0aCBzdG9wV2hlbiwgZ2V0IHR5cGVkIEpTT04gYmFjayB3aXRoIE91dHB1dC5vYmplY3QsIGFuZCBzaG93IGV2ZXJ5IHRvb2wgY2FsbCBpbiBhIFZ1ZSBjaGF0IHdpdGggdXNlQ2hhdC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy90b29scy1hbmQtc3RydWN0dXJlZC1vdXRwdXQtd2l0aC10aGUtYWktc2RrLTci%2Cs_fpyb-VuNgeC2lO-z.png"}]}
```