Migrating from LangGraph

Learn what it takes to move a working LangGraph project to B4.run.

B4.run runs on LangGraph, so your graph's nodes, edges, imported tools and state definitions can stay where they are. What changes is the code around the graph: the project layout and the deploy config. Most of the migration is moving files around, plus checking how the graph is invoked once it's deployed.

B4.run's conventions (tools in a route's tools/ folder, state in a sibling state.ts) are for agent and workflow routes. A raw graph keeps its own definitions.

tl;drCopy link to section: tl;dr

  • Your StateGraph nodes and edges can stay. Export the compiled object as a named graph route.
  • Your graph can keep its imported tools. Agent and workflow routes can use tool files next to the route.
  • Your raw graph keeps its channel and state definitions. Dynamic segment values come from the caller's JSON input.
  • b4 build replaces your hand-written langgraph.json. It still outputs a langgraph.json.
  • Model providers and LangChain packages can stay. Check the checkpointer configuration on each deploy target.
  • LangSmith deploys the output of b4 build.

The shape of the moveCopy link to section: The shape of the move

Here is a typical LangGraph TypeScript project:

text
my-agents/
├── langgraph.json
├── package.json
├── tsconfig.json
└── src/
    ├── graphs/
    │   ├── support.ts
    │   └── triage.ts
    ├── tools/
    │   ├── lookupOrder.ts
    │   └── escalate.ts
    └── state.ts

Here is the same project under B4.run:

text
my-agents/
├── .b4/b4.generated.d.ts
├── b4.config.ts
├── package.json
├── tsconfig.json
└── src/
    ├── app/
    │   ├── support/
    │   │   └── index.ts
    │   └── triage/
    │       └── index.ts
    ├── graphs/
    │   ├── support.ts
    │   └── triage.ts
    ├── tools/
    │   ├── lookupOrder.ts
    │   └── escalate.ts
    └── state.ts

Directories grouped by kind (graphs/, tools/) become folders named by endpoint. Tools you convert can live next to their route, and an imported graph keeps its tool imports. B4.run builds the route registry (which graph answers which path) from the file tree, so you don't maintain it by hand.

Construct by constructCopy link to section: Construct by construct

StateGraph → routeCopy link to section: StateGraph → route

The graph's nodes and edges stay the same. A small route module that re-exports the compiled graph replaces the hand-written assistant_id entry.

Before:

src/graphs/support.ts
import { StateGraph, START, END } from "@langchain/langgraph"
import type { SupportState } from "../state.js"
import { lookupOrder } from "../tools/lookupOrder.js"
import { escalate } from "../tools/escalate.js"
 
export const support = new StateGraph<SupportState>({
  channels: {
    messages: { reducer: (a, b) => [...a, ...b], default: () => [] },
    orderId: null,
  },
})
  .addNode("lookup", async (state) => {
    const result = await lookupOrder.invoke({ orderId: state.orderId })
    return { messages: [result] }
  })
  .addNode("escalate", async (state) => {
    await escalate.invoke({ reason: "no order" })
    return state
  })
  .addEdge(START, "lookup")
  .addEdge("lookup", END)
  .compile()

After, the route re-exports the compiled graph as-is:

src/app/support/index.ts
export { support as graph } from "../../graphs/support.js"

The folder src/app/support/ becomes the endpoint /support. The only change is the export name, graph. The generated assistant_id is /support#graph.

If the graph is all you want to migrate, the route is done. Tools and state can keep their old import paths. Check one thing before you cut over.

The B4.run local HTTP runtime does not translate its Agent Protocol thread id into a precompiled raw graph's configurable.thread_id. If the graph's checkpointer needs that id, add a wrapper or configuration adaptation, and validate that target boundary before cutover. A checkpointer that worked behind another server won't necessarily get the same config from B4.run.

Raw graph state stays with the graphCopy link to section: Raw graph state stays with the graph

Keep the graph's TypeScript state type, channels, reducers and defaults with the compiled graph. Callers send the same JSON the graph already expects.

A dynamic folder names a route id with a parameter. It doesn't fill in the value. For /support/[tenant], the caller includes { "tenant": "acme", ... } in the JSON input, with the field named after the segment.

B4.run applies a sibling state.ts (its defaults and reducers/) automatically only in agent routes. A workflow route can import the same schema and call state.parse(input). A raw graph ignores it. See State when you convert a graph to an agent or workflow.

LangChain tools → B4.run agent and workflow toolsCopy link to section: LangChain tools → B4.run agent and workflow tools

When you convert a graph to an agent or workflow route, its tools become default-exported async functions in the route's tools/ directory. B4.run infers the types at build time, so the hand-written schemas go away. A raw graph can keep its LangChain tools and imports.

Before:

src/tools/lookupOrder.ts
import { tool } from "@langchain/core/tools"
import { z } from "zod"
 
export const lookupOrder = tool(
  async ({ orderId }: { orderId: string }) => {
    const res = await fetch(`https://api.example.com/orders/${orderId}`)
    return await res.json()
  },
  {
    name: "lookupOrder",
    description: "Look up an order by id.",
    schema: z.object({ orderId: z.string() }),
  },
)

After:

src/app/support/tools/lookupOrder.ts
export default async (
  input: { readonly orderId: string },
  ctx: { signal: AbortSignal },
) => {
  const res = await fetch(`https://api.example.com/orders/${input.orderId}`, {
    signal: ctx.signal,
  })
  return (await res.json()) as { readonly status: string }
}

Let's quickly review:

  • The file name is the tool name.
  • B4.run reads the input type from the parameter and the output type from the return type.
  • b4 typegen writes both to .b4/b4.generated.d.ts, and b4 build uses the generated schemas for the deploy entries.

In an agent route, the model decides when to call the tool. A workflow(input, ctx) gets a RuntimeContext, so it can call ctx.tools.lookupOrder({ orderId }) with full IntelliSense. A raw graph has no ctx.tools, so it keeps calling the tools it imports.

Conditional edges and routing → middleware + dispatchCopy link to section: Conditional edges and routing → middleware + dispatch

LangGraph has two separate mechanisms here, and so does B4.run.

Graph-level conditional edges stay where they are. addConditionalEdges belongs to the graph, and B4.run leaves it alone.

inside a graph route (unchanged)
.addConditionalEdges("triage", (state) => {
  if (state.priority === "p0") return "escalate"
  return "respond"
})

Request-level branching (auth, tenant gating, picking an assistant) moves to middleware.ts. The middleware decides whether a request runs and what context reaches the tools.

Before, the branching lives in the server entry point:

src/server.ts (sketch)
app.post("/runs/wait", async (req, res) => {
  if (!req.headers["x-api-key"]) return res.status(401).end()
  const which = req.body.tenant === "internal" ? internalGraph : publicGraph
  const result = await which.invoke(req.body.input)
  res.json(result)
})

After:

src/middleware.ts
import { allow, defineMiddleware, reject } from "@b4run/sdk"
 
export default defineMiddleware(async (req) => {
  if (!req.headers["x-api-key"]) return reject(401, { error: "Missing x-api-key" })
  return allow({ tenant: req.params.tenant ?? "public" })
})

To pick between assistants, make them two routes: /support/internal and /support/public, each with its own graph. The caller picks one by route id.

langgraph.json → b4 buildCopy link to section: langgraph.json → b4 build

The hand-written config becomes a build output.

Before:

langgraph.json
{
  "dependencies": ["."],
  "graphs": {
    "support": "./src/graphs/support.ts:support",
    "triage": "./src/graphs/triage.ts:triage"
  },
  "env": ".env"
}

After, there is no source langgraph.json. There is a b4.config.ts instead:

b4.config.ts
export default {
  appDir: "src/app",
}

b4 build walks src/app/, runs typegen, and writes .b4/build/langgraph.json plus one entry file per route. Each route's assistant_id is <routeId>#<kind>, such as /support#graph or /support/[tenant]#agent. LangSmith deploys the .b4/build/ directory.

The same step writes .b4/b4.generated.d.ts, with the route registry, the tool registry and the typed RouteTools<P> map. The starter template ignores .b4/, so you regenerate it in development and CI (unless you choose to commit it).

What can stayCopy link to section: What can stay

  • LangSmith. B4.run leaves tracing, evaluations and datasets to LangSmith. Set LANGSMITH_API_KEY and traces flow. b4 dev sets LANGCHAIN_TRACING_V2=true for you when that key is present.
  • Checkpointer and persistence. The checkpointer you pass to .compile({ checkpointer }) stays attached to the raw graph. B4.run's local Agent Protocol thread id isn't passed as configurable.thread_id, though, so adapt and test any graph that relies on it.
  • Model providers. Raw graph and chain routes keep whatever LangChain providers you create yourself. The built-in agent() route becomes a LangChain chat model. B4.run infers the provider for known model families and loads the matching LangChain package on demand. Set provider yourself for aliases, ambiguous model names, local models, or provider-router model ids.
  • LangChain ecosystem packages. @langchain/core, @langchain/openai, retrievers and document loaders all work inside a route.
  • LangSmith deploy. The target is the same. b4 build emits the generated langgraph.json and entry files LangSmith consumes.

Migration orderCopy link to section: Migration order

I recommend moving one graph at a time.

  1. Scaffold a B4.run project next to the existing one. Run npm create b4-app@latest my-agents-b4. Don't merge the two repos yet. The old project keeps shipping while the new one takes shape.

  2. Move one graph at a time. Start with the lowest-risk graph. Create src/app/<route>/index.ts and export the existing StateGraph as graph. Tools, state and prompts keep their old import paths. Once it runs the same in staging, move the next graph.

  3. Cut over deployment last. Both projects can deploy to LangSmith side by side under different assistant_ids. When every graph works the same in B4.run, switch production to the B4.run assistant_ids and retire the old project.