# 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

```ts
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 1
```

```python
ws = 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 1
```

```sh
shard 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 `mode` keeps 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`).
- `caps` size each execution's VM, as they size a processful workspace (see
  [Workspace size](https://docs.shardflux.dev/concepts/workspaces.md#workspace-size)). Caps passed to a later open apply from then on.

## Run commands as executions

```ts
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;   // 2
```

```python
run = 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  # 2
```

```sh
shard 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/user` that a command deletes is back at the next execution.
- `cwd` must be under `/home/user`. `env`, `stdin` (up to 1 MiB), `timeout` and `secret_refs` work 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`;
  HTTP `200` instead of `201`). 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_unavailable` with `details.reason: no_execution_host`
  (no host had room; `Retry-After` given) 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 `failed` or `lost` execution, 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]` or
  `GET /v1/workspaces/{id}/executions/{execution_id}` on the cell endpoint (`202` while 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 get` prints it.
- A change can require a revision: `ifTreeRevision` (TypeScript), `if_tree_revision` (Python), `--if-revision` (CLI)
  or `If-Match: <revision>` (HTTP). If the tree has moved on, nothing changes and the call is refused with
  `409 conflict`, `details.reason: tree_revision_mismatch` and `details.current_tree_revision`; the SDKs raise
  `TreeRevisionMismatchError`. 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_revision` on a patch work as on a processful
  workspace, for files of any size (see [Search and edit files](https://docs.shardflux.dev/guides/files.md#revisions)).

```ts
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](https://docs.shardflux.dev/limits.md#file-first-workspaces).

## Agent tools and the MCP server

- TypeScript **(0.9.0+)**: `workspaceTools(ws)` of a file-first workspace offers `exec` and the files tools only. Its
  `exec` runs an execution and adds `execution_id`, `state`, `tree_revision` and `changed` (up to 200 paths,
  `changed_truncated`) to the result. `workspaceTools(ws, { mode })` builds the definitions without reading the
  workspace, and `onExecution(id)` is called with each execution id before it is sent.
- MCP server **(0.4.0+)**: `exec` runs 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; its `exec` tool refuses a file-first workspace with
  `not_supported_for_mode`. Use `ws.executions.run()`.

See [Errors](https://docs.shardflux.dev/reference/errors.md#file-first-workspaces) for every refusal and the [HTTP API](https://docs.shardflux.dev/reference/http-api.md#file-first-workspaces)
for the requests.
