Primitives
Filesystem
Named virtual filesystems with glob, full-text search and directory forks that share large files.
A filesystem is a virtual workspace inside a group. A group can hold as many as you need, each with its own immutable id and a name: a workspace for the agent, a docs corpus, an uploads area. Read and write them through the SDK, REST or MCP, search them with globs and full-text queries, fork a directory in one call for a sub-agent, and snapshot, fork or pause each filesystem on its own.
Creating a filesystem
const workspace = await group.fs.create('workspace'); // 409 already_exists if taken; workspace.id is its id
const docs = group.fs('docs'); // by name in the group, resolved on first use
const same = p.fs(workspace.id); // the same filesystem, by idworkspace = await group.fs.create("workspace")
same = p.fs(workspace.id)From the CLI: primitives primitive create workspace --type fs --group agent-42 (prints the id).
Reading and writing
await workspace.write('/repo/plan.md', plan);
const text = await workspace.readText('/repo/plan.md');
const bytes = await workspace.read('/repo/logo.png'); // Uint8Array
const entries = await workspace.list('/repo'); // directories first, then by path
const info = await workspace.stat('/repo/plan.md'); // { path, kind, size, hash, mode, mtime }await workspace.write("/repo/plan.md", plan)
text = await workspace.read_text("/repo/plan.md")
entries = await workspace.list("/repo", recursive=True)| Method | Description |
|---|---|
write(path, content, { mode? }) | Create or replace a file. Missing parent directories are created. mode defaults to the existing file's, else 0o644. |
read(path) / readText(path) | A file's bytes, or its contents as UTF-8 text. |
list(dir = '/', { recursive? }) | Entries of a directory, or all descendants with recursive. |
stat(path) | Metadata of a file or directory. |
mkdir(path) | Create a directory and its parents. Idempotent. |
move(from, to, { overwrite? }) | Rename a file or a directory with its subtree. |
remove(path, { recursive? }) | Delete a file, or a directory. A non-empty directory needs recursive. |
Each entry is { path, kind: 'file' | 'dir', size, hash, mode, mtime }, where hash is the BLAKE3 hash of a file's contents and mtime is in milliseconds.
Paths are absolute and normalized: relative paths start at /, and ., .. and empty segments are rejected. Paths are at most 1,024 bytes and files at most 32 MiB. Writing over a directory, or a file where a directory is expected, fails with 409 fs_conflict.
Glob and search
const files = await workspace.glob('/repo/src/**/*.ts'); // ['/repo/src/index.ts', …]
const hits = await workspace.search('lease expired', { path: '/repo' });
// [{ path: '/repo/src/queue.ts', snippet: '…returns **lease** **expired**…' }]glob(pattern, { limit? })matches absolute paths.*stays within one path segment,**crosses segments, and?,[ab]and{a,b}work as usual.search(query, { path?, limit? })is full-text search over text files up to 1 MiB, best matches first. Every word in the query must match. Matched terms insnippetare wrapped in**.
Both return 50 results by default and at most 1,000.
Directory forks
forkDir copies a directory and its subtree to a new path for a sub-agent. Files over 64 KiB share their contents with the source instead of being copied, so forking a large repository is cheap:
const fork = await workspace.forkDir({ from: '/repo' }); // { path: '/repo-fork-x1y2z3', files: 128 }
await workspace.forkDir({ from: '/repo', to: '/scratch/attempt-1' });fork = await workspace.fork_dir("/repo")
await workspace.move(fork["path"], "/scratch/attempt-1")The fork is an ordinary directory: work in it, then move the files you want to keep and remove the rest. Forking onto an existing path fails with 409 fs_conflict.
Snapshots, forks and pause
Each filesystem has its own lifecycle, independent of the other primitives in the group:
await workspace.snapshot('before-refactor'); // this filesystem only
await workspace.restore('before-refactor');
const attempt = await workspace.fork({ name: 'workspace-try' }); // a whole new filesystem, new id
const sub = await workspace.fork({ group: 'agent-42-sub' }); // into another group; 404 not_found if it doesn't exist
await workspace.pause(); // data calls fail with 423 paused
await workspace.resume();A filesystem fork shares files over 64 KiB with its source instead of copying them; smaller files are copied. Use forkDir to try something inside one filesystem, and fork to hand a separate filesystem to another agent. See Groups.
Mounts
Coming soon: mounting a group's filesystem into a sandbox or container over FUSE or NFS. Today, sandboxes reach the filesystem through the REST API, the SDKs or MCP. See Use with a sandbox.
Over REST
Every filesystem endpoint lives under the filesystem's id, as returned by POST /v1/primitives:
curl -X PUT http://localhost:7777/v1/primitives/$ID/files/repo/plan.md \
--data-binary @plan.md
curl http://localhost:7777/v1/primitives/$ID/files/repo/plan.mdA GET on a file returns its raw bytes with X-Primitives-Hash, X-Primitives-Size and X-Primitives-Mtime headers. On a directory, or with ?list=1, it returns { "data": [entries] }. Add ?stat=1 for metadata only. Set X-Primitives-Mode: 755 on a PUT to choose permission bits. All endpoints are in the REST API reference.
MCP tools
| Tool | Read-only | Arguments |
|---|---|---|
fs_read | yes | primitive?, path, list?, recursive? |
fs_write | no | primitive?; action: write (default), move, delete or mkdir; path; content?, encoding? (utf8 or base64), to?, recursive? |
fs_search | yes | primitive?; glob, or query with path?; limit? |
fs_fork | no | primitive?, from, to? |
primitive is the filesystem to use: its id or its name from primitive_list. It can be left out when the group has exactly one filesystem. fs_read returns at most 256 KiB of a file's content and sets truncated beyond that. Binary files come back base64-encoded.