atypica.AI is a multi-agent research platform where AI agents collaborate to conduct user research. Users ask business questions, and the system orchestrates multiple specialized agents—Study Agent plans research, Scout Agent discovers target users from social media, Interview Agent conducts automated interviews with AI personas, and Report Agent generates comprehensive insights. The entire process involves 30+ AI tools and generates extensive chat histories stored in PostgreSQL.
When Vercel released AI SDK v5 with breaking changes to message formats and tool APIs, we faced a challenge: migrate 200 files and 30+ tools while keeping 10 million+ existing chat conversations accessible. This article documents our 3-day migration journey.
Final stats: 200 files changed, 4,206 insertions, 3,094 deletions, 27 commits.
The most fundamental change in v5 is how messages are structured.
In v4, you could use either content (simple string) or parts (structured array). Most of our code used content because it was simpler.
In v5, parts is the only source of truth. The content field is automatically derived from parts and is read-only.
Migration impact: Every place that accessed message.content needed to be refactored to iterate through message.parts.
The codemod handled mechanical replacements:
Message → UIMessageparameters → inputSchemaoutputSchema placeholdersBut it left many FIXME comments because it couldn't understand semantic changes.
We had code like this everywhere:
v5 migration: We created a utility to extract text from parts:
This pattern appeared in:
This was the second major breaking change.
v4 tool part structure:
v5 tool part structure:
Key changes:
toolInvocation object)"tool-${toolName}" not generic "tool-invocation""call" → "input-available", "result" → "output-available"args → input, result → output"output-error" with errorTextField name changed from reasoning to text.
Our PostgreSQL database stores messages in v4 format:
Users expect to:
We couldn't migrate the database because:
Created src/ai/v4.ts:
Result: Old messages load transparently. New messages save in v5 format. No database migration needed.
We have 5 independent chat systems with different tools:
Initial approach: One global UIToolConfigs type.
Problem: Type pollution. Study components got autocomplete for Interview tools. Runtime errors from tool type mismatches.
Solution: Domain-specific tool types.
Each domain gets its own directory structure:
Benefit: Full type safety within each domain. No cross-contamination.
Before v5, components accessed content directly:
After v5, components iterate over parts:
Tool detection pattern:
We migrated 25+ UI components with this pattern.
There's one more important detail: messages to the model have a different format.
UIMessage - From model, for UI:
UserModelMessage - To model:
This only matters when constructing messages for streamText or generateObject:
We had to fix this in 30+ tool definitions and artifact generators.
Hook method renames:
append → sendMessagereload → regenerateinitialMessages → messagesTransport config:
experimental_prepareRequestBody → prepareSendMessagesRequestField renames:
name → filenamecontentType → mediaTypeexperimental_attachments → part of parts arrayKey insight: In v5, only check parts.length, not content.
The content field in v5 is derived from parts, so checking it is redundant.
Don't forget to update all state checks.
In edge cases (aborted streams, errors), toolCall.toolName might be undefined:
Client messages might not have IDs:
Phase 1: Dependencies
ai to v5, zod to v4npx @ai-sdk/codemod@latest upgradeMessage → UIMessage renamePhase 2: Content Access
message.content accessPhase 3: Tool Parts
"tool-invocation" → part.type.startsWith("tool-")args/result → input/output"call"/"result" → "input-available"/"output-available""output-error" handlingPhase 4: Backward Compatibility
Phase 5: UI Components
partsPhase 6: Tool Definitions
parameters → inputSchemaoutputSchemaUserModelMessage for model messagescontent (not parts) when calling streamText/generateObjectPhase 7: React Hooks
useChat: append → sendMessage, reload → regenerateexperimental_prepareRequestBody → prepareSendMessagesRequestPhase 8: Edge Cases
toolCall.toolNamecontent.trim() checksThe shift from content as source of truth to parts as source of truth is fundamental. Every message access pattern needs review.
Budget time for:
message.content references (use global search)The codemod handles:
It doesn't handle:
content access patterns)Budget 70% of time for post-codemod fixes.
Users don't care about your migration. Their old chats must just work.
Strategies:
We chose convert-on-read because:
With multiple chat systems, proper type organization prevents:
Invest time in domain-specific types early.
We migrated systems in order of complexity:
Each system validated the patterns before applying them broadly.
Before v5:
content vs parts)Message type used everywhereAfter v5:
parts arrayTime breakdown:
Total: ~24 hours focused work over 3 days.
getTextFromParts(), convertV4ToV5(), etc.parts is source of truthThe migration is substantial but manageable with proper planning. v5's parts-based approach is more flexible and type-safe. The pain of migration is short-term; the benefits are long-term.
// v4 Message{ id: "msg1", role: "assistant", content: "The weather is sunny", // String representation parts: [ // Structured data (optional) { type: "text", text: "The weather is " }, { type: "tool-invocation", toolInvocation: { ... } }, { type: "text", text: "sunny" } ]}// v5 UIMessage{ id: "msg1", role: "assistant", parts: [ // Only source of truth { type: "text", text: "The weather is " }, { type: "tool-getWeather", toolCallId: "...", state: "output-available", output: {...} }, { type: "text", text: "sunny" } ], content: "The weather is sunny" // Auto-generated, read-only}npm install ai@^5.0.59 zod@^4.0.0npx @ai-sdk/codemod@latest upgrade// v4 pattern - accessing content directlyconst lastMessage = messages[messages.length - 1];if (lastMessage.content.includes("weather")) { // ...}// For title generationconst title = message.content.substring(0, 50);// For logginglogger.info({ userMessage: message.content });// messageUtils.tsexport function getTextFromParts(parts: UIMessage["parts"]): string { return parts .filter((part) => part.type === "text") .map((part) => part.text) .join("");}// Usageconst text = getTextFromParts(message.parts);if (text.includes("weather")) { // ...}{ type: "tool-invocation", toolInvocation: { state: "result", toolCallId: "call_123", toolName: "searchPersonas", args: { query: "coffee lovers" }, result: { personas: [...] } }}{ type: "tool-searchPersonas", // Typed as tool-${toolName} toolCallId: "call_123", state: "output-available", // States renamed input: { query: "coffee lovers" }, // args → input output: { personas: [...] } // result → output}// v4{ type: "reasoning", reasoning: "Let me think..." }// v5{ type: "reasoning", text: "Let me think..." }SELECT parts FROM chat_messages WHERE id = 'msg_old';-- Returns v4 format with tool-invocation and reasoning fieldsexport type V4ToolInvocation = | { state: "call"; toolCallId: string; toolName: string; args: any } | { state: "result"; toolCallId: string; toolName: string; args: any; result: any };export type V4MessagePart = | { type: "text"; text: string } | { type: "reasoning"; reasoning: string } // v4 format | { type: "tool-invocation"; toolInvocation: V4ToolInvocation };export type V5MessagePart = UIMessage["parts"][number];export function convertToV5MessagePart(part: V4MessagePart | V5MessagePart): V5MessagePart { // Text parts are identical in v4 and v5 if (part.type === "text") { return part; } // Reasoning: reasoning field → text field if (part.type === "reasoning") { if ("reasoning" in part) { return { type: "reasoning", text: part.reasoning }; } return part; // Already v5 } // Tool invocation: nested → flat, args/result → input/output if (part.type === "tool-invocation" && "toolInvocation" in part) { const inv = part.toolInvocation; return { type: `tool-${inv.toolName}`, toolCallId: inv.toolCallId, state: inv.state === "result" ? "output-available" : "input-available", input: inv.args, output: inv.state === "result" ? inv.result : undefined, } as V5MessagePart; } // Already v5 format return part as V5MessagePart;}// messageUtils.tsexport async function convertDBMessagesToAIMessages(userChatId: string): Promise<UIMessage[]> { const dbMessages = await prisma.chatMessage.findMany({ where: { userChatId }, orderBy: { createdAt: "asc" }, }); return dbMessages.map((msg) => ({ id: msg.messageId, role: msg.role as "user" | "assistant", parts: (msg.parts as V4MessagePart[]).map(convertToV5MessagePart), }));}// src/ai/tools/types.ts - Study systemexport type StudyUITools = { reasoningThinking: { input: z.infer<typeof reasoningThinkingInputSchema>; output: z.infer<typeof reasoningThinkingOutputSchema>; }; searchPersonas: { input: z.infer<typeof searchPersonasInputSchema>; output: z.infer<typeof searchPersonasOutputSchema>; }; // ... 20+ other tools};export type TStudyMessageWithTool = UIMessage<unknown, UIDataTypes, StudyUITools>;// src/app/(interviewProject)/tools/types.ts - Interview systemexport type TInterviewUITools = { endInterview: { input: z.infer<typeof endInterviewInputSchema>; output: z.infer<typeof endInterviewOutputSchema>; }; requestInteractionForm: { input: z.infer<typeof requestInteractionFormInputSchema>; output: z.infer<typeof requestInteractionFormOutputSchema>; };};export type TInterviewMessageWithTool = UIMessage<unknown, UIDataTypes, TInterviewUITools>;src/app/(myDomain)/├── tools/│ ├── types.ts # Tool type definitions│ └── ui.tsx # Tool UI rendering└── types.ts # Message type for this domain// v4 componentexport function ChatMessage({ message }: { message: Message }) { return <div>{message.content}</div>;}// v5 componentexport function ChatMessage({ message }: { message: UIMessage }) { return ( <div> {message.parts.map((part, i) => { if (part.type === "text") { return <Markdown key={i}>{part.text}</Markdown>; } else if (part.type === "reasoning") { return <ReasoningBlock key={i}>{part.text}</ReasoningBlock>; } else if (part.type.startsWith("tool-")) { return <ToolDisplay key={i} toolPart={part} />; } })} </div> );}// v4if (part.type === "tool-invocation") { const toolName = part.toolInvocation.toolName; const result = part.toolInvocation.result;}// v5if (part.type.startsWith("tool-") && "toolCallId" in part) { const toolName = part.type.replace("tool-", ""); const output = part.state === "output-available" ? part.output : undefined;}{ role: "user", parts: [{ type: "text", text: "Hello" }]}{ role: "user", content: [{ type: "text", text: "Hello" }] // Note: content, not parts}import { UserModelMessage } from "ai";const result = await streamText({ model: llm("gpt-4"), messages: [ { role: "user", content: [{ type: "text", text: prompt }] // content for model } ] as UserModelMessage[], tools: { ... }});// v4const { append, reload, initialMessages } = useChat({ id: chatId, experimental_prepareRequestBody({ messages, requestBody }) { return { message: messages[messages.length - 1], ...requestBody }; },});// v5const { sendMessage, regenerate, messages } = useChat({ transport: new DefaultChatTransport({ api: "/api/chat/study", prepareSendMessagesRequest({ id, messages }) { return { body: { id, message: messages[messages.length - 1], userChatToken: token, }, }; }, }),});// v4{ experimental_attachments: [{ name: "file.pdf", contentType: "application/pdf", url: "..." }];}// v5 - part of parts array{ parts: [{ type: "file", filename: "file.pdf", mediaType: "application/pdf", data: "..." }];}// v4 - check bothif (streamingMessage.parts?.length && streamingMessage.content.trim()) { await persistentAIMessageToDB(userChatId, streamingMessage);}// v5 - only check partsif (streamingMessage.parts?.length) { await persistentAIMessageToDB(userChatId, streamingMessage);}// v4 - this workedmessage.content = "New text";// v5 - this does nothing (content is derived)message.content = "New text"; // ❌ Silently ignored// v5 - correct waymessage.parts = [{ type: "text", text: "New text" }]; // ✅// v4if (message.content) { ... }// v5 - this can be misleadingif (message.content) { ... } // content is auto-generated, might be empty string// v5 - correct checkif (message.parts.some(p => p.type === "text" && p.text)) { ... }// v4 states"partial-call" | "call" | "result";// v5 states"input-available" | "output-available" | "output-error";// Safe accessstep.toolCalls.map((call) => call?.toolName ?? "unknown");// v4onFinish: async ({ reasoning, text, usage }) => {};// v5onFinish: async ({ reasoningText, text, usage }) => {};await persistentAIMessageToDB(userChatId, { ...newMessage, id: newMessage.id ?? generateId(),});