A while back I was building a flight planning assistant for pilots. The kind of thing that helps you work through a cross-country: pull the relevant sections of the Pilot Operating Handbook for the airframe, cross-reference FAA publications, sanity-check weight and balance, surface the right NOTAMs, and answer questions the pilot might ask along the way. LangChain and LangGraph were the right runtime for it — an orchestrator agent that delegated to research subagents, each grounded by RAG over specific source corpora: the Cessna 172 POH, the FAR/AIM, the AIM updates, FAA advisory circulars. The architecture was right. The model choices were right. The retrieval was right.
The codebase was a swamp.
It started cleanly enough. One graph, one tool, one prompt. Then I added a tool for weight and balance, and another for V-speeds, and another for the airport directory. Where do they live? I made a tools/ folder. Then I added a research subagent that does its own RAG over the POH. Its tools weren’t general-purpose — they were specific to that subagent. Do they go in the same tools/ folder? A new folder? I made subagents/poh/tools/. Then I added a subagent for FAA publications and had to decide all over again. I picked a convention. I wrote it down. Two weeks later I’d forgotten where I put it.
Then the questions started piling up.
Where are the tools defined? Some in tools/, some in subagents/<name>/tools/, some in a shared lib/ because I’d extracted them when two subagents needed the same lookup. There was no answer that was true in general.
How do I add a subagent? Open four files. Register the agent in the orchestrator’s tool list. Add its prompt to the prompts folder. Add its tools. Make sure the orchestrator’s system prompt mentions when to delegate to it. Re-run the streaming test and hope the UI still rendered delegation events.
Where is the memory persisted? Honestly? In a file I re-read on every turn, with a comment apologizing for the design. I had not yet decided whether memory was application state, model state, or a file on disk. So it was all three.
Do we need to roll the planning loop ourselves? The orchestrator needed to plan multi-step research before delegating. I’d read enough about Deep Agents and the planning pattern to know what I wanted: a todos channel, a writeTodos tool, a planning fragment in the prompt, and a stream event so the frontend could render the plan as the agent worked through it. I started writing it. It was a lot of code that wasn’t about flight planning.
How do I divide responsibilities and prompts cleanly? This was the real question under all the others. There was no boundary that said this is the orchestrator’s concern, this is the POH subagent’s concern, this is the FAA subagent’s concern. There was a graph. There were prompts in a folder. There were tools in another folder. There was no shape.
Around month four I started typing the conventions into a Notion doc so I wouldn’t lose them again. I was three paragraphs in when I stopped.
I was writing a framework. Badly. In Notion. After the fact.
That was the moment Dawn started. Not from a clever insight about graphs. From the much more boring observation that if I need a memo to explain where new code goes, the framework should probably do more.
What Dawn is
Dawn is to LangGraph.js what Next.js is to React: an opinionated meta-framework that turns a runtime into an application shape.
You author agents as filesystem routes. Dawn discovers them, generates types, runs a local dev server, and emits a langgraph.json package that LangSmith can deploy. You stop hand-writing the graph wiring and start writing the parts that are actually about your product.
Here’s the contrast.
Without Dawn, a one-tool agent with a deployable LangGraph package looks like this:
// graph.ts
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph"
import { ToolNode } from "@langchain/langgraph/prebuilt"
import { ChatOpenAI } from "@langchain/openai"
import { tool } from "@langchain/core/tools"
import { z } from "zod"
const greet = tool(async ({ name }) => `Hello, ${name}!`, {
name: "greet",
description: "Greet a user by name.",
schema: z.object({ name: z.string() }),
})
const model = new ChatOpenAI({ model: "gpt-5" }).bindTools([greet])
const tools = new ToolNode([greet])
async function callModel(state: typeof MessagesAnnotation.State) {
return { messages: [await model.invoke(state.messages)] }
}
function shouldContinue(state: typeof MessagesAnnotation.State) {
const last = state.messages.at(-1) as any
return last?.tool_calls?.length ? "tools" : END
}
export const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", callModel)
.addNode("tools", tools)
.addEdge(START, "agent")
.addConditionalEdges("agent", shouldContinue, ["tools", END])
.addEdge("tools", "agent")
.compile()
// langgraph.json
{
"dependencies": ["."],
"graphs": { "hello": "./graph.ts:graph" },
"node_version": "22",
"env": ".env"
}
With Dawn, the same agent is two files:
// src/app/(public)/hello/[tenant]/index.ts
import { agent } from "@dawn-ai/sdk"
export default agent({
model: "gpt-5",
systemPrompt: "You are a helpful assistant for the {tenant} organization.",
})
// src/app/(public)/hello/[tenant]/tools/greet.ts
export default async ({ name }: { name: string }) => `Hello, ${name}!`
dawn build writes the langgraph.json. The graph, the tool node, the conditional edge, the schema declaration — all of it is the framework’s job now. Your code is the descriptor and the function.
That’s the trade Dawn proposes. You give up control over graph wiring you weren’t enjoying writing anyway. You get back the convention that makes a TypeScript project a TypeScript project: a file tree that explains the application.
Framework, runtime, harness
LangChain recently described the stack as three layers: a framework that gives you abstractions, a runtime that makes graphs durable, and a harness that engineers the layer around the model — prompts, tools, planning, memory, delegation, verification.
That distinction matters because it names the work most teams are doing without realizing it.
A model is not an application. A graph is not an application either. The application is the harness: what the agent can see, what it can touch, how it plans, how it delegates, how it remembers, how it streams progress to the UI, and how you prove it did the right thing.
Today, most of that lives in the gaps between files. Tools in one folder. State in another. Graph entries in a registry. Tests that drift away from the routes they protect. Deployment artifacts hand-edited at release time. Every team reinvents the same shape.
Dawn is opinionated about that shape. LangGraph.js stays the runtime — Dawn doesn’t replace it, doesn’t hide it, doesn’t replace LangSmith as the deployment target. Dawn is the application layer that sits on top, the same way Next.js sits on top of React.
One route, growing
The honest test of a framework is what happens on day 30, not day 1. So let’s follow one route from the first commit to production.
Day 1: scaffold
pnpm create dawn-ai-app support
cd support
pnpm install
export OPENAI_API_KEY=sk-...
Dawn requires Node 22.12 or later. The scaffold writes a single route under src/app/:
support/
dawn.config.ts
package.json
src/
app/
(public)/
hello/
[tenant]/
index.ts
state.ts
tools/
greet.ts
The route id is /hello/[tenant]. The (public) folder is a route group — it organizes code without changing the path. The [tenant] folder is a dynamic segment, so tenant becomes a typed parameter. If you’ve used the Next.js App Router this is the convention you already know; if you’re coming from Angular, think of the route folder as a feature boundary that owns its own entry, state, tools, and tests.
The entry file exports one descriptor:
import { agent } from "@dawn-ai/sdk"
export default agent({
model: "gpt-5",
systemPrompt:
"You are a helpful assistant for the {tenant} organization.",
})
No graph. No tool node. No conditional edge. The route says what it is, and Dawn materializes the LangGraph entry during build.
The tool is just a function:
export default async (input: { readonly tenant: string }) => {
return { name: input.tenant, plan: "starter" }
}
There’s no tool registry. No duplicated input schema. No second file telling the framework greet.ts exists. Dawn reads the TypeScript signature, generates the JSON schema the model needs, and wires the tool into the graph. The TypeScript source is the contract.
Run it:
echo '{"tenant":"acme"}' | pnpm exec dawn run '/hello/[tenant]'
That’s the local feedback loop: edit the tool, run the route, inspect the normalized output. No server. No frontend. One pipe.
Day 3: types appear
After the first dawn verify, peek at .dawn/dawn.generated.d.ts:
declare module "dawn:routes" {
export type DawnRoutePath = "/hello/[tenant]"
export interface DawnRouteParams {
"/hello/[tenant]": { tenant: string }
}
export interface DawnRouteTools {
"/hello/[tenant]": {
readonly greet: (
input: { readonly tenant: string },
) => Promise<{ name: string; plan: string }>
}
}
export interface DawnRouteState {
"/hello/[tenant]": { readonly context: string }
}
}
The file tree became types. The dynamic segment became a typed param. The tool function became a typed callable. This is the developer experience you expect from a mature TypeScript framework, applied to agents.
Day 7: planning
The agent is doing real work now and the support team wants to see what it’s doing mid-run. Dawn’s planning convention is opt-in: drop a plan.md next to index.ts:
- [ ] Understand the customer request
- [ ] Check account context
- [ ] Decide whether to answer or escalate
- [ ] Write the final response
That file is the opt-in. Dawn now contributes four things to the route automatically:
- a
writeTodostool the model can call - a
todoschannel in state - a planning fragment in the prompt
- a
plan_updatestream event on/runs/streamwhenever the plan changes
The model sends the whole list each time it updates:
writeTodos({
todos: [
{ content: "Understand the customer request", status: "completed" },
{ content: "Check account context", status: "in_progress" },
{ content: "Write the final response", status: "pending" },
],
})
The frontend sees the plan as an event, not as hidden reasoning:
{
"type": "plan_update",
"data": {
"todos": [
{ "content": "Check account context", "status": "in_progress" }
]
}
}
The plan is application state. The UI can render it without asking the model to leak its chain of thought.
Day 14: harness memory and files
The support agent needs to remember team norms — “escalate billing exceptions to finance,” “use tenant policy tools before answering refund questions” — and it needs to read and write files in a sandboxed working directory.
Create a workspace/ folder at the app root:
support/
workspace/
AGENTS.md
notes/
onboarding.md
src/
app/
...
If workspace/AGENTS.md exists, Dawn injects it into the prompt as live harness memory and re-reads it every turn. The file is the memory:
# Harness Memory
- Prefer concise customer replies.
- Escalate billing exceptions to the finance queue.
- Use tenant-specific policy tools before answering refund questions.
When workspace/ exists, Dawn also contributes harness tools: readFile, writeFile, listDir, runCommand. All four are path-jailed to the workspace directory. The default backends use node:fs and child_process, but the workspace package exposes backend interfaces — the same agent code can run against an in-memory filesystem in tests, or a remote sandbox in production, without changing the route.
This is where harness engineering stops being a concept. The agent operates on real files with real boundaries, and no individual route reinvents shell execution, sandbox strategy, or memory loading.
Day 21: subagents
The support agent keeps getting derailed by policy lookup. Give it a specialist:
src/app/support/[tenant]/
index.ts
tools/
lookupAccount.ts
subagents/
research/
index.ts
tools/
searchDocs.ts
The child is a real route with its own prompt, model, and tools:
import { agent } from "@dawn-ai/sdk"
export default agent({
model: "gpt-5",
description: "Find relevant product and policy documentation.",
systemPrompt:
"You research internal documentation and return concise findings with sources.",
})
The parent gets a task tool. The runtime constrains the subagent value to known children, so the parent can only delegate to specialists that exist:
task({
subagent: "research",
input: "Find the refund policy for annual plans.",
})
And child activity surfaces on the parent’s stream as subagent.* events — subagent.start, subagent.tool_call, subagent.tool_result, subagent.message, subagent.plan_update, subagent.end. The UI can show delegated work as delegated work, instead of pretending everything happened in one flat agent loop.
The important property is that the specialist isn’t a hidden helper function buried in the parent’s prompt. It’s a route. It has a boundary, its own tests, its own future capabilities. It can be reviewed, refactored, or replaced without touching the parent.
Day 30: tests, verify, ship
Scenarios live next to the route they protect:
// src/app/support/[tenant]/run.test.ts
export default [
{
name: "answers a basic tenant question",
input: {
tenant: "acme",
messages: [{ role: "user", content: "What plan is this tenant on?" }],
},
expect: { status: "passed" },
},
]
No describe() wrapper. The route is inferred from the directory. Run with pnpm exec dawn test.
Before shipping, run the integrity gate:
pnpm exec dawn verify
Output:
Dawn app integrity OK: 4 checks passed, 2 routes discovered.
Four checks under the hood — app resolves the project, routes discovers them, typegen regenerates declarations, deps confirms runtime packages and environment variables. The JSON form (dawn verify --json) is what you wire into CI.
Then build the deployment artifact:
pnpm exec dawn build
Build complete: .dawn/build
2 route(s) compiled
langgraph.json written to .dawn/build/langgraph.json
The generated langgraph.json maps each route’s assistant id to a compiled graph entry:
{
"graphs": {
"/support/[tenant]#agent": "./.dawn/build/support-tenant.ts:graph",
"/support/[tenant]/research#agent": "./.dawn/build/support-tenant-research.ts:graph"
},
"dependencies": ["."],
"env": ".env",
"node_version": "22"
}
Your source stays organized as a TypeScript application. The output is still exactly what LangGraph and LangSmith expect. That’s the bridge.
What you don’t get
Dawn is not a model provider abstraction. It’s not trying to hide LangGraph. It’s not necessary for every project — if you have one graph, two tools, and no plans to grow past that, raw LangGraph.js is the right answer.
Dawn earns its place when the project starts behaving like an application: routes that need boundaries, tools that need homes, state that needs a contract, tests that need to live near the code they protect, a frontend that needs streaming events, and a deployment target that expects a clean package.
What this buys you
When agent codebases have shape, they get the lifecycle we expect from every other application.
The team that inherits this code in 18 months can open the route tree and read what the agent does. The new hire can find a tool by following a folder name. The principal engineer can review a route the same way they review any other feature — boundary, contract, tests, deployment. The agent that becomes obsolete can be deleted without an archaeology dig.
That’s not magic. It’s the ordinary outcome of caring about structure. Next.js developers got it for pages. Angular developers got it for features. Agent developers should get it for routes.
LangGraph.js is the runtime. Dawn is the application around it.
Routes are folders. Tools are files. State is typed. The dev loop is local. The build output is explicit.
That’s what lets a demo become an application.
Try Dawn
- Read the getting started guide and scaffold your first route in a few minutes
- Browse the Dawn documentation for routes, tools, state, planning, subagents, memory, middleware, testing, and deployment
- Star or contribute on GitHub
- Coming from raw LangGraph? Read migrating from LangGraph
Dawn is MIT-licensed and built on LangGraph.js. If you’re building agent applications that have outgrown a single file, I’d love to hear what you build with it.