Migrating from LangGraph

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

Dawn does not replace LangGraph. The graph runtime, the channels, the checkpointer, LangSmith — none of it changes.

What changes is the code around the graph. Project layout, tool registration, state typing, the deploy config. That code becomes a folder convention.

The migration is mostly moving code, not rewriting it.

tl;dr

  • Your StateGraph does not change. Default-export it as a graph route.
  • Your tools become co-located TypeScript files. The hand-written Zod schemas go away.
  • Your typed state moves into a sibling state.ts. Dynamic path segments populate fields by name.
  • Your langgraph.json is replaced by dawn build. The output is still a langgraph.json.
  • LangSmith, the checkpointer, model providers, every LangChain package — unchanged.
  • LangSmith consumes the build output generated by dawn 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 Dawn:

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

Flat directories named by kind become folder routes named by endpoint. Tools that belong to a graph live next to that graph. The registry — what graph answers which path, which tools each graph binds — is read from the file tree, not maintained by hand.

Construct by construct

StateGraph → route

The graph itself does not change. What disappears is the file that imports it, registers it under an assistant_id, and exports a builder.

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:

import { StateGraph, START, END } from "@langchain/langgraph"
import type { z } from "zod"
import type state from "./state.js"
 
type SupportState = z.infer<typeof state>
 
export default new StateGraph<SupportState>({
  channels: {
    messages: { reducer: (a, b) => [...a, ...b], default: () => [] },
    orderId: null,
  },
})
  .addNode("lookup", async (s) => s)
  .addNode("escalate", async (s) => s)
  .addEdge(START, "lookup")
  .addEdge("lookup", END)
  .compile()

The graph builder is identical. The folder path src/app/support/ becomes the endpoint /support. Export the compiled graph as a named graph export. The generated assistant_id is /support#graph.

If the graph is the only thing you want to migrate, stop here. Tools and state can stay imported from their old locations and the route still runs.

TypedDict / Pydantic state → state.ts

Typed state moves to a sibling file. Dawn merges path segments onto the schema-derived shape at runtime, so dynamic segments are not declared in the schema.

Before:

src/state.ts
export interface SupportState {
  readonly tenant: string
  readonly orderId: string | null
  readonly messages: readonly string[]
}

After:

src/app/support/[tenant]/state.ts
import { z } from "zod"
 
export default z.object({
  orderId: z.string().nullable().default(null),
  messages: z.array(z.string()).default([]),
})

tenant is dropped from the schema. The folder path src/app/support/[tenant]/ injects it onto state by name. The route's entry sees state.tenant: string without declaring it.

The TypeScript type is z.infer<typeof state>. Custom merge per field goes in reducers/<field>.ts — see State.

Tools (@tool, BaseTool) → tools/X.ts

Tools become default-exported async functions in the route's tools/ directory. Type inference at build time replaces every hand-written schema.

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. dawn typegen writes both into .dawn/dawn.generated.d.ts; dawn build uses the generated tool schemas when materializing deployment entries.

Inside an agent route, the LLM picks when to invoke. Inside a workflow or graph route, call it through ctx.tools.lookupOrder({ orderId }) with full IntelliSense.

Conditional edges and routing → middleware + dispatch

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

Graph-level conditional edges stay where they are. addConditionalEdges is a runtime concern of the graph. Dawn 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 "@dawn-ai/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.jsondawn 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 dawn.config.ts:

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

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

.dawn/dawn.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 .dawn/, so regenerate it during development and CI unless your project chooses to commit generated artifacts.

What stays exactly the same

  • LangSmith. Tracing, evaluations, datasets — Dawn does not wrap or proxy. Set LANGSMITH_API_KEY and traces flow.
  • Checkpointer and persistence. Whatever you pass to .compile({ checkpointer }) keeps working. Dawn does not own the graph runtime.
  • 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; Dawn 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. dawn 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 Dawn project alongside the existing one. Run pnpm create dawn-ai-app my-agents-dawn and let it generate the scaffold. Don't merge the two repos yet. The existing project keeps shipping; the Dawn 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 can keep their old import paths during this step. Once it deploys and runs at parity in staging, peel the state into state.ts and the tools into tools/<name>.ts. Repeat per graph.

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

What to read next

  1. I want to scaffold a Dawn 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

Related