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

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.

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; user approves or denies
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.

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

Related