Workflows
ShippingThe unit of serious work: expertise captured as a Nix-built DAG of sandboxed steps, where the hard decisions pass through committees and every run is a record you can replay.
Not every wish deserves a workflow — the orchestrator routes quick things straight to an agent. A workflow is what you reach for when the work is serious: multi-step, reviewable, worth doing the same way twice. It is a directed acyclic graph of steps, defined in Nix, executed by the engine with the same discipline a build system applies to derivations.
Defined in Nix, discovered from flakes Shipping
A workflow is a flake artifact like everything else in Tixim: a Nix file
that assembles steps, edges, instructions, and tools into a fixture the
daemon discovers from its inventory (tix.workflows.<name>). Arguments are
templated into steps at run time — paths, branch names, and prior outputs
flow through filters like slugify and tojson instead of through string
concatenation in prompts. The recipe is code: versioned, diffable,
reviewable, and shared by pinning a flake.
Real recipes get big — our landing workflow is 38 steps and 86 edges in one file — and that is the point: every one of those edges is a decision that no agent has to remember, because the graph remembers it. The recipe documents its own control flow in its header:
Typed at the door Shipping
Workflows take arguments, and arguments have types — strings, lists, enums, and directories that carry their own mount declaration, so the sandbox composition follows from the schema instead of from convention. Defaults can be templated from other arguments. Condensed from the real landing workflow:
mkWorkflowFixture {
name = "land-feature";
description = "Land {{ args['feature-branch'] | join(', ') }} onto {{ args['base-ref'] }} …";
arguments = [
{
name = "source-repo";
type = "directory"; # a mount, not a string: path-var, access,
required = true; # and mapping all declared in the schema
mount = { mapping = "transparent"; path-var = "SOURCE_REPO"; access = "rw"; };
}
{ name = "feature-branch"; type = "list"; required = true; }
{ name = "rebase"; type = "enum"; default = "auto"; } # auto|always|never|managed
{
name = "aggregate-branch";
type = "str";
default-template =
"lf-aggregate/{{ args['base-ref'] | slugify }}/{{ args['feature-branch'] | join('+') | slugify }}";
}
];
}
Like everything in Nix, workflows are fail-fast and check early. A
missing required argument, a value outside its enum, a directory that
doesn’t exist — the submit is rejected before a single container starts.
The checks keep running at that altitude all the way down: every
harness#model pairing is validated at dispatch, and the dispatch step
proves its endgame is reachable at second zero — our landing workflow
verifies it will actually be able to advance the target checkout before
the run, because discovering that at the end would mean the merge “would
fail after the full review panel had run. Fail now.” A wrong argument
should cost milliseconds, not a review panel.
Anatomy of a step Shipping
Steps come in three kinds. Script steps run a program — no model, no tokens, deterministic. Agent steps put a model in a harness with declared tools. Subgraph steps embed a whole child workflow, so recipes compose like functions. All three run inside the same sandbox stack — rootless container, seccomp, a Landlock ruleset that limits reads to the step’s pinned closure — described on the security page. That container is the default runtime, with MicroVM and gVisor step tiers planned for work that warrants a harder boundary.
Concurrency is declared, not hoped for. A step that touches a shared resource takes a lease on it, and the daemon’s allocator queues conflicting runs instead of letting them race:
leases = [{
resource = "git:merge:{{ args['target-repo'] }}:{{ args['base-ref'] }}";
mode = "exclusive";
acquire-timeout-secs = 600;
}];
Edges carry the decisions Shipping
Steps do the work; edges decide what happens next. An edge connects
two steps and carries an optional condition — a small expression the engine
evaluates against what the predecessor actually produced. When it is true
the edge is followed; when it is false the target is skipped, and the
record says why. A step listed in a predecessor’s next is an
unconditional edge; everything with a decision in it lives in the
workflow’s edges list, with a description, so the graph can explain its
own routing.
The condition is a sandboxed Rhai expression — a stripped-down engine with operation limits and no standard library — and it sees exactly four things from the step that just finished, plus two from the run:
exit_code— the raw exit status, even when the step declared it a success;stdoutandstderr— the captured streams, so a script can talk to the router by printing a marker line;step_name— which predecessor this is, for edges shared between steps;args— the workflow’s typed arguments, for argument-dependent routing;input— the operator’s answer when the predecessor was a gate, empty otherwise.
That is the whole vocabulary, and it is enough. Here is the fan-out after the preflight step of our landing workflow, verbatim: one script step, five outgoing edges, and the branch is decided by a marker it printed and an argument the requester passed:
edges = [
{
from = "prepare-branches";
to = "prepare-branches-commit";
condition = {
script = ''exit_code == 0 && stdout.contains("PREPARE_DIRTY_PRESENT=1")'';
description = "dirty worktrees found — commit + remove them first";
};
}
{
from = "prepare-branches";
to = "rebase";
condition = {
script = ''exit_code == 0 && !stdout.contains("PREPARE_DIRTY_PRESENT=1")
&& args["rebase"] != "never" && args["rebase"] != "managed"'';
description = "branches free, scripted rebase requested";
};
}
{
from = "prepare-branches";
to = "aggregate";
condition = {
script = ''exit_code == 0 && !stdout.contains("PREPARE_DIRTY_PRESENT=1")
&& args["rebase"] == "never"'';
description = "branches free, rebase skipped";
};
}
{
from = "prepare-branches";
to = "report-final";
condition = {
script = "exit_code != 0";
description = "fatal preflight error (branch conflict, remove failed)";
};
}
];
Gates route the same way. A user-input step pauses the run and asks the
operator a question; the answer — acknowledged or rejected, a named option,
an optional comment — is recorded as that step’s output and bound into
input for its outgoing edges. approve → merge is guarded by
input.acknowledged == true, approve → report-final by
input.acknowledged == false, and a rejected plan can loop back through a
fix step to the same gate, which asks a fresh question each time around.
A few step attributes change how edges are read. A step can declare
success-exit-codes, so a verify gate that exits 3 for “assessed:
incomplete” completes instead of failing the run — the edge conditions
still see the raw 3 and route it to a fixup step. An always-run step
fires even when a predecessor failed, which is how cleanup and reports
happen on the bad path too. And a re-verification step can declare that it
supersedes an earlier step’s failure: when the re-check goes green, the
failure it re-verified is retired and the run resumes down the normal path
instead of staying latched red. Every evaluation is recorded — the TUI shows
each edge with the condition it checked and a tick or a cross — so when a
run went somewhere surprising, the reason is in the record, not in anyone’s
memory.
Agents where judgment lives, capsules where it doesn’t Shipping
Agent steps pick a harness and model per step — a cheap model for mechanical work, an escalation ladder for hard problems — and the pairing is validated at dispatch, so a typo fails in seconds instead of silently running the wrong model for an hour. Tools are declared in Rhai on the host side; the heaviest lesson of our own development is baked into the pattern: git plumbing and file shuffling run as host-side capsules — one-second scripts, zero tokens — so models are spent only where judgment is actually required.
When the stakes justify it, decisions go through committees: an implementer, an independent trainee, reviewers who score the result and return ACK or NACK. Nothing lands on one model’s say-so.
Runs are records Shipping
A run is event-sourced: every step start, output, container lifecycle event, and branch decision is recorded and replayable. Logs carry timestamps, stream, and worker identity as structured fields — never parsed back out of text. Budgets meter tokens and cost per run; the scheduler admits steps against real host capacity; the TUI shows the DAG, per-step provenance (harness, model, duration, exit), and live CPU/memory/token graphs while the run is in flight.
This is what the result DAG of a real landing run looks like — skipped branches where cached results made work unnecessary, worker steps with their harness and model on record, and two failed reviewers sitting next to the respawned one that succeeded:

That evidence trail is what makes the whole loop work: when a run fails, the autopsy is in the record, and the fix lands in the recipe — the story told in Debug the recipe, not the run. Runs also post experiences, so what a workflow learned outlives the run that learned it.