Migrating from LangGraph

This page is for teams with a working LangGraph project who want to know what a B4.run conversion actually costs.

B4.run does not replace LangGraph. A graph's nodes, edges, imported tools, and state definitions can stay in place, while the deployment and invocation boundary still needs validation.

What changes is the code around the graph: project layout and deploy config. B4.run's co-located tool convention applies to agent and workflow routes, while sibling state.ts is agent-only; a raw graph keeps its own definitions.

The migration is mostly moving code, not rewriting it.

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; B4.run agent and workflow tools can use co-located TypeScript files instead.
  • Your raw graph keeps its existing channel and state definitions. Dynamic segment values still come from the caller's JSON state.
  • Your langgraph.json is replaced by b4 build. The output is still a langgraph.json.
  • Model providers and LangChain packages can stay; validate checkpointer configuration at each target boundary.
  • LangSmith consumes the build output generated by b4 build.

The shape of the move

Before — 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

After — 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

Flat directories named by kind become folder routes named by endpoint. Tools you move into B4.run's tool convention can live next to a route; an imported graph keeps its existing tool imports. The route registry — what graph answers which path — is read from the file tree, not maintained by hand.

Construct by construct

StateGraph → route

The graph's nodes and edges do not need to change. Its hand-maintained assistant_id registration is replaced by a small route module that re-exports the compiled graph.

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 real compiled graph without changing its nodes, edges, or imported tool calls:

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

The route re-exports the graph object you authored. The folder path src/app/support/ becomes the endpoint /support; only its exported name changes to B4.run's graph route convention. The generated assistant_id is /support#graph.

If the graph is the only thing you want to migrate, this completes the route entry. Tools and state can stay imported from their old locations; validate the runtime boundary described next before cutover.

The B4.run local HTTP runtime does not translate its Agent Protocol thread id into a precompiled raw graph's configurable.thread_id. If that graph's checkpointer requires the configurable id, add an explicit target-boundary wrapper or configuration adaptation and validate that target boundary before cutover. Do not assume a checkpointer that worked behind another server receives the same invocation config from B4.run.

Raw graph state stays with the graph

A raw graph route does not use a sibling state.ts. Keep the graph's existing TypeScript state type, channels, reducers, and defaults with the compiled graph; callers send the JSON state that graph already expects.

Dynamic folders still describe a parameterized route id rather than injecting values. For /support/[tenant], the caller includes { "tenant": "acme", ... } in the JSON state, with the field name aligned to the segment. Sibling state.ts, its defaults, and reducers/ are B4.run agent-route features; adopt them only when deliberately converting the graph to an agent route. See State for that path.

LangChain tools → B4.run agent and workflow tools

When converting behavior to a B4.run agent or workflow route, tools become default-exported async functions in the route's tools/ directory. Type inference at build time replaces every hand-written schema. A raw graph may instead keep its existing LangChain tools and imports unchanged.

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 }
}

The file basename is the tool name. The input type is read from the parameter annotation. The output type is read from the return type. b4 typegen writes both into .b4/b4.generated.d.ts; b4 build uses the generated tool schemas when materializing deployment entries.

Inside an agent route, the LLM picks when to invoke. A workflow(state, ctx) receives RuntimeContext, so it can call ctx.tools.lookupOrder({ orderId }) with full IntelliSense. A raw graph route does not receive ctx.tools; an existing graph keeps calling the tools it imports.

Conditional edges and routing → middleware + dispatch

Two different mechanisms in LangGraph become two different mechanisms in B4.run. Don't conflate them.

Graph-level conditional edges stay where they are. addConditionalEdges is a runtime concern of the graph. B4.run does not touch it.

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

Request-level branching — auth, tenant gating, routing requests between assistants — moves to middleware.ts. The middleware decides whether the request runs at all and what context flows into tools.

Before — branching inside the graph 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" })
})

Routing between assistants is a route concern: /support/internal and /support/public are two routes, each with its own graph. The route id is the dispatch.

langgraph.jsonb4 build

The hand-maintained 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:

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

b4 build walks src/app/, runs typegen, and writes .b4/build/langgraph.json plus per-route entry files. Every route's assistant_id is <routeId>#<kind>/support#graph, /support/[tenant]#agent. That .b4/build/ directory is what LangSmith deploys.

.b4/b4.generated.d.ts is the type side of the same step: the route registry, the tool registry, the typed RouteTools<P> map. The starter template ignores .b4/, so regenerate it during development and CI unless your project chooses to commit generated artifacts.

What can stay, with boundary validation

  • LangSmith. Tracing, evaluations, datasets — B4.run does not wrap or proxy. Set LANGSMITH_API_KEY and traces flow; b4 dev auto-sets LANGCHAIN_TRACING_V2=true when that key is present.
  • Checkpointer and persistence. The checkpointer attached with .compile({ checkpointer }) remains attached to the raw graph. Its required invocation config does not appear automatically: B4.run's local Agent Protocol thread id is not translated into configurable.thread_id, so adapt and test any graph that relies on that value.
  • Model providers. Raw graph and chain routes keep whatever LangChain-compatible providers you instantiate yourself. The built-in agent() route materializes to a LangChain chat model; B4.run infers providers for known model families and lazy-loads the matching LangChain integration package. Set provider explicitly to one of the supported built-in provider ids for aliases, ambiguous model names, local models, or provider-router model ids.
  • LangChain ecosystem packages. @langchain/core, @langchain/openai, retrievers, document loaders — every one works inside a route.
  • LangSmith deploy. Same target. b4 build emits the generated langgraph.json and entry files LangSmith consumes.

Migration order

The conversion is incremental. Don't try to land it in one branch.

  1. Scaffold a B4.run project alongside the existing one. Run pnpm create b4-app my-agents-b4 and let it generate the scaffold. Don't merge the two repos yet. The existing project keeps shipping; the B4.run project is where the new shape lives.

  2. Move one graph at a time, route by route. Pick the lowest-risk graph first. Create src/app/<route>/index.ts and named-export the existing StateGraph as graph — tools, state, and prompts keep their old import paths. Once it deploys and runs at parity in staging, repeat with the next graph.

  3. Cut over deployment last. Both projects can deploy to LangSmith side by side under different assistant_ids. When every graph has a B4.run equivalent at parity, switch the production assistant_ids to the B4.run-built ones and retire the old project.

  1. I want to scaffold a B4.run project now.Getting Started
  2. I want the boundary in one page.Mental Model
  3. I want construct-level depth.Routes, Agents, Tools, State, Middleware