← All posts
·21 min read

Claude Code with Vercel AI SDK: streamText, Tools, Structured Output

Claude CodeVercel AI SDKAIStreaming
Claude Code with Vercel AI SDK: streamText, Tools, Structured Output

Why the AI SDK without CLAUDE.md drops streaming and tool-call patterns

The Vercel AI SDK is designed to make streaming AI responses and tool-calling feel like first-class primitives in a Next.js or React application. The API is small: streamText for streaming, generateText for full responses, generateObject for structured output, tool() for function definitions, and useChat on the client. The problem is that Claude Code does not know which patterns are load-bearing and which are optional shorthand, because the SDK's composability means there are many ways to write code that looks correct but silently drops streamed tokens or hangs the tool loop.

Without explicit constraints, Claude reaches for generateText when the user expects streaming, forgets .toDataStreamResponse() on the API route so the useChat hook receives an unparseable response, writes tool execute functions with the wrong parameter signature, sets no maxSteps so a multi-step tool loop fires once and stops, or imports useChat from 'ai' instead of 'ai/react' so the hook is undefined. None of these produce an obvious error. Streaming silently returns nothing. The tool loop silently terminates. The chat hook silently throws at runtime.

This guide covers the CLAUDE.md configuration that anchors Claude Code to the AI SDK's actual model: streamText for streaming routes, toDataStreamResponse() on every streaming API route, Zod schemas on every tool, maxSteps for any tool that feeds back into the model, generateObject for typed structured output, and the correct import paths for both server and client code. If you are building the Next.js app layer that wraps these routes, Claude Code with Next.js covers the App Router conventions that compose with this setup.

The AI SDK CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every session. For an AI SDK integration it needs to declare: the SDK version and provider packages installed, the streaming vs. non-streaming decision rule, tool definition format with Zod schemas, the maxSteps requirement for tool loops, structured output patterns, client-side hook imports, the API route response format, and the hard rules that block the patterns Claude generates most often without guidance.

# Vercel AI SDK rules

## Stack
- ai 4.x (npm package: ai)
- @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google (provider packages)
- Next.js 15.x App Router
- TypeScript 5.x strict
- zod 3.x for all tool and schema definitions

## Provider model strings
- Anthropic: anthropic('claude-sonnet-4-5')
- OpenAI: openai('gpt-4o')
- Google: google('gemini-2.0-flash-001')
- Import providers from their scoped package: import { anthropic } from '@ai-sdk/anthropic'
- NEVER import providers from 'ai' directly

## Streaming vs non-streaming decision
- Use streamText when the route serves a chat UI or any response the user waits on
- Use generateText when the response is consumed programmatically (batch job, cron, server action that returns a value)
- NEVER swap streamText for generateText in a route that feeds useChat on the client

## API route pattern (App Router)
- File: app/api/chat/route.ts
- Export: export async function POST(req: Request)
- Parse body: const { messages } = await req.json()
- Call streamText with model + messages + any tools
- Return: return result.toDataStreamResponse()
- NEVER return result.text, result.textStream, or a plain Response wrapping the stream
- toDataStreamResponse() is the ONLY correct return for a useChat-compatible route

## streamText
- import { streamText } from 'ai'
- import { anthropic } from '@ai-sdk/anthropic'
- const result = await streamText({ model: anthropic('claude-sonnet-4-5'), messages, tools, maxSteps })
- result.toDataStreamResponse() converts to the wire format useChat expects

## generateText
- import { generateText } from 'ai'
- const { text } = await generateText({ model, prompt })
- Use prompt for single-turn, messages for multi-turn
- NEVER use prompt and messages together. They are mutually exclusive

## Tool definitions
- import { tool } from 'ai'
- import { z } from 'zod'
- Every tool needs: description (string), parameters (z.object), execute (async function)
- execute receives the parsed parameter object: execute: async ({ q }) => { ... }
- execute must return a value. The return is fed back to the model as a tool result
- NEVER use parameters: z.any() or skip the execute function

## Multi-step tool loops (maxSteps)
- Add maxSteps: 5 to any streamText/generateText call that uses tools
- Without maxSteps the model makes one tool call and stops, even if it needs more
- maxSteps: 5 means up to 5 model turns (each may include tool calls)
- Increase to 10 for complex agent loops; keep at 3 for simple single-tool lookups

## generateObject (structured output)
- import { generateObject } from 'ai'
- const { object } = await generateObject({ model, schema: z.object({ ... }), prompt })
- schema is a Zod schema. The return is typed and validated automatically
- Use mode: 'json' (default) unless the provider requires 'tool' or 'grammar'
- NEVER parse JSON manually from a generateText response when generateObject is available

## useChat hook (client-side)
- import { useChat } from 'ai/react' (NOT from 'ai')
- const { messages, input, handleSubmit, isLoading, error } = useChat({ api: '/api/chat' })
- handleSubmit replaces the form onSubmit, it handles message appending automatically
- messages is Message[]. Each item has id, role ('user' | 'assistant'), content (string)
- NEVER manually append to the messages array. useChat manages state

## useCompletion hook (client-side, non-chat)
- import { useCompletion } from 'ai/react'
- Use when the UI shows a single streaming completion, not a chat history
- const { completion, complete, isLoading } = useCompletion({ api: '/api/completion' })

## Error handling
- Wrap streamText calls in try/catch
- Network errors from the provider surface as APICallError from 'ai'
- Tool execute errors surface as ToolExecutionError from 'ai'
- Return a 500 with a JSON body { error: message } so the client can display it
- useChat exposes error state. Always render it in the UI

## Hard rules
- NEVER import useChat or useCompletion from 'ai'. Use 'ai/react' always
- NEVER return anything other than result.toDataStreamResponse() from a useChat-connected POST route
- NEVER omit maxSteps when tools are defined
- NEVER write parameters: z.any() on a tool. Always use a specific z.object schema
- NEVER use prompt and messages in the same generateText/streamText call
- NEVER manually stream tokens from generateText, use streamText for streaming
- ALWAYS add execute to every tool definition, tools without execute are client-side tools and require different handling

Four rules here prevent the majority of silent failures Claude generates without them.

The toDataStreamResponse() rule is the most impactful. streamText returns a StreamTextResult object. The readable stream is in result.textStream, but useChat on the client does not consume a raw text stream. It expects the AI SDK wire format, which includes role, content, finish reason, tool call payloads, and usage data in a specific envelope. .toDataStreamResponse() wraps all of that. Returning result.textStream directly or wrapping it in a new Response() produces a stream that useChat cannot parse, and the chat UI shows nothing without throwing any error.

The maxSteps rule is subtle because the omission looks correct. A streamText call with tools defined will fire the first tool call correctly. Without maxSteps, the SDK stops after that one turn. The model cannot take the tool result and reason about whether to call another tool or return a final response. Setting maxSteps: 5 gives the model up to five turns to complete its reasoning. Claude will omit maxSteps because the API does not require it, and the one-step behavior looks correct until you trace why the model keeps returning incomplete answers.

The 'ai/react' import rule matters because ai and ai/react are different entry points. import { useChat } from 'ai' fails silently in some bundler configurations or throws useChat is not a function at runtime. Claude generates the wrong import because the correct one is not inferable from the ai package name alone.

The mutually exclusive prompt/messages rule prevents a confusing API error. streamText and generateText accept either prompt: string for single-turn use or messages: CoreMessage[] for multi-turn use, but not both. Claude generates prompt for single-turn and then extends it to multi-turn by adding messages alongside prompt, which throws a validation error at runtime.

Provider setup

The AI SDK separates the core library from provider packages. Install the core and the providers you need:

npm install ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/google zod

Each provider is initialised from its scoped package. Add provider configuration to CLAUDE.md:

## Provider configuration

### Anthropic
import { anthropic } from '@ai-sdk/anthropic'
// Uses ANTHROPIC_API_KEY from environment
const model = anthropic('claude-sonnet-4-5')
// Vision: anthropic('claude-sonnet-4-5', { experimental_attachments: true })
// Longer context: anthropic('claude-opus-4-5')

### OpenAI
import { openai } from '@ai-sdk/openai'
// Uses OPENAI_API_KEY from environment
const model = openai('gpt-4o')
// Structured output works best on gpt-4o-mini for cost

### Google
import { google } from '@ai-sdk/google'
// Uses GOOGLE_GENERATIVE_AI_API_KEY from environment
const model = google('gemini-2.0-flash-001')
// Faster, cheaper: google('gemini-1.5-flash')

### Environment variables (.env.local)
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GOOGLE_GENERATIVE_AI_API_KEY=AIza...

### Switching providers
- To switch the model for an entire route, change the model variable
- Never hardcode the model string in multiple places, define it once per route file
- Use a MODELS constant in lib/ai.ts if the same model is used across multiple routes

Provider packages read their API keys from environment variables automatically. No explicit key passing is needed unless you are using a custom key per user (multi-tenant). In that case, pass anthropic('claude-sonnet-4-5', { apiKey: userKey }). Claude will either hardcode the key or skip the environment variable pattern entirely without the explicit declaration. For the full Next.js environment variable handling, Claude Code with Next.js covers .env.local conventions in the App Router.

streamText vs generateText

streamText and generateText share the same parameter shape but return different types. The choice is not about the model. It is about what the caller needs to do with the response.

Add a decision section to CLAUDE.md:

## When to use each function

### streamText: returns ReadableStream + StreamTextResult
- Chat UI (useChat hook reads the stream)
- Long-form generation the user waits for in real time
- Any response over ~200 tokens where latency matters
- Server-Sent Events endpoints

### generateText: returns { text, usage, finishReason }
- Server actions that return a value to the UI
- Background jobs (summarisation, classification, extraction)
- Testing and evaluation pipelines
- Any case where the full response is needed before proceeding

### streamText patterns
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'

// API route
export async function POST(req: Request) {
  const { messages } = await req.json()
  const result = await streamText({
    model: anthropic('claude-sonnet-4-5'),
    messages,
  })
  return result.toDataStreamResponse()
}

// Reading the stream directly (not via useChat)
const result = await streamText({ model, prompt: 'Tell me a story' })
for await (const chunk of result.textStream) {
  process.stdout.write(chunk)
}

### generateText patterns
import { generateText } from 'ai'

const { text, usage } = await generateText({
  model: anthropic('claude-sonnet-4-5'),
  prompt: 'Summarise this in one sentence: ...',
})
console.log(text)
console.log('Tokens used:', usage.totalTokens)

// Multi-turn with messages
const { text } = await generateText({
  model,
  messages: [
    { role: 'user', content: 'What is the capital of France?' },
    { role: 'assistant', content: 'Paris.' },
    { role: 'user', content: 'And Germany?' },
  ],
})

Tool definitions with Zod schemas

Tools are the mechanism for giving the model access to external data and actions. The AI SDK requires three properties on every tool: a description the model uses to decide when to call the tool, a parameters Zod schema the SDK uses to validate the model's arguments, and an execute function the SDK calls with the validated arguments.

Add a tool definition section to CLAUDE.md:

## Tool definition patterns

### Basic tool
import { tool } from 'ai'
import { z } from 'zod'

const searchTool = tool({
  description: 'Search the web for current information on a topic',
  parameters: z.object({
    q: z.string().describe('The search query'),
    maxResults: z.number().min(1).max(10).default(5).describe('Number of results to return'),
  }),
  execute: async ({ q, maxResults }) => {
    const results = await mySearchAPI(q, maxResults)
    return results.map(r => ({ title: r.title, url: r.url, snippet: r.snippet }))
  },
})

### Tool with error handling
const databaseTool = tool({
  description: 'Look up a customer record by email address',
  parameters: z.object({
    email: z.string().email().describe('Customer email address'),
  }),
  execute: async ({ email }) => {
    try {
      const record = await db.customers.findUnique({ where: { email } })
      if (!record) return { found: false, email }
      return { found: true, id: record.id, name: record.name, plan: record.plan }
    } catch (err) {
      return { error: 'Database lookup failed', detail: String(err) }
    }
  },
})

### Using tools in streamText
const result = await streamText({
  model: anthropic('claude-sonnet-4-5'),
  messages,
  tools: {
    search: searchTool,
    lookupCustomer: databaseTool,
  },
  maxSteps: 5,
})

### Rules for tools
- description must be specific enough that the model knows when NOT to call the tool
- parameters must use z.describe() on every field so the model knows what to pass
- execute must always return a value (even { error: '...' }), never throw or return undefined
- The return value is serialised to JSON and sent back to the model as a tool result
- NEVER use z.any() on parameters, always a specific schema

The description quality directly affects whether the model calls the right tool at the right time. A description like 'Search' is worse than 'Search the web for current information on a topic. Use this when you need facts published after your knowledge cutoff.' Claude generates vague descriptions by default because it cannot know the calling convention your application needs. Include an example description in CLAUDE.md so Claude has a style to copy.

The execute return value is equally important. The model receives whatever execute returns as its next input. If execute returns undefined because the function throws an uncaught error, the model receives nothing and typically hallucates a response. Returning { error: '...' } lets the model decide whether to retry, try a different tool, or report the failure to the user.

Multi-step tool calls with maxSteps

A single streamText call with maxSteps: 5 allows the model to complete up to five turns before returning to the client. Each turn can include tool calls. The loop works like this: the model generates a response with a tool call, the SDK calls execute, the result is appended to the messages, the model generates again. This continues until the model returns a final text response with no tool calls, or maxSteps is reached.

Add a multi-step section to CLAUDE.md:

## Multi-step tool loop patterns

### Basic loop
const result = await streamText({
  model: anthropic('claude-sonnet-4-5'),
  prompt: 'Find the CEO of Stripe and then look up their LinkedIn profile',
  tools: {
    webSearch: searchTool,
    linkedinLookup: linkedinTool,
  },
  maxSteps: 5,
  // The model will: call webSearch -> get CEO name -> call linkedinLookup -> return answer
})

### Observing steps
const result = await streamText({ model, prompt, tools, maxSteps: 5 })
for await (const step of result.steps) {
  console.log('Step type:', step.stepType)    // 'initial' | 'tool-result' | 'continue'
  console.log('Tool calls:', step.toolCalls)
  console.log('Tool results:', step.toolResults)
  console.log('Text:', step.text)
}

### Tracking tool usage
const { text, steps, usage } = await generateText({
  model,
  prompt: 'Research the top 3 AI companies',
  tools: { webSearch: searchTool },
  maxSteps: 10,
})
const toolCallCount = steps.flatMap(s => s.toolCalls).length
console.log(`Used ${toolCallCount} tool calls, ${usage.totalTokens} tokens`)

### onStepFinish callback (streaming)
const result = await streamText({
  model,
  messages,
  tools,
  maxSteps: 5,
  onStepFinish: ({ stepType, toolCalls, toolResults, text, usage }) => {
    // Log each step for observability, useful for debugging loops
    console.log({ stepType, toolCalls: toolCalls.length })
  },
})

### When to increase maxSteps
- 3: Simple lookup (search once, return answer)
- 5: Multi-step research (search, filter, search again, synthesise)
- 10: Agent workflows (plan, execute, verify, retry on failure)
- NEVER set maxSteps above 20, cost and latency grow linearly

The onStepFinish callback is the observability hook for tool loops in production. Without it, a five-step loop is a black box between the initial request and the final response. Logging stepType and toolCalls.length from each step tells you whether the model is looping correctly, stopping early, or burning steps on redundant calls. Claude will not add this callback without being shown the pattern.

Structured output with generateObject

generateObject enforces that the model's response matches a Zod schema. The return is typed. No JSON parsing, no validation, no runtime errors from malformed model output. It is the correct tool for extraction, classification, data normalisation, and any case where the response must feed a typed function.

Add a structured output section to CLAUDE.md:

## generateObject patterns

### Basic structured extraction
import { generateObject } from 'ai'
import { z } from 'zod'

const { object } = await generateObject({
  model: anthropic('claude-sonnet-4-5'),
  schema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    confidence: z.number().min(0).max(1),
    summary: z.string().max(200),
    topics: z.array(z.string()).max(5),
  }),
  prompt: `Analyse this review: "${reviewText}"`,
})
// object is typed: { sentiment: 'positive', confidence: 0.92, summary: '...', topics: ['...'] }

### Array output
const { object: items } = await generateObject({
  model,
  output: 'array',
  schema: z.object({
    name: z.string(),
    category: z.enum(['frontend', 'backend', 'devops']),
    priority: z.number().int().min(1).max(5),
  }),
  prompt: 'List the top 5 skills a full-stack developer needs in 2026',
})
// object is typed: Array<{ name: string, category: ..., priority: number }>

### Enum output (classification only)
const { object: category } = await generateObject({
  model,
  output: 'enum',
  enum: ['billing', 'technical', 'feature-request', 'other'],
  prompt: `Classify this support ticket: "${ticketText}"`,
})
// object is typed: 'billing' | 'technical' | 'feature-request' | 'other'

### Rules
- schema must be a z.object (not z.string, z.array at the top level) unless using output: 'array' or output: 'enum'
- Use z.describe() on schema fields to give the model context
- Prefer generateObject over generateText + JSON.parse, always
- The model may retry internally if it produces invalid JSON, this is automatic
- Set mode: 'json' (default); use mode: 'tool' if the provider requires it for structured output

The output: 'array' and output: 'enum' modes are the two most commonly missed options. Claude defaults to z.object at the top level because that is the most common shape, but classification tasks that return a single string label are cleaner with output: 'enum', and extraction tasks that return a list are cleaner with output: 'array'. Both are typed correctly. Using generateText and parsing the JSON manually for these cases introduces a stringly-typed gap that generateObject eliminates.

useChat hook in React and Next.js

The useChat hook manages the chat message state, the streaming connection to the API route, the input field value, and the loading and error states. The server route must return result.toDataStreamResponse() for the hook to work correctly.

Add a client-side section to CLAUDE.md:

## useChat patterns

### Minimal setup
// app/api/chat/route.ts
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'

export async function POST(req: Request) {
  const { messages } = await req.json()
  const result = await streamText({
    model: anthropic('claude-sonnet-4-5'),
    messages,
  })
  return result.toDataStreamResponse()
}

// components/Chat.tsx
'use client'
import { useChat } from 'ai/react'

export function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: '/api/chat',
  })

  return (
    <div>
      <div>
        {messages.map(m => (
          <div key={m.id}>
            <span>{m.role === 'user' ? 'You' : 'AI'}: </span>
            <span>{m.content}</span>
          </div>
        ))}
        {isLoading && <span>Thinking...</span>}
        {error && <span>Error: {error.message}</span>}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} disabled={isLoading} />
        <button type="submit" disabled={isLoading}>Send</button>
      </form>
    </div>
  )
}

### useChat with initial messages
const { messages } = useChat({
  api: '/api/chat',
  initialMessages: [
    { id: '1', role: 'assistant', content: 'Hello! How can I help?' },
  ],
})

### useChat with extra body data
const { messages, handleSubmit } = useChat({
  api: '/api/chat',
  body: { userId: session.user.id, threadId },  // sent with every request
})
// In the route: const { messages, userId, threadId } = await req.json()

### useChat with file attachments (vision)
// Client
const { handleSubmit } = useChat({ api: '/api/chat' })
const fileInputRef = useRef<HTMLInputElement>(null)

const onSubmit = (e: React.FormEvent) => {
  const files = fileInputRef.current?.files
  handleSubmit(e, {
    experimental_attachments: files ? Array.from(files) : undefined,
  })
}

// Route (Anthropic vision)
const model = anthropic('claude-sonnet-4-5')
// attachments are automatically converted to message content parts by the SDK

### Rules
- ALWAYS import useChat from 'ai/react', never from 'ai'
- NEVER manually push to the messages array, useChat manages it
- Use handleInputChange with the input element, not a custom state variable
- The api option must match the exact route path including any dynamic segments
- isLoading is true while streaming, disable inputs during loading
- error surfaces network errors and non-200 responses from the route

The body option on useChat is the correct way to send per-session data (user ID, thread ID, feature flags) to the route with every message. Claude will either hardcode these values in the route or try to encode them in the message content. The body option keeps the message array clean and the route stateless.

The experimental_attachments option enables vision inputs. Without this in CLAUDE.md, Claude either ignores the need for vision entirely or tries to base64-encode images manually and append them to the message content, which bypasses the SDK's provider-specific conversion logic and typically fails with a provider validation error.

Error handling patterns

Add an error handling section to CLAUDE.md:

## Error handling

### Route-level (catch before the stream starts)
export async function POST(req: Request) {
  try {
    const { messages } = await req.json()
    const result = await streamText({
      model: anthropic('claude-sonnet-4-5'),
      messages,
      tools,
      maxSteps: 5,
    })
    return result.toDataStreamResponse()
  } catch (err) {
    if (err instanceof APICallError) {
      return Response.json({ error: err.message }, { status: 502 })
    }
    return Response.json({ error: 'Internal error' }, { status: 500 })
  }
}

### Tool-level (return error, do not throw)
const riskyTool = tool({
  description: 'Fetch data from an external API',
  parameters: z.object({ endpoint: z.string().url() }),
  execute: async ({ endpoint }) => {
    try {
      const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) })
      if (!res.ok) return { error: `HTTP ${res.status}`, endpoint }
      return await res.json()
    } catch (err) {
      return { error: 'Fetch failed', detail: String(err), endpoint }
    }
  },
})

### Client-level (useChat exposes error state)
const { error } = useChat({ api: '/api/chat' })
// error is set on non-200 responses from the route
// Render it so the user knows what happened:
{error && <div role="alert">Something went wrong: {error.message}. Please try again.</div>}

### Provider-specific errors
- APICallError: provider returned an error response (rate limit, invalid key, content policy)
- ToolExecutionError: execute function threw (not returned an error, this is why return > throw)
- NoTextGeneratedError: model returned no text (e.g. only tool calls with no final text)
- InvalidToolArgumentsError: model passed arguments that failed the Zod schema validation
- All importable from 'ai'

### Rate limit handling
import { APICallError } from 'ai'
if (err instanceof APICallError && err.statusCode === 429) {
  return Response.json({ error: 'Rate limit exceeded. Try again in a moment.' }, { status: 429 })
}

The distinction between throwing and returning in execute is the most important error handling rule for tools. The SDK catches thrown errors and wraps them as ToolExecutionError, which it sends back to the model. Depending on the provider and maxSteps, the model may attempt to recover or may halt. Returning { error: '...' } is more predictable: the model receives the error as a normal tool result, which it can reason about and decide to retry, escalate, or report to the user. Claude defaults to throw because that is the idiomatic JavaScript error pattern. The explicit return > throw rule in CLAUDE.md changes the default.

Generative UI with streamUI

For cases where the AI response should render React components rather than text, the AI SDK provides streamUI. This is distinct from streamText: instead of streaming tokens, it streams React component trees.

Add a generative UI section to CLAUDE.md:

## streamUI (generative UI)

### When to use
- The AI response is a structured UI element, not prose
- You want the model to choose which component to render based on context
- Chat messages should show rich cards, charts, or interactive elements, not markdown

### Basic pattern (server action or route)
import { streamUI } from 'ai/rsc'
import { anthropic } from '@ai-sdk/anthropic'
import { z } from 'zod'

async function submitMessage(userInput: string) {
  'use server'
  const result = await streamUI({
    model: anthropic('claude-sonnet-4-5'),
    prompt: userInput,
    text: ({ content }) => <p>{content}</p>,   // fallback for plain text
    tools: {
      showWeather: {
        description: 'Show a weather card for a location',
        parameters: z.object({ city: z.string(), unit: z.enum(['celsius', 'fahrenheit']) }),
        generate: async function* ({ city, unit }) {
          yield <Spinner />                    // shown while fetching
          const data = await fetchWeather(city, unit)
          return <WeatherCard data={data} />   // shown when ready
        },
      },
    },
  })
  return result.value
}

### Rules
- streamUI lives in 'ai/rsc', not 'ai'
- The generate function is an async generator, yield for loading states, return for final
- text prop handles the plain-text fallback
- Each tool has generate instead of execute
- Use this only for RSC (React Server Components) environments
- For client-side chat, useChat + server route is simpler and more portable

Common gotchas

The following issues appear most often when Claude Code generates AI SDK code without the CLAUDE.md above:

Wrong import for hooks. import { useChat } from 'ai' silently fails in some build setups or throws useChat is not a function. The correct import is 'ai/react'. This is the most common single-file error Claude generates.

Missing toDataStreamResponse(). A route that returns result.textStream wrapped in new Response() looks correct but produces output that useChat cannot parse. The chat UI shows nothing. The route returns 200. There is no error to debug. Always use result.toDataStreamResponse().

No maxSteps on tool calls. The model calls the tool once and returns. If the task requires multiple tool calls, the response is incomplete. The user sees a partial answer. Adding maxSteps: 5 is a one-line fix that changes single-step into a loop.

prompt and messages together. streamText({ model, prompt: '...', messages: [...] }) throws a validation error at runtime. Use one or the other: prompt for single-turn, messages for multi-turn.

Tool execute throws instead of returns. A thrown error in execute produces a ToolExecutionError that the model may not handle gracefully. Returning { error: '...' } gives the model a structured result it can reason about.

Missing provider package. import { anthropic } from 'ai' throws a module-not-found error. The provider is in @ai-sdk/anthropic. The core ai package does not include providers.

Vision without experimental_attachments. Sending image content via useChat without the experimental_attachments option in handleSubmit bypasses the SDK's image encoding. The provider receives malformed content and rejects the request. Always pass files through experimental_attachments.

For the full React component layer that wraps these chat components, Claude Code with React covers the component conventions that compose with the AI SDK patterns here.

Building AI features that work in production

The Vercel AI SDK CLAUDE.md in this guide produces AI integrations where streaming works because toDataStreamResponse() is always the return value, tool loops complete because maxSteps is always set, tool errors are recoverable because execute returns rather than throws, structured output is typed because generateObject replaces manual JSON parsing, and client-side state is correct because useChat is imported from 'ai/react'.

The underlying principle is the same across every Claude Code integration. Without a CLAUDE.md, Claude reaches for the patterns that look correct in isolation: streamText without toDataStreamResponse(), tools without maxSteps, useChat from the wrong entry point. Each one produces a different failure mode and none produce an error that points directly at the cause.

With the configuration above, Claude generates AI SDK code that matches the SDK's actual model on the first pass. The gap between what looks correct and what works in production collapses when the constraints are explicit.

Claudify includes a Vercel AI SDK CLAUDE.md template pre-configured for the streaming rules, tool definition format, multi-step loop settings, structured output patterns, and useChat hook conventions shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame