All docs
Docs · Reference
Workspaces
A workspace is an owned, stateful execution environment your stack
addresses by name. Where an op:// nano-op is a
pure JSON→JSON transform in a sandbox with no filesystem and no network, a
workspace keeps files between calls, has a full runtime, and (in the fleet)
reaches the public internet. Think “a machine that belongs to this stack”,
without you operating a machine.
WHEN @web.req.url.path == "/build"
EXEC "workspace://builder/exec"
WITH command = "git pull && make",
into = "_build" The mental model: lambda-like, but it has a name
- Owned. A workspace belongs to one
(tenant, stack, name).toolsin stackagentsof tenantacmeis always the same environment. - Stateful. Files written by one exec are there for the next — across requests, across chassis restarts.
- Lambda-shaped in cost. It wakes on exec, sleeps when idle, and is destroyed after long disuse (the data is gone; the next exec starts fresh). You pay for runs, not residency.
- Exit codes are data. A command that exits 3 is a successful dispatch whose result says
exit: 3. A transport failure — the workspace would not wake, the command timed out — merges asworkspace.errorinstead. Either way the op is not dropped.
Vocabulary: a computer executes work; a workspace is the owned, stateful environment that provides one; a run is one wake→sleep; a task spans runs.
The ref
workspace://<name>/<verb> The verb is the segment after the last /, so a name may nest: workspace://pony/paris/exec is workspace pony/paris, verb exec.
Names are 1–4 /-separated segments, each a DNS-label-shaped token
(^[a-z0-9][a-z0-9-]{0,62}$, no --), and no segment may equal a verb.
WITH workspace = <expr> replaces the ref’s name entirely — the ref’s
name is a static default, the WITH value (a literal or an envelope path)
wins:
EXEC "workspace://pony/exec"
WITH workspace = ._in.slug, # e.g. "pony/paris"
command = "npm test" | Verb | What it does | Result under into |
|---|---|---|
exec | Run a command (creating and waking the workspace as needed) | {exit, stdout, stderr, …} |
create | Make sure the workspace exists, without waking it | {state: "created"} |
wake | Warm it ahead of a task’s first exec | {state: "running"} + _txc.workspace.run |
checkpoint | Snapshot it (WITH comment = "…"); providers with the capability only | {state: "checkpointed", checkpoint_ref} |
sleep | Park it (a no-op where the provider sleeps implicitly) | {state: "sleeping"} |
destroy | Remove it — files are gone; the next exec starts fresh | {state: "destroyed"} |
exec also takes WITH checkpoint = true (and an optional comment): after
an exec that exits 0 the workspace is snapshotted and checkpoint_ref is
added to the result — “after cloning”, “after install” are yours to name.
A checkpoint that fails does not fail the exec; checkpoint_error {code, message} says why. A provider without the capability (the local one)
reports code = "unsupported".
exec — the request
WITH key | Meaning |
|---|---|
command | A shell line, run by the workspace’s /bin/sh -c |
args | An argv (array of strings), no shell — use for untrusted arguments. command and args are mutually exclusive |
stdin | Bytes fed to the process (a string; a JSON value is fed as its text) |
cwd | Working directory, relative to the workspace and inside it |
env | An object of extra environment variables |
into | Where the result lands (default _workspace) |
timeout | Wall clock for the whole exec — create/wake, the command, output capture; the command is killed when it expires. Default --workspace-default-timeout (5m), capped by --op-timeout-max (10m). A synchronous HTTP request is also bounded by whatever fronts the chassis — the hosted edge allows 20 s for response headers — so anything longer must use WITH mode = "continuable" (202 + poll); when the client gives up, the request is cancelled and the command is killed |
secrets.env.<NAME>.secret / .format / .optional | A stored secret, materialized into the environment as NAME (format = "Bearer {}" templates it). The only place a workspace op takes a secret — secrets.headers.* / .body.* are refused |
stream = true | Send stdout to the client as it is produced (see Streaming output) |
checkpoint = true, comment | Snapshot after a successful exec (see the verbs) |
The command sees a scrubbed environment — PATH, HOME (= the
workspace), TMPDIR (inside it), plus your env and any secrets.env.* — never the chassis’s.
Secrets never come back out. Every materialized secret’s value is
replaced with [REDACTED] in stdout, stderr and error messages before
the result is built, so neither the envelope nor the trace step carries
it — even if the command prints its environment. Values shorter than 8
bytes are not scrubbed (scrubbing “1234” would shred unrelated output);
real tokens are far longer. A format-templated value still contains the
raw secret, so scrubbing the raw value covers it.
exec — the result
{
"_workspace": {
"exit": 0,
"stdout": "hello\n",
"stderr": "",
"stdout_truncated": false,
"stderr_truncated": false
},
"_txc": {
"workspace": {
"provider": "local",
"computer": "<the provider's id for it>",
"run": "<run id>",
"exit": 0,
"duration_ms": 4
}
}
} stdout and stderr are each capped at --workspace-max-output-bytes (1 MiB); the excess is dropped and flagged *_truncated. On a fleet
provider, output a command produces in roughly its first 20 ms may come
back with stderr merged into stdout and stderr empty (the provider
replays the session’s history, which has no stream separation); exit is
exact regardless. Gate on exit, not on stderr being non-empty.
_txc.workspace.* is the chassis’s provenance stamp, written after the command returns. The command’s output is a string under into; it
never produces envelope JSON, so it can never forge the stamp (the
output sanitizer lets _txc.workspace.* through for the workspace
transport only, and strips every other reserved _txc.* path as it does
for any untrusted producer).
On a transport or provider failure:
{
"_workspace": {},
"workspace": {
"error": {
"code": "timeout",
"message": "…"
}
}
} Codes: timeout, provider, capacity, bad_request, not_allowed, unsupported_verb. Top-level (not under _txc) so a rule can gate on it: WHEN .workspace.error.code == "timeout".
The op is dropped — logged at ERROR, nothing merged — only on authoring
errors: a malformed ref, a bad name, no provider configured, an
untenanted request, or a malformed secrets block.
Streaming output
A build or a test suite is worth watching while it runs. WITH stream = true sends the command’s stdout to the client as it is produced,
instead of collecting it and returning it in the envelope:
WHEN @web.req.url.path == "/build"
EXEC "workspace://builder/exec"
WITH command = "make -j8",
stream = true,
into = "_build" The client gets a chunked response: the first bytes arrive as soon as the command produces them, and the connection stays open until it exits.
What changes in the result:
{
"_build": {
"exit": 0,
"stdout_streamed": true,
"stdout_bytes": 184320,
"stderr": ""
}
} stdout is absent by design — the bytes went to the client, not into the
envelope, so a 2 MB log never rides the envelope, the trace step, or a
continuation. stdout_bytes is there so a rule can still gate on whether
anything was produced. stderr is unaffected: still captured, still capped.
Three consequences worth knowing before you use it:
- Once bytes flow, the response is the stream. The status and headers
go out with the first chunk, so a later scope can no longer change them
and the envelope’s JSON rendering never reaches the client. Set
@web.res.statusand any headers before the EXEC if you need something other than200 text/plain. A command that produces no output never opens the stream, so the request renders its normal JSON response. - It outruns proxy timeouts. A front proxy typically limits how long it waits for response headers, not how long a body may take. Streaming sends the head immediately, so a long command survives a limit that would kill the same command run synchronously. (On the hosted service that limit is 20 seconds.)
- Backpressure is real. Chunks block until the client takes them, so a command that outruns a slow reader is throttled rather than buffered. If the client disconnects, the request is cancelled and the command is killed, exactly as on any other timeout.
stream is refused, in band, in four cases where it would be a lie: with LOOP (each pass would reopen one response), with mode (a promoted
request has no client left), with secrets (a value split across two
chunks could not be redacted), and on a run with no live HTTP client
(cron, a resumed continuation). Each returns workspace.error.code = "bad_request" with the reason.
Testing it locally: txco dev turns debug breakpoints on by default,
and breakpoints deliberately disable streaming (they dump the whole
envelope instead). Run TXCO_DEBUG_BREAKPOINTS=false txco dev --allow-local-workspace to see a stream in dev, and use curl -N so curl
doesn’t buffer it back up.
Not streaming, but large? Capture as usual and drop the value once you are
done with it, so it stops riding the envelope: EMIT @delete = ["_build.stdout"] — see EMIT @delete.
Loops, fuel, usage, trace
workspace:// may LOOP:
its failures are in-band data and its wall-clock is fuel-metered, which
is what a loop needs. Each exec pays the flat EXEC dispatch plus 1 fuel
per started 30 seconds of wall clock (2 per minute), minimum 1 — a
5 minute exec is 10 fuel. (Not the nano-op rate of 10 per millisecond:
while a command runs, the provider’s machine does the work and the
chassis only waits.) The machine time itself is reported on the usage
event with src=workspace (duration, bytes in/out, status), and every
dispatch writes a trace step with transport=workspace.
Providers
The chassis runs workspaces through a provider chosen by --workspace-provider. Unset, workspace:// is off and an op that fires
fails loudly.
local (bundled) — a directory per workspace under --workspace-local-root (<root>/<tenant>/<stack>/<name>); commands run
as the chassis’s own uid. There is no isolation: a rule author on
that chassis can run anything the chassis process can. It is therefore
refused unless --workspace-allow-local is also set — never implied by --env — and the chassis logs a WARN pair at boot when it is on. For dev
and single-operator self-hosting only.
txco dev --allow-local-workspace # sets the three flags for this run;
# workspaces land in .txco/dev/workspaces/ Fleet providers (a machine per workspace, with network policy and checkpoints) register the same way from the overlay and are selected by name; the stack’s txcl does not change.
| Flag | Default | Meaning |
|---|---|---|
--workspace-provider | (unset) | local, or an overlay provider |
--workspace-allow-local | false | Required for local; see above |
--workspace-local-root | ./chassis/data/workspaces | Root for local |
--workspace-default-timeout | 5m | Per-exec default when WITH timeout is absent (sized for builds, tests, tool runs) |
--workspace-max-output-bytes | 1048576 | stdout/stderr capture cap, each |
--workspace-reap | 720h | Idle window before the reaper destroys a workspace (fleet background service) |
Identity, runs, and the reaper
Each (tenant, stack, name) has a row in the workspaces table of the
runtime DB: the provider’s reference for it, when it was last used, the
current run id, the latest checkpoint. The row makes the identity durable
across chassis restarts and shared across fleet nodes; it records, it never
locks — concurrent execs on one workspace are allowed, and the author owns
file-level conflicts.
A run is one stretch of execution between wake and sleep. On the local
provider every exec is its own run. On a fleet provider that idles for a
while before it sleeps, execs closer together than that window share a run
id; _txc.workspace.run is how a trace tells them apart, and a task that
spans a sleep simply sees a new run id on its next exec.
The reaper destroys. Where the workspace-reaper background service
runs, a workspace idle longer than --workspace-reap (30 days by default)
is deleted at the provider and its row marked destroyed. Its files are
gone — no snapshot is kept — and the next exec starts fresh. Operators can
do the same by hand (txco workspace ls --idle-days=30, txco workspace rm <tenant> <stack> <name>) or take a checkpoint first.
Example
examples/workspace-hello — a command,
a counter file that persists across requests, a three-pass loop, and a
non-zero exit merging as data.