AI SDK v6 Crash Course

repository·main·Indexed 42 days ago

https://github.com/ai-hero-dev/ai-sdk-v6-crash-course

A comprehensive, hands-on course for mastering AI SDK v6. Covers core concepts such as streaming, tool calling, and structured generation using Output.object, as well as advanced production patterns like memory management, multi-provider support, and agentic workflows with ToolLoopAgent. Includes practical exercises on implementing conversational interfaces using useChat and streamText.

Tokens
66K
Snippets
194
Records
223
Agent score
54%

What's inside AI SDK v6 Crash Course

  1. What is an Immediately Indexed Mapped Type (IIMT)?

    main

    An Immediately Indexed Mapped Type (IIMT) is a TypeScript pattern used to transform a union or an object's keys into a new union of objects.

    It works in two steps:

    1. Create a mapped type: Iterate over a set of keys or a union to create a temporary object type where each key maps to a new structure.
    2. Immediately index: Use the original keys/union to index into that mapped type, which collapses the object into a union of its values.

    This pattern is highly effective for creating discriminated unions where you need to preserve context from the original union (like the specific member being iterated) within the new object structure.

    type SomeObject = {
      a: string;
      b: number;
    };
    
    // The IIMT pattern
    export type Example = {
      [K in keyof SomeObject]: {
        key: K;
      };
    }[keyof SomeObject];
    
    // Resulting type:
    // | { key: 'a' } | { key: 'b' }
  2. Use streamObject for structured data suggestions

    main

    When you need to return structured data (like an array of strings) instead of plain text, replace streamText with streamObject. This allows you to use a Zod schema to enforce the shape of the response. Instead of iterating over textStream, you should iterate over partialObjectStream to receive the object as it is being built by the LLM.

    import { streamObject } from 'ai';
    import { z } from 'zod';
    
    const followupSuggestionsResult = streamObject({
      model: google('gemini-2.5-flash'),
      schema: z.object({
        suggestions: z.array(z.string()),
      }),
      messages: [
        // ... messages
      ],
    });
    
    // Iterate over the partial object stream
    for await (const partialObject of followupSuggestionsResult.partialObjectStream) {
      // partialObject contains the structured data as it streams in
      writer.write({
        id: dataPartId,
        type: 'data-suggestions',
        data: partialObject.suggestions,
      });
    }
  3. How MCP transport types work

    main

    The AI SDK supports three different transport types for connecting to Model Context Protocol (MCP) servers. Choosing the right transport depends on whether the server is running locally or remotely and your requirements for latency and security.

    TransportUse CaseTrade-offs
    Standard IOLocal MCP serversSimpler setup, but executes code on your machine
    HTTPRemote MCP serversNo local code execution, but depends on external service
    Server-Sent Events (SSE)Real-time remote serversLower latency, but less widely supported
  4. Understand UIMessageStream event types

    main

    When iterating over a UIMessageStream, you will encounter several event types that describe the lifecycle of tool calls and text generation:

    Tool Call Events

    • tool-input-start: Indicates a tool is being called. Contains toolCallId and toolName.
    • tool-input-delta: Provides incremental updates to the tool's input (e.g., inputTextDelta).
    • tool-input-available: Emitted when the complete tool input is ready. Contains the full input object.
    • tool-output-available: Emitted when the tool execution completes. Contains the output object.

    Text Generation Events

    • text-start: Marks the beginning of a text response.
    • text-delta: Provides incremental text chunks (delta).
    • text-end: Marks the end of the text response.

    Lifecycle Events

    • start: The beginning of the stream.
    • start-step: Indicates the beginning of a new step (often associated with a tool call).
  5. Ensure `onFinish` callbacks execute by consuming streams

    main

    By default, the AI SDK does not always wait for a stream to complete. If you do not consume the stream, the onFinish callback provided to functions like streamText may never be triggered. This is particularly critical when using onFinish for persistence logic (e.g., saving a chat message to a database).

    To ensure a stream completes and triggers its lifecycle callbacks, you must use one of the following methods:

    1. Manual Iteration: Use a for await...of loop to iterate over the stream chunks (e.g., streamTextResult.textStream).
    2. The consumeStream() method: Call .consumeStream() on the result object returned by streamText.
    3. The top-level consumeStream() function: Use the standalone consumeStream function from the ai package, passing in a stream (e.g., via .toUIMessageStream()).
    import { google } from '@ai-sdk/google';
    import { streamText } from 'ai';
    
    const streamTextResult = streamText({
      model: google('gemini-2.5-flash'),
      prompt: 'Hello, world!',
      onFinish: () => {
        console.log('Stream finished!');
      },
    });
    
    // Option 1: Manual iteration
    for await (const chunk of streamTextResult.textStream) {
      process.stdout.write(chunk);
    }
    
    // Option 2: Using the method on the result
    await streamTextResult.consumeStream();
  6. Create a custom UI message type with InferUITools

    main

    The UIMessage type from the AI SDK accepts type parameters to allow for tool-aware messaging. To automatically infer the correct tool types from your tool definitions, use the InferUITools utility type. This ensures that when you access message parts in your UI, the tool arguments and results are correctly typed.

    import { type UIMessage, type InferUITools } from 'ai';
    
    // Assuming 'tools' is your module-scoped tool definition object
    export type MyUIMessage = UIMessage<InferUITools<typeof tools>>;
  7. Implement a Model Router pattern

    main

    A model router is a pattern where an initial, fast LLM call is used to classify the complexity of a user's query. Based on this classification, the system routes the request to either a 'basic' (cheaper/faster) or 'advanced' (more capable) model. This optimizes for both cost and performance.

    Implementation Steps

    1. Routing Call: Use generateText to call a lightweight model. The prompt should instruct the model to return a specific token (e.g., 0 for basic, 1 for advanced) based on query complexity.
    2. Selection Logic: Parse the result from modelRouterResult.text.trim() to determine the model type. Always implement a fallback to the 'basic' model if the output is ambiguous.
    3. Execution: Use the selected model for the main task.
    4. Metadata Injection: Pass the selected model name into the messageMetadata of the stream so the UI can display which model handled the request.
    // 1. The Routing Call
    const modelRouterResult = await generateText({
      model: basicModel,
      system: "Return 0 for simple queries, 1 for complex queries.",
      prompt: userPrompt,
    });
    
    // 2. The Selection Logic
    const modelSelected: 'advanced' | 'basic' = 
      modelRouterResult.text.trim() === '1' ? 'advanced' : 'basic';
    
    // 3. The Main Execution & Metadata Injection
    const streamTextResult = await streamText({
      model: modelSelected === 'advanced' ? advancedModel : basicModel,
      messages,
    });
    
    writer.merge(
      streamTextResult.toUIMessageStream({
        messageMetadata: {
          model: modelSelected,
        },
      }),
    );
  8. Implement Chain of Thought prompting for complex tasks

    main

    Chain of Thought (CoT) prompting is a technique used to improve LLM performance on complex tasks by instructing the model to "think step by step" or plan its work before providing a final answer. This involves two phases in the model's output:

    1. Thinking Phase: The model outputs tokens where it displays its reasoning, planning, or decomposition of the problem.
    2. Answer Phase: The model provides the actual solution based on its reasoning.

    To implement this effectively, you should:

    • Instruct the model to plan the optimal path for user understanding.
    • Explicitly tell the model to break down individual components or syntax elements.
    • Use structured formats like XML tags to separate the reasoning from the final output.
    const result = streamText({
      model: google('gemini-2.5-flash-lite'),
      prompt: `
        <task-context>
        You are a helpful TypeScript expert...
        </task-context>
    
        <the-ask>
        Explain the code, using the article as a reference.
        </the-ask>
      `,
    });
  9. Implement a Generator-Evaluator Workflow

    main

    A Generator-Evaluator workflow is a deterministic alternative to autonomous agents. Instead of giving an LLM full control over tool calling, you define a specific sequence of steps to improve output quality.

    In this pattern, you:

    1. Generate: Use an LLM to create an initial draft (e.g., a Slack message).
    2. Evaluate: Use a second LLM call to critique the draft based on specific criteria (e.g., professionalism, clarity).
    3. Refine: Use a final LLM call that takes the original context, the first draft, and the evaluation feedback to produce a high-quality final version.

    This approach is more reliable than agents for tasks where a specific sequence of steps must always occur.

    export const POST = async (req: Request): Promise<Response> => {
      const body: { messages: UIMessage[] } = await req.json();
      const { messages } = body;
    
      // 1. Generate initial draft
      const writeSlackResult = TODO; 
    
      // 2. Evaluate the draft
      const evaluateSlackResult = TODO; 
    
      // 3. Produce final draft and stream
      const finalSlackAttempt = TODO; 
    
      return finalSlackAttempt.toUIMessageStreamResponse();
    };
  10. Understand the differences between `onFinish` callbacks in AI SDK

    main

    When implementing message persistence, you must distinguish between the different onFinish callbacks provided by streamText and toUIMessageStreamResponse. Using the wrong callback or property will result in losing UI-specific data or failing to capture the full conversation history.

    Callback Comparison

    Callback SourcePropertyContent TypeBest Use Case
    streamTextresponse.messagesModel messages (AssistantModelMessage or ToolModelMessage)Avoid for UI persistence; contains minimal info and no UI data.
    toUIMessageStreamResponsemessagesFull message history (including originalMessages)Ideal for persisting entire conversation histories.
    toUIMessageStreamResponseresponseMessageSingle messageGood for persisting only the most recent assistant response.

    Implementation Example

    To capture the full conversation history for persistence, use the onFinish callback within toUIMessageStreamResponse and ensure you pass the originalMessages to it.

    export const POST = async (req: Request): Promise<Response> => {
      const body: { messages: UIMessage[] } = await req.json();
      const { messages } = body;
    
      const result = streamText({
        model: google('gemini-2.5-flash'),
        messages: await convertToModelMessages(messages),
      });
    
      return result.toUIMessageStreamResponse({
        originalMessages: messages,
        onFinish: ({ messages, responseMessage }) => {
          // 'messages' contains the full history, ideal for database persistence
          // 'responseMessage' contains only the latest message
          console.log('Full history:', messages);
          console.log('Latest message:', responseMessage);
        },
      });
    };
  11. Use the ToolLoopAgent class for modular agent construction

    main

    In AI SDK v6, the ToolLoopAgent class is used to separate agent definition from agent invocation. Instead of calling streamText() directly with tools, you first create a ToolLoopAgent instance containing the model, instructions, and tools. This instance can then be shared across your codebase or published as a package.

    To invoke the agent in a web environment (like a Next.js or Hono POST handler), use createAgentUIStreamResponse().

    // 1. Define the agent
    const agent = new ToolLoopAgent({
      model: myModel,
      instructions: 'You are a helpful assistant',
      tools: { ... }
    });
    
    // 2. Invoke the agent in a POST handler
    export const POST = async (req: Request): Promise<Response> => {
      const body: { messages: MyAgentUIMessage[] } = await req.json();
      const { messages } = body;
    
      return createAgentUIStreamResponse({
        agent,
        uiMessages: messages,
      });
    };
  12. Prompt template components and their purposes

    main

    A robust prompt template should include the following components in this specific order to leverage LLM attention biases:

    1. Task Context: High-level role-based prompting (e.g., "You are an AI career coach").
    2. Tone Context: Defines the persona's voice (e.g., "friendly customer service tone").
    3. Background Data: Reference information or documents (e.g., a knowledge base) wrapped in XML tags to prevent confusion.
    4. Rules: Detailed instructions and edge-case handling (e.g., "If unsure, say X"). This is often where most engineering time is spent.
    5. Examples: Few-shot prompting examples to demonstrate desired response patterns.
    6. Conversation History: Previous interactions to maintain coherence.
    7. The Ask: The actual user query and the primary instruction. This is the most critical part.
    8. Thinking Instructions: Prompts the LLM to use Chain of Thought (e.g., "Think about your answer first").
    9. Output Formatting: Explicit instructions on the return format (e.g., "Put your response in <response> tags").