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 in dawn.config.ts.
  • Agent-facing workspace toolsreadFile, writeFile, listDir, and runBash, wired into agent routes when a workspace/ directory exists at the app root. The LLM calls these by name; they gate through the permission system before touching the backend.
  • ctx.fs — the WorkspaceFs handle available on DawnToolContext (route tools) and RuntimeContext (workflow/graph entries). Same gate, same backend, but your code drives it. Always present on the context; reads simply surface ENOENT if the workspace/ directory doesn't exist yet.

The pluggable backend

FilesystemBackend is the contract every backend must satisfy:

MethodRequiredNotes
readFile(path, ctx, opts?)YesUTF-8; opts.maxBytes overrides the default cap
realPath(path, ctx)YesCanonicalize an absolute path (resolve symlinks) so the permission gate compares real targets; backends without symlinks return the path unchanged
readBinaryFile(path, ctx, opts?)NoRaw bytes (Uint8Array); required for ctx.fs.readBinaryFile
writeFile(path, content, ctx)YesReturns { bytesWritten }
listDir(path, ctx)YesReturns leaf names, not full paths
statFile(path, ctx)NoRequired for offload GC
removeFile(path, ctx)NoRequired for offload GC eviction
touchFile(path, ctx)NoUsed by LRU-by-access offload tracking
mkdir(path, ctx)NoUsed 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_INFINITY for uncapped reads).
  • writeFile creates missing parent directories — writing to reports/result.md works without a separate mkdir call.

Configure a custom backend in dawn.config.ts:

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:

dawn.config.ts
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:

ts
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.

text
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.

text
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.

text
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.

text
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:

ts
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

src/app/(public)/describe/tools/describeImage.ts
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

src/app/(public)/report/index.ts
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:

text
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.

PathDecision
Inside workspace/Always allowed silently
Outside workspace/ — allow rule matchesAllowed
Outside workspace/ — deny rule matchesDenied
Outside workspace/ — no rule (unknown), interactive mode, agent-route toolInteractive prompt shown; kind: "path" interrupt pauses the run for human approval
Outside workspace/ — no rule (unknown), non-interactive modeFail-closed
Outside workspace/ — no rule (unknown), workflow/graph entryFail-closed with guidance
bypass modeEverything 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:

text
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.

Related