Filesystem
create_deep_agent always installs FilesystemMiddleware, so a Deep Agents agent always has ls, read_file, write_file, and edit_file. What decides whether a user interface can render that workspace is not the middleware. It is the backend. The running example is a dispatch filing desk that gathers airport data, keeps working notes, and files a report, and this page walks the three files behind its workspace panel and its write approval.
What the demo does
The Run tab shows the prebuilt <chat> composition beside a workspace panel. The welcome suggestion, "Runway note for KASE", asks the agent to work up a runway suitability note: save the raw lookups to /notes/kase-data.md, then write the finished note to /reports/kase-runway.md.
The scratch file under /notes/ appears in the panel the moment the agent writes it. The report does not. A write under /reports/ pauses the run, and an approval card appears below the tree while the target path is already listed as a dimmed, italic row badged "awaiting approval".
Accept approves the pending action and resumes the run. Ignore rejects that action; the agent may propose another action afterward, so rejection does not guarantee a terminal response or the absence of a file. The card also offers Edit and Respond, which this example leaves unhandled. Selecting any file in the tree shows its contents in the preview underneath.
How it is built
Three files carry the feature: a Python graph that builds the agent, an application config that registers it, and an Angular component that projects the workspace and maps the approval buttons onto resume payloads. Open the Code tab to read them in place.
The lookups the notes are made of
The agent needs something to write down. Two ordinary LangChain tools answer field elevation and runway length for a handful of ICAO codes, and the system prompt tells the agent to gather that data before it writes anything.
@tool
def lookup_field_elevation(airport: str) -> str:
"""Return the field elevation in feet for a four-letter ICAO airport code."""
elevation = FIELD_ELEVATION_FT.get(airport.upper())
if elevation is None:
return f"No field elevation on file for {airport.upper()}."
return f"{airport.upper()} field elevation is {elevation} ft."
@tool
def lookup_runway_length(airport: str) -> str:
"""Return the longest runway length in feet for a four-letter ICAO airport code."""
length = RUNWAY_LENGTH_FT.get(airport.upper())
if length is None:
return f"No runway data on file for {airport.upper()}."
return f"{airport.upper()} longest runway is {length} ft."Nothing about these tools is filesystem specific; they are the source of the content the agent files.
The backend that makes the workspace renderable
StateBackend stores the agent's files on the graph state under files, so every write reaches the client as a values update. A backend that stores files anywhere else, such as a host directory or a remote store, puts nothing on the state, and a panel bound to files stays empty no matter how busy the agent is. FilesystemPermission is the second half: a rule over operations and path patterns, and in interrupt mode a matching call pauses for human approval instead of executing.
def build_filesystem_agent():
"""Build the filesystem agent.
`permissions` reaches `FilesystemMiddleware`, which pairs with
`HumanInTheLoopMiddleware` to raise the interrupt. Anchor the pattern with a
literal prefix: bulk tools (`ls`, `glob`, `grep`) decide whether to fire
based on whether their search subtree could overlap the anchored prefix, so
an unanchored pattern over-fires on every listing.
"""
return create_deep_agent(
model=ChatOpenAI(model="gpt-4.1", temperature=0),
tools=[lookup_field_elevation, lookup_runway_length],
system_prompt=(PROMPTS_DIR / "filesystem.md").read_text(),
backend=StateBackend(),
permissions=[
FilesystemPermission(operations=["write"], paths=["/reports/**"], mode="interrupt"),
],
)StateBackend comes from deepagents.backends and FilesystemPermission from deepagents.middleware; no interrupt wiring is needed beyond the rule, because an interrupt-mode rule auto-installs HumanInTheLoopMiddleware.
The agent provider
provideAgent() registers the agent once for the whole application, and it is the only provider the <chat> composition requires. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};Your own application does not need the factory. Pass the values directly:
provideAgent({
apiUrl: 'https://your-deployment.langgraph.app',
assistantId: 'da-filesystem',
});assistantId must match the graph name in langgraph.json, here da-filesystem.
The pending write, read off the interrupt
A proposed new file is not yet saved, while a proposed replacement may target an existing file. The Angular component reads only the first action request of the first interrupt from the langGraphInterrupts() Signal exposed by injectAgent(). The payload is { action_requests: [{ name, args }], review_configs: [...] }, and for write_file the target path is args.file_path.
/**
* The path a pending `write_file` approval would create.
*
* The interrupt payload is `{ action_requests: [{ name, args }] }`; the path
* lives on `args.file_path`.
*/
protected readonly pendingPath = computed<string | null>(() => {
for (const interrupt of this.agent.langGraphInterrupts() ?? []) {
const value = (interrupt as { value?: unknown }).value as
| { action_requests?: Array<{ name?: string; args?: Record<string, unknown> }> }
| undefined;
for (const request of value?.action_requests ?? []) {
const path = request.args?.['file_path'];
if (typeof path === 'string') return path;
}
}
return null;
});Reading it lets the tree show the file before it lands, so the reviewer sees where the write is headed while deciding.
The file map, projected into a tree
files is a flat map from absolute path to a file record. This Angular projection displays a string value directly and calls JSON.stringify on the whole record for any non-string value; it does not extract the record's content field. agent.value() returns the live graph state that holds the map. The projection reads that map, adds the pending path as a ghost entry when one is open, and splits each key on its last slash to derive a directory and a name.
/** Live projection of `state.files`, plus a ghost row for a pending write. */
protected readonly files = computed<WorkspaceFile[]>(() => {
const raw = (this.agent.value() as Record<string, unknown> | undefined)?.['files'];
const entries = new Map<string, string>();
if (raw && typeof raw === 'object') {
for (const [path, contents] of Object.entries(raw as Record<string, unknown>)) {
entries.set(path, typeof contents === 'string' ? contents : JSON.stringify(contents));
}
}
const pending = this.pendingPath();
if (pending && !entries.has(pending)) entries.set(pending, '');
return [...entries.entries()]
.map(([path, contents]) => {
const slash = path.lastIndexOf('/');
return {
path,
directory: slash > 0 ? path.slice(0, slash) : '/',
name: path.slice(slash + 1),
contents,
pending: path === pending,
};
})
.sort((a, b) => a.path.localeCompare(b.path));
});Because the panel is a projection of state rather than a replay of write_file calls, an edit that rewrites an existing file shows up as one changed file here and as two entries in a tool call log.
A second computed groups the flat list by directory, which is all the structure a tree needs.
/** Files grouped by directory, so the panel reads as a tree. */
protected readonly tree = computed(() => {
const groups = new Map<string, WorkspaceFile[]>();
for (const file of this.files()) {
const bucket = groups.get(file.directory) ?? [];
bucket.push(file);
groups.set(file.directory, bucket);
}
return [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([directory, files]) => ({ directory, files }));
});The panel
The sidebar renders the grouped tree, a preview of the selected file, and the interrupt panel. A pending row carries data-pending, which dims and italicizes it; the badge is rendered from the same pending flag. <chat-interrupt-panel> is the same component every other LangGraph interrupt uses. There is no Deep Agents specific approval component, because there is no Deep Agents specific interrupt.
<div sidebar class="panel">
<h3 class="cap">Workspace</h3>
@if (files().length === 0) {
<p class="empty">No files yet</p>
}
@for (group of tree(); track group.directory) {
<div class="dir" data-testid="file-dir">
<span class="dir__name">{{ group.directory }}</span>
@for (file of group.files; track file.path) {
<button
type="button"
class="file"
data-testid="file-row"
[attr.data-path]="file.path"
[attr.data-pending]="file.pending ? 'true' : null"
[class.file--selected]="selectedPath() === file.path"
(click)="select(file.path)"
>
<span class="file__name">{{ file.name }}</span>
@if (file.pending) {
<span class="file__badge">awaiting approval</span>
}
</button>
}
</div>
}
@if (selectedFile(); as file) {
<div class="preview" data-testid="file-preview">
<span class="cap">{{ file.path }}</span>
<pre class="preview__body">{{ file.contents }}</pre>
</div>
}
<h3 class="cap">Approval</h3>
<chat-interrupt-panel [agent]="agent" (action)="onInterruptAction($event)" />
</div>Keeping the tree and the approval in one sidebar is the point of the layout: the reviewer reads the destination and the decision in the same glance.
Resuming with a decision
<chat-interrupt-panel> emits an InterruptAction of accept, edit, respond, or ignore, and the component turns the two it handles into resume payloads. HumanInTheLoopMiddleware resumes on an object with a decisions list, one ordered decision per protected action request, each { "type": "approve" }, { "type": "edit" }, or { "type": "reject" }.
protected onInterruptAction(action: InterruptAction): void {
if (action === 'accept') {
void this.agent.submit({ resume: { decisions: [{ type: 'approve' }] } });
} else if (action === 'ignore') {
void this.agent.submit({ resume: { decisions: [{ type: 'reject' }] } });
}
// 'edit' and 'respond' would need the tool args echoed back; out of scope here.
}The Angular demo always sends exactly one decision. The backend can pause several protected actions in one interrupt, so this frontend does not handle every possible batch. The required decision count is the number of protected action_requests, in their original order, rather than every unresolved tool call. Notes and report actions can be scheduled in parallel: a mixed pause can have files: {} and three pending tool calls but only one protected report decision.
After either decision, subsequent graph state determines the files shown in the tree. Approval can still encounter a tool error; rejection may be followed by another proposed action. Neither button predicts final file contents or guarantees that a future write cannot occur.
The middleware reads interrupt(request)["decisions"], so a bare list or a bare string raises a TypeError on the server rather than a validation error the browser can show. The failure appears as a dead run rather than as a rejected submission, so the shape is worth getting right the first time.
React preview
Choose React in the Example UI selector for a native React workspace with literal UTF-8 and legacy text projection, separate live and checkpoint-confirmed files, and complete ordered Approve or Reject batch decisions. Its Docs, Code and Run modes share the selected frontend; Angular remains the default. React suggestions fill a draft and only Send creates the owned thread.
Permission rules
A FilesystemPermission carries three fields: the operations it covers, the paths it matches, and the mode it applies. Rules are evaluated in declaration order and the first match wins; a call that matches no rule is allowed. Subagents inherit the parent rules unless they declare permissions of their own, which replaces the parent set entirely.
The pinned /reports/** rule protects report descendants. Exact /reports writes and edits are allowed; deletion of /reports or / is protected because the deletion subtree overlaps the rule.
The three modes are allow, which lets the call proceed, deny, which returns a permission-denied error to the model, and interrupt, which pauses the call for human approval. Path patterns must start with / and may not contain ...
Give the pattern a literal prefix, as /reports/** does. Bulk tools such as ls, glob, and grep decide whether to fire the permission based on whether their search subtree could overlap the anchored prefix. A fully unanchored pattern collapses to the root and fires on every listing, which turns an approval gate into an interruption on each directory read.