Skip to content

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-order
tenant: 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.

Terminal window
$ 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.0

Compiling 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-intake
tenant: acme
effects: [http_egress]
---
## Step: check
```validate
$.orderId != null | "orderId is required" | syntactic
ctx.headers["idempotency-key"] != null | "Idempotency-Key is required" | syntactic
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/orders
method: POST
read_timeout_ms: 5000
headers: {"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() and now() 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 PUT that sets a record to a known state survives a second attempt; a POST that 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:

Terminal window
$ nexus deploy forward-order.flow.md --version 1.0.0
error: registering acme/forward-order v1.0.0: artifact already exists: tenant=acme name=forward-order version=1.0.0

Each 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-order
tenant: acme
effects: [http_egress]
---
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST

The 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