Middleware

Dawn supports a single global request middleware for authentication, request shaping, and per-request context. The middleware runs once per route-execution request, before the route executes, and its decision (allow or reject) gates execution.

File location

Define middleware as a default-exported function in src/middleware.ts (or middleware.ts at the app root):

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

If no middleware file is present, every request is allowed. Dawn probes four paths, in this order, and the first one that exists is the one it loads: src/middleware.ts, src/middleware.js, middleware.ts, middleware.js. A later candidate is never a fallback for an earlier one that fails to load — see When middleware fails to load.

API

defineMiddleware(fn)

Identity helper that types fn as DawnMiddleware. Use it for editor inference:

ts
type DawnMiddleware = (
  req: MiddlewareRequest,
) => Promise<MiddlewareResult> | MiddlewareResult

MiddlewareRequest

The argument every middleware receives. All values are pre-parsed:

FieldShapeNotes
headersReadonly<Record<string, string>>Lowercase keys (Node convention). Multi-value headers joined with , .
paramsReadonly<Record<string, string>>Dynamic-segment values extracted from the request input, e.g. { tenant: "acme" } for /hello/[tenant]. Always {} on /resume and /pending_interrupts — see Where middleware runs.
routeIdstringE.g. "/hello/[tenant]".
assistantIdstringE.g. "/hello/[tenant]#agent".
methodstringHTTP method. "POST" on every gated endpoint except /pending_interrupts, which is a "GET".
urlstringPath + query, e.g. "/threads/t-1/runs/wait".

reject(status, body?)

Stops execution and responds with the given HTTP status. Optional body is JSON-encoded into the response.

ts
return reject(401, { error: "Unauthorized" })
return reject(403)  // body omitted

allow(context?)

Lets the request proceed. Optional context is a record of arbitrary values that flows into every tool invocation as ctx.middleware. See Context flow to tools below.

ts
return allow()
return allow({ userId, plan: "pro" })

Context flow to tools

Whatever you pass to allow({ ... }) is delivered to every tool call for that request via the second argument:

export default defineMiddleware(async (req) => {
  const userId = await verifyJwt(req.headers.authorization)
  return allow({ userId })
})

Context is per-request — there is no shared state between requests. The middleware field is undefined if no middleware is defined, or if the middleware called allow() without arguments.

Single-function model

Dawn middleware is intentionally a single global function, not an array of layered middlewares. If you need branching by route, do it with normal control flow inside the function:

ts
export default defineMiddleware(async (req) => {
  if (req.routeId.startsWith("/admin/")) {
    return await requireAdmin(req)
  }
  return await requireApiKey(req)
})

This keeps the request lifecycle predictable and avoids ordering questions about per-route middleware.

Where middleware runs

Middleware runs in every Dawn HTTP runtime: dawn dev, the Node runtime served by dawn start or the generated server.mjs, and Hono builds. These endpoints invoke it before any route executes:

Endpointreq.methodreq.params
/threads/:id/runs/wait"POST"Dynamic segments read from the request input.
/threads/:id/runs/stream"POST"Dynamic segments read from the request input.
/threads/:id/resume"POST"Always {} — a resume body carries decisions, not route input.
/threads/:id/pending_interrupts"GET"Always {} — a GET has no body to read them from.
/agui/:routeId"POST"Dynamic segments read from the AG-UI run input.

Middleware is not the only gate on those five. An app that has a thread-access policy also authorizes each of them against the thread they name, and the two compose as AND: middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request needs both to pass. An app with no policy file is gated by middleware alone, exactly as before.

The endpoints middleware does not see are /healthz (a liveness probe), thread create, read and delete, GET /threads/:id/state, POST /threads/:id/cancel, and the memory-candidate endpoints. Of those, every thread endpoint is on the thread-access policy — so "no middleware" no longer means "ungated". /healthz and the memory-candidate endpoints have no gate of either kind; put an outer boundary around them.

Two of the five resolve their route identity from the thread itself, so their thread-access check runs before middleware and answers first: /resume and /pending_interrupts. On those, a caller middleware would have refused with a 401 receives the policy's deny instead. See the run endpoints and middleware.

It also runs on Windows. Earlier versions handed the raw filesystem path to Node's ESM loader, which rejects a drive letter as an unknown URL protocol, and the resulting failure was swallowed — so middleware never ran there at all. If you develop on Windows, expect a middleware file that was previously inert to start gating requests.

The langsmith build target is different: its generated graph entries do not include Dawn HTTP middleware. Put equivalent authentication at the LangSmith/platform boundary when you deploy those entries.

When middleware fails to load

Middleware is an authorization gate, so Dawn refuses to start rather than start without one it was supposed to have. A middleware file that is present but cannot be loaded fails the boot with DAWN_E3004, naming the file and the underlying cause:

text
Middleware at /app/src/middleware.ts failed to import, so every endpoint it
gates would run ungated. Fix the file, or delete it if this app has no
middleware.
 
    Error: JWT_SECRET is not set

This covers a middleware file that throws while it is being imported — a missing environment variable, an ESM/CJS interop break, a syntax error, an unresolved dependency — and a file Dawn cannot even probe, such as one inside a directory it lacks permission to read. Existence is decided before the import, so a permission error is never mistaken for "this app has no middleware".

Three consequences worth knowing:

  • An app with no middleware file is unaffected. All four candidates are definitively absent, so the boot proceeds with every request allowed, exactly as before.
  • The first existing candidate is the only candidate. If src/middleware.ts exists but fails to import, Dawn does not quietly fall back to a middleware.ts at the app root.
  • A file that exports no middleware function is not fatal. It is ignored, with a warning on stderr naming the file, because the built manifest binds the same way and dev must not diverge from it.

In dawn dev the failure is printed and the watcher restarts the child once you fix the file. In dawn start, a container, or a built server.mjs, the process exits non-zero — so the failure surfaces to your deploy's health check before traffic reaches an ungated server.

Errors thrown from middleware

This is the separate case of a middleware function that loads fine and then throws while handling a request. That request is rejected with HTTP 500; the server keeps running. Prefer explicit reject(status, body?) over throwing — it gives users a meaningful response.