Workspace Filesystem
The workspace/ directory at the app root is where a B4.run app keeps its files. Create it, and agent routes get five built-in tools: listDir, readFile, writeFile, editFile and runBash. There's nothing else to configure. Every file read and write goes through one permission gate, whether the model calls a tool or your code calls ctx.fs.
There are three layers:
- The backend (
@b4run/workspace): a plain object that reads, writes and lists files.localFilesystem()is the default, and you can swap in your own inb4.config.ts. - The agent tools:
readFile,writeFile,editFile,listDirandrunBash. B4.run adds them to agent routes whenworkspace/exists at the app root. The model calls them by name, and each call passes the permission gate before it reaches the backend. ctx.fs: theWorkspaceFshandle onB4ToolContext(route tools) andRuntimeContext(workflow and graph entries). It uses the same gate and backend, but your code calls it. It's always on the context. Reads throwENOENTuntilworkspace/exists.
The pluggable backendCopy link to section: The pluggable backend
Every backend implements the FilesystemBackend interface:
| Method | Required | Notes |
|---|---|---|
readFile(path, ctx, opts?) | Yes | UTF-8. opts.maxBytes overrides the default cap |
realPath(path, ctx) | Yes | Resolves symlinks so the permission gate sees the real target. Backends without symlinks return the path as-is |
readBinaryFile(path, ctx, opts?) | No | Raw bytes (Uint8Array). Needed 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, with two defaults:
- A 256 KiB read cap per call. Override it with
opts.maxBytes(for example,Number.POSITIVE_INFINITYfor no cap). writeFilecreates missing parent directories, so writingreports/result.mdworks without amkdirfirst.
To use your own backend, set it in b4.config.ts:
import { myRemoteBackend } from "./backends/remote.js"
export default {
backends: {
filesystem: myRemoteBackend(),
},
}MiddlewareCopy link to section: Middleware
FilesystemMiddleware is (next: FilesystemBackend) => FilesystemBackend. Wrap the default backend with compose() to add behavior to every call, such as logging:
import { compose, withFilesystemLogging } from "@b4run/workspace"
import { localFilesystem } from "@b4run/workspace/node"
export default {
backends: {
// compose(...) takes middlewares and returns a wrapper; apply it to the base backend.
filesystem: compose(withFilesystemLogging())(localFilesystem()),
},
}withFilesystemLogging writes each method name and its arguments to stderr. For writeFile, that includes the whole file content, so be careful where those logs go. Pass a destination function for structured output:
withFilesystemLogging({
destination: ({ method, args }) => {
structuredLogger.debug("workspace", { method, args })
},
})readBinaryFile logs the path only, never the bytes.
The five agent-facing toolsCopy link to section: The five agent-facing tools
All paths are relative to workspace/. A path outside it goes through the permission gate (see Permissions below).
listDirCopy link to section: listDir
Lists the file and folder names in a workspace directory. It returns names, without the directory path.
listDir({ path: "corpus" })
// → ["intro.md", "api-reference.md", "changelog.md"]Leave out path to list the workspace root.
readFileCopy link to section: readFile
Reads a UTF-8 file. The default cap is 256 KiB. A larger file returns an error, with no partial content.
readFile({ path: "corpus/intro.md" })Files in workspace/tool-outputs/ (where offloaded tool results go) have no size limit, so the agent can read them back in full with the same readFile call.
Pass startLine and endLine (1-based, inclusive) to read part of a file. endLine may run past the end of the file, and a startLine after endLine is an error. A ranged read starts with a header line giving the file's length, so the model knows where it is:
readFile({ path: "src/server.ts", startLine: 1200, endLine: 1260 })
// → "[src/server.ts lines 1200-1260 of 3644]\n..."Without a range, readFile returns the file exactly as before, with no header. The tool's description tells the model to read large files in ranges and change them with editFile. A range doesn't lift the read cap: B4.run still reads the whole file first, so a file over 256 KiB fails with or without a range.
writeFileCopy link to section: writeFile
Writes a UTF-8 file. It creates missing parent directories, so writeFile({ path: "reports/2024-q1.md", content: "..." }) works before reports/ exists.
writeFile({ path: "reports/summary.md", content: "# Summary\n\n..." })
// returns: "wrote 1234 bytes to reports/summary.md"writeFile replaces the whole file. To change an existing file, use editFile: a model that rewrites a large file in full can drop or summarize the parts it didn't mean to change.
editFileCopy link to section: editFile
Replaces an exact span of text in an existing file. oldText must match the file character for character, whitespace and line endings included, and must occur exactly once. Overlapping matches count, so aa occurs twice in aaa. Set replaceAll: true to replace every occurrence instead, matched left to right without overlap. The rest of the file is written back unchanged. If newText equals oldText, the call returns no change and writes nothing.
editFile({ path: "src/config.ts", oldText: "retries: 3,", newText: "retries: 5," })
// returns: "replaced 1 occurrence in src/config.ts at line 42"If oldText is missing, the call fails with oldText not found in <path>, and says so when the file uses CRLF line endings but oldText has bare \n. If it occurs more than once without replaceAll, the call fails with the count and asks for more surrounding text. editFile reads and writes through the same permission gate as readFile and writeFile, so a path outside workspace/ is gated as a read and as a write.
A few limits:
- UTF-8 only.
editFilereads the file's bytes and refuses one that isn't valid UTF-8, leaving it unchanged, instead of rewriting it with replacement characters. - The read cap applies. A file over the 256 KiB cap can't be edited.
- Not atomic.
editFilereads the file and then writes it. A change another writer makes in between is overwritten.
Denying writeFile in a route's tool scope also withholds editFile.
runBashCopy link to section: runBash
Runs a shell command in the workspace root. A command runs only if it matches an allow rule or a person approves it (see Permissions).
runBash({ command: "node scripts/fetch-source.mjs https://example.com/api" })ctx.fs for tools and routesCopy link to section: ctx.fs for tools and routes
ctx.fs is a WorkspaceFs handle. It's a smaller, workspace-relative view of 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 relative to the workspace, so "images/logo.png" means <appRoot>/workspace/images/logo.png. Anything outside the workspace root goes through the permission gate (see Permissions below).
Here is a route tool that reads a binary file:
import type { B4ToolContext } from "@b4run/sdk"
export const description = "Describe an image stored in the workspace."
export default async (
input: { readonly path: string },
ctx: B4ToolContext,
) => {
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") turns the Uint8Array into a base64 string. If your backend has no readBinaryFile, the call throws an error that tells you how to fix it.
And here's a workflow that lists and reads files. A workflow gets the caller's input as-is, so it parses it first:
import type { RuntimeContext } from "@b4run/sdk"
import type { RouteTools } from "b4:routes"
import { z } from "zod"
const Input = z.object({ topic: z.string() })
export async function workflow(input: unknown, ctx: RuntimeContext<RouteTools<"/report">>) {
const { topic } = Input.parse(input)
const entries = await ctx.fs.listDir("drafts")
const first = entries[0]
if (!first) return { topic, summary: "no drafts found" }
const content = await ctx.fs.readFile(`drafts/${first}`)
return { topic, summary: content.slice(0, 500) }
}listDir() with no argument lists the workspace root. Both readFile and listDir pass the permission gate before they reach the backend.
Navlog scaffold exampleCopy link to section: Navlog scaffold example
The navlog template (create-b4-app --template navlog) shows how these tools fit together. These paths are relative to the B4.run app root, which is the generated project's server/ package:
workspace/
AGENTS.md ← agent memory (see /docs/memory)
poh/ ← Cessna 172N POH tables the agent reads
regs/ ← regulation excerpts (VFR cruising altitudes, fuel reserves)
reports/ ← agent-written navlogs (writeFile creates this)
tool-outputs/ ← large tool results the runtime offloadsHere's how the agent uses them:
readDocreads a POH table underpoh/or a regulation excerpt underregs/, and an offloaded result undertool-outputs/.writeFilesaves each navlog toworkspace/reports/, creating the directory on the first write.- The runtime offloads a large tool result to
workspace/tool-outputs/and keeps a preview in context. - The route denies
runBash, so the agent never runs a shell command.
Agent memory lives in workspace/AGENTS.md, and B4.run adds it to the system prompt. See Memory.
Tool-output offloadingCopy link to section: Tool-output offloading
When a tool returns a large result, B4.run can save it to workspace/tool-outputs/ and put a short stub in the context. The agent reads the full result back with readFile when it needs it. See Context Management for the options and how offloading works with summarization.
PermissionsCopy link to section: Permissions
Every file read and write goes through the same permission gate, whether the model calls a workspace tool or your code calls ctx.fs:
| 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. A 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 runs and workflow/graph entries fail closed. Workflow and graph entries run outside the LangGraph agent graph, so they can't pause for a prompt. When a path outside workspace/ has no matching rule, the gate returns an error that tells 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 b4.config.ts.Allow rules match real paths. The gate compares paths after resolving symlinks, so write allow rules with the canonical path. If the workspace or target sits under a symlink (like macOS /var → /private/var, or a symlinked home or project directory), a rule written with the symlinked path won't match, and the call fails closed.
Symlinks resolve before the gate decides. localFilesystem resolves them with realPath. A symlink inside workspace/ that points outside is treated like any outside path: prompted or denied in interactive mode, and denied otherwise. Custom backends get the same protection through realPath, which the type requires.