pleach
Integrate

Migrating from LangChain

Move from `Runnable` chains, agents, and LangGraph to a `SessionRuntime` — what maps cleanly, where the models differ, and what to keep.

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

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.

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

LangChain@pleach/coreWhat'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 + channelsTopology is constrained to the 4 stages; concurrency is a channel concern, not a chain primitive.
LangGraphThe substrate's graph + channelsSame family of primitives; the lattice + call-class typing are the additions.
Agents (ReAct, OpenAI Functions)tool-loop stage + seamsThe agent loop is built in. You provide tools; the runtime drives the loop.
Callbacks (callbacks, BaseCallbackHandler)Stream events + audit ledgerAudit is structural, not optional. Events are typed.
Memory (BaseMemory, ConversationBufferMemory)SessionState.messages + @pleach/core/storeMemory 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 tracingAudit ledger + custom adaptersThe ledger is the trace store. Plug an OTel adapter for LangSmith parity.

Keep your retrieval; wrap it as a tool

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

// 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.

Replace the chain with a SessionRuntime

// 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.

Convert callbacks to stream events + plugins

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

// 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.

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.

// 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.

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
StateGraphThe compiled graph (declarative; lattice-constrained)
NodeA graph node belonging to one of 4 stages
EdgeConstrained to the 4-stage lattice transitions
Annotation.Root / channelsSame idea — LastValue, Topic, BinaryOperatorAggregate, etc.
interrupt()HumanInterrupt envelope (LangGraph-compatible shape)
Conditional edgesChannel-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.

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.

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.

What you gain after the migration

CapabilityBefore (LangChain)After
Per-call auditCallbacks, manual aggregationAuditableCall ledger row per LLM call
Family-locked routingPer-chain provider choiceSession-scoped family + transport lock
Singleton synthesisDIYStructurally enforced
Replay determinismPartial via LangSmith recordFingerprint-based, byte-identical
Time travelPartial (LangGraph checkpoints)Built-in runtime.checkpoints.rollback
Plugin contractModular but unconstrainedBounded; can't break the lattice

What you keep paying

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.

Common migration pitfalls

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

Where to go next

On this page