Skip to content

State: let / vars / persist

Three state blocks, three purposes.

Terminal window
let:
base-url: "${config.endpoint}" # immutable, evaluated once at start
greeting: "Hello, ${user.name}!"
vars:
counter: !int 0 # mutable, transient (lost after run)
buffer: !str-list
persist:
cursor: !str # mutable, survives across runs
last-sync: !str # auto-loaded at start, auto-saved on success
BlockMutable?LifetimeUse for
let:NoOne run, evaluated once at startAnything computed once at flow start.
vars:YesOne run, transientLoop accumulators, transient bookkeeping.
persist:YesAcross runsPagination cursors, last-seen timestamps, watermark IDs.

persist: values are auto-loaded at the start of each run and auto-saved when the flow completes successfully. They live under activity/persistence/ in the storage tree.

Assign with $name: syntax in steps:, or batch with set::

Terminal window
output:
counter: !int
steps:
- $counter: "${= counter + 1}" # single assignment
- set: # batch — committed atomically
cursor: "${products.last-id}"
last-sync: "${= str(_run-id)}"
seen-count: "${= seen-count + len(products.rows)}"
- return:
counter: "${counter}"

Prefer set: over multiple $var: lines when updating several variables at once — it commits them atomically.

A flow that pages through a source and remembers where it left off between runs:

Terminal window
using:
- zenvara/http
persist:
cursor: !str ""
output:
items: !any
steps:
- $page:
invoke: http.get
with:
Url: "https://api.example.com/items?after=${cursor}"
- set:
cursor: "${page.body.next-cursor}"
- return:
items: "${page.body}"

Dotted access into body works here because the response carries a JSON Content-Typehttp.* auto-parses in that case. Against a non-JSON API, reach into it with path() instead: "${= path(page.body, \"$['next-cursor']\")}".

The next run picks up from the stored cursor automatically. Pair persist: with the delta: transformer for change-detection pipelines.