Your first workflow
This tutorial builds a single workflow from scratch. By the end you will have a YAML file that boots two Calimero nodes, writes a value on one node, waits for it to replicate, reads it back on the other node, and asserts the result — the canonical shape of a merobox end-to-end test.
We build it one step at a time and explain each piece as it appears, including
how outputs: captures values and how {{name}} references them later. The
finished file is at the end of the page.
The shape of a workflow
Section titled “The shape of a workflow”Every workflow is a YAML file with a bit of metadata, a nodes: block declaring
the cluster, and an ordered list of steps:. Steps run top to bottom, and the
run stops the moment one fails.
name: My First Workflowdescription: Write a value on one node and read it back on another.
nodes: count: 2 image: ghcr.io/calimero-network/merod:edge prefix: calimero-node
steps: # ... we fill this in belowcount: 2 boots two nodes named by prefix with a numeric suffix:
calimero-node-1 and calimero-node-2. Every step addresses a node by that
full name.
Run the file at any point with:
merobox bootstrap run my-first-workflow.ymlStep 1 — install the application
Section titled “Step 1 — install the application”A context runs a WASM application, so the first step installs one. We use the
bundled kv_store app (a simple key/value store) that ships under
workflow-examples/res/.
- name: Install Application on Node 1 type: install_application node: calimero-node-1 path: workflow-examples/res/kv_store.wasm dev: true outputs: app_id: applicationIdThis is where variable capture enters. When a step succeeds, its outputs:
block copies fields out of the response into a shared value store:
outputs: app_id: applicationId # store response.applicationId under the name "app_id"The left side (app_id) is a name you choose; the right side
(applicationId) is the field in the step’s response. From now on,
{{app_id}} anywhere in a later step resolves to that installed application’s
ID. Nothing is hardcoded — the ID is discovered at runtime and threaded forward.
Step 2 — create a namespace
Section titled “Step 2 — create a namespace”A namespace is the identity and application-instance boundary that a shared
context lives in. We create one on node 1, tied to the app we just installed —
note the {{app_id}} reference pulling in the value captured in step 1.
- name: Create Namespace on Node 1 type: create_namespace node: calimero-node-1 application_id: '{{app_id}}' outputs: namespace_id: namespaceIdWe capture the new namespace’s ID as namespace_id for the steps that create
the context and the invitation.
Step 3 — create a context
Section titled “Step 3 — create a context”The context is the actual running instance of the app whose state the nodes will
keep in sync. It belongs to the namespace (passed as group_id) and runs the
installed app.
- name: Create Context on Node 1 type: create_context node: calimero-node-1 application_id: '{{app_id}}' group_id: '{{namespace_id}}' outputs: context_id: contextId member_public_key: memberPublicKeyA single step can capture several outputs. Here we keep context_id (which
every later call targets) and member_public_key (node 1’s identity inside
the context). Both come from the same response.
Step 4 — invite and join
Section titled “Step 4 — invite and join”Node 2 does not know about the context yet. Bringing it in is a two-part handshake: node 1 issues a namespace invitation, then node 2 joins the namespace and the context.
-
Node 1 creates an invitation to its namespace and captures the invitation payload:
- name: Invite Node 2type: create_namespace_invitationnode: calimero-node-1namespace_id: '{{namespace_id}}'outputs:invitation: invitation -
Wait briefly so the namespace gossip reaches node 2 before it tries to join.
waitjust pauses for a fixed number of seconds:- name: Wait for Namespace Gossiptype: waitseconds: 5 -
Node 2 joins the namespace using the captured invitation:
- name: Node 2 Joins Namespacetype: join_namespacenode: calimero-node-2namespace_id: '{{namespace_id}}'invitation: '{{invitation}}' -
Node 2 joins the context via its group membership. It references the same
context_idnode 1 created:- name: Node 2 Joins Contexttype: join_contextnode: calimero-node-2context_id: '{{context_id}}'
Both nodes are now members of the same context and will replicate its state.
Step 5 — call a method (set)
Section titled “Step 5 — call a method (set)”call invokes a method on the app running in a context. The kv_store app
exposes set and get. We write hello = world on node 1:
- name: Set a Value on Node 1 type: call node: calimero-node-1 context_id: '{{context_id}}' method: set args: key: hello value: worldargs: are passed to the method as its arguments. This mutates the context’s
state on node 1 — which then needs to propagate to node 2.
Step 6 — wait for sync
Section titled “Step 6 — wait for sync”Rather than guessing with a fixed wait, use wait_for_sync. It polls the
named nodes’ context state hashes and returns as soon as they converge (or
fails after timeout seconds). This is the reliable way to gate on replicated
state.
- name: Wait for State Sync type: wait_for_sync context_id: '{{context_id}}' nodes: - calimero-node-1 - calimero-node-2 timeout: 30When this step passes, both nodes agree on the context state — so node 2 now has the value node 1 wrote.
Step 7 — read it back (get)
Section titled “Step 7 — read it back (get)”Now we call get on node 2 and capture the result, proving the write
crossed the network:
- name: Get the Value from Node 2 type: call node: calimero-node-2 context_id: '{{context_id}}' method: get args: key: hello outputs: get_result: resultWe store the returned value as get_result so the final step can check it.
Step 8 — assert the result
Section titled “Step 8 — assert the result”assert evaluates one or more boolean statements and fails the workflow if any
is false. This turns the workflow into a real test. {{get_result}} expands to
the value we captured from node 2:
- name: Assert the Value Propagated type: assert statements: - "is_set({{context_id}})" - "contains({{get_result}}, 'world')"is_set(...) checks a value is present and non-empty; contains(...) checks a
substring. If node 2 returned world, the assertion passes and the workflow
exits successfully.
The complete workflow
Section titled “The complete workflow”The whole run as one sequence across merobox and the two nodes it drives:
Putting every step together:
name: My First Workflowdescription: Write a value on one node and read it back on another.
nodes: count: 2 image: ghcr.io/calimero-network/merod:edge prefix: calimero-node
steps: - name: Install Application on Node 1 type: install_application node: calimero-node-1 path: workflow-examples/res/kv_store.wasm dev: true outputs: app_id: applicationId
- name: Create Namespace on Node 1 type: create_namespace node: calimero-node-1 application_id: '{{app_id}}' outputs: namespace_id: namespaceId
- name: Create Context on Node 1 type: create_context node: calimero-node-1 application_id: '{{app_id}}' group_id: '{{namespace_id}}' outputs: context_id: contextId member_public_key: memberPublicKey
- name: Invite Node 2 type: create_namespace_invitation node: calimero-node-1 namespace_id: '{{namespace_id}}' outputs: invitation: invitation
- name: Wait for Namespace Gossip type: wait seconds: 5
- name: Node 2 Joins Namespace type: join_namespace node: calimero-node-2 namespace_id: '{{namespace_id}}' invitation: '{{invitation}}'
- name: Node 2 Joins Context type: join_context node: calimero-node-2 context_id: '{{context_id}}'
- name: Set a Value on Node 1 type: call node: calimero-node-1 context_id: '{{context_id}}' method: set args: key: hello value: world
- name: Wait for State Sync type: wait_for_sync context_id: '{{context_id}}' nodes: - calimero-node-1 - calimero-node-2 timeout: 30
- name: Get the Value from Node 2 type: call node: calimero-node-2 context_id: '{{context_id}}' method: get args: key: hello outputs: get_result: result
- name: Assert the Value Propagated type: assert statements: - "is_set({{context_id}})" - "contains({{get_result}}, 'world')"
stop_all_nodes: falseSave it as my-first-workflow.yml and run:
# Validate the schema first (no nodes started)merobox bootstrap run my-first-workflow.yml --dry-run
# Then run it for realmerobox bootstrap run my-first-workflow.ymlstop_all_nodes: false leaves the nodes running afterward so you can inspect
them with merobox health or merobox logs. Set it to true to shut them down
at the end, or clean up manually with merobox stop --all.
Where to go next
Section titled “Where to go next”- Workflow engine — the full run and step lifecycle, variable substitution, failure handling, retries, and parallelism.
- Workflow YAML reference — every step type and field.
- Testing with merobox — drive workflows from pytest.
- CLI reference — every command and flag.