Migrating from the Vercel AI SDK
Move from `streamText` + `useChat` to a `SessionRuntime` — what maps cleanly, what doesn't, and how to keep the AI SDK as your provider.
The Vercel AI SDK is the most common starting point for chat
applications. @pleach/core doesn't replace it — the AI SDK
remains an excellent provider integration, and AiSdkProvider
keeps it in the stack. What changes is what wraps the provider:
a typed runtime with sessions, per-call audit, family lock,
checkpointing, and replay.
This guide walks the migration. You can stop at any step — the runtime works with a single provider, no plugins, no audit adapter, no checkpointer. Add capabilities as you need them.
When to migrate, when not to
You don't need this migration if
- The app is a single-shot chat with tools and no audit needs.
- Streaming,
useChat, and provider switching cover your shape. - You don't need per-tenant cost allocation or per-call audit.
Migrate to @pleach/core when one of these lands on your roadmap:
- A regulator, customer, or finance team will eventually ask "show me which tools this session invoked, which subagents it spawned, and what each cost — attributed to the turn the user typed." The lattice stages are a structural invariant; the ledger is where the variable surface lives.
- Multi-tenant cost allocation requires
(tenantId, turnId, toolName, subagentDepth, modelId, tokens)joined cleanly. - You need to replay a recorded turn deterministically for eval or regression testing.
- You need to swap providers mid-product without breaking tool-call dialect or refusal handling.
The migration cost is real but bounded. The runtime contract is the new mental model; everything else is configuration.
Keep your provider; add a runtime
Today (AI SDK alone):
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const result = await streamText({
model: anthropic("claude-sonnet-4-5"),
messages,
tools,
});
for await (const part of result.fullStream) {
// ...
}After migration:
import { SessionRuntime, AiSdkProvider } from "@pleach/core";
import { anthropic } from "@ai-sdk/anthropic";
const runtime = new SessionRuntime({
provider: new AiSdkProvider({
model: anthropic("claude-sonnet-4-5"),
maxSteps: 5,
}),
userId: req.user.id,
});
const session = await runtime.createSession({
tools: { enabled: Object.keys(tools) },
});
for await (const event of runtime.executeMessage(session.id, prompt)) {
// ...
}What changed:
- A
SessionRuntimeconstructed once at startup (or per request, depending on your architecture). - A
Sessioninstead of a raw message list — the runtime owns the conversation state. - An
AsyncGenerator<StreamEvent>instead of afullStream— the events are typed and richer (tool lifecycle, sync, interrupts, subagents, checkpoints).
What stayed the same: the provider. AiSdkProvider wraps
streamText exactly; your model factory, tool defs, and stream
mechanics are unchanged.
Convert your tools
AI SDK tools translate to defineTool 1:1:
// Before
import { tool } from "ai";
import { z } from "zod";
const searchCorpus = tool({
description: "Search the corpus",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => fetchCorpus(query),
});
// After
import { defineTool } from "@pleach/core";
const searchCorpus = defineTool({
name: "search_corpus",
description: "Search the corpus",
inputSchema: z.object({ query: z.string() }),
async execute(input, ctx) {
return fetchCorpus(input.query, { signal: ctx.signal });
},
});Two real differences:
nameis explicit — the AI SDK derives it from the object key; the runtime requires it on the definition.- The second arg is
ToolContextwith anAbortSignal. Thread it through every fetch or spawn — tools that ignore the signal keep burning resources after the user hits stop.
Register tools through a plugin or the legacy
setOrchestratorRegistry shim. See Tools for the
options.
Convert the stream consumer
The AI SDK's fullStream carries typed chunks
(text-delta / tool-call / tool-result / finish). The
runtime's StreamEvent is a richer union with the same shapes
plus lifecycle, sync, and interrupt events.
// Before
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta": onTextDelta(part.textDelta); break;
case "tool-call": onToolCall(part); break;
case "tool-result": onToolResult(part); break;
case "finish": onFinish(part); break;
}
}
// After
for await (const event of runtime.executeMessage(session.id, prompt)) {
switch (event.type) {
case "message.delta": onTextDelta(event.delta); break;
case "tool.started": onToolCall(event.toolCall); break;
case "tool.completed": onToolResult(event.toolCall, event.result); break;
case "message.complete": onFinish(event.message); break;
}
}The mapping is mostly mechanical. See Stream events for the full catalog — there's more you can react to, but porting only the four above gets your existing UI working.
Convert the React surface
useChat and useHarness cover overlapping ground; the runtime's
hook is shaped for the richer event stream.
// Before
import { useChat } from "ai/react";
function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: "/api/chat",
});
return <>...</>;
}
// After
import { HarnessProvider, useHarness } from "@pleach/core/react";
function App() {
return (
<HarnessProvider runtime={runtime}>
<Chat />
</HarnessProvider>
);
}
function Chat() {
const { messages, sendMessage, isLoading } = useHarness();
return <>...</>;
}Two structural differences:
HarnessProviderowns the runtime — single source of truth across components.useChatis per-component.sendMessage(text)instead ofhandleSubmit(e)— the runtime handles event wiring; the form submit is yours to compose.
The messages array carries the same shape (role, content,
id), so existing message-rendering components transfer.
Pick storage and a checkpointer
The AI SDK doesn't persist sessions. The runtime expects you to pick a storage adapter even if it's in-memory:
import { SessionRuntime } from "@pleach/core";
import { MemoryAdapter } from "@pleach/core/sessions";
import { MemorySaver } from "@pleach/core/checkpointing";
const runtime = new SessionRuntime({
storage: new MemoryAdapter(),
checkpointer: new MemorySaver(),
provider: new AiSdkProvider({ model }),
userId: "user_123",
});For production, swap MemoryAdapter/MemorySaver for
SupabaseAdapter/SupabaseSaver and apply the schema bundle —
see Storage and CLI.
Add the API route (optional)
If you were using the AI SDK's pattern with a Next.js API route, swap the handler:
// Before (Next.js app router)
import { streamText } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({ model, messages, tools });
return result.toDataStreamResponse();
}
// After
export async function POST(req: Request) {
const { sessionId, content } = await req.json();
const stream = new ReadableStream({
async start(controller) {
for await (const event of runtime.executeMessage(sessionId, content)) {
controller.enqueue(`data: ${JSON.stringify(event)}\n\n`);
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
}Or use the shipped createPleachRoute from @pleach/core/quickstart
for a one-line POST handler — see API routes for
the full wire contract.
What you gain after the migration
| Capability | Before | After |
|---|---|---|
| Per-call audit row | None | AuditableCall ledger row per LLM call |
| Per-turn cost allocation | Manual log aggregation | GROUP BY turn_id against the ledger |
| Tool lifecycle | tool-call / tool-result chunks | tool.started / tool.delta / tool.completed / tool.failed |
| Human-in-the-loop | DIY | interrupt.requested / interrupt.resolved |
| Time travel | None | runtime.checkpoints.rollback(sessionId, checkpointId) |
| Replay determinism | None | Fingerprint-based reproducibility |
| Multi-tenant isolation | DIY | tenantId in the fingerprint key + RLS in the schema |
| Sync across devices | DIY | Version-vector outbox |
What you keep paying
Costs that don't go away
- The runtime contract is a model you have to learn. The
SessionRuntimeConfigsurface is small but real. - Storage and checkpointing add a database dependency. Mock mode works for dev; production needs schema migrations.
- Plugin authoring is a new abstraction. You don't need it on day one — the substrate stands on its own.
Common migration pitfalls
| Symptom | Likely cause |
|---|---|
| Tools fire but don't appear in the stream | Tool registered but not added to createSession({ tools: { enabled: [...] } }) |
executeMessage throws on first call | Missing setHarnessModuleLoader if your host has legacy orchestrator integration |
[UXParity:metaToolNames-config-missing] warning | Pass metaToolNames on the runtime config — see SessionRuntime |
| Stream silently disconnects in production | SSE response wasn't flushed; check Cache-Control: no-cache and disable response buffering |
| React hooks return stale data | HarnessProvider re-mounted because runtime was re-created on every render — wrap in useMemo |
See Troubleshooting for the longer list.