Workspace Filesystem
A Dawn app's sandboxed file area is the workspace/ directory at the app root. 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 |
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.
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.
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; user approves or denies |
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.
Path jail is lexical. The local backend receives an already-resolved absolute path. A symlink inside workspace/ that points to a path outside the workspace will be followed by the local filesystem — the jail does not dereference symlinks. Don't place untrusted symlinks inside workspace/.