Core concepts
The execution model
Ballast is a graph interpreter with a database behind it. A workflow is a graph; a run walks that graph one node at a time; state is a dictionary that flows along the edges; and a checkpoint is written after every node so nothing is ever lost. Understand these six objects and every endpoint, event, and console screen becomes obvious.
Everything the engine does is captured by six database objects. Each has a stable shape and a precise relationship to the others — nodes become steps, edges become order, state becomes output, and every material transition is recorded.
| Field | Type | Default | Description |
|---|---|---|---|
| Workflow | graph | versioned | A named, directed graph of nodes and edges, plus a status (draft | published) and a version number. The unit you build and publish. |
| Run | execution | one per trigger | A single execution of a workflow: a status, an input, an output, accumulated USD cost, a duration, and an ordered list of steps. |
| RunStep | node exec | one per node | One node execution inside a run — its input snapshot, output, own cost and timing, and error if it failed. |
| Checkpoint | durability | after each node | A snapshot of {state, completed, queue} written after every node completion, at a gate, and on failure. The resume point. |
| WorkflowVersion | history | on graph save | An immutable snapshot of the previous graph, captured every time the graph is replaced. |
| AuditLog | governance | append-only | One row per material action — create, update, run, approve, reject, resume, cancel — with actor and target. |
Workflows and the graph
A workflow is stored as JSON with two arrays, nodes and edges. Each node carries an id, a type, a canvas position ({x, y}, cosmetic — it never affects execution), and a type-specific config object. Each edge carries an id, a source, a target, and optionally a condition_value or label. What you draw on the canvas is exactly what executes: the engine compiles the same JSON the builder saves.
{
"nodes": [
{"id": "classify", "type": "agent",
"position": {"x": 0, "y": 150},
"config": {"label": "Classify Ticket", "model": "mock",
"user_message": "Ticket: {ticket}", "output_key": "category"}}
],
"edges": [
{"id": "e1", "source": "classify", "target": "draft"}
]
}
There are six node types — agent, tool, condition, loop, human_gate, and merge — documented field-by-field in the node reference. Node ids must be unique within a graph; they identify steps in the timeline and are the default state key each node writes to.
Start nodes and edge firing
Execution begins at every node with no incoming edge — there can be several, and they all start at once. When a node finishes, its outgoing edges fire and their targets are queued. Most node types fire allof their outgoing edges, which is how a single node fans out into parallel branches. Condition and loop nodes are the exception: they select which edges fire by matching the edge's condition_value (falling back to its label) against a boolean result of "true" or "false". Matching is case-insensitive.
Unlabelled edges out of a branch fire on both
An edge leaving a condition or loop node that has nocondition_value and no label is treated as unconditional — it fires whether the result is true or false. Always label both branches of a decision. See /docs/expressions for the expression side.A graph in which every node has an incoming edge has no start node and is rejected before it can run. That, along with missing edge targets, unknown tools, and malformed expressions, is caught by POST /api/workflows/{id}/validate — errors block a run, warnings do not. The full rule set is in the node reference.
Run state and how data flows
Every run owns a single state dictionary. It is the only thing that flows between nodes — there are no wires carrying values, only this shared dictionary that nodes read from and write to.
How state is seeded
At the start of a run the state is built from the trigger input in two ways at once: the whole input object is placed under the key input, and every top-level key of the input is spread in alongside it. So a trigger of {"ticket": "..."} yields a state where both {ticket} and {input} resolve.
// trigger body
{ "input": { "ticket": "Charged twice for March", "priority": "high" } }
// initial run state
{
"input": { "ticket": "Charged twice for March", "priority": "high" },
"ticket": "Charged twice for March",
"priority": "high"
}
output_key: how nodes write state
Agent and tool nodes write their result back into state under their output_key. When you omit output_key, it defaults to the node's id — so a node writes over any earlier value at that key. An agent writes its reply text (a string); a tool writes its full result object. Condition, loop, and merge nodes do not write to state (they only steer edges), though a loop maintains an internal counter described below.
// "Classify Ticket" agent with output_key: "category"
// → state["category"] = "billing"
// downstream "Draft Reply" agent reads it back:
{ "user_message": "Ticket: {ticket}\nCategory: {category}" }
{templates}: reading state
Any string in a node's config can interpolate state with {placeholder} syntax. Substitution is Pythonstr.format-style and applies recursively through params objects and lists — only string leaves are templated; numbers, booleans, and nested keys are left as-is.
{
"user_message": "Ticket: {ticket}\nCategory: {category}",
"params": {
"text": "Lead scored {score}",
"url": "https://api.example.com/leads/{lead_id}"
}
}
- An unknown placeholder leaves the whole string unchanged rather than failing the node — a typo in
{catgory}renders literally, it does not error - A placeholder that resolves to a dict or list renders its Python
str()form — useful for passing a whole tool result into an agent, e.g.{raw_data} - Templating reads state; the expression language evaluates state — condition and loop nodes use expressions, every other config field uses
{templates}
Public vars vs internal __ keys
Keys that begin with a double underscore (__) are internal. The engine uses them for bookkeeping — the only one today is a loop's counter, __loop_<node_id>. Internal keys are invisible to {templates} and to expressions, and they are stripped from anything a client sees:
- The rendered view a node sees (its templated strings and its expression scope) contains only public keys
- A step's input snapshot and the run's final
outputare the public state only - The default agent
user_message(used when you omit one) serializes public state to JSON, never the internal keys
When a run finishes successfully, its public state becomes the run's output — the accumulated result of every node that wrote to it.
Runs and steps
A run is one execution of a workflow. It records the graph version it ran, a status, the trigger input, the final output, accumulated cost_usd, a duration_ms, an error_message if it failed, and — while paused — a pending_gate. Fetch it whole at GET /api/runs/{id}.
Each node execution produces one step, stored in order by a sequence number. A step carries its own status, input snapshot, output, cost, timing, and error — so a run is fully auditable node by node without re-running anything.
| Field | Type | Default | Description |
|---|---|---|---|
| seq | int | — | Monotonic order within the run; resume continues the sequence, it does not reset it. |
| node_id / node_type | string | — | Which node produced the step, and its type. |
| label | string | node id | The node's config label, falling back to its id. |
| status | string | running | running, success, failed, or waiting (a gate awaiting review). |
| input | object | public state | State snapshot at step start. Agent steps store null here to avoid duplicating the whole prompt; gates store {prompt}. |
| output | object | null | Agent → {text, model, input_tokens, output_tokens}; tool → the result object; condition/loop → {expression, result/continue}. |
| cost_usd | float | 0.0 | Priced token cost for agent steps; 0 for every other node type. |
The run lifecycle
A run moves through five statuses. The path is not linear — paused and failed are recoverable, not terminal.
| Field | Type | Default | Description |
|---|---|---|---|
| pending → running | auto | immediate | The engine picks up the run, sets status running, and queues every start node. Triggering returns 201 immediately — execution is asynchronous. |
| running → success | auto | graph drained | Every reachable node has completed with nothing left to run. Public state is written to output, duration is stamped, and any evals are scored. |
| running → paused | gate | at a human_gate | A human gate is reached. In-flight branches finish first, then the run parks with a pending_gate and a checkpoint. It does not consume resources while paused. |
| running → failed | error | node exhausts retries | A node raises after its retries, or the run is cancelled. A failure checkpoint is written and error_message names the node. |
| paused → running | approve | POST /approve | Approval records the decision under approval_<node_id>, merges any reviewer input into state, marks the gate complete, and resumes from the checkpoint. |
| paused → failed | reject / cancel | POST /approve|/cancel | Rejecting a gate (approved:false) or cancelling a paused run settles it as failed with a clear message; its checkpoints are kept. |
| failed → running | resume | POST /resume | Re-runs from the last checkpoint. Completed nodes never re-execute. 400 if the run is paused (use /approve) or has no checkpoint. |
running, success, failed, and waiting for a gate step that is parked awaiting review.Checkpoints and durability
Durability is not a feature you turn on — it is how the engine runs. After every node completes, the engine writes a checkpoint row containing three things: the full state (public and internal keys), the list of completed node ids, and the queue of what runs next.
{
"state": { "input": {...}, "category": "billing", "draft_reply": "Hi …" },
"completed": ["classify", "draft"],
"queue": ["approve"]
}
Checkpoints are written at four moments, each tagged in the step_name field so the trail reads like a log:
| Field | Type | Default | Description |
|---|---|---|---|
| after:<node_id> | checkpoint | every node | Written the instant a node succeeds, before the next wave launches. |
| gate:<node_id> | checkpoint | on pause | Written when the run parks at a human gate; the gate sits at the front of the saved queue. |
| failed:<node_id> | checkpoint | on failure | Written when a node exhausts its retries; the failed node leads the queue so resume retries it first. |
| approved:<node_id> | checkpoint | on approve | Written by the approve endpoint after rewriting state with the decision and advancing past the gate. |
This is what makes runs durable. A crash, a deploy, or a kill -9between two nodes loses at most the node that was in flight — every completed node's output is already persisted. POST /api/runs/{id}/resume reads the latest checkpoint, restores state, completed, and queue, and continues — completed nodes never re-execute, so no duplicate emails, no double charges. The full mechanics, including idempotency of external effects, are in Reliability & checkpoints.
Cancellation preserves the checkpoint
Cancelling a running or paused run settles it asfailed with "Cancelled by user" but keeps every checkpoint — a cancelled run can still be resumed from where it stopped.Human gates and resumption
A human gate pauses the run and records what it is waiting for. The run reports status: paused with a pending_gate of {"node_id", "prompt"}, and the gate's step sits in status waiting. Approving injects a record under approval_<node_id> (the boolean decision plus any reviewer input, which is also merged into state), marks the gate complete, and resumes from the checkpoint. Rejecting fails the run. Because gates are ordinary nodes, they can sit anywhere — including between parallel branches, where in-flight work finishes before the run parks. See the human-in-the-loop guide.
Parallelism
When a node fans out to several successors, the ready nodes execute concurrently — up to 4 at a time (MAX_PARALLEL_NODES). Each pass through the loop gathers every currently-runnable node into a wave, launches them together, and checkpoints after each one settles. A merge node is a barrier: it waits until every one of its incoming branches has completed before it runs. The rules — including what happens when a gate pauses while sibling branches are mid-flight, and how a failure in one branch settles the others — are in Parallel execution.
Cost attribution
Every LLM call returns its token usage. The engine prices that usage against a per-model table (USD per million tokens), rounds to six decimals, and records it on the step. Steps sum to the run's cost_usd, runs sum to the workflow, and the dashboard reports spend per workflow — so “what does one resolved ticket cost?” is a query, not a spreadsheet.
| Field | Type | Default | Description |
|---|---|---|---|
| claude-sonnet-4-6 | input / output | $3.00 / $15.00 | Per million tokens. The high-capability default for production agents. |
| claude-haiku-4-5 | input / output | $1.00 / $5.00 | Fast and cheap; good for classification and scoring. |
| gpt-4o | input / output | $2.50 / $10.00 | OpenAI, used when OPENAI_API_KEY is set. |
| gpt-4o-mini | input / output | $0.15 / $0.60 | The lowest-cost hosted option. |
| mock | input / output | $0.00 / $0.00 | Deterministic offline model — why local development shows $0.00. Also the fallback when a provider key is absent. |
Only agent steps carry cost; tools, conditions, loops, and merges are priced at zero. An unrecognized model name is a validation warning, not an error — the workflow still runs, but its cost is tracked as $0.
Versioning
Saving a new graph over an old one is not destructive. On any PUT that changes the graph, the engine first writes the previous graph to an immutable WorkflowVersion snapshot at its current version number, then replaces the live graph and increments version. Every run records the version it executed, and the full history is available at GET /api/workflows/{id}/versions, newest first.
[
{ "id": "1de2a9c04b11", "version": 2, "graph": {...}, "created_at": "2026-07-05T14:02:11Z" },
{ "id": "0ac91f77b210", "version": 1, "graph": {...}, "created_at": "2026-07-05T13:49:20Z" }
]
The audit log
Every material action appends one row to a tamper-evident, append-only audit trail: the action, the actor, the target type and id, a small details object, and a timestamp. Read it newest-first at GET /api/audit. This is the record you hand to compliance — who ran what, who approved what, and when.
| Field | Type | Default | Description |
|---|---|---|---|
| create / update / delete | action | workflow, tool… | Resource lifecycle. An update records which fields changed, including the new version on a graph save. |
| run | action | run | A run was triggered — by the API, a webhook, a schedule, or the MCP server. |
| approve / reject | action | run | A human gate was resolved; details carry the node_id of the gate. |
| resume | action | run | A failed run was resumed from its last checkpoint. |
| cancel | action | run | A pending, running, or paused run was cancelled. |
{
"id": "77b21c09aa04",
"action": "approve",
"actor_id": "3f0c…",
"target_type": "run",
"target_id": "af772b3602ef",
"details": { "node_id": "review" },
"created_at": "2026-07-05T14:12:40Z"
}
GET /api/runs/{id}, /versions, and the API reference.