# Migrating from the Vercel AI SDK (/docs/migrating-from-ai-sdk)



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 [#when-to-migrate-when-not-to]

<Callout type="info" title="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.
</Callout>

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.

<Steps>
  <Step>
    ## Keep your provider; add a runtime [#keep-your-provider-add-a-runtime]

    Today (AI SDK alone):

    ```typescript
    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:

    ```typescript
    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.
  </Step>

  <Step>
    ## Convert your tools [#convert-your-tools]

    AI SDK tools translate to `defineTool` 1:1:

    ```typescript
    // 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](/docs/tools) for the
    options.
  </Step>

  <Step>
    ## Convert the stream consumer [#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.

    ```typescript
    // 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](/docs/stream-events)
    for the full catalog — there's more you can react to, but
    porting only the four above gets your existing UI working.
  </Step>

  <Step>
    ## Convert the React surface [#convert-the-react-surface]

    `useChat` and `useHarness` cover overlapping ground; the runtime's
    hook is shaped for the richer event stream.

    ```tsx
    // 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.
  </Step>

  <Step>
    ## Pick storage and a checkpointer [#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:

    ```typescript
    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](/docs/storage) and [CLI](/docs/cli).
  </Step>

  <Step>
    ## Add the API route (optional) [#add-the-api-route-optional]

    If you were using the AI SDK's pattern with a Next.js API route,
    swap the handler:

    ```typescript
    // 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](/docs/api-routes) for
    the full wire contract.
  </Step>
</Steps>

## What you gain after the migration [#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 [#what-you-keep-paying]

<Callout type="info" title="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.
</Callout>

## Common migration pitfalls [#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](/docs/session-runtime)          |
| 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](/docs/troubleshooting) for the longer list.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Providers" href="/docs/providers" description="`AiSdkProvider` config — keeping your AI SDK setup intact." />

  <Card title="Tools" href="/docs/tools" description="Converting AI SDK tools to `defineTool`." />

  <Card title="Stream events" href="/docs/stream-events" description="The richer event union your stream consumer now reads." />

  <Card title="Recipes" href="/docs/recipes" description="Multi-tenant, BYO storage, and other end-to-end patterns." />
</Cards>
