Workspace Filesystem
A Dawn app's sandboxed file area is the workspace/ directory at the app root. Creating that directory opts an agent route into four built-in tools: listDir, readFile, writeFile, and runBash. No configuration is required — the directory's presence is the activation signal. All file I/O in the runtime flows through one permission gate, whether the request comes from the LLM calling an agent-facing tool or from your own code calling ctx.fs.
There are three layers:
- The pluggable backend (
@dawn-ai/workspace) — a plain-object interface that reads, writes, and lists files.localFilesystem()ships as the default; you can substitute any backend indawn.config.ts. - Agent-facing workspace tools —
readFile,writeFile,listDir, andrunBash, wired into agent routes when aworkspace/directory exists at the app root. The LLM calls these by name; they gate through the permission system before touching the backend. ctx.fs— theWorkspaceFshandle available onDawnToolContext(route tools) andRuntimeContext(workflow/graph entries). Same gate, same backend, but your code drives it. Always present on the context; reads simply surfaceENOENTif theworkspace/directory doesn't exist yet.
The pluggable backend
FilesystemBackend is the contract every backend must satisfy:
| Method | Required | Notes |
|---|---|---|
readFile(path, ctx, opts?) | Yes | UTF-8; opts.maxBytes overrides the default cap |
realPath(path, ctx) | Yes | Canonicalize an absolute path (resolve symlinks) so the permission gate compares real targets; backends without symlinks return the path unchanged |
readBinaryFile(path, ctx, opts?) | No | Raw bytes (Uint8Array); required for ctx.fs.readBinaryFile |
writeFile(path, content, ctx) | Yes | Returns { bytesWritten } |
listDir(path, ctx) | Yes | Returns leaf names, not full paths |
statFile(path, ctx) | No | Required for offload GC |
removeFile(path, ctx) | No | Required for offload GC eviction |
touchFile(path, ctx) | No | Used by LRU-by-access offload tracking |
mkdir(path, ctx) | No | Used to create the tool-outputs/ directory |
localFilesystem() implements all of them. Its defaults:
- 256 KiB read cap per call. Override per-call with
opts.maxBytes(e.g.Number.POSITIVE_INFINITYfor uncapped reads). writeFilecreates missing parent directories — writing toreports/result.mdworks without a separatemkdircall.
Configure a custom backend in dawn.config.ts:
import { myRemoteBackend } from "./backends/remote.js"
export default {
backends: {
filesystem: myRemoteBackend(),
},
}Middleware
FilesystemMiddleware is (next: FilesystemBackend) => FilesystemBackend. Wrap the default backend with compose() to add cross-cutting behavior:
import { compose, localFilesystem, withFilesystemLogging } from "@dawn-ai/workspace"
export default {
backends: {
// compose(...) takes middlewares and returns a wrapper; apply it to the base backend.
filesystem: compose(withFilesystemLogging())(localFilesystem()),
},
}withFilesystemLogging writes method names and arguments to stderr by default — note that for writeFile this includes the full file content, so route logs accordingly. Supply a destination function for structured output:
withFilesystemLogging({
destination: ({ method, args }) => {
structuredLogger.debug("workspace", { method, args })
},
})readBinaryFile is logged with the path only — the bytes are never serialized into the log entry.
The four agent-facing tools
All paths are workspace-relative. Paths outside workspace/ are permission-gated — see Permissions below for the full decision table.
listDir
Lists the leaf names (not full paths) of a directory inside the workspace.
listDir({ path: "corpus" })
// → ["intro.md", "api-reference.md", "changelog.md"]path defaults to "." (the workspace root) when omitted.
readFile
Reads a UTF-8 file. The default cap is 256 KiB — files larger than that return an error rather than partial content.
readFile({ path: "corpus/intro.md" })The 256 KiB cap applies to agent tool calls. Files inside workspace/tool-outputs/ (where offloaded tool results are stored) are read without a size limit — the agent retrieves them with the same readFile call.
writeFile
Writes a UTF-8 file. Missing parent directories are created automatically — calling writeFile({ path: "reports/2024-q1.md", content: "..." }) works even if reports/ does not exist yet.
writeFile({ path: "reports/summary.md", content: "# Summary\n\n..." })
// returns: "wrote 1234 bytes to reports/summary.md"runBash
Executes a shell command with the workspace root as the working directory. runBash is gated by the permissions system: a command must match an allow rule or pass an interactive prompt before it runs.
runBash({ command: "node scripts/fetch-source.mjs https://example.com/api" })ctx.fs for tools and routes
ctx.fs is a WorkspaceFs handle — a narrower, workspace-relative surface over the backend:
interface WorkspaceFs {
readFile(path: string, opts?: { readonly maxBytes?: number }): Promise<string>
readBinaryFile(path: string, opts?: { readonly maxBytes?: number }): Promise<Uint8Array>
writeFile(path: string, content: string): Promise<{ readonly bytesWritten: number }>
listDir(path?: string): Promise<readonly string[]>
}Paths are workspace-relative — "images/logo.png" resolves to <appRoot>/workspace/images/logo.png. The handle resolves relative paths against the workspace root and permission-gates anything that lands outside it (see Permissions below).
Example: a route tool reading a binary file
import type { DawnToolContext } from "@dawn-ai/sdk"
export const description = "Describe an image stored in the workspace."
export default async (
input: { readonly path: string },
ctx: DawnToolContext,
) => {
const bytes = await ctx.fs.readBinaryFile(input.path)
const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`
// Pass dataUrl to a vision model call, return the description, etc.
return { dataUrl }
}Buffer.from(bytes).toString("base64") converts the Uint8Array to a base64 string. If the configured backend does not implement readBinaryFile, the call throws with a message naming the fix.
Example: a workflow entry listing and reading files
import type { RuntimeContext } from "@dawn-ai/sdk"
import type { RouteTools } from "dawn:routes"
export async function workflow(
state: { readonly topic: string },
ctx: RuntimeContext<RouteTools<"/report">>,
) {
const entries = await ctx.fs.listDir("drafts")
const first = entries[0]
if (!first) return { ...state, summary: "no drafts found" }
const content = await ctx.fs.readFile(`drafts/${first}`)
return { ...state, summary: content.slice(0, 500) }
}listDir() with no argument defaults to the workspace root. Both readFile and listDir run through the permission gate before touching the backend.
Research scaffold example
The research template (create-dawn-ai-app --template research) shows how these tools fit together:
workspace/
AGENTS.md ← agent memory (see /docs/memory)
corpus/ ← source documents the agent reads
scripts/
fetch-source.mjs ← network fetch script called via runBash
reports/ ← agent-written output (writeFile creates this)The agent uses listDir and readFile to explore and read corpus documents. It calls runBash to run scripts/fetch-source.mjs when it needs to pull a new source — that command is intentionally left off the allow list so the fetch step requires human approval. Reports land in workspace/reports/ via writeFile; the directory is created on the first write.
Agent memory lives at workspace/AGENTS.md and is automatically injected into the system prompt. See Memory for details.
Tool-output offloading
When a tool returns a large result, Dawn can spill it to workspace/tool-outputs/ and replace the in-context payload with a short stub the agent can read back on demand using readFile. This keeps the context window tidy without losing information. See Context Management for configuration options and how offloading composes with conversation summarization.
Permissions
All file I/O — whether initiated by the LLM calling a workspace tool or by your code calling ctx.fs — goes through the same permission gate.
| Path | Decision |
|---|---|
Inside workspace/ | Always allowed silently |
Outside workspace/ — allow rule matches | Allowed |
Outside workspace/ — deny rule matches | Denied |
Outside workspace/ — no rule (unknown), interactive mode, agent-route tool | Interactive prompt shown; kind: "path" interrupt pauses the run for human approval |
Outside workspace/ — no rule (unknown), non-interactive mode | Fail-closed |
Outside workspace/ — no rule (unknown), workflow/graph entry | Fail-closed with guidance |
bypass mode | Everything allowed (dev only) |
Non-interactive and workflow/graph entries fail closed. Workflow and graph entries run outside the LangGraph graph, where the interrupt mechanism is not available. If a path outside workspace/ hits an unknown permission, the gate returns an error telling you to add an allow rule:
Permission denied: /etc/hosts is outside the workspace and interactive
permission prompts are not available in this execution context.
Add an allow rule for "readFile" to the permissions config in dawn.config.ts.One permission model regardless of whether the LLM or your code initiates the I/O.
Allow rules match canonical paths. Because the gate compares symlink-resolved paths, an allow rule for a path outside the workspace must reference the canonical (symlink-resolved) path. On systems where the workspace or target lives under a symlink — e.g. macOS /var → /private/var, or a symlinked home or project directory — a rule written against the non-canonical alias will not match and the operation fails closed.
Symlinks are resolved before the gate. localFilesystem resolves symlinks before the gate decision (via the required realPath), so a symlink inside workspace/ that points outside is correctly gated — prompted or denied in interactive mode, fail-closed otherwise — rather than silently followed. Custom backends get the same protection by implementing realPath, which the type system now requires.