Page actions

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 lets the write land and the run continue. Ignore rejects it, and the agent finishes without the 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.

graph.py — the lookup tools
@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.

graph.py — the agent
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.

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

While an approval is open the file does not exist yet. It is an argument on a paused tool call, so the only place to find it is the interrupt payload, which injectAgent() exposes as the langGraphInterrupts() Signal. The payload is { action_requests: [{ name, args }], review_configs: [...] }, and for write_file the target path is args.file_path.

filesystem.component.ts — the pending 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; the text is on its content field, which is why the projection stringifies anything that is not already a string. 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.

filesystem.component.ts — the file projection
/** 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.

filesystem.component.ts — grouping by directory
/** 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.

filesystem.component.ts — the workspace panel
<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 decision per paused tool call, each { "type": "approve" }, { "type": "edit" }, or { "type": "reject" }.

filesystem.component.ts — resuming the run
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 demo always sends exactly one decision, which is enough because only one write is ever paused here. The middleware rejects a resume whose decision count differs from the number of hanging tool calls, so a turn that batches two writes into one interrupt needs two decisions.

The consequence is visible in the tree. On Accept the write lands, the ghost row stops being pending, and the preview shows the real file content. On Ignore the interrupt clears without a file being written, so the row that only ever existed as a projection of the pending path disappears.

Warning: The resume payload is an object, not a list

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.

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

Warning: Anchor the permission pattern

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.

What's Next

Looking for something specific?