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/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. |
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 |
|---|---|
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.
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:
- Wrap the LangChain tool as a
defineToolcall. The tool's_callbecomes yourexecute; the tool's schema becomes your Zod schema. Quick; preserves the implementation. - 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. - Build native
defineToolimplementations. For domain-specific tools, the explicit Zod schema + named batching strategy is worth the rewrite.
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
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
| 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
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.
Migrating from Anthropic Enterprise
Keep your Anthropic Enterprise contract. Pleach closes three downstream gaps: per-axis cost rollup, an audit row in your DB, replay-deterministic eval.