Skip to content
May 31, 2026Article

AI SDK 7 Crash Course: Build a Production Chat UI

How one text call grows into a typed, streaming, multimodal, tool-using chat route, and why AI SDK 7 gives you one contract to build all of it on.

Back to Blog

Almost every AI feature starts the same way. You call a model, you get text back, and you ship the demo. It works. It even feels a little magical the first time the answer appears.

Then the app grows. A better model ships, and it happens to come from a different provider. So you swap it in. But it is never just the model name. The import changes. The client changes. The method changes. And the response shape changes too, so now you are reaching into a different object just to pull out the same string you already had. The UI still wants exactly what it wanted before. The code suddenly has to relearn where that thing lives.

That is the problem the AI SDK is trying to solve. It hands your app one contract to build on top of: generateText, streamText, UI message streams, typed message parts, Output.* for structured output, and tool() definitions you can share across the route and the UI. The provider can change underneath you. The shape your app speaks does not.

This is a crash course in that contract. We will start with a single text call and grow it, step by step, into a typed, streaming, tool-using chat feature. Not because every app needs all of it, but because watching it grow is the fastest way to understand why each piece exists.

A note on versions

This walks through the AI SDK 7 shape. Every snippet here uses the current API: Output.* for structured output, async convertToModelMessages, the stateless toUIMessageStream and createUIMessageStreamResponse helpers, isStepCount on the loop, onEnd for lifecycle logging, and inputSchema on tools. Still, details move between releases, so check the AI SDK 7 introduction (opens in new tab) before you copy anything into production. The product lesson underneath does not change: isolate your provider choice, stream when a person is waiting, render message parts explicitly, type your structured output and your tools, and put a cap on the loop.

TipStateless UI stream helpers in v7

result.toUIMessageStreamResponse() still works in AI SDK 7, but it is deprecated. The route steps here use the stateless toUIMessageStream and createUIMessageStreamResponse helpers instead. They do the same job and are the future-proof shape.

What we are actually learning here

We are not going to start with a finished architecture and admire it. We are going to earn it. The point is to feel why each layer needs to exist before it shows up.

  • Why provider SDKs quietly turn every switch into adapter work. Imports, clients, methods, response access, streaming, usage, tool outputs. All of it drifts.
  • How the AI SDK puts one contract over those providers so the rest of your app can keep the same shape.
  • When generateText is genuinely enough, and the exact moment it starts to feel wrong: when someone on their phone is waiting on a long answer.
  • How streamText changes the feel of the product, not just the API call, by sending chunks into the UI as they land.
  • Why a React chat UI should render message parts instead of pretending every message is a single block of text.
  • How files, structured outputs, tools, step limits, usage, and metadata all fit into that same one contract.

You do not need to memorize every API on this page. I would rather you walk away seeing the same problem get more real, more than once, without provider details leaking into every route and component you write.

Streaming is feedback, not decoration

For a chat UI, streaming is not a nice-to-have polish layer. It is the feedback.

The video version of this lesson is refreshingly practical about it. generateText is completely fine when the answer can land all at once. It starts to feel wrong the instant someone is sitting on their phone waiting for it. streamText hands the app chunks as the model produces them, so the wait turns into something the user can watch.

In the production shape, the route kicks off streamText and returns a UI message stream. The client renders text, file parts, tool states, and tool errors out of message.parts, while message-level details like token counts ride along on message.metadata. Same contract, more of it on display.

Waiting is silence. Streaming is a heartbeat.

Typed output and tools, kept on a leash

The back half of this course is where a chat response stops being "some text" and becomes a real product contract.

Output.object keeps generated data inside a schema, so what comes back is shaped, not guessed at. tool() exposes one narrow capability with a description, a Zod input schema, a small execute, typed UI tool parts, and a clear stop condition. And here is the part people miss: the SDK stops after a single step by default. stopWhen is how you opt into multi-step tool continuation, and just as importantly, how you cap it so the loop cannot run away from you.

A structured output is a promise about the shape. A tool call is a loop you have to put a ceiling on.

1. The adapter tax nobody warns you about

Provider SDKs are not interchangeable. They look like they should be, and they are not.

In the raw-SDK version, moving from one provider to another is almost never "change the model name." You change the import, you new up a different client, you call a different method, and then you reach into a different response layout to find your text. The app still wants one string. The code has to go hunting for it in a new shape.

Doing that once is fine. It gets expensive the moment your product starts comparing providers, adding streaming, logging usage, or showing tool state in the UI. Every one of those becomes its own little adapter.

2. One contract over all of them

This is the shift. The AI SDK gives your app a stable layer to build against, and pushes the provider differences down underneath it.

You still pick the provider and the model. That choice stays yours. But everything above that choice gets to think in the same primitives: text, usage, messages, structured output, tool calls, tool results. One vocabulary.

In code, that means OpenAI and Anthropic can sit behind a single app-level model function. Your route never has to know which client got created, or which provider-specific response shape came back. The provider is a detail now, not a rewrite.

The model can change. The contract stays still.

3. Install the smallest set that works

Start small. The core SDK, the React hook package, the OpenAI provider, and Zod. That is enough to get the whole contract under your hands.

Add a second provider package only when you actually want to test a real provider switch, not before. Keeping the surface this small is not laziness, it is what makes the first version easy to reason about. And because the provider boundary is already clean, growing later costs you almost nothing.

4. Give the SDK its key

Then give the SDK its key. Create a .env.local at the project root, drop OPENAI_API_KEY inside, and keep the file out of git. The SDK reads it from the environment at runtime.

One file holds the secret. The rest of the app just asks for a model.

5. Get text back first

generateText is the right starting point when nobody is watching the answer form live, or when the next step in your code needs the whole result before it can do anything useful.

It is also just the cleanest way to learn the contract. You pass a model and a prompt, then you read result.text and the usage. The highlighted lines really are the entire deal: model in, final text and usage out. Nothing hidden.

Now picture where that prompt actually comes from. Someone on their phone, asking a long support question. With generateText, nothing shows up until the full answer is finished. That is technically correct. It can also feel exactly like the app froze. Hold onto that feeling, because it is the reason the next few steps exist.

For hard coding, reasoning, and tool-heavy workflows, the current OpenAI model docs recommend starting with gpt-5.5. If you want cheaper or lower-latency work, benchmark the smaller variants like gpt-5.4-mini or gpt-5.4-nano and see what holds up.

One thing worth knowing: with the OpenAI provider, openai("gpt-5.5") uses OpenAI's Responses API path by default, which is the right mental model for modern reasoning, tool, and multimodal workflows.

6. Hide the provider behind your own function

The SDK makes provider switching small. Your app still wants a convention so it stays small as the codebase grows.

So put model selection in one file. That file is allowed to know about both providers and all their setup. Everywhere else, the rest of your app just calls appModel() and gets back the same output shape it always does.

That is the layout change that actually matters here. Provider-specific setup collapses into one boundary file, and your routes, jobs, and tests all stay written against the AI SDK contract. When the next better model arrives, you change one file. Not your whole app.

This is also where safe lives. requireEnv fails at startup with the exact key that is missing, instead of failing mid-request with a vague 401. The switch is exhaustive, so a new provider is a type error until you wire it fully.

Minimal on purpose. Safe by default.

7. Stream the moment latency is visible

Reach for streamText whenever a person is waiting on the answer. Chat, writing assistants, copilots, live summaries, anything long.

The code barely moves. You call streamText instead, then loop over result.textStream. That is it. What moves a lot is the product: the phone can show the answer forming, word by word, instead of staring at a spinner and wondering if anything is happening.

So the rule I keep in my head is simple. Use generateText when the response is a background artifact nobody is watching. Use streamText when the latency is visible and the user needs a sign that work is actually happening.

generateText makes you wait. streamText lets you watch.

8. Watch it stream into a real chat

The code is only half the idea. The other half is what the person on the other end feels.

In this moment, someone asks a long, mobile-style question, and the answer fills into a phone-sized chat UI as streamed message parts. Seeing it land that way is what turns streamText from "an API I chose" into "a product behavior I shipped." That is the whole point of stopping to look at it here.

9. The route is your protocol boundary

The route owns the model call. That is its job, and keeping it that way keeps everything else clean.

The browser sends UI messages. The route converts them with convertToModelMessages, calls streamText, and returns a UI message stream through toUIMessageStream and createUIMessageStreamResponse. That boundary does two things at once: it keeps provider details out of your React tree, and it gives the server one obvious place to add tools, metadata, logging, and policy later. One door in, one door out.

10. Route events, but as something you can see

Now the phone shows that server boundary as state the user can actually watch.

The request goes into /api/chat, the model stream starts, and the UI starts receiving message parts. Nothing mysterious is happening. That is just the protocol boundary doing exactly its job, made visible.

11. Render the chat with useChat

Here is the habit to build early: in AI SDK 7, message content is parts-based.

So you loop through message.parts and handle the part types your product supports. Text is just the first one. Files, tool calls, and tool outputs can all show up in that same stream, while metadata stays attached to the message itself rather than getting mixed into the content.

A message is a list of parts. It is not just a string. Get comfortable with that now and the multimodal and tool steps later will feel obvious instead of surprising.

12. The hook is driving the state

This phone moment is deliberately simple: submitted, streaming, ready.

That is honestly enough. Those three states let you disable the send button, show that something is in progress, and keep the user from ever wondering whether the app quietly died on them. You do not need a state machine diagram. You need the user to feel held.

13. Add files at the edge, not the core

File input belongs at the UI boundary, where the user actually is. It should not ripple back into your route.

For the common image-input chat path, the route does not need a separate image branch at all. You send { text, files }, let the transport carry the parts, and let convertToModelMessages produce the model-facing shape. For broader file types, be honest and add explicit handling instead of assuming every file can just be forwarded. But for images in a chat, this stays beautifully small.

14. An image is still just a message

The phone does not need a whole separate conversation model for images. That is the relief here.

It receives a file part, and then text streams back through the exact same UI contract you already built. Same parts, same loop, one more part type.

15. Render the part you actually got

Once messages are parts-based, rendering gets clearer, not messier.

Text parts become paragraphs. Image file parts become images. Other files can get download links, previews, or an honest unsupported-state fallback. The habit underneath all of it is the same one from before: render the part you actually received, instead of assuming the message is plain text and getting surprised when it is not.

16. Structured objects with Output.object

In AI SDK 7, structured generation lives inside the same text APIs you already know. You do not jump to a separate world for it.

Instead of reaching for a dedicated object-generation path, you use generateText with output: Output.object(...). The model still returns typed structured data, but your app keeps one mental model across text, structure, and usage. Fewer concepts to hold, same guarantees.

17. Lists and labels: Output.array and Output.choice

Objects are not the only shape worth pinning down.

Use Output.array when the UI needs a validated list of items. Use Output.choice when the model should pick exactly one fixed label: sentiment, category, route, intent, that kind of thing. In both cases the schema is the contract. You are not leaving the UI to parse prose and hope the model phrased it the way you expected this time.

18. Stream the structure too

The same idea works with streamText. Structure does not have to wait for the end either.

For object-shaped output, partialOutputStream lets you watch the object fill in. That is genuinely useful when your interface can progressively render a title, then bullets, then fields or draft sections, instead of holding everything back until the whole structure is complete. The shape arrives in pieces, and so does the UI.

19. Watch the object fill in

When you stream structure, the product gets to render fields the moment they exist.

In this phone moment, the title and the first bullet are already on screen while the next bullet is still streaming in. Nobody is staring at an empty card waiting for a complete object. They are watching it assemble.

20. Tools should be boring and specific

A good tool is narrow on purpose. This one looks up a single order status. That is the whole job.

It does not hand the model a general database client, an arbitrary HTTP fetch, shell access, or broad customer mutation. It looks up one order, like ord_123456, and returns its status. My rule of thumb: a production tool should be small enough that you can describe its full authority in one sentence. If you cannot, it is doing too much.

21. Wire the tool in, and cap the loop

Here is the part that trips people up. Tool calling is a loop, not a single call.

The model asks for a tool. The SDK validates the input against your schema. Your execute runs. The result goes back to the model. And then the model either answers or asks for another tool, and around it goes. stopWhen: isStepCount(4) is how you opt into that multi-step continuation and put a ceiling on it. Pick the number for your actual workflow, then test both the normal completion and the tool-heavy edge cases. The cap is not paranoia. It is the difference between a feature and a runaway bill.

22. A tool call should not feel like a stall

When a tool runs, the user should not get a dead pause and start wondering what broke.

In this phone moment, you can watch the transition from input streaming to output available, with the step limit visible as part of the contract rather than hidden inside the server. The tool call becomes something the user can read, not a gap they have to guess about.

23. Let the UI know what the tool returns

The UI should not have to guess what a tool part contains. So do not make it guess.

Infer the UI tool types straight from the server tool object, pass the message type into useChat, and then render the states explicitly: input streaming, input available, output available, and output error. Each one becomes a real workflow state the user can see, instead of a mysterious gap where something might be happening. The types flow from the server, so the UI and the route agree by construction.

24. Treat usage as production behavior

Token usage is not a dashboard afterthought you bolt on at the end. It is production behavior, and it is worth wiring in from the start.

Use onEnd to log usage, finish reason, and model metadata on the server, where you can keep the full record. Then send only the UI-safe pieces back with messageMetadata, like the final token count or the finish reason. The server holds the whole operational story. The UI gets the small, responsible slice of it that a user can actually see.

Usage is a number you owe yourself in production. It is not something you discover later when the bill arrives.

25. The receipt shows up after finish

This last phone moment is the metadata contract as a UI-safe summary.

The server can hold the entire operational record behind the scenes. The phone only surfaces the finish reason and the total token count. Just enough for the user to trust what happened, and nothing they were never meant to see.

The shape you end up with

Step back and the whole thing settles into three boundaries that do their own jobs.

The model boundary stays small. You choose a model, you call generateText or streamText, and the provider-specific details live in one place where they cannot leak out.

The route boundary stays stable. It converts UI messages to model messages, attaches tools, caps the loop, logs the result, and returns a UI message stream. One door in, one door out, and every new concern has an obvious home.

The UI boundary stays typed. It renders message parts, file parts, tool states, and metadata from the same message contract, so the screen never has to guess what it is holding.

That, honestly, is the whole difference between a demo chat box and a chat feature you can keep growing for the next year without dreading the next model swap.

Further reading

Official reference: AI SDK docs (opens in new tab).

Enjoyed this post?

Get notified when I publish something new. No spam, just fresh content.