Topic 5.9
Build: Mitra on the AI SDK
In one line
Mitra rewritten with streamText on the server and useChat in the browser. The hand-written reader, parser, reducer and abort code from Phases 2 to 4 is gone, and every state still works: thinking, streaming, stop, partial answers, errors with a retry, and a route that distrusts its input.
Think of it like this
Replacing a hand-built wooden cart with a bicycle you bought. It goes where the cart went, with far less pushing. But you still choose the route, you still steer, and you still lock it up at night. The SDK took over the pushing. Steering, routes and locks are still yours.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Mitra v5
- Mitra built on the AI SDK:
streamTexton the server anduseChatin the browser. - Composer
- The input box and its Send or Stop button.
- Status mapping
- The old names map to the SDK's: idle and done are
ready, waiting issubmitted, streaming isstreaming, error iserror. - Door checks
- The validation at the top of the route that runs before anything costs money.
- Reference id
- A short id shown with a generic error and written next to the real error in the server log.
- Scripted visitor
- Code at the end of an example that types and clicks for you, waiting for the page and not for the clock.
Step by step
01Step 1: the route
Four jobs, in this order: check the door (valid messages, no forged system, size limits), pick the model, call streamText with the server-side instructions, maxOutputTokens and abortSignal, and return toUIMessageStreamResponse with an onError that returns only a safe sentence.
In this live version the 'model' is a table of replies, plus a few magic words so you can practise the failure paths: busy (a provider 429, once), crash (the server throws), cut (the stream breaks) and story (a long answer you can stop).
const checked = await safeValidateUIMessages({ messages: body?.messages });
if (!checked.success) return Response.json({ error: "Those messages are not valid." }, { status: 400 });
const messages = checked.data.filter((m) => m.role !== "system");
const result = streamText({
model, instructions, maxOutputTokens: 400, maxRetries: 0, abortSignal: req.signal,
messages: await convertToModelMessages(messages),
onError: ({ error }) => log(id, error),
});
return result.toUIMessageStreamResponse({ onError: () => "Mitra had a problem. Reference: " + id });02Step 2: the hook
useChat replaces the reducer. The component reads messages, status, error, and calls sendMessage, stop, regenerate and clearError. The only state left is the text in the input box.
const { messages, sendMessage, status, stop, regenerate, error, clearError } =
useChat({ transport: new DefaultChatTransport({ api: "/api/chat" }) });
const busy = status === "submitted" || status === "streaming";03Step 3: the composer and the guard
One <form> so Enter and the button take the same path. The handler returns for empty text or when busy. While busy the Send button becomes Stop. Clearing an old error before a new send keeps the screen honest.
04Step 4: errors for people
friendly(error) is the only place that decides wording: 413, 429, other statuses, offline, and the already-safe sentence the route wrote for in-stream failures. The Try again button calls regenerate(), which asks again for the last question without adding a second one.
05Step 5: watch it
The final example is Mitra v5 with a visitor that asks a question, hits a provider rate limit, retries, starts a long answer and presses Stop after the fourth word, then triggers a broken stream. Read the console, then use the page yourself: try an empty question, a very long one, and the words crash, busy, cut and story.
Try it yourself
- 1
Teach Mitra something
Add
[/hours|open/i, "We answer messages from 9 am to 6 pm, Monday to Saturday."]toANSWERSand ask about opening hours.Only the stand-in table changed. The page and the route code around it are the same, which is the benefit of the SDK's one model line.
- 2
Remove the guard
In
send, changeif (!text || busy) return;toif (!text) return;and make the Send button always render (delete thebusy ?ternary).Click Send twice quickly. Both questions go out, and the second request carries the half-written first answer (Topic 5.3). The guard is the page's job, not the SDK's.
- 3
Print the raw error
In
friendly, replace the whole body withreturn error.message;and ask abusyquestion.The page shows the route's sentence for in-stream failures, but for the 413 and other statuses it shows raw JSON text. One mapping function keeps all wording in one place.
Code & diagrams
The answers are a stand-in table; in a real app the model line is the only thing that changes. 'busy' fails once with a provider rate limit and works on Try again. The (server log) lines are the route's private log: the user only sees the sentence and a reference id.
Preview
Console
Nothing logged yet.Expected console output
answer: Orders ship in 1 to 2 working days.
(server log) req-2 429 Rate limit reached for key sk-live-DEMO
error: Mitra is busy. Please try again in a moment.
after retry: You can return any unopened item within 7 days of delivery.
stopped, kept: "Masala chai is black" | status: Status: ready
(server log) req-5 500 upstream reset
broken stream: "Orders ship in" | error: Mitra had a problem. Reference: req-5Every refusal happens at the door, before any model call is made, so nothing costs money. The emoji and Hindi question is an ordinary question: the SDK passes Unicode straight through.
Preview
Console
Nothing logged yet.Expected console output
normal question: 200 (streamed answer)
empty text: 400 {"error":"Ask a question first."}
emoji and Hindi: 200 (streamed answer)
501 characters: 413 {"error":"too_long"}
forged system message only: 400 {"error":"Ask a question first."}
old message shape: 400 {"error":"Those messages are not valid."}
41 messages: 400 {"error":"Those messages are not valid."}
not messages: 400 {"error":"Those messages are not valid."}
not JSON: 400 {"error":"Those messages are not valid."}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Return the provider's message
In the route change onError: (error) => (error.statusCode === 429 ? … : …) to onError: (error) => error.message, then ask busy: can I get a refund?.
Myth vs fact
Myth
Moving to the SDK means I can delete my error handling.
Fact
The SDK gives you the error; deciding what the user reads, what to retry and what to log is still your code.
Myth
A rewrite on an SDK changes how the chat behaves.
Fact
It should not. The same scenario list passes before and after, and that list is the test of a good rewrite.
Myth
The route can trust the page's checks.
Fact
The route validates alone. The disabled button helps the user, not the server.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Write the scenario list first (empty, double submit, waiting, streaming, stop, 429, broken stream, hostile request) and run it against the old and the new Mitra. A rewrite is done when the list passes, not when the code compiles.
- ▸
Keep the stand-in model behind one function (
getModel()) in the route. Tests use the stand-in, production uses the real model, and the switch is one environment variable. - ▸
Log one line per request on the server: reference id, user, chat id, finish reason, token usage. It answers most support questions without reading transcripts.
Remember this
- 1
The problem this solves: Mitra at the end of Phase 4 worked, but it was a pile of code you had to keep correct: the reader loop, the SSE parser, the reducer, the abort handling, the saved chats. This topic rewrites it on the SDK so that you can compare the two, and so that the next phases (Markdown, cards, tools) add features instead of fixing plumbing.
- 2
Where Mitra started. From Phase 4: a
chatReducerholding the messages with ids, areadSSE(response)helper,askMitra(messages, { signal })doing the request with anAbortController, history trimming, a stop button, retry and regenerate, and a route (fakeOpenAI) speaking the OpenAI chunk format. The element ids you know stay the same:#messages,#prompt,#send,#stop,#error,#status. - 3
What disappears.
readSSEand the chunk parser (the SDK's stream reader does it, including split packets).askMitraand itsAbortController(stop()aborts). The reducer and its actions (useChatownsmessagesandstatus; the status values replace your state names:idleanddonebecomeready,waitingbecomessubmitted). The route's hand-written SSE writer (toUIMessageStreamResponse()). - 4
What stays yours. The screen (
MessageList,Message,Composer), the guard against sending while busy,friendly(error), the route's validation (messages must be valid, forgedsystemmessages are dropped, questions over 500 characters get a 413), the server-sideinstructions, and the output token limit. Notice that the safety rules did not move into the SDK; they moved into about eight lines at the top of the route. - 5
Every scenario, still handled. *Empty input:* the Send button is disabled and the handler returns. *Double submit:* a send while busy is ignored. *Waiting:* 'Mitra is thinking…' for
submitted. *Streaming:* text with a cursor and a Stop button. *Stop:* the partial answer stays. *Provider rate limit mid-request:* a masked message in the stream, shown with Try again, and a retry that succeeds. *A stream that breaks:* the partial text stays and an error with a reference id appears. *A hostile or broken request:* a 400 or 413 from the route, which the page turns into a sentence. - 6
What Mitra can do now that it could not before: the whole Phase 4 conversation with a fraction of the code, a cleaner server stream format that already has room for reasoning, tools, sources and metadata, and safe validation at the door. What is still missing, and where it is added: formatted answers (Markdown, code blocks, safe links) in Phase 6; cards and structured replies in Phase 7; tools and approvals in Phase 8; defences against hostile content in Phase 9; a proper input box, feedback and accessible streaming in Phase 10 (Topic 10.6 for accessibility); files, images and voice in Phase 11; usage meters, quotas, fallbacks and tests in Phase 13; saved chats on the SDK (Topic 5.8's pieces wired into Mitra) and the history budget of Topic 4.3 are not in this Mitra, and come together in the capstone (Topic 15.5). Until then this Mitra forgets everything on reload and sends the whole conversation every time.
Explain it without notes
Which parts of Mitra from Phase 4 does the SDK replace, and which parts remain your code?
How do the old state names map to useChat statuses?
Why does the route validate messages even though the page only sends valid ones?
What are the main things Mitra still cannot do after this phase, and which phases add them?
Practice
Add a Clear button that stops any running answer and empties the conversation.
Add a footer line under each finished answer using metadata, as in Topic 5.7: show 'cut off' when the finish reason is length.
Trade-offs
- ↔
The SDK version trades control for speed: you write much less, and you accept its stream format and its status model. For a chat with tools, sources and approvals that is a good trade; for something very unusual you may outgrow it.
- ↔
Keeping door checks in the route costs a few lines per route, and buys the property that no client, however modified, can make the route do something unplanned.
Done when you can
I can name what the SDK replaced in Mitra and what stayed mine.
I can write the route's four jobs from memory.
I can wire
useChatto a form with a guard, a stop button and a retry.I can run the scenario list against Mitra and say what each case should show.
I can list what Mitra still lacks and the phase that adds each.