NewWacht Bench is live — AI-assisted development for Wacht
GuidesTasks

Workspace and artifacts

Every task gets a /task/ filesystem. How agents use it, how to surface files to your UI.

Every board item gets a filesystem mounted at /task/ inside agent sandboxes. Work-in-progress lives there, deliverables get written there, and the journal accumulates there. The filesystem is shared across every thread working the board item, so a coordinator and its executor lanes read and write the same files. Your UI reads from it through the workspace file API.

The /task/ layout

The runtime maintains three files at the workspace root:

/task/
├── TASK.md       ← the task brief, maintained by the runtime
├── JOURNAL.md    ← append-only log of handoffs, one entry per completion
└── AUDIT.log     ← runtime audit trail

Everything else under /task/ is convention, not enforced structure. By convention agents write outputs under /task/artifacts/ and pass intermediate files between lanes under their own subpaths. Uploaded files land under /task/uploads/ (see File uploads). Nothing creates these directories for you; the agent makes them when it writes.

Two paths matter beyond the root files:

  • /task/artifacts/ is the conventional output directory. The agent prompts steer deliverables here, but the path is not enforced — completion validates that each declared artifact exists, not that it sits under a fixed prefix.
  • /task/uploads/ is where user-attached files are written, with a runtime-assigned name.

A separate /scratch/ mount (top-level, not under /task/) is ephemeral working space that does not persist with the task. Use /task/ for anything you want to survive the run.

How agents read and write

Agents reach the filesystem through built-in tools. The tools operate on any mounted path:

OperationTool call
Read a fileread_file(path="/task/artifacts/script.txt")
Write a filewrite_file(path="/task/artifacts/summary.md", content="...")
Edit in placeedit_file(path="/task/draft.md", old_string="...", new_string="...")
Append to a fileappend_file(path="/task/draft.md", content="...")
Read an imageread_image(path="/task/uploads/diagram.png")

There is no list_directory tool. To list a directory the agent runs a shell command (ls /task/artifacts) through the command-execution tool, or uses code_runner, which can read and write /task/ directly — run ffmpeg, parse a PDF, emit a derived file.

The filesystem is shared across all threads on the board item. A coordinator can write a brief, an executor reads it; one lane can leave a file for the next. Lanes coordinate through files, not through shared memory.

Surfacing files to your UI

Your frontend browses and downloads workspace files through the backend SDK or the React hook. The hook relays through your backend.

Listing a directory

Use this to render an artifacts browser, or to confirm an expected output exists before linking to it.

import { ai } from "@wacht/backend";

const listing = await ai.listProjectTaskBoardItemFilesystem(
  projectId,
  taskId,
  "/task/artifacts",
);

// listing.exists: boolean — false if the path is missing
// listing.files: [{ path, name, is_dir, size_bytes?, modified_at? }]
let listing = wacht::try_get_client()?
    .ai()
    .actor_projects()
    .fetch_board_item_filesystem(project_id, task_id)
    .path("/task/artifacts")
    .send()
    .await?;

// listing.exists: bool
// listing.files: Vec<TaskWorkspaceFileEntry { path, name, is_dir, size_bytes, modified_at }>

exists is false when the path has not been created yet — early in a task /task/artifacts may not exist. Render an empty state rather than treating that as an error. Each entry's is_dir distinguishes a subdirectory from a file; descend into directories with another listing call.

On the frontend, useProjectTaskBoardItem returns a live taskWorkspace listing for the workspace root plus a listTaskWorkspaceDirectory(path) method for any subpath:

import { useProjectTaskBoardItem } from "@wacht/nextjs";

function ArtifactsList({ projectId, taskId }: { projectId: string; taskId: string }) {
  const { taskWorkspace, taskWorkspaceLoading } = useProjectTaskBoardItem(projectId, taskId);
  if (taskWorkspaceLoading) return <div>Loading…</div>;
  if (!taskWorkspace.exists) return <div>No files yet.</div>;
  return (
    <ul>
      {taskWorkspace.files.map((f) => (
        <li key={f.path}>
          {f.name} {f.is_dir ? "(dir)" : `(${f.size_bytes ?? 0} bytes)`}
        </li>
      ))}
    </ul>
  );
}

The hook takes (projectId, taskId) — both are required. It revalidates on a 15-second interval, so a file the agent writes appears within that window without a manual refresh.

Downloading a file

Use this to serve an artifact back to the browser. Stream it — do not buffer large media into memory.

const file = await ai.downloadProjectTaskBoardItemFilesystemFile(
  projectId,
  taskId,
  "/task/artifacts/teaser-final.mp4",
);

return new Response(file.body, {
  headers: { "content-type": file.contentType ?? "application/octet-stream" },
});
let file = wacht::try_get_client()?
    .ai()
    .actor_projects()
    .download_board_item_filesystem_file(
        project_id,
        task_id,
        "/task/artifacts/teaser-final.mp4",
    )
    .send()
    .await?;

// file.body is a stream; pipe it back to the client
Ok(Response::builder()
    .header(
        "content-type",
        file.content_type.unwrap_or_else(|| "application/octet-stream".into()),
    )
    .body(file.body.into())
    .unwrap())

A path that does not exist returns an error, not an empty body — guard the call or rely on the listing to confirm the file is there first. The frontend equivalent is downloadTaskWorkspaceFile(path) on useProjectTaskBoardItem; for text you want to render inline, getTaskWorkspaceFile(path) returns the file content directly.

Driving the viewer from deliverables

The workspace holds both outputs and scratch files. To show only what the agent considers output, read the artifact paths from the board item's deliverables array rather than listing the whole filesystem.

"use client";
import { useProjectTaskBoardItem } from "@wacht/nextjs";

export function TaskArtifacts({ projectId, taskId }: { projectId: string; taskId: string }) {
  const { item } = useProjectTaskBoardItem(projectId, taskId);
  const deliverables = item?.deliverables ?? [];
  const latest = deliverables[deliverables.length - 1];

  if (!latest) return <div>No deliverables yet.</div>;

  return (
    <section>
      <h2>{latest.result_summary}</h2>
      <ul>
        {latest.artifacts.map((a) => (
          <li key={a.path}>
            <a href={`/api/task/${taskId}/file?path=${encodeURIComponent(a.path)}`}>
              {a.path.split("/").pop()}
            </a>
          </li>
        ))}
      </ul>
    </section>
  );
}

deliverables is the stable list of declared outputs; the filesystem listing is everything, including intermediate files. Each artifact entry is an object with a path. See Deliverables and the journal for the full shape.

Mounting external storage

You can mount an S3 object into /task/<mount-path> so an agent reads large reference data without copying it into the workspace. Use this when the input is huge — hours of video, a large dataset — or shared across many tasks.

await ai.createProjectTaskBoardItem(projectId, {
  title: "Annotate the demo footage",
  description: "Mark up the demo recording with chapter markers.",
  mounts: [
    {
      mount_path: "/task/source",
      s3_relative_key: "demos/2026-q2/demo-recording.mp4",
      mode: "ro",
    },
  ],
});
use wacht::models::{CreateProjectTaskBoardItemRequest, ScheduleMount};
use serde_json::json;

let mount: ScheduleMount = serde_json::from_value(json!({
    "mount_path": "/task/source",
    "s3_relative_key": "demos/2026-q2/demo-recording.mp4",
    "mode": "ro",
}))?;

wacht::try_get_client()?
    .ai()
    .actor_projects()
    .create_board_item(
        project_id,
        CreateProjectTaskBoardItemRequest {
            title: "Annotate the demo footage".into(),
            description: Some("Mark up the demo recording with chapter markers.".into()),
            mounts: Some(vec![mount]),
            ..Default::default()
        },
    )
    .send()
    .await?;

mount_path is where the agent sees the object; s3_relative_key is the key in your configured bucket; mode is ro or rw. The Rust ScheduleMount is a free-form map, so build it from JSON with those keys. The agent sees /task/source/demo-recording.mp4 as a regular file; reads stream from S3 with no local copy. mounts is a backend-SDK capability — it is not on the React updateItem request type.

Limits and cleanup

  • Per-file uploads cap at 64 MB. For anything larger, mount it instead of uploading.
  • Scratch files under /task/ persist for the life of the task. Archive the task to release the workspace.
  • For recurring schedules, instruct the agent to clean up after itself — leftover files accumulate across runs on the same board item.

Where to go next

On this page