# Migrating from LangChain (/docs/migrating-from-langchain)



LangChain and `@pleach/core` overlap structurally — both treat
agent execution as composable primitives with explicit control
flow — but they make different load-bearing decisions. LangChain
optimizes for *integration breadth*: hundreds of pre-built
loaders, retrievers, agents, and tools. `@pleach/core` optimizes
for *structural guarantees*: a 4-stage lattice, family-locked
routing, append-only audit, replay determinism.

This guide walks the migration when you've decided the structural
guarantees are what you need. If your LangChain code is mostly
using the integration catalog (loaders, vector stores, retrievers),
keep using LangChain for that — the migration target is the
agent + chain surface, not the data layer.

## When to migrate, when not to [#when-to-migrate-when-not-to]

<Callout type="info" title="You don't need this migration if">
  * The integration catalog (document loaders, vector stores,
    retrievers) is the value.
  * LangGraph's per-node control flow already covers your shape and
    you don't need the lattice / audit / family-lock guarantees.
  * You're shipping a RAG pipeline first, an agent second.
</Callout>

Migrate to `@pleach/core` when:

* You need per-call audit rows, joinable to sessions and turns.
* You need replay determinism for eval or regression testing.
* You need family-locked provider routing (no silent cross-family
  widening when a provider fails).
* You need the singleton synthesize seam (one user-facing answer
  per turn, structurally enforced).

You can run both — keep LangChain for the retrieval side, point
the runtime at it through a `defineTool` wrapper, and let the
runtime own the agent execution.

## The mental-model shift [#the-mental-model-shift]

| LangChain                                         | `@pleach/core`                                 | What's different                                                                                                                           |
| ------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `Runnable`                                        | (no direct equivalent)                         | The substrate doesn't model individual steps as composable runnables. The graph topology is declarative; the per-call surface is the seam. |
| Chains (`RunnableSequence`, `RunnableParallel`)   | Stage lattice + channels                       | Topology is constrained to the 4 stages; concurrency is a channel concern, not a chain primitive.                                          |
| LangGraph                                         | The substrate's graph + channels               | Same family of primitives; the lattice + call-class typing are the additions.                                                              |
| Agents (ReAct, OpenAI Functions)                  | `tool-loop` stage + seams                      | The agent loop is built in. You provide tools; the runtime drives the loop.                                                                |
| Callbacks (`callbacks`, `BaseCallbackHandler`)    | Stream events + audit ledger                   | Audit is structural, not optional. Events are typed.                                                                                       |
| Memory (`BaseMemory`, `ConversationBufferMemory`) | `SessionState.messages` + `@pleach/core/store` | Memory is session state; cross-session memory is a separate subpath.                                                                       |
| Document loaders / vector stores                  | (out of scope)                                 | The substrate doesn't ship retrieval. Keep LangChain or another retrieval library for that.                                                |
| LangSmith tracing                                 | Audit ledger + custom adapters                 | The ledger is the trace store. Plug an OTel adapter for LangSmith parity.                                                                  |

<Steps>
  <Step>
    ## Keep your retrieval; wrap it as a tool [#keep-your-retrieval-wrap-it-as-a-tool]

    The most common LangChain investment is in document loaders +
    retrievers. Don't migrate that — wrap it.

    ```typescript
    // Before — LangChain
    import { ChatPromptTemplate } from "@langchain/core/prompts";
    import { RunnableSequence } from "@langchain/core/runnables";

    const chain = RunnableSequence.from([
      { context: retriever, question: (input) => input.question },
      promptTemplate,
      llm,
    ]);

    // After — wrap the retriever as a defineTool
    import { defineTool } from "@pleach/core";

    export const retrieveDocs = defineTool({
      name: "retrieve_docs",
      description: "Retrieve relevant documents for a query.",
      inputSchema: z.object({
        query: z.string().min(1),
        k:     z.number().int().min(1).max(20).default(5),
      }),
      async execute(input, ctx) {
        const docs = await retriever.invoke(input.query, {
          configurable: { k: input.k },
          signal:       ctx.signal,
        });
        return { docs: docs.map((d) => ({
          pageContent: d.pageContent,
          metadata:    d.metadata,
        })) };
      },
    });
    ```

    The retriever's full configuration (vector store, embedding
    model, reranker, MMR settings) stays inside the tool. The
    runtime just sees a typed tool with a Zod schema.
  </Step>

  <Step>
    ## Replace the chain with a SessionRuntime [#replace-the-chain-with-a-sessionruntime]

    ```typescript
    // Before — agent with chain
    import { createOpenAIFunctionsAgent, AgentExecutor } from "langchain/agents";

    const agent = await createOpenAIFunctionsAgent({
      llm, tools: [retrieveDocs, searchDb],
      prompt: agentPromptTemplate,
    });
    const executor = new AgentExecutor({ agent, tools: [...] });

    const result = await executor.invoke({ input: "What is X?" });

    // After
    import { SessionRuntime, AiSdkProvider } from "@pleach/core";

    const runtime = new SessionRuntime({
      provider: new AiSdkProvider({ model: openai("gpt-4o") }),
      storage,
      userId,
    });

    const session = await runtime.createSession({
      tools: { enabled: ["retrieve_docs", "search_db"] },
    });

    for await (const event of runtime.executeMessage(session.id, "What is X?")) {
      // stream events
    }
    ```

    The agent loop is built into the `tool-loop` stage. You don't
    write a `createXAgent` call — the runtime drives the
    LLM-decision ↔ tool-execution cycle until the plan resolves.
  </Step>

  <Step>
    ## Convert callbacks to stream events + plugins [#convert-callbacks-to-stream-events--plugins]

    LangChain's callback handlers are an event subscription surface;
    the runtime's equivalent is the stream + plugin contract.

    ```typescript
    // Before — BaseCallbackHandler
    class MyHandler extends BaseCallbackHandler {
      name = "my-handler";

      async handleLLMStart(llm, prompts) {
        metrics.increment("llm.starts");
      }

      async handleLLMEnd(output) {
        metrics.timing("llm.tokens", output.llmOutput?.tokenUsage?.totalTokens);
      }

      async handleToolStart(tool, input) {
        metrics.increment("tool.starts", { name: tool.name });
      }
    }

    // After — tool starts arrive as `tool.started` StreamEvents on the
    // executeMessage() iterable (they are stream events, not emitter events)
    for await (const event of runtime.executeMessage(sessionId, input)) {
      if (event.type === "tool.started") {
        metrics.increment("tool.starts", { name: event.toolCall.name });
      }
    }

    // The audit ledger is the per-LLM-call surface. Wrap your ledger
    // adapter to also emit metrics:
    class MetricsAwareLedger implements ProviderDecisionLedger {
      constructor(private primary: ProviderDecisionLedger) {}
      async recordCall(call: AuditableCall) {
        metrics.increment("llm.calls", { model: call.call.model });
        metrics.timing("llm.latency", call.outcome.latencyMs);
        return this.primary.recordCall(call);
      }
    }
    ```

    The audit ledger is the per-LLM-call observability surface —
    every call writes a row with `model`, `family`, `tokenUsage`,
    `latencyMs`. That's what LangChain's `handleLLMEnd` gave you,
    made structural.
  </Step>

  <Step>
    ## Convert memory to session state [#convert-memory-to-session-state]

    LangChain's `ConversationBufferMemory` (and variants) maps to
    `SessionState.messages` directly. The runtime owns the
    conversation history; you don't construct a memory class.

    ```typescript
    // Before
    const memory = new ConversationBufferMemory({ returnMessages: true });
    await memory.saveContext({ input: userInput }, { output: assistantOutput });
    const { history } = await memory.loadMemoryVariables({});

    // After
    // The runtime persists messages automatically via the storage adapter.
    // Read history via:
    const session = await runtime.sessions.find(sessionId);
    const history = session?.state.messages ?? [];
    ```

    For cross-session memory (LangChain's `VectorStoreRetrieverMemory`,
    `ConversationSummaryMemory` with persistence), use the
    `@pleach/core/store` cross-session memory primitives or wrap an
    external store as a tool.
  </Step>

  <Step>
    ## Convert LangGraph nodes to the lattice [#convert-langgraph-nodes-to-the-lattice]

    LangGraph users will find the closest mental match in the
    substrate's graph + channels. The migration shape:

    | LangGraph                    | `@pleach/core`                                                                |
    | ---------------------------- | ----------------------------------------------------------------------------- |
    | `StateGraph`                 | The compiled graph (declarative; lattice-constrained)                         |
    | Node                         | A graph node belonging to one of 4 stages                                     |
    | Edge                         | Constrained to the 4-stage lattice transitions                                |
    | `Annotation.Root` / channels | Same idea — `LastValue`, `Topic`, `BinaryOperatorAggregate`, etc.             |
    | `interrupt()`                | `HumanInterrupt` envelope (LangGraph-compatible shape)                        |
    | Conditional edges            | Channel-driven scheduling — a node fires when its subscribed channel advances |

    The `HumanInterrupt` shape is intentionally LangGraph-compatible
    — external tooling (LangGraph Inspector, dashboards) interops
    without translation. See [Interrupts](/docs/interrupts).

    What changes: the lattice constrains where nodes live. A node
    that "doesn't fit" any of the 4 stages is a signal that it's two
    nodes (often a planner that should be in `anchor-plan` plus a
    quality scorer that should be in `post-turn`). The substrate's
    graph topology is more opinionated than LangGraph's
    free-form state machine.
  </Step>

  <Step>
    ## Tool ecosystem [#tool-ecosystem]

    The substrate doesn't ship LangChain's tool catalog. Three paths
    for the tools you depended on:

    1. **Wrap the LangChain tool as a `defineTool` call.** The tool's
       `_call` becomes your `execute`; the tool's schema becomes
       your Zod schema. Quick; preserves the implementation.
    2. **Rewrite using `@pleach/tools`.** The sibling SKU ships
       filesystem / HTTP / shell / structured-parse primitives with
       Zod schemas and consistent error handling. Use for common
       tools.
    3. **Build native `defineTool` implementations.** For
       domain-specific tools, the explicit Zod schema + named
       batching strategy is worth the rewrite.
  </Step>
</Steps>

## What you gain after the migration [#what-you-gain-after-the-migration]

| Capability            | Before (LangChain)              | After                                   |
| --------------------- | ------------------------------- | --------------------------------------- |
| Per-call audit        | Callbacks, manual aggregation   | `AuditableCall` ledger row per LLM call |
| Family-locked routing | Per-chain provider choice       | Session-scoped family + transport lock  |
| Singleton synthesis   | DIY                             | Structurally enforced                   |
| Replay determinism    | Partial via LangSmith record    | Fingerprint-based, byte-identical       |
| Time travel           | Partial (LangGraph checkpoints) | Built-in `runtime.checkpoints.rollback` |
| Plugin contract       | Modular but unconstrained       | Bounded; can't break the lattice        |

## What you keep paying [#what-you-keep-paying]

<Callout type="info" title="Costs that don't go away">
  * LangChain's integration breadth is gone. You bring the loaders
    and retrievers; the substrate doesn't ship them.
  * The lattice is opinionated. Code that fits the LangGraph
    free-form state-machine model has to be re-shaped to fit the 4
    stages.
  * The runtime adds a storage dependency. Mock mode works for
    dev; production wants a real database with the schema bundle
    applied.
</Callout>

## Common migration pitfalls [#common-migration-pitfalls]

| Symptom                                   | Likely cause                                                                                               |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Agent loop runs once and exits            | `maxSteps` not passed on `AiSdkProvider`; default is 1                                                     |
| Tool fires but the LLM ignores the result | Tool name doesn't match what the model emits — Zod-validated names are stricter than LangChain's tolerance |
| LangGraph node doesn't fit any stage      | Almost always 2 nodes — split into a planner (`anchor-plan`) and a scorer (`post-turn`)                    |
| Callback handler doesn't fire             | LangChain callbacks have no analogue for stream-level events — use `runtime.on` for that                   |
| Memory class missing                      | Memory IS session state; no separate class needed                                                          |

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

<Cards>
  <Card title="Providers" href="/docs/providers" description="`AiSdkProvider` config — same `streamText` you're already using." />

  <Card title="Tools" href="/docs/tools" description="`defineTool` — wrapping LangChain tools as Zod-validated definitions." />

  <Card title="Architecture" href="/docs/architecture" description="The 4-stage lattice and call classes that constrain where nodes live." />

  <Card title="Interrupts" href="/docs/interrupts" description="The LangGraph-compatible `HumanInterrupt` envelope." />
</Cards>
