Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions apps/web/app/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,29 @@ export function generateImageParams() {
return getAllPosts().map((p) => ({ slug: p.slug }))
}

// Fraunces variable font (Open Font License, hosted by google/fonts on GitHub).
// next/og needs the raw font bytes — fetched at build time per generated image.
const FRAUNCES_URL =
"https://github.com/google/fonts/raw/main/ofl/fraunces/Fraunces%5BSOFT%2CWONK%2Copsz%2Cwght%5D.ttf"

async function loadFraunces(): Promise<ArrayBuffer | null> {
try {
const res = await fetch(FRAUNCES_URL)
if (!res.ok) return null
return await res.arrayBuffer()
} catch {
return null
}
}

export default async function Image({ params }: { params: { slug: string } }) {
const post = getPost(params.slug)
const title = post?.title ?? "Dawn"
const eyebrow = post?.type === "release" ? `Release · v${post.version}` : "Essay"

const fraunces = await loadFraunces()
const titleFontFamily = fraunces ? "Fraunces" : "ui-serif, Georgia, serif"

return new ImageResponse(
<div
style={{
Expand All @@ -24,7 +42,7 @@ export default async function Image({ params }: { params: { slug: string } }) {
padding: "80px",
background: "linear-gradient(180deg,#fff7e0 0%,#ffe2a8 100%)",
color: "#1a1530",
fontFamily: "system-ui",
fontFamily: "system-ui, sans-serif",
}}
>
<div
Expand All @@ -44,12 +62,18 @@ export default async function Image({ params }: { params: { slug: string } }) {
lineHeight: 1.05,
letterSpacing: "-0.02em",
maxWidth: "1040px",
fontFamily: titleFontFamily,
}}
>
{title}
</div>
<div style={{ fontSize: 24, color: "#6d5638" }}>dawnai.org/blog</div>
</div>,
size,
{
...size,
...(fraunces && {
fonts: [{ name: "Fraunces", data: fraunces, weight: 600, style: "normal" }],
}),
},
)
}
41 changes: 40 additions & 1 deletion apps/web/app/components/blog/PostHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Post } from "./post-index"
import Image from "next/image"
import Link from "next/link"
import { AUTHORS, type Author, type Post } from "./post-index"

function formatDate(iso: string): string {
return new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", {
Expand All @@ -14,6 +16,11 @@ export function PostHeader({ post }: { readonly post: Post }) {
post.type === "release"
? `Release · v${post.version}`
: `Essay · ${post.readingTimeMinutes} min read`
const author: Author = AUTHORS[post.author] ?? {
name: "Brian Love",
avatar: "/brand/brian.jpg",
url: "https://github.com/blove",
}
return (
<header className="mb-8 pb-8 border-b border-border-subtle">
<div className="text-[11px] uppercase tracking-widest text-text-muted mb-2">{eyebrow}</div>
Expand All @@ -25,6 +32,38 @@ export function PostHeader({ post }: { readonly post: Post }) {
</h1>
<p className="text-lg text-text-secondary leading-relaxed">{post.description}</p>
<div className="text-sm text-text-muted mt-4">{formatDate(post.date)}</div>

{/* Mobile-only: author byline + tags. Desktop sees these in the PostMeta left rail. */}
<div className="md:hidden mt-5 flex items-center gap-3">
<Image
src={author.avatar}
alt={author.name}
width={28}
height={28}
className="rounded-full"
/>
<a
href={author.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-text-primary hover:text-accent-amber-deep transition-colors"
>
{author.name}
</a>
</div>
{post.tags.length > 0 && (
<div className="md:hidden mt-3 flex flex-wrap gap-1.5">
{post.tags.map((tag) => (
<Link
key={tag}
href={`/blog/tags/${tag}`}
className="text-xs px-2 py-0.5 rounded-full bg-bg-card/60 text-text-secondary hover:text-accent-amber-deep transition-colors"
>
{tag}
</Link>
))}
</div>
)}
</header>
)
}
9 changes: 8 additions & 1 deletion apps/web/app/components/blog/rss-feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ const samplePost: Post = {
describe("buildRssFeed", () => {
it("includes channel metadata", () => {
const xml = buildRssFeed([samplePost], { siteUrl: "https://dawnai.org" })
expect(xml).toContain('<rss version="2.0">')
expect(xml).toContain('<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">')
expect(xml).toContain("<title>Dawn</title>")
expect(xml).toContain("<link>https://dawnai.org/blog</link>")
})

it("includes atom:link self-reference for validator compliance", () => {
const xml = buildRssFeed([samplePost], { siteUrl: "https://dawnai.org" })
expect(xml).toContain(
'<atom:link href="https://dawnai.org/blog/rss.xml" rel="self" type="application/rss+xml"/>',
)
})

it("includes one item per post with required fields", () => {
const xml = buildRssFeed([samplePost], { siteUrl: "https://dawnai.org" })
expect(xml).toContain("<title>Why we built Dawn</title>")
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/components/blog/rss-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ export function buildRssFeed(posts: readonly Post[], opts: BuildOpts): string {
})
.join("\n")
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Dawn</title>
<link>${siteUrl}/blog</link>
<atom:link href="${siteUrl}/blog/rss.xml" rel="self" type="application/rss+xml"/>
<description>Writing on the agent stack, type-safety, and the tools we're building.</description>
<language>en</language>
${items}
Expand Down
106 changes: 100 additions & 6 deletions apps/web/content/blog/2026-05-12-why-we-built-dawn.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,110 @@ type: post
author: brian
---

Agents are the new frontier of application development, but the tooling has lagged the moment. The Python ecosystem moved first, and TypeScript got bolt-ons.
Dawn started as a folder of helper scripts that kept showing up in every LangGraph project we touched. By the third project, it was clear the helpers were a framework wearing a trench coat.

## The mismatch
This post is about the friction those scripts were absorbing, and why we decided to make that work explicit.

When we started prototyping in LangGraph from a Next.js app, the friction wasn't the framework, it was the impedance. Stack-trace boundaries, type erasure, and a runtime mental model that didn't match the rest of our code.
## The moment it stopped scaling

## What we wanted
LangGraph is excellent at what it does. The runtime, the channels, the checkpointer — they all hold up. What didn't hold up was the *code around* the graph.

Type-safety everywhere. File-system routing for agent graphs. No DSL overhead. The same authoring ergonomics that Next.js brought to web apps, applied to agent stacks.
Our second production agent project had four graphs, eleven tools, and a `langgraph.json` that no one wanted to touch. The graphs lived in one folder, the tools in another, the state shapes in a third, and a `registry.ts` glued them together. Every new feature touched all four locations.

We started writing internal docs explaining where things were supposed to go. That was the moment. If the framework is making you write a memo about file layout, the framework is missing a piece.

## The friction list

The same pain showed up in every project. None of it is LangGraph's fault — these are just things LangGraph leaves to you.

- **Graph boilerplate.** Every agent route involves the same `StateGraph` plumbing — `addNode`, `addConditionalEdges`, `bindTools`, an exported `compile()`. The interesting code is twenty lines; the wiring is sixty.
- **No project structure.** LangGraph has no opinion on where graphs, tools, or state live. So every team invents their own layout, then re-invents it six months later when the first one stops working.
- **Hand-written tool schemas.** Every tool means a function, a Zod schema, and a `tool()` wrapper. The schema duplicates the TypeScript types you already wrote, and drifts the moment you forget.
- **No local dev loop.** `langgraph dev` works but is heavyweight. Iterating against a route meant pushing to staging, watching the deploy, and waiting on traces. Tight loops aren't tight.
- **Lost types at the boundary.** Tool inputs, state shape, dynamic routing parameters — none of these are typed across the graph boundary by default. You learn at runtime what a misspelled key costs.

Each item is solvable on its own. The trouble is that every team solves them slightly differently, and the solutions don't compose.

## What "meta-framework" means here

Dawn is not a competitor to LangGraph. The runtime, the graph compiler, the checkpointer, LangSmith — all unchanged. The output of `dawn build` is a `langgraph.json` that LangSmith deploys without translation.

What Dawn replaces is the *code around* the graph. Concretely:

Without Dawn, a hello-world tool-calling agent looks like this:

```ts
// 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-4o-mini" }).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()
```

Plus a hand-maintained `langgraph.json` pointing at the export.

With Dawn, it's two files:

```ts
// src/app/(public)/hello/[tenant]/index.ts
import { agent } from "@dawn-ai/sdk"

export default agent({
model: "gpt-4o-mini",
systemPrompt: "You are a helpful assistant for the {tenant} organization.",
})
```

```ts
// src/app/(public)/hello/[tenant]/tools/greet.ts
export default async ({ name }: { name: string }) => `Hello, ${name}!`
```

`dawn build` produces the `langgraph.json`. The `greet` tool is bound to the agent because it lives under `tools/`. The `[tenant]` segment becomes a typed field on state. Same deploy target; roughly a quarter of the code.

The point isn't lines saved. The point is that every concept — route, tool, state, middleware — has *one place* it lives, and that place is on disk where your editor can find it.

## The bet: agent code should feel like Next.js code

The Next.js App Router did one thing extraordinarily well: it gave every concern a coordinate. A route is a folder. A layout is `layout.tsx`. A server action is a function in a route file. You can point at a thing, search for it, refactor it, write a test next to it.

Agent codebases lack that. The runtime is reasonable, but the *codebase shape* is whatever you negotiated with your team last quarter.

Our bet is that agent code should feel like Next.js code. A route is a folder under `src/app/`. The folder path is the agent endpoint. Tools, state, middleware, and tests live next to the route they belong to. Types are generated from the file tree, not maintained by hand.

That's the whole framework. Filesystem routes, inferred types, a real dev server, and a build step that emits the artifact LangSmith already understands.

## Where we are now

Dawn is in active development. The docs cover the working pieces; what's next is detailed on the roadmap and in the upcoming release notes.
Dawn 0.3 ships the route conventions, the typed tool inference, the dev server, and the build step. The mental model is documented at [`/docs/mental-model`](/docs/mental-model) and the route conventions at [`/docs/routes`](/docs/routes). If you have a working LangGraph project, the migration walkthrough at [`/docs/migrating-from-langgraph`](/docs/migrating-from-langgraph) covers the construct-by-construct move.

What's next is Deep Agents — composable sub-agents — and a deeper streaming story. Both are coming in 0.4.

If you've felt the friction described above, the [getting started](/docs/getting-started) guide takes about a minute end to end. That's the right way to evaluate whether Dawn matches the mental model you already have.
99 changes: 92 additions & 7 deletions apps/web/content/blog/2026-05-19-app-router-for-ai-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,101 @@ type: post
author: brian
---

The Next.js App Router did something subtle but important: it gave web developers a *location* for every concern.
The Next.js App Router did something subtle but important. It didn't introduce a new runtime, and it didn't change how React rendered. What it changed was the *answer to the question "where does this code go?"*.

## Routes are locations, not graphs
Every concern got a coordinate. A page is `page.tsx`. A layout is `layout.tsx`. A server action is a function in a server file. Middleware lives at `middleware.ts`. There is one place each concept lives, and that place is a file on disk.

A file path is a coordinate. You can point to it, search for it, refactor it. Agent frameworks that hide structure behind builders give up that property.
Agent codebases don't have that yet. Dawn is an attempt to fix it.

## Type-safety as a forcing function
## The missing coordinate system

When tool arguments are inferred from TypeScript types rather than authored as JSON schema, the type system becomes the contract.
If you've worked on a LangGraph project past the second graph, you know the shape of the problem. You have graphs in one folder, tools in another, state definitions in a third, and a registry that ties them together. The connections between those files are implicit — a tool is "for" a graph because it's imported by it, not because it lives next to it.

## What this unlocks
That works fine at the kitchen-table scale. It stops working when you need to answer questions like:

The same code intelligence you have for routes, components, and APIs — applied to agents. That's the bet.
- Which tools does the `support` graph have access to?
- If I rename `lookupOrder`, what else needs to change?
- Where do I add the tenant-aware auth check for the `triage` route?
- Can I delete this tool? Who calls it?

These are all answerable, but the answer lives in your head or in a wiki page, not on disk. The codebase doesn't know its own structure.

## File-system routes as the answer

The fix is borrowed wholesale from web frameworks. A route is a folder. The folder path is the endpoint. Everything that belongs to the route lives next to it.

A real Dawn project tree:

```text
my-agents/
├── dawn.config.ts
├── package.json
└── src/
└── app/
├── support/
│ ├── [tenant]/
│ │ ├── index.ts
│ │ ├── state.ts
│ │ ├── middleware.ts
│ │ └── tools/
│ │ ├── lookupOrder.ts
│ │ └── escalate.ts
│ └── internal/
│ └── index.ts
└── triage/
├── index.ts
└── state.ts
```

Read that tree out loud and you've read the architecture. `support/[tenant]/` is a parameterized route. It has its own state shape, its own auth middleware, and two tools the agent can call. `support/internal/` is a separate route — a different endpoint, a different graph. `triage/` is unrelated to either.

There's no registry. There's no `tools: [...]` array. The connection between a tool and the route that uses it is the *folder it's in*.

## Co-location as a feature, not aesthetics

Putting files near the thing they belong to isn't a style preference. It changes what your tools can do.

**Refactoring works.** Move a folder, move the route. Delete a folder, delete the route. There's no central registry whose edits you'll forget.

**Search works.** "Find references" on a tool finds the route that uses it because the route imports it directly. "Find references" on a state field finds the agent prompt that mentions it.

**Tests don't drift.** A scenario test for `support/[tenant]/` lives in `support/[tenant]/run.test.ts`. When you change the route, the test is right there in the same diff. The chance of test rot drops a lot when the test is sitting next to the thing it tests.

**Onboarding works.** New engineers can open the tree and read the surface area without a Loom video. The structure is the documentation.

Frameworks that hide structure behind a builder API give all of this up. Once your routes are constructed at runtime from a `.register()` call, your editor cannot tell you what your codebase looks like — only the running process can.

## Types as the route boundary

The other half of the coordinate system is types. A route is a contract, and the contract should be checked at the boundary.

In Dawn, a tool is just a TypeScript function:

```ts
// src/app/support/[tenant]/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 }
}
```

There's no Zod schema. There's no `tool()` wrapper. The parameter type *is* the schema — `dawn typegen` reads it at build time and emits both a JSON schema for the LLM and a typed `ctx.tools.lookupOrder` for any code that calls it directly.

This sounds like a small convenience. It's actually a forcing function. Because the type is the contract, drift is impossible. If you change the tool's signature, callers fail to typecheck. If you misspell a tool name in a workflow route, the editor underlines it. The route boundary is checked the same way the rest of your TypeScript is checked.

Generated types live in `dawn.generated.d.ts`. Commit it or regenerate on the fly — both work, the same way `next-env.d.ts` works.

## What this unlocks at scale

The interesting thing isn't the first agent. It's the tenth.

A codebase with ten agents, forty tools, and a dozen middleware files needs the same code-intelligence features a web app does — go-to-definition that works, refactors that don't break things in production, tests that run on a tight loop, deploys that don't require a memo. Those features don't show up by accident; they require the codebase to have a *shape* the tools can reason about.

That's the whole pitch. Dawn isn't asking you to learn a new graph runtime. The graph runtime is fine. Dawn is the coordinate system around it — the answer to "where does this code go" — and the type machinery that makes the answer load-bearing.

If the mental model resonates, [`/docs/mental-model`](/docs/mental-model) is the one-page version, and [`/docs/routes`](/docs/routes) is where the conventions are spelled out in full.
Loading
Loading