Tools
Tools are the units of work a route's entry can invoke. They live in a tools/ subdirectory inside a route, are discovered automatically, and have their input and output types inferred from TypeScript source — no Zod schemas required for tools. (Route state in state.ts does use Zod; see State.)
A minimal tool
export default async (input: { readonly tenant: string }) => {
return { greeting: `Hello, ${input.tenant}!` }
}That's it. Dawn extracts the input and output types at build time using the TypeScript compiler API and writes them into .dawn/dawn.generated.d.ts. The tool becomes available as ctx.tools.greet inside workflow/graph route entries (fully typed), and is wired into generated agent deployment entries by dawn build.
Shared tools
Tools resolve from two locations. Each route gets its own tools/ directory for tools that belong to that route, and there's a shared src/tools/ directory for tools reused across routes:
src/
├── tools/ ← shared across every route
│ ├── lookupOrder.ts
│ └── escalate.ts
└── app/
└── support/
├── index.ts
└── tools/ ← route-local to /support
└── escalate.tsBoth sets are merged into the route's ctx.tools. When the same tool name exists in both, the route-local tool shadows the shared one — in the tree above, /support sees its own escalate and the shared lookupOrder. Put a tool in src/tools/ when more than one route needs it; keep it in the route's tools/ when it's specific to that route or you want to override a shared one.
Scoping a route's tools
By default a top route's agent sees every tool available to it — its own tools/*.ts, the shared src/tools/, and every capability-contributed tool (writeFile, runBash, task, writeTodos, readSkill, remember/recall, …). You can narrow that surface per route by passing tools: { allow, deny } to agent():
denyrevokes a tool — it is never offered to the model.allowgrants a withheld capability tool back into the set.denywins when a name appears in both.- Omitting
toolsentirely keeps the current behavior (all available tools).
// src/app/research/index.ts — top route: everything except shell
export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } })Scope is enforced at composition time: a withheld tool is never wired into the generated entry, so the model cannot call it. dawn check validates the names you reference — an unknown tool name (absent from the route's available set) is a build-time error, so typos fail loud.
Subagents are least-privilege by default
A subagent does not inherit the parent's capability tools. By default it gets only its own route-local tools/*.ts; the ambient capability tools (writeFile, runBash, task, writeTodos, remember/recall, …) are withheld unless you name them in allow. Grant back exactly what the worker needs:
// src/app/research/subagents/researcher/index.ts — read-only worker
// keeps its own tools/*.ts; writeFile/runBash/task withheld by default; grant readFile:
export default agent({ model: "gpt-5-mini", systemPrompt: "…", tools: { allow: ["readFile"] } })Requiring approval per call
approve is the third tools knob, alongside allow and deny. Any tool named in approve — an authored route tool or a capability tool — requires a human-in-the-loop prompt before each call:
export default agent({
model: "gpt-5",
systemPrompt: "…",
tools: { deny: ["runBash"], approve: ["deployProd", "sendEmail"] },
})The prompt shows the call's arguments (a display-only JSON preview), but the decision itself is name-level:
- Once — this call runs; the next call to the same tool prompts again.
- Always — persists the tool name to
.dawn/permissions.json, so future calls to that exact tool name proceed without prompting. - Deny — the call is blocked; the model receives the denial reason as the tool result.
See Permissions for the full interrupt payload and resume flow.
Constraining arguments
constrain is the fourth scoping knob: a predicate per tool, run at call time against the model's arguments. It returns true (allow), a string (deny — returned to the model as the tool result), or { approve: true } (escalate to the approval prompt).
export default agent({
model: "gpt-5",
systemPrompt: "…",
tools: {
constrain: {
deployProd: (args, ctx) => {
const { env } = args as { env?: string } // args is typed `unknown`
if (env === "prod") return { approve: true } // human-in-the-loop
if (env === "staging") return true // allow
return `Unknown environment "${env}".` // deny, model sees this
},
},
},
})The predicate receives the parsed args and a read-only ctx ({ toolName, routeId, threadId?, signal, params? }); it may be async. A predicate that throws — or returns anything other than the three shapes above — fails closed (the call is denied). Predicate bodies are not statically validated; dawn check validates only the tool names. Don't list a tool in both approve and constrain: constrain wins (it can escalate via { approve }) and dawn check warns.
The runtime signature
The full runtime signature accepted by tool discovery is (input, ctx) => ..., where ctx is DawnToolContext from @dawn-ai/sdk — carrying signal, middleware?, and fs:
import type { DawnToolContext } from "@dawn-ai/sdk"
export default async (
input: { readonly tenant: string },
ctx: DawnToolContext,
) => {
// Cooperate with cancellation — the AbortSignal is always present.
const res = await fetch(`https://example.com/hello?tenant=${input.tenant}`, {
signal: ctx.signal,
})
// ctx.middleware is the readonly bag populated by allow({ ... }) in src/middleware.ts.
// ctx.fs is a sandboxed WorkspaceFs handle for reading and writing workspace files.
const notes = await ctx.fs.readFile("notes.txt").catch(() => "")
return { greeting: await res.text(), notes }
}The second parameter is optional but recommended for any tool that does network I/O, holds long-running work, needs middleware-derived context, or reads/writes workspace files. See Middleware for how defineMiddleware and allow(context) populate ctx.middleware, and Workspace Filesystem for ctx.fs.
Tool descriptions
The description the LLM sees for an agent-route tool comes from a JSDoc comment directly above the default export:
/** Greet the tenant organization by name. */
export default async (input: { readonly tenant: string }) => {
return { greeting: `Hello, ${input.tenant}!` }
}dawn typegen extracts the comment into the route's tools.json manifest. To set it programmatically instead, export a string constant alongside the default export — an explicit export const description takes priority over the JSDoc comment:
export const description = "Greet the tenant organization by name."
export default async (input: { readonly tenant: string }) => {
return { greeting: `Hello, ${input.tenant}!` }
}Tools without a description still work, but the LLM only sees the tool name — write the one-liner.
Invoking a tool
Inside a workflow or graph route, tools are invoked through ctx.tools.<name>(...):
// workflow form
import type { RuntimeContext } from "@dawn-ai/sdk"
import type { RouteTools } from "dawn:routes"
import type { z } from "zod"
import type state from "./state.js"
type HelloState = z.infer<typeof state>
export async function workflow(
state: HelloState,
ctx: RuntimeContext<RouteTools<"/hello/[tenant]">>,
) {
const result = await ctx.tools.greet({ tenant: state.tenant })
return { ...state, greeting: result.greeting }
}ctx.tools.greet has full IntelliSense — input shape, return shape, everything.
Inside an agent route, you do not call tools yourself. dawn build wires the route's tools into the generated LangGraph entry and the LLM invokes them as needed. See Routes for both forms side by side.
Input and output rules
- 1
Use a typed input parameter
Annotate the input parameter with an inline type. This is what Dawn's compiler pass extracts.
tsexport default async (input: { readonly tenant: string; readonly limit?: number }) => { ... } - 2
Mark fields readonly
readonlyis preserved through type generation. Use it on every field — tool inputs are pure data, never mutated. - 3
Keep inputs and outputs JSON-serializable
Dawn serializes through the runtime boundary. Classes, Dates, Maps, functions — none of them survive. Use primitives, plain objects, and arrays.
- 4
Return a plain object
Always return an object literal, not a primitive or array. Dawn's type inference handles shape-based returns cleanly; a bare
return someStringmuddles the generated types.
The generated declarations
dawn typegen writes two artifacts:
.dawn/dawn.generated.d.ts— the ambient type module that backsimport type { RouteTools } from "dawn:routes". The emitted shape pivots over aRouteTools<P>lookup keyed by each discovered route pathname;dawn typegenpopulates the entries with concrete tool signatures inferred from source..dawn/routes/<routeSlug>/tools.json— per-route tool-schema manifests consumed bydawn buildwhen emitting LangGraph entries.
To inspect the exact shape Dawn emits in your app, run dawn typegen and open .dawn/dawn.generated.d.ts.
Regenerate manually if needed:
dawn typegenCommon patterns
- External API wrappers — one tool per endpoint. Input shape mirrors the endpoint's parameters; output is the parsed response.
- Database reads — input includes the filters; output is the record(s). Keep queries cheap — tools are meant to be fast.
- LLM calls — input is the prompt variables; output is the parsed model response. Defer orchestration to the route entry.
- Pure transformations — no side effects, just shape-to-shape mapping. Dawn's testing makes these trivial to verify.
- Wrapping an existing LangChain tool — instantiate the community tool at module scope, then expose it through a plain function with a typed input. Dawn tools are plain functions (not
tool()wrappers) so the input/output types can be inferred from the signature:
import { TavilySearch } from "@langchain/tavily"
const tavily = new TavilySearch({ maxResults: 5 })
/** Search the web for current information. */
export default async (input: { readonly query: string }) => {
const results = await tavily.invoke({ query: input.query })
return { results: String(results) }
}Wrap the wrapped tool's output in a plain object (per the rules above) so Dawn can infer the return shape — community tools often return loosely-typed values.