pleach
Integrate

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 SessionRuntime constructed once at startup (or per request, depending on your architecture).
  • A Session instead of a raw message list — the runtime owns the conversation state.
  • An AsyncGenerator<StreamEvent> instead of a fullStream — 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:

  1. name is explicit — the AI SDK derives it from the object key; the runtime requires it on the definition.
  2. The second arg is ToolContext with an AbortSignal. 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:

  1. HarnessProvider owns the runtime — single source of truth across components. useChat is per-component.
  2. sendMessage(text) instead of handleSubmit(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

CapabilityBeforeAfter
Per-call audit rowNoneAuditableCall ledger row per LLM call
Per-turn cost allocationManual log aggregationGROUP BY turn_id against the ledger
Tool lifecycletool-call / tool-result chunkstool.started / tool.delta / tool.completed / tool.failed
Human-in-the-loopDIYinterrupt.requested / interrupt.resolved
Time travelNoneruntime.checkpoints.rollback(sessionId, checkpointId)
Replay determinismNoneFingerprint-based reproducibility
Multi-tenant isolationDIYtenantId in the fingerprint key + RLS in the schema
Sync across devicesDIYVersion-vector outbox

What you keep paying

Costs that don't go away

  • The runtime contract is a model you have to learn. The SessionRuntimeConfig surface 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

SymptomLikely cause
Tools fire but don't appear in the streamTool registered but not added to createSession({ tools: { enabled: [...] } })
executeMessage throws on first callMissing setHarnessModuleLoader if your host has legacy orchestrator integration
[UXParity:metaToolNames-config-missing] warningPass metaToolNames on the runtime config — see SessionRuntime
Stream silently disconnects in productionSSE response wasn't flushed; check Cache-Control: no-cache and disable response buffering
React hooks return stale dataHarnessProvider re-mounted because runtime was re-created on every render — wrap in useMemo

See Troubleshooting for the longer list.

Where to go next

On this page