---
title: "Execution model"
description: "How Axiom runs a flow: immutable compiled artifacts, unary vs pipeline (SSE) invocation, the Executions list and detail pages, and durable resume after worker failure."
category: concept
surfaces: [canvas, console, http-api]
related: [concepts/nodes-packages-flows, concepts/type-system, getting-started/invoke-via-api, guides/use-interactive-api-docs, guides/api-keys, guides/flow-configs, guides/debug-a-flow, reference/http-api, reference/glossary]
last_reviewed: 2026-06-10
---

# Execution model

Every run of a flow is an **execution**: the platform compiles the flow into
an immutable compiled artifact, assigns an execution ID when the request is
accepted, and records durable checkpoints as each node completes. A flow runs
in one of two modes — **unary** (one input message in, one result out) or
**pipeline** (a stream of frames delivered over Server-Sent Events) — and
either way the run survives infrastructure failure: if a worker dies mid-run,
a replacement resumes from the last checkpoint instead of starting over.

## Flows run as compiled artifacts

Invoking a flow never executes an editable draft — it always executes a
[compiled artifact](/docs/reference/glossary#compiled-artifact). Compilation
validates that every edge connects type-compatible messages, resolves every
node placement, fixes the [entry node](/docs/reference/glossary#entry-node)
and [terminal node](/docs/reference/glossary#terminal-node), and freezes the
execution mode (unary or pipeline) into the artifact. An artifact is
immutable: editing the flow and compiling again produces a new artifact with
a new ID.

The artifact ID is the `graph_id` you pass when invoking the flow over HTTP.
In the editor, the flow inspector's **API** section shows the flow's invoke
endpoint, and its **Use via API (curl)** action compiles the current canvas
and produces a ready-to-paste curl command with the compiled `graph_id`
filled in. That `graph_id` is pinned to the version of the flow that was
compiled — after editing the flow, reopen the dialog to get an updated
command. The same section's **Open interactive docs** button opens a live
API reference generated for that exact artifact (see
[Use the interactive API docs](/docs/guides/use-interactive-api-docs)).

Workers cache compiled artifacts by ID, so repeat invocations skip graph
resolution entirely.

## Unary invocation

A unary flow handles one input message and produces one result per
invocation. This is the default mode for flows that contain no pipeline
nodes. Invoke it with `POST /invocations/v1/flows/invoke` on your deployment's
origin, authenticated with an API key created under **Console → API Keys**
(the key must belong to the same account that owns the flow):

```bash
# Replace the origin and graph_id with the values from "Use via API (curl)"
curl -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"graph_id": "01JX3Z9KQ4WV8RT2M5XB7CDEFG", "input": {"name": "Ada"}, "wait": true}'
```

The request body fields:

- `input` — a JSON object matching the entry node's input message. The
  platform converts it to protobuf for you; callers never deal with binary
  serialization.
- `wait` — when `true`, the call blocks until the flow completes and the
  response carries `result`, with the terminal node's output decoded back to
  JSON in `result.output`. The default wait timeout is 30 seconds; set
  `timeout_seconds` to override it. When `false`, the flow runs
  asynchronously and only `execution_id` is returned.
- `webhook` — **not currently supported.** Asynchronous webhook delivery was
  removed along with the egress-delivery service, so a request that includes a
  non-empty `webhook.url` is rejected with HTTP 400 (`webhook delivery not
  supported`) rather than silently accepted and then dropped. For asynchronous
  runs, omit `webhook` and poll the execution result (or use `wait: true`).
- `config_id` — optional [flow config](/docs/reference/glossary#flow-config)
  profile; when omitted the default hierarchy applies (tenant default → flow
  default). See [Flow configs](/docs/guides/flow-configs).

A successful `wait: true` response (HTTP 202):

```json
{
  "accepted": true,
  "execution_id": "0af7651916cd43dd8448eb211c80319c",
  "result": {
    "output": { "greeting": "Hello, Ada!" },
    "success": true,
    "completed_at": 1780000000
  }
}
```

The `execution_id` identifies this run everywhere — the Executions pages,
the events API, and distributed traces all reference it.

## Pipeline invocation streams frames over SSE

A pipeline node is a streaming generator: declared with `type: pipeline` in
`axiom.yaml`, it emits a sequence of output frames instead of a single
output message. A flow that contains any pipeline node always runs in
pipeline mode — frames flow continuously between nodes, and results stream
back to the caller progressively.

Pipeline flows compile to a pipeline artifact and must be invoked on the SSE
endpoint — `wait` applies only to unary invokes:

```bash
# -N disables curl's buffering so frames render as they arrive
curl -N -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke/stream' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"graph_id": "01JX3ZAB2PIPE8RT2M5XB7CDEF", "input": {"name": "Ada"}}'
```

The response is a Server-Sent Events stream (`Content-Type:
text/event-stream`). Each event is one frame:

```text
data: {"execution_id":"0af7651916cd43dd8448eb211c80319c","frame_index":0,"payload":{"greeting":"Hello, Ada!"},"is_final":false,"success":true}

data: {"execution_id":"0af7651916cd43dd8448eb211c80319c","frame_index":1,"is_final":true,"success":true}
```

`payload` is the flow output for that frame, decoded to JSON using the
terminal node's output message. The stream ends when a frame arrives with
`is_final: true`; on failure the final frame carries an `error` string
instead of a payload. If no final frame arrives within the timeout (30
seconds by default, `timeout_seconds` to override), the stream closes with a
final error frame.

## Running a flow from the editor

The **Run** button sits at the bottom-center of the editor canvas and opens
the **Run Graph** dialog. The dialog shows the flow's type — "Unary — single
request / response" or "⚡ Pipeline — streams multiple frames via SSE" — with
a **Switch** action to toggle between them. Switching is disabled when the
flow contains a pipeline node, because a pipeline node's streaming frames
cannot be delivered through a unary run.

Fill in the entry node's input fields and press **▶ Run**. Unary runs also
offer a **Debug Mode** toggle to watch the execution in real time node by
node (see [Debug a flow](/docs/guides/debug-a-flow)). Every run started from
the editor is recorded as an execution, exactly like an API-triggered run.

## The executions list

Click **Executions** in the top header to open `/executions`: one row per
execution, newest first, with **Status**, **Started**, **Duration**,
**Flow**, and **Execution** columns. Filter by status, by flow, or by date
range. The same data is available over HTTP at `GET
/invocations/v1/executions`.

An execution's status is one of:

| Status | Meaning |
|---|---|
| Queued | Accepted, not yet picked up by a worker |
| Running | A worker is executing nodes |
| Paused (HITL) | Waiting on a human-in-the-loop approval |
| Waiting on gate | Waiting for parallel branches to converge at a gate |
| Debug paused | Stopped at a breakpoint in a debug session |
| Completed | Terminal node finished; result available |
| Failed | A node failed and the flow did not complete |
| Compensating | Running compensation steps after a failure |
| Compensated | Compensation finished draining; the flow ended rolled back |
| Cancelled | Stopped by an explicit cancel request |

A running execution can be cancelled with `DELETE
/invocations/v1/flows/{execution_id}`; cancellation takes effect immediately,
even if the flow is blocked inside a long-running node.

## Execution detail

Selecting an execution opens `/executions/<execution-id>`: a read-only
canvas that replays the run on the flow's topology, a timeline scrubber to
step through it, and a header showing the status plus Started, Updated,
Completed, and Duration timestamps. Six tabs break the run down:

- **Timeline** — the run's events in time order, driving the scrubber.
- **Events** — the persisted per-node event log, filterable by node,
  including `NODE_RETRY_SCHEDULED`/`STARTED`/`EXHAUSTED` events with an
  "attempt N/M" chip on the row. Also available at `GET
  /invocations/v1/executions/{id}/events`.
- **Checkpoints** — the durable checkpoint written after each completed
  node, including its recorded output. Also available at `GET
  /invocations/v1/executions/{id}/checkpoints`.
- **Pauses** — human-in-the-loop pauses and how each was resumed.
- **Branches** — gate activity for parallel branches.
- **Output** — the flow's final output (or failure), decoded against the
  terminal node's output message.

When any node in the run retried, a **retry rollup banner** appears below
the header, above the tabs: a per-node summary such as "3 attempts,
succeeded on #3" or "5 attempts, failed after exhausting retries," derived
from the run's own event log. This is a run-level summary distinct from the
per-event "attempt N/M" chip on individual rows in the Events tab above —
the banner tells you at a glance which nodes retried and how the retries
resolved without opening the Events tab and reading the log; it's hidden
entirely when nothing in the run retried.

The detail page is fed entirely from persisted history, so it renders the
same run identically on every reload — including after the run finished.

## Durable resume

After every completed node, the platform durably records a checkpoint
containing that node's output before moving on. Checkpoints are what make an
execution survive infrastructure failure: if the worker executing a flow
dies mid-run, the platform redelivers the run to a replacement worker, which
loads the checkpoint history, skips every node that already completed, and
re-runs only the node that was in flight when the worker died. The execution
keeps its ID and completes normally — callers waiting on a result or an SSE
stream do not need to retry.

Checkpoints are retained for 30 days by default and are visible on the
**Checkpoints** tab of the execution detail page. Large node outputs are
stored out-of-band and rehydrated transparently when a resumed run (or the
detail page) needs them.

Pauses are durable the same way: an execution in **Paused (HITL)** or
**Waiting on gate** holds its state indefinitely — across worker restarts —
until it is resumed or times out per its configured policy.

## Retry and backoff

A node can carry a **retry policy** (see [Configure per-node retry and
backoff](/docs/guides/flow-configs#configure-per-node-retry-and-backoff)):
on a retryable failure, instead of ending the execution the platform
durably schedules another attempt and releases the worker slot in the
meantime — the wait never blocks a worker, which is what lets a retry back
off for minutes at a time without starving other flows. A node with a
retry policy carries a 🔄 badge on the canvas, and a node currently backing
off shows a live **retrying** state with a countdown to its next attempt.

### Attempts, backoff, and timeouts

Each attempt is durably counted against the node's `(execution_id,
step_number)` — the same identity a resumed execution replays after a
worker crash, so a crash mid-backoff picks the attempt count back up rather
than resetting it. The wait between attempts grows exponentially up to a
configured ceiling, bounded by two independent timeouts: a per-attempt
timeout (how long any single attempt may run) and an overall timeout across
every attempt (ScheduleToClose) — once the overall budget is spent, retries
stop even with attempts remaining.

### Retryable vs. non-retryable failures

Not every failure is worth retrying. A transport-level failure (a dropped
connection, a timeout) is retryable by default; a deterministic business
failure — the node ran and decided the input was invalid — is
non-retryable by default, because retrying it just reproduces the same
failure. A policy can narrow this further with an explicit non-retryable
list, but it can never widen it to force a retry of a business failure.

### Scope

Retry applies to **unary** nodes only in this release. Pipeline (streaming)
nodes and heartbeat-based liveness checks are explicit non-goals.

## Failure edges and typed errors

A node's terminal failure no longer has to end the execution. If the node
has an outgoing **failure edge** (an edge authored to fire only on failure,
after any retry policy exhausts its attempts), the worker builds a typed
error payload — a closed error code, a safe message, and structured detail
— and routes it down that edge exactly like a normal output, **without**
cancelling the flow. The receiving node runs as usual, and if its own
output reaches the flow's terminal node, the execution **completes**
instead of failing. On the canvas, a node with a failure edge shows a second,
red output handle so the branch is visible next to its normal outputs.

Ordering is fixed and deterministic: a retry policy's attempts exhaust
first, then a failure edge (if one exists) gets a chance to route the
error, then join-tolerance on any downstream fan-in gate, and only then
does the platform fall back to cancelling the flow. A failure a failure
edge handles is never also recorded as a failed join input — the two
mechanisms never double-handle the same failure.

## Compensation (saga rollback)

Some nodes declare a paired **compensation node** — the undo for whatever
that node did. The platform tracks these as a stack, pushed just before a
node with a compensation link runs and popped once it succeeds. When an
execution reaches a terminal failure with a non-empty stack, the platform
sets its status to **Compensating** and unwinds the stack **LIFO** — most
recently succeeded step compensated first — dispatching each compensation
node in turn and popping its frame only after that node itself succeeds.

Draining the whole stack moves the execution to the **Compensated**
terminal status; a compensation node that itself fails stops the drain and
the execution ends **Failed** instead, leaving the remaining frames
recorded for inspection. Compensation survives a worker crash the same way
ordinary execution does: a replacement worker reloads the stack and resumes
the drain from the exact frame that was in flight, never re-running a
compensation that already succeeded.

An author can also force a rollback explicitly by routing a failure edge
into one marked as rollback-forcing — useful when a node's failure should
undo prior work even though the author is otherwise handling that failure
on the canvas.
