File-first workspaces
File-first workspaces keep a versioned file tree and run each command in a fresh VM: executions, tree revisions, limits, and when to choose them.
A workspace that is only its files
A file-first workspace has no VM between commands. Its state is a versioned tree of the files under /home/user.
File calls (read, write, list, search, patch, remove, move) work on that tree directly, without a VM. Each command runs
as an execution: a fresh VM starts from the workspace's template with the tree's files in place, runs the command,
and the files the command changed under /home/user become the next revision of the tree. Nothing else survives an
execution: processes, memory and files outside /home/user are gone when it ends.
File-first workspaces are opt-in. The default, a processful workspace, keeps one VM with its processes and memory between calls and is unchanged. The mode is chosen when a key is first opened and never changes.
| Client | Version | Open a file-first workspace |
|---|---|---|
| TypeScript SDK | @shardflux/sdk (0.9.0+) |
cloud.workspaces.open({ key, template, mode: 'file_first' }) |
| Python SDK | shardflux (0.5.0+) |
sf.open(key=..., template=..., mode="file_first") |
| CLI | @shardflux/cli (0.5.0+) |
shard ws open <key> --template <slug> --mode file-first |
| MCP server | @shardflux/mcp (0.4.0+) |
workspace_open with mode: "file_first", or SHARDFLUX_WORKSPACE_MODE=file_first |
| HTTP API | /v1 |
POST /v1/workspaces/open with "mode": "file_first" |
When to choose file-first
| Processful (default) | File-first | |
|---|---|---|
| Between tool calls | A VM with its processes and memory; suspended when idle | Only the files; no VM |
| A command | An exec session: output streams while it runs, and it can keep running in the background | An execution: the call returns when the command has ended, with its output |
| What persists | Disk, memory and running processes | Files under /home/user |
| File calls | Served by the workspace's VM (a suspended workspace is woken for changes) | Served from the tree; never wait for a VM |
| Tools | Commands, files, terminals, processes, git and browser | Commands (as executions) and files |
| Lifecycle | Suspend, resume, fork, snapshot, reset, save as template | Open and delete |
| Running workspaces at once | Counts while it runs | Never counts |
| Compute | RAM GiB-hours for as long as it runs | Only while an execution runs |
Choose file-first when an agent mostly reads and edits files and runs commands that finish: tests, builds, linters, scripts, code generation. Nothing has to stay running between its tool calls, file calls never wait for a VM to start or wake, every change is a numbered revision, and an idle workspace uses no compute and no running slot.
Choose processful when anything must keep running or stay in memory between calls (a dev server, a REPL, a
database, a terminal session), when you install software outside /home/user between commands (for example with
apt), or when you need terminals, the process, git or browser tools, forks, snapshots or shared volumes.
Each execution starts a VM, so a short command takes longer end to end than on a running processful workspace. Git and
a browser still work inside one command: git clone https://... /home/user/repo in an execution leaves the repository
in the tree, and a script can drive the template's browser while it runs.
Open a file-first workspace
const ws = await cloud.workspaces.open({ key: 'customer-42/repo', template: 'python-node-browser', mode: 'file_first' });
ws.mode; // 'file_first'
ws.treeRevision; // 0: the empty tree
await ws.cell().files.write('/home/user/app/main.py', 'print("hi")\n', { createParents: true }); // revision 1ws = sf.open(key="customer-42/repo", template="python-node-browser", mode="file_first")
ws.mode, ws.tree_revision # ("file_first", 0)
ws.files.write("/home/user/app/main.py", "print('hi')\n", create_parents=True) # revision 1shard ws open customer-42/repo --template python-node-browser --mode file-first
shard files write customer-42/repo /home/user/app/main.py --from main.py # prints the new tree revision- The open answers at once with a tool token: no VM starts, so there is nothing to wait for.
- A file-first workspace is always persistent.
lifetime: 'session'is refused (422 validation_failed,details.reason: not_supported_for_mode). - Reopening the key without
modekeeps its mode. Reopening it with the other mode is refused (409 conflict,details.reason: mode_mismatch). - The template version must support layered disks; otherwise the open is refused (
409 conflict,details.reason: layout_unsupported). capssize each execution's VM, as they size a processful workspace (see Workspace size). Caps passed to a later open apply from then on.
Run commands as executions
const r = await ws.executions.run(['bash', '-lc', 'cd app && python3 main.py > out.txt && cat out.txt'], { timeoutMs: 600_000 });
r.state; // 'succeeded' (the command ran to its end), 'failed' or 'lost'
r.exitCode; // 0
r.stdoutText; // 'hi\n' (r.stdout holds the bytes)
r.changed; // [{ path: '/home/user/app/out.txt', change: 'added', type: 'file' }]
r.treeRevision; // 2run = ws.executions.run("cd app && python3 main.py > out.txt && cat out.txt", timeout=600)
run.ok, run.exit_code, run.text() # (True, 0, "hi\n")
[(c.change, c.path) for c in run.changed] # [("added", "/home/user/app/out.txt")]
run.tree_revision # 2shard ws exec customer-42/repo -v -- bash -lc "cd app && python3 main.py > out.txt && cat out.txt"The call returns when the command has ended. The result has:
| Field | Meaning |
|---|---|
state |
succeeded: the command ran to its end, whatever its exit code; check exit_code and timed_out. failed: the command could not be run or its result could not be published. lost: the execution's host stopped answering. A failed or lost execution changed nothing. |
exit_code, term_signal, timed_out |
How the command ended (exit_code is -1 when a signal ended it). |
stdout, stderr |
The output, each up to the output limit (1 MiB by default, up to 16 MiB); stdout_truncated and stderr_truncated say whether more was cut. |
base_revision, tree_revision |
The revision the command ran on, and the revision after it: base_revision + 1 when it changed files, base_revision when it changed nothing, null unless it succeeded. |
changed |
What the command added, modified or deleted under /home/user (path, change, type), up to 10,000 entries (changed_truncated). |
error |
For failed and lost: code, message, retryable and details.reason, for example exec_failed_to_start or lease_expired. retryable: true means a new execution may succeed. |
timings |
Milliseconds: queued, run and publish time, and the host's steps. |
- Output is not streamed; it comes with the result.
- One execution runs at a time. While it runs, a second execution and every file change are refused with
409 workspace_busy(details.reason: execution_in_progress,details.execution_id). The SDKs and the CLI wait for the running one, within their transition timeout (120 seconds by default). - An execution cannot be canceled. Aborting the call, or Ctrl-C in the CLI, stops waiting; the command runs to its end and its result can be fetched later.
- The command sees the template's files too. They are not part of the tree until a command changes one. A template
file under
/home/userthat a command deletes is back at the next execution. cwdmust be under/home/user.env,stdin(up to 1 MiB),timeoutandsecret_refswork as for commands on a processful workspace.
Execution ids make retries safe
Every execution has an id: 8 to 128 characters of A-Z a-z 0-9 . _ : -, starting with a letter or a digit. The SDKs
and the CLI generate ex-<uuid> unless you pass one (executionId, execution_id, --execution-id).
- The same id with the same request returns the recorded result and never runs the command again (
replayed; HTTP200instead of201). The same id with a different request is refused (409 conflict,details.reason: execution_id_reused). - The SDKs and the CLI retry network failures and
503 service_unavailablewithdetails.reason: no_execution_host(no host had room;Retry-Aftergiven) with the same id, so a retried call runs the command at most once. Pass your own id to make a call safe to repeat across processes too. - They never switch to a new id by themselves. After a
failedorlostexecution, run it again with a new id if you want to. - Read a result again with
ws.executions.get(id)(TypeScript,{ waitMs }to wait for a running one),ws.executions.get(id, wait=...)(Python),shard executions get <key> <id> [--wait 10m]orGET /v1/workspaces/{id}/executions/{execution_id}on the cell endpoint (202while it runs). Results are kept for 7 days.
Tree revisions
Revision 0 is the empty tree. Every change that changes something publishes the next revision: a write, patch, remove, directory creation or move, and an execution that changed files.
- Every files response carries the revision it was served from or produced, in
X-Tree-Revision. The SDK handles track the newest one they have seen (ws.treeRevision,ws.tree_revision);shard ws getprints it. - A change can require a revision:
ifTreeRevision(TypeScript),if_tree_revision(Python),--if-revision(CLI) orIf-Match: <revision>(HTTP). If the tree has moved on, nothing changes and the call is refused with409 conflict,details.reason: tree_revision_mismatchanddetails.current_tree_revision; the SDKs raiseTreeRevisionMismatchError. Read what changed, then retry with the new revision. - Without a condition, a change that races another writer is applied to the newest revision.
- A file's own revision (the SHA-256 of its content) and
expected_revisionon a patch work as on a processful workspace, for files of any size (see Search and edit files).
import { TreeRevisionMismatchError } from '@shardflux/sdk';
try {
await ws.cell().files.write('/home/user/app/main.py', 'print("bye")\n', { ifTreeRevision: ws.treeRevision! });
} catch (err) {
if (!(err instanceof TreeRevisionMismatchError)) throw err;
console.log('the tree is at', err.currentTreeRevision); // nothing changed
}Paths. Only paths under /home/user exist. / and /home are directories that cannot change. A read elsewhere
is 404 not_found and a change elsewhere 422 validation_failed, both with details.reason: outside_tree_root. File
names must be valid UTF-8. A file's modified_at is the time of its revision, and uid and gid are not reported.
Size. Every change records the whole tree, so changes take longer as the tree grows: about 0.6 seconds per change at 100,000 files.
What a file-first workspace cannot do
These calls are refused with 409 conflict and details.reason: not_supported_for_mode (details.mode,
details.operation). The SDKs refuse them before sending anything (NotSupportedForModeError, local: true), the CLI
exits 1 with a hint, and the MCP server returns the error with a hint.
| Refused | Instead |
|---|---|
| Suspend and resume | Nothing to do: no VM runs between executions. |
| Snapshot, fork, reset, save as template | Use a processful workspace. |
Exec sessions (exec.start, output, attach, signal, cancel), terminals, processes |
Run each command as an execution. |
| The git and browser tools | Run git or a browser script inside an execution. |
| Workspace changes against the template, shared volumes, idle policies, keepalives | - |
A file-first workspace is never suspended, so nothing wakes: wake() returns false and the wake hint answers
resident.
On a processful workspace, reading an execution is refused with 409 conflict and execution_id in an exec request
with 422 validation_failed, both with details.reason: not_supported_for_mode. The cell ignores If-Match there,
so the SDKs refuse ifTreeRevision / if_tree_revision on a processful workspace before sending anything.
Billing and limits
- A file-first workspace never counts toward your plan's running workspaces at once, idle or not.
- Each execution is metered like a workspace that runs for as long as the execution does: RAM GiB-hours at the execution VM's memory, and the CPU time the command used. Between executions nothing is metered for compute.
- Retained state counts the tree's current revision.
- An open is admitted like a start: the plan's allowances and billing state apply (
402 allowance_exhausted,402 entitlement_required).
The limits are in Pricing and limits.
Agent tools and the MCP server
- TypeScript (0.9.0+):
workspaceTools(ws)of a file-first workspace offersexecand the files tools only. Itsexecruns an execution and addsexecution_id,state,tree_revisionandchanged(up to 200 paths,changed_truncated) to the result.workspaceTools(ws, { mode })builds the definitions without reading the workspace, andonExecution(id)is called with each execution id before it is sent. - MCP server (0.4.0+):
execruns an execution in the same way. A server pinned to a file-first workspace lists only the tools that mode has and tells the client when that list changes (notifications/tools/list_changed). - Python:
workspace_tools()does not run executions yet; itsexectool refuses a file-first workspace withnot_supported_for_mode. Usews.executions.run().
See Errors for every refusal and the HTTP API for the requests.