Core concepts
The execution model, in one page: what a flow is, what travels through it, and what happens between the file on your disk and a response on the wire. Read this once and the rest of the documentation is reference.
A flow
A flow is one .flow.md file. It has front matter — a block of key: value lines between two
--- delimiters — and a sequence of ## Step: sections. Nothing else is required.
---flowmarkdown_version: "0.1"flow: forward-ordertenant: acme---
## Step: shape```ntd{ "id": "{{ $.orderId }}", "at": "{{ date_format(now()) }}" }```A flow is identified by the pair (tenant, flow). That pair is its address everywhere: in the
registry, in the log, in the audit trail, and in its URL — POST /flows/acme/forward-order/run.
Tenants
Every flow belongs to exactly one tenant, declared in the file and not overridable at deploy time. The tenant is the isolation boundary:
- Two tenants may each have a flow named
forward-order; they are unrelated artifacts. - An API key is issued for one tenant and cannot call another tenant’s flows.
- Log entries, audit entries and queue files are partitioned by tenant.
A tenant is just a name — create one by deploying a flow that declares it.
The message
One value travels through the flow. It is called ctx.input, and in expressions you write it as
$. It starts as the request body and ends as the response body.
It is a value, not bytes. A JSON request becomes an object; an XML request becomes an XML value that keeps elements, attributes and order. See JSON and XML.
Each step may replace it. That is the central rule — a step does not “add to” the message, it returns a new one.
| Step | Effect on the message |
|---|---|
ntd or xslt fence |
Replaced by the value the template produced |
http_egress |
Replaced by the response body of the call |
grpc_egress |
Replaced by the response message |
validate, condition, route |
Unchanged — these decide, they do not transform |
| No fence, no effect | Unchanged |
restore_body: on the step |
Replaced by a copy saved earlier |
So a step that calls a downstream system loses the request you sent it. If you need the original
afterwards, save a copy first — see save_body.
Variables
Alongside the message there is a set of named variables, ctx.vars. You read one as
ctx.<name> in any expression. A variable that was never set reads as null rather than
failing.
Variables are not part of the message. They survive every step, including steps that replace the message, and they are not returned to the caller.
Some are set by the platform on every synchronous run:
| Variable | Contents |
|---|---|
ctx.flow |
The flow name |
ctx.correlation_id |
The value of the X-Correlation-ID or X-Request-ID request header, or a generated one |
ctx.headers |
The request headers, as an object with lowercase names |
ctx.headers is filled only on the synchronous path — a queued, scheduled or connector-driven run has
no request to take headers from, and there the variable is an empty object, so reading a header
off it gives null. Nothing downstream treats that as a blank: a headers: value built on it omits
the header with a warning, and a dedup_key built on it is refused on every message. ctx.tenant is set on every
path, but it is the same value for all of a flow’s traffic.
Others are written by steps as they run — save_body: writes the message into a variable of your
choosing, a secret_read step writes the secret, an http_egress step writes the response status
into ctx.HTTP_SC. Each is documented with the feature that produces it.
Compile, deploy, execute
Three separate acts.
$ nexus tenant create --id acme --display-name "Acme Ltd" # once per tenant$ nexus validate forward-order.flow.md$ nexus compile forward-order.flow.md --pretty$ nexus deploy forward-order.flow.md --version 1.0.0Compiling parses the file and lowers it into an intermediate representation — a JSON document holding the steps, their declared effects, and every expression already turned into a tree. All the errors that can be found without running anything are found here: an unknown effect, a malformed expression, a duplicate step name, a timeout of zero, a route pointing at a step that does not exist.
validate runs the parse and the semantic checks but stops before lowering, so a handful of
errors — a bad timeout value, a malformed template in an endpoint: — only surface under
compile. If you want certainty before you deploy, run compile.
Deploying compiles, then stores the result and registers it under a version you choose. It
refuses a tenant: that has not been registered with
nexus tenant create, and refuses one that has been disabled. Creating
the tenant implicitly would mean a typo in tenant: silently becomes a real tenant, with its own
keys, its own audit chain, and no way to tell it from a new customer.
Executing loads the stored artifact and runs its steps in order. No parsing happens at request time; the source file is not consulted and does not need to exist on the server.
An execution runs to completion
There is no cancellation. Once a flow has started, nothing stops it before its last step.
A caller that closes the connection, a caller whose own timeout expires, a proxy or gateway that cuts the request short — none of that reaches the flow. It keeps running and produces every effect on its way: each outbound HTTP or gRPC call, each message handed to another system, and whatever those systems do as a result.
So a timeout is not a “did not happen”. A caller that gave up knows one thing only: no answer arrived in time. It does not know whether the work never started, stopped halfway, or finished correctly a moment later.
Three things follow.
| A retry may be a duplicate | Treat the abandoned attempt as having succeeded, not failed, and make the retry safe to repeat. See Retrying safely. |
| The response is lost, the record is not | A synchronous run returns its output to the caller and there is no route to read it afterwards, so a closed connection loses it for good. That the execution ran, and how it ended, is still recorded under its correlation ID — see Logging and audit. |
| Nothing is rolled back | A flow is not a transaction. Steps that already ran keep their effects, and there is no compensation unless you wrote one yourself as further steps. |
What actually bounds an execution
Most time budgets are per step. One is not.
| Bound | Default | Where |
|---|---|---|
| HTTP egress, establishing the connection | 10 s | connect_timeout_ms on the step |
| HTTP egress, the whole request | 30 s | read_timeout_ms on the step |
| gRPC, establishing the connection | 10 s | connect_timeout_ms on the step |
| gRPC, the whole call including a collected stream | 30 s | deadline_ms on the step |
| The whole execution | 2 h | flow_max_duration_secs, set server-side |
The execution ceiling is checked between steps. A step that has started runs to completion —
nothing here is interruptible — so what it bounds is how many further steps begin. Reaching it is a
500 whose body names the step that did not start. Everything the flow already did stands.
timeout_ms on a split fence is checked once the branches have finished rather than interrupting
them, so a fan-out can overshoot its own budget and report it afterwards.
Retrying safely
The server deduplicates only what a flow asks it to. Without dedup_key: in the front matter, two
identical requests are two executions, on /run and on /enqueue alike. Declare it and the
platform claims the key before the flow runs: a second delivery of the same key is answered from
the recorded outcome instead of executing again — on /run, on a queued message when the worker
picks it up, on the gRPC door and on a cron tick alike, because the claim sits in the delivery
bracket all four share. The keys, the verdicts and the replay header are in
The flow file.
That is the platform’s half. It bounds repeats of the same delivery; it says nothing about the
same business operation arriving twice by different routes, and it does not make the downstream
system idempotent. Outside the server, the JMS connector suppresses a broker
redelivery of a message-id it has already forwarded. Beyond that, suppressing a duplicate is the
job of the system that owns the data, and the flow’s job is to give it something to suppress on.
As a caller. Pick a key that identifies the business operation — an order number, an invoice
id, a UUID you minted before the first attempt — and send the same key on every retry. A fresh
key per attempt is how one timeout becomes two orders. Sending it as X-Correlation-ID also puts
it on every log entry for the execution, which is how you find out afterwards what the first
attempt did.
In the flow. Forward that key to the system that has to absorb the repeat.
---flowmarkdown_version: "0.1"flow: order-intaketenant: acmeeffects: [http_egress]---
## Step: check```validate$.orderId != null | "orderId is required" | syntacticctx.headers["idempotency-key"] != null | "Idempotency-Key is required" | syntactic```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ordersmethod: POSTread_timeout_ms: 5000headers: {"Idempotency-Key": "{{ ctx.headers['idempotency-key'] }}", "X-Correlation-ID": "{{ ctx.correlation_id }}"}The validate fence is there because a headers template whose value is absent omits the
header rather than sending it empty — with a warning in the server log, but nothing the caller
sees: a caller who forgot the key would otherwise get no protection and no error. Make the key part of the contract and say so at the door.
Two rules keep such a key usable:
- Derive it from the message, never from the clock or a random value.
uuid()andnow()produce something new on every attempt and defeat the deduplication they were meant to enable;$.orderId, or a header the caller repeats, does not. - Prefer effects that are safe to repeat. A
PUTthat sets a record to a known state survives a second attempt; aPOSTthat appends a row does not.
On /enqueue a timeout tells you even less. The route answers 202 as soon as the message is
written and the flow runs afterwards, so a request that failed on the way back may already have a
message being processed; submitting again is a second message and a second execution. Queued
messages need the same discipline for a second reason — delivery is at least once. See
Queues, retries and the DLQ.
Versions and immutability
The compiled artifact is stored under the hash of its own bytes, and that hash is re-checked every time it is read. An artifact cannot be edited in place, and a deployed version cannot change under you.
Deploying the same (tenant, flow, version) twice is an error:
$ nexus deploy forward-order.flow.md --version 1.0.0error: registering acme/forward-order v1.0.0: artifact already exists: tenant=acme name=forward-order version=1.0.0Each deployment also moves the flow’s latest pointer to the new artifact. latest is what the
HTTP endpoints serve, so deploying a new version is how you release one. Older versions stay in the
registry, byte-for-byte as you deployed them.
Version strings are yours to choose; the platform treats them as opaque labels and does not order or compare them.
Effects are declared
Anything a flow does beyond transforming its own message is an effect: calling an HTTP
endpoint, opening a gRPC channel, fetching an OAuth2 token, reading a secret. Effects are listed
in an effects: key, and they are listed twice — once on the flow, once on the step.
---flowmarkdown_version: "0.1"flow: forward-ordertenant: acmeeffects: [http_egress]---
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTThe two declarations do different jobs.
On the flow it is a whitelist. A step may not declare an effect the flow has not declared; compilation fails if it does. This is what makes the header of the file an honest summary of the flow’s reach — you do not have to read every step to know whether it touches the network.
On the step it is the permission that is actually enforced. Before a step runs, the executor
looks up a handler for each effect the step declares and invokes it. A step that does not declare
http_egress has no way to make an HTTP call, whatever else it says: endpoint: and method: on
a step with no effects are inert text.
Declaring an effect on the flow alone does nothing at runtime and costs nothing. Declaring one on a step commits you to it: if the server has no handler for that effect, the step fails. See The effect catalog for what is available.
Where to go next
- The flow file — every front-matter key.
- Steps — every step key.
- Expressions — how
$andctx.are written. - Handling failures — what happens when a step fails.