Skip to content

Workflow YAML reference

This is the full reference for a merobox workflow file: the top-level schema, how to declare nodes, and the complete catalogue of step types. For how a workflow runs (lifecycle, variable capture, retries, parallelism), see the workflow engine.

Every field documented here is grounded in the schema merobox actually validates (merobox/commands/bootstrap/config.py) and the step implementations (merobox/commands/bootstrap/steps/). Validate a file before running it:

Terminal window
merobox bootstrap run workflow.yml --dry-run

A workflow is a mapping with a name, a node declaration, and a list of steps.

name: My Workflow
description: What this workflow proves
nodes:
count: 2
image: ghcr.io/calimero-network/merod:prerelease
prefix: calimero-node
steps:
- type: wait
seconds: 5
Key Required Type Meaning
name yes string Workflow name (shown in output).
description no string Free-text description.
nodes one of nodes/remote_nodes object Local Docker/binary node config (see Node configuration).
remote_nodes one of nodes/remote_nodes map Externally-running nodes addressed by URL (see Remote nodes).
steps no list Ordered list of steps to run.
nuke_on_start no bool (false) Delete all node data before the run.
nuke_on_end no bool (false) Delete all node data after the run.
stop_all_nodes no bool (false) Stop all nodes at the end (otherwise they keep running).
restart no bool (false) Stop then start nodes at the beginning instead of reusing running ones.
wait_timeout no int (60) Default timeout (seconds) for waiting operations.
force_pull_image no bool (false) Re-pull Docker images before the run (Docker mode only).
auth_service no bool (false) Run a mero-auth container behind a Traefik proxy (Docker mode only).
auth_image no string Custom auth-service image.
auth_use_cached no bool (false) Use a cached auth frontend.
webui_use_cached no bool (false) Use a cached WebUI frontend.
auth_mode no string Node auth mode, e.g. embedded, enabling login/refresh/ws_connect steps.
log_level no string (debug) Log level applied to nodes.
rust_backtrace no string (0) RUST_BACKTRACE value for nodes.
no_docker no bool (false) Run nodes as native merod binaries instead of containers.
binary_path no string Path to the merod binary for no_docker mode.
e2e_mode no bool (false) Clear merod’s default public bootstrap list and assign a private per-workflow rendezvous namespace.
preserve_default_bootstrap no bool (false) Under e2e_mode, keep merod’s default boot-node list instead of clearing it.
bootstrap_nodes no list Explicit bootstrap peers to connect to.
topology no object Startup wiring: bootstrap (discovery only) or nat (relay). See Topology.

The quickest form: ask for N identical nodes and let merobox name and port them.

nodes:
count: 3
prefix: calimero-node # nodes become calimero-node-1, -2, -3
image: ghcr.io/calimero-network/merod:prerelease
base_port: 2428 # first node's server port (increments per node)
base_rpc_port: 2528 # first node's RPC port (increments per node)
Field Required Type Meaning
count yes (this mode) int ≥ 1 Number of nodes to create.
prefix no string (calimero-node) Name prefix; nodes are <prefix>-<n>.
image no string Docker image for the nodes.
base_port no int Server port of the first node (increments).
base_rpc_port no int RPC port of the first node (increments).
config_path no string Shared config.toml for all nodes (skips init).
use_image_entrypoint no bool (false) Use the Docker image’s entrypoint.
mdns no bool Force discovery.mdns on/off (set false to exercise the rendezvous/relay path).
network_admin no bool (true) Add NET_ADMIN capability so inject_network_fault works.

Name each node and give it per-node settings. Use this when nodes need distinct ports, config, or a mock-TEE boot.

nodes:
tee-owner:
port: 7080
rpc_port: 7180
tee-replica:
port: 7081
rpc_port: 7181
mock_tee: true # boot as `merod run --mock-tee`

Per-node keys include port, rpc_port, config_path (per-node override), and mock_tee. Explicit mode is mutually exclusive with count.

Address already-running nodes by URL instead of managing containers. A workflow with only remote_nodes runs in remote-only mode (no local node lifecycle).

remote_nodes:
prod-node-1:
url: https://node1.example.com
description: Production node 1
auth:
method: api_key # none | api_key | password
api_key: ${NODE1_API_KEY}
Field Required Type Meaning
url yes string Node base URL.
auth.method no none|api_key|password Authentication method.
auth.username / auth.password no string Credentials for password.
auth.api_key / auth.key no string API key for api_key.
description no string Human label.

Setting topology replaces the default startup wiring. By default merobox puts every node on one bridge, writes all of its siblings into each node’s bootstrap.nodes, and leaves mDNS on — convenient, and unlike any deployment, where a node is told about one or two bootstrap peers, has to ask one of them who else exists, and has no multicast path to peers on other hosts.

Two variants:

type: bootstrap — one plain bridge, one boot-node, mDNS forced off, and a single bootstrap address per client. Clients can only learn a sibling’s address by asking the boot-node, so this exercises bootstrap → identify → kad → rendezvous and nothing else. Use it when discovery itself is what you want to test: a regression there fails the scenario and means only that.

topology:
type: bootstrap
boot_node:
image: ghcr.io/calimero-network/boot-node:edge # optional

type: nat — a boot-node on a public bridge, a NAT gateway, and clients on an --internal LAN bridge with no direct path to each other. Required to exercise relay-reservation recovery and DCUtR hole-punching. It also exercises the discovery path above, which is why a discovery regression can surface here as a NAT failure; prefer bootstrap when the relay is not the point.

topology:
type: nat
nat_mode: cone # cone | symmetric
boot_node:
image: merobox/boot-node:local # optional; auto-built when omitted
keypair: ./boot-node-key.json # optional; fresh keypair when omitted

Both variants require Docker mode, force discovery.mdns = false on every client, and skip the sibling bootstrap wiring. nodes: must use the count: form. Unknown keys under topology are rejected rather than ignored, so a nat_mode left behind on a bootstrap block fails validation instead of silently doing nothing.

Every step is a mapping with a type. These keys are available on all steps:

Field Required Type Meaning
type yes string The step type (one of the catalogue below).
name no string Human-readable label shown in output.
outputs no map Capture response values into workflow variables — see capturing outputs.
expected_failure no bool (false) Pass only if the operation is refused. A step that unexpectedly succeeds fails.
expected_error no string Pass only if the recorded error contains this substring (case-sensitive), {{placeholders}} resolved first. Requires expected_failure: true.

Without expected_error, expected_failure: true accepts any error at all, including the node being unreachable standing in for the refusal under test. Pin the reason whenever the workflow is gating a specific refusal:

- type: upgrade_group
node: calimero-node-1
group_id: '{{group_id}}'
target_application_id: '{{app_v2}}'
expected_failure: true
expected_error: 'identity downgrade forbidden'

The call step is the one exception to the unexpected-success rule: it keeps warning and passing, because workflows use expected_failure on a read as a soft “may not have propagated yet” probe and rely on its None error exports.

Fields specific to each step are documented per type. Placeholder references ({{var}}) and ${ENV} expansion are described in the engine reference.


Install a WASM application on a node.

Field Required Type Meaning
node yes string Target node.
path yes string Path to the .wasm file.
dev no bool (false) Install in dev mode.
- type: install_application
node: calimero-node-1
path: ./workflow-examples/res/kv_store.wasm
dev: true
outputs:
app_id: applicationId

Remove an installed application. Fields: node, application_id.

- type: uninstall_application
node: calimero-node-1
application_id: '{{app_id}}'

Read an application record. Fields: node, application_id.

- type: get_application
node: calimero-node-1
application_id: '{{app_id}}'
outputs:
bytecode_blob: application.blob.bytecode

List retained bytecode blob versions for an application. Fields: node, application_id.

- type: list_application_versions
node: calimero-node-1
application_id: '{{app_id}}'

Create a context bound to an application inside a namespace/group.

Field Required Type Meaning
node yes string Target node.
application_id yes string Application to instantiate.
group_id yes string Namespace/group the context belongs to.
service_name no string Optional service name.
- type: create_context
node: calimero-node-1
application_id: '{{app_id}}'
group_id: '{{namespace_id}}'
outputs:
context_id: contextId
member_key: memberPublicKey

Delete a context. Fields: node, context_id. The node resolves the acting identity from the authenticated session.

- type: delete_context
node: calimero-node-1
context_id: '{{context_id}}'

Create a new identity (keypair) on a node.

- type: create_identity
node: calimero-node-2
outputs:
private_key: privateKey
public_key: publicKey

Join an existing context via group membership (the node must already be a group member). Fields: node, context_id.

- type: join_context
node: calimero-node-2
context_id: '{{context_id}}'
outputs:
member_identity: memberIdentity

Materialize a node’s inherited Open-subgroup membership without an admin-signed invitation. Fields: node, group_id (the Open subgroup).

- type: join_subgroup_inheritance
node: calimero-node-2
group_id: '{{subgroup_id}}'

A namespace is a root group. These steps manage namespaces, subgroups, and their membership.

Create a namespace tied to an application. Deprecated alias: create_group.

Field Required Type Meaning
node yes string Target node.
application_id yes string Application for the namespace.
app_key no string Hex 32-byte bytecode blob id pinning a specific app version.
- type: create_namespace
node: calimero-node-1
application_id: '{{app_id}}'
outputs:
namespace_id: namespaceId

Create an invitation to a namespace. Deprecated alias: create_group_invitation.

Field Required Type Meaning
node yes string Target node.
namespace_id yes* string Namespace to invite to (group_id accepted as deprecated alias).
recursive no bool (false) Create a recursive invitation.
- type: create_namespace_invitation
node: calimero-node-1
namespace_id: '{{namespace_id}}'
outputs:
invitation: invitation

Join a namespace using an invitation. Deprecated alias: join_group.

Field Required Type Meaning
node yes string Target node.
namespace_id yes* string Namespace to join (group_id accepted as deprecated alias).
invitation yes string Invitation data.
- type: join_namespace
node: calimero-node-2
namespace_id: '{{namespace_id}}'
invitation: '{{invitation}}'
outputs:
member_identity: memberIdentity
member_account: memberAccount

memberIdentity is the bs58 key the node signs with; memberAccount is the 64-hex account governance rows are keyed by. They are different values and are not interchangeable — see Naming a member.

Create a subgroup under a namespace.

Field Required Type Meaning
node yes string Target node.
namespace_id yes string Parent namespace.
group_name no string Subgroup display name.
visibility no open|restricted Birth visibility (default: server default restricted).
- type: create_group_in_namespace
node: calimero-node-1
namespace_id: '{{namespace_id}}'
group_name: engineering
visibility: open

Atomically move a subgroup to a new parent within the same namespace. Replaces the removed nest_group/unnest_group steps.

Field Required Type Meaning
node yes string Target node.
child_group_id yes string Group to move.
new_parent_id yes string New parent group.
- type: reparent_group
node: calimero-node-1
child_group_id: '{{child_id}}'
new_parent_id: '{{parent_id}}'

Read-only lookups. Each takes node plus the id shown.

Step Extra fields Reads
list_namespaces Namespaces on the node.
node_identity Who this node is: accountId, deviceId, publicKey, accountRootPublicKey. Takes no namespace — none of it varies by one.
list_namespace_groups namespace_id Subgroups of a namespace.
list_subgroups group_id Subgroups of a group.
get_group_info group_id Full group record.
list_group_contexts group_id Contexts registered in a group.
list_group_members group_id Members of a group.
- type: list_group_members
node: calimero-node-1
group_id: '{{namespace_id}}'

A node has two ids in a namespace and they are not interchangeable. Its signing key is bs58; its account is 64 hex characters, and that is the principal governance rows are keyed by. The encodings differ on purpose, so passing one where the other belongs is a 400 rather than a silent no-op.

Read them from join_namespace (memberIdentity / memberAccount) or node_identity (publicKey / accountId).

Use the account everywhere: member_id on every member-addressing step, remove_group_members, and add_group_membersidentity. That last one also accepts a key, for a subject the node holds no account for yet; it is resolved to an account on apply and refused if bound to none.

Add members with roles to a group.

Field Required Type Meaning
node yes string Target node.
group_id yes string Group to add to.
members yes list of {identity, role} Members and their roles. identity is an account, or a signing key for a subject with no account here yet.
- type: add_group_members
node: calimero-node-1
group_id: '{{group_id}}'
members:
- identity: '{{member_account}}'
role: Member

Remove members. Fields: node, group_id, members (list of accounts).

- type: remove_group_members
node: calimero-node-1
group_id: '{{group_id}}'
members:
- '{{member_account}}'

Change a member’s role. Fields: node, group_id, member_id, role (Admin, Member, ReadOnly — case-insensitive variants accepted).

- type: update_member_role
node: calimero-node-1
group_id: '{{group_id}}'
member_id: '{{member_account}}'
role: Admin

Set a member’s capability bitmask. Fields: node, group_id, member_id, capabilities (u32 integer). Read it back with get_member_capabilities.

- type: set_member_capabilities
node: calimero-node-1
group_id: '{{group_id}}'
member_id: '{{member_account}}'
capabilities: 7

Set the group’s default capability bitmask for new members. Fields: node, group_id, capabilities (u32).

- type: set_default_capabilities
node: calimero-node-1
group_id: '{{group_id}}'
capabilities: 3

Toggle a member’s auto-follow flags for new contexts and nested subgroups.

Field Required Type Meaning
node yes string Target node.
group_id yes string Group.
member_id yes string Target member account (64 hex).
auto_follow_contexts yes bool Auto-join new contexts in this group.
auto_follow_subgroups yes bool Self-admit into nested subgroups.
- type: set_member_auto_follow
node: calimero-node-1
group_id: '{{group_id}}'
member_id: '{{member_account}}'
auto_follow_contexts: true
auto_follow_subgroups: false

Set a subgroup’s visibility. open inherits parent members that hold CAN_JOIN_OPEN_SUBGROUPS; restricted requires explicit membership.

Field Required Type Meaning
node yes string Target node.
group_id yes string Subgroup.
visibility yes open|restricted New visibility (case-insensitive).
- type: set_subgroup_visibility
node: calimero-node-1
group_id: '{{subgroup_id}}'
visibility: open

leave_context, leave_group, and leave_namespace remove the calling node from a context, group, or namespace. Each takes node plus the relevant id.

- type: leave_namespace
node: calimero-node-2
namespace_id: '{{namespace_id}}'

Metadata records are string→string maps with an optional record name. The node resolves the acting identity from the authenticated session.

Step Fields
set_group_metadata node, group_id, record_name?, data?
get_group_metadata node, group_id
set_member_metadata node, group_id, member_id, record_name?, data?
get_member_metadata node, group_id, member_id
set_context_metadata node, group_id, context_id, record_name?, data?
get_context_metadata node, group_id, context_id
- type: set_group_metadata
node: calimero-node-1
group_id: '{{group_id}}'
record_name: team-info
data:
owner: alice
tier: gold

Detach a context from its group. Fields: node, group_id, context_id.

- type: detach_context_from_group
node: calimero-node-1
group_id: '{{group_id}}'
context_id: '{{context_id}}'

Diagnostic: trigger governance sync for a group. Fields: node, group_id.

- type: sync_group
node: calimero-node-2
group_id: '{{group_id}}'

Register a signing key for a group. Fields: node, group_id, signing_key (64 hex chars or a {{placeholder}}).

- type: register_group_signing_key
node: calimero-node-1
group_id: '{{group_id}}'
signing_key: '{{admin_signing_key}}'

delete_group and delete_namespace delete a group / namespace. Each takes node and the id (group_id / namespace_id). The node resolves the acting identity from the authenticated session.

- type: delete_namespace
node: calimero-node-1
namespace_id: '{{namespace_id}}'

These older, context-centric step names still validate but map to namespace invitation flows. Prefer the namespace steps above and the open-invitation steps below in new workflows.

Step Maps to Fields
invite, invite_identity namespace invitation node, namespace_id (or group_id), recursive?
join join namespace node, namespace_id (or group_id), invitation
invite_open open invitation (see below) node, context_id, granter_id, valid_for_blocks?
join_open join via open invitation node, namespace_id (or group_id), invitation
create_group create_namespace see create_namespace
create_group_invitation create_namespace_invitation see above
join_group join_namespace see above

Create an open invitation anyone can use to join a context.

- type: invite_open
node: calimero-node-1
context_id: '{{context_id}}'
granter_id: '{{admin_pub}}'
valid_for_blocks: 2000
outputs:
invitation: invitation

Join a context via an open invitation.

- type: join_open
node: calimero-node-2
invitee_id: '{{new_member_pub}}'
invitation: '{{invitation}}'

One person, many devices. An account is the principal a grant names; a device is a key that signs for it. So one account grant covers every device its holder enrols, and revoking a device withdraws that one key without touching the account.

An ordering constraint worth knowing before writing a scenario: a device link travels as an encrypted group operation, so account_create must run after the node has joined the context and holds the scope key. Placed before the join it deadlocks.

A worked scenario across all of these lives in workflow-examples/account-identity.yml.

Enrol a device for a fresh account in a namespace, publishing the device link. Exports the genesis halves a second device needs — pass them to account_pair.

Field Required Type Meaning
node yes string Node that enrols the account.
namespace_id yes string Namespace to enrol in.

Outputs: accountId, deviceId, accountRootKey, accountNonce.

- type: account_create
node: calimero-node-2
namespace_id: '{{namespace_id}}'
outputs:
account: accountId
root_key: accountRootKey
nonce: accountNonce

Pair a second node onto an account that already exists elsewhere, running both halves of the exchange. One step rather than two because the ordering is forced: the new device cannot mint its DeviceId until it knows the account (the id is H(account ‖ nonce)), and the holder cannot certify that device until it knows the id and both of its keys.

The step also compares the confirmation code both sides derive and fails on a mismatch — that comparison is the security promise of pairing, and forwarding the code unchecked would make this step the very “pasted alongside the keys it describes” channel the code exists to defeat.

The paired node needs no membership of its own: being a device of a member’s account is its whole route in.

Field Required Type Meaning
node yes string The new device’s node.
holder yes string Node that already holds the account root.
namespace_id yes string Namespace the account lives in.
root_key yes string Genesis root key, from account_create.
nonce yes string Genesis nonce, from account_create.

Outputs: accountId, deviceId, keyDelivered.

- type: account_pair
node: calimero-node-3 # the new device
holder: calimero-node-2 # holds the account root
namespace_id: '{{namespace_id}}'
root_key: '{{root_key}}'
nonce: '{{nonce}}'
outputs:
paired_device: deviceId

Withdraw a device from its account and rotate the scope key, so the revoked device can neither write nor read past the rotation. Run on a node with the authority — an admin, or the account itself. A device cannot revoke itself.

keyRotated is exported so a scenario asserts the rotation happened rather than inferring it from a later read that might pass for other reasons.

Field Required Type Meaning
node yes string Admin, or the account itself.
namespace_id yes string Namespace the device is bound in.
device_id yes string Device to withdraw.

Outputs: keyRotated.

- type: account_revoke
node: calimero-node-1
namespace_id: '{{namespace_id}}'
device_id: '{{paired_device}}'
outputs:
rotated: keyRotated

Report which account a node speaks for in a namespace, and the device it holds there. A read: it mints nothing, and the account id exists whether or not a device was ever enrolled, because it is derived from the node’s root and the namespace.

deviceId comes back null when the node holds no device there. That is a real answer rather than missing data, and it is what makes this step usable for asserting a revocation stuck.

Field Required Type Meaning
node yes string Node to ask.
namespace_id yes string Namespace to report on.

Outputs: accountId, deviceId.

# One account, two devices — the whole point of the account plane.
- type: account_show
node: calimero-node-2
namespace_id: '{{namespace_id}}'
outputs:
laptop: accountId
- type: account_show
node: calimero-node-3
namespace_id: '{{namespace_id}}'
outputs:
phone: accountId
- type: json_assert
statements:
- 'json_equal({{laptop}}, {{phone}})'

Run an offline merod subcommand against a stopped node’s data directory, in a one-shot container from the node’s own image.

Some operations are CLI-only and cannot run against a live node: merod account export|import opens the datastore directly and RocksDB’s lock is exclusive. That rules out both obvious routes — docker exec needs a running container, and the admin api does not expose the recovery key (serving a secret whose point is to live offline over HTTP would be the wrong shape). What works is that the data directory is a host bind mount and the image’s entrypoint is merod itself.

The image and mount are read off the existing container rather than reconstructed from workflow config, so the step cannot disagree with how the node was actually started. A running node is refused unless allow_running says otherwise — attempting it yields an opaque lock error several layers down, which reads like a node fault.

Field Required Type Meaning
node yes string Node whose data directory to run against.
args yes list[string] Subcommand and flags, e.g. ['account', 'export'].
files no map Container path → contents, written into the mount before running. Paths outside /app/data are refused.
allow_running no bool Run even if the node is up. Unsafe for anything that opens the datastore.
expected_failure no bool Assert the command is refused rather than succeeding.

Outputs: stdout, stdout_first_line (the value a single-value command emits, ahead of its advisory output), stderr, exit_code.

- type: stop_node
node: calimero-node-2
- type: node_exec
node: calimero-node-2
args: ['account', 'export']
outputs:
phrase: stdout_first_line
# `files:` is how a command that reads a file gets its input — no stdin plumbing.
# Importing over an existing root is refused, and that refusal is the guard worth
# asserting: a root that already certified devices has no second copy.
- type: node_exec
node: calimero-node-2
files:
/app/data/recovery.txt: '{{phrase}}'
args: ['account', 'import', '--from', '/app/data/recovery.txt']
expected_failure: true
- type: start_node
node: calimero-node-2

A member that holds no node can still author its own writes: it signs a warrant naming one method, one operator and one nonce; a relay runs the method; the result is attributed to the member. This is the case a thin client is in — a device with only a signing key can neither run the application (the runtime is a JIT) nor decrypt the state (it never received a scope key).

Three things a scenario must do first, and none is implied by the others:

  1. Add the author’s ACCOUNT as a member of the group owning the context. Its device joins nothing — it is in no group’s binding rows and never will be, so a key-based membership check would refuse every delegated write.
  2. Grant the relay CAN_AUTHOR_ON_BEHALF on that group. Not implied by membership, and not implied by admin. Without it the intent is refused before anything executes.
  3. Mint a warrant with sign_warrant, then spend it with perform_intent.

A warrant is single-use. Re-presenting a spent one is refused, and that is the point of the nonce ledger rather than a limitation: the signature stays valid forever, so replay is not forgery and the envelope check cannot be what stops it.

Sign a member’s consent for one delegated write. Contacts nothing — no node field, and that is deliberate rather than an omission: the key that signs a warrant must never reach the node that runs the request, or that node could forge writes in the member’s name.

The key material comes from the scenario. merobox cannot mint it and should not: account_pair binds a device to a node and never hands the secret out. Supply either a fixed test credential or one minted out-of-band with merod account sign-cert.

Note the encodings, which are core’s and are not interchangeable: context_id is base58, executor is hex. The author’s account is read out of credential rather than configured, so it cannot disagree with the key that signs.

Field Required Type Meaning
context_id yes string Context the intent runs in, base58.
executor yes string Account allowed to spend it, hex — the relay’s, from node_identity.
method yes string Method the warrant authorises.
args no mapping Arguments it commits to. Placeholders resolve.
device_secret yes string The author’s device signing secret, 64 hex chars. Never sent.
credential yes string Hex device credential proving that key belongs to the author’s account.
nonce no int Monotonic per author device. Default 1.
valid_for no int Seconds the relay will still spend it for. Default 300.

Outputs: warrant, authorAccount, authorDeviceKey, intentHash, notAfter.

Only H(method, args) is committed to, never the plaintext — the envelope this rides in is readable by anything subscribed to the context’s topic, so the method and its arguments stay sealed and only their hash travels in the clear.

- type: sign_warrant
context_id: '{{context_id}}'
executor: '{{identity_account_id_calimero-node-1}}'
method: set
args:
key: delegated
value: from-a-member-with-no-node
device_secret: '{{author_secret}}'
credential: '{{author_credential}}'
nonce: 1
outputs:
warrant: warrant
author: authorAccount

Have a node run one method on a member’s behalf. The relay executes and signs the envelope with its own key; the change is attributed to the author, and both halves travel together so every peer re-checks that the member consented rather than taking the relay’s word.

Only the author’s half is sent. The node attaches its own credential, so a scenario never learns which of the node’s processes runs the intent, and a re-key on its side does not void a warrant already minted.

rootHash is exported because it is the node’s own claim that the run changed something. Assert on it rather than on the status: an accepted intent that advanced no state is a real failure mode, and it is far cheaper to catch here than several steps later on a value that was never written.

Field Required Type Meaning
node yes string The relay: the node that runs the method.
context_id yes string Context to run in.
method yes string Method to run — must match the warrant.
args no mapping Arguments — must match what the warrant committed to.
warrant yes string Hex warrant, from a sign_warrant step.
author_proof yes string The author’s hex device credential, the same one the warrant was signed against.

Outputs: rootHash, returns.

- type: perform_intent
node: calimero-node-1
context_id: '{{context_id}}'
method: set
args:
key: delegated
value: from-a-member-with-no-node
warrant: '{{warrant}}'
author_proof: '{{author_credential}}'
outputs:
root: rootHash

Both steps need a calimero-client-py carrying the sign_warrant and perform_intent bindings, and a node whose core has the warrant types.

Invoke a method on an application in a context (JSON-RPC).

Field Required Type Meaning
node yes string Target node.
context_id yes string Context to call in.
method yes string Method name.
args no map Method arguments (dynamic values resolved recursively).
executor_public_key no string Identity to execute as.
expected_failure no bool (false) Pass only if the call is rejected.
unauthenticated no bool (false) Force a no-token request (negative auth test).
- type: call
node: calimero-node-1
context_id: '{{context_id}}'
method: set
args:
key: hello
value: world
executor_public_key: '{{member_key}}'
outputs:
set_result: result

Negative test — assert an unauthenticated call is rejected with 401:

- type: call
node: calimero-node-1
context_id: '{{context_id}}'
method: get
args: { key: hello }
unauthenticated: true
expected_failure: true

Authenticate against a node’s embedded auth (auth_mode: embedded) and seed its token cache so later call/ws_connect steps on that node are authenticated.

Field Required Type Meaning
node yes string Node to authenticate against.
username yes string Username (public key).
password yes string Password.
bootstrap_secret no string Out-of-band secret to mint the first root key (defaults from MERO_AUTH_BOOTSTRAP_SECRET).
expected_failure no bool (false) Pass only if authentication is rejected.
- type: login
node: calimero-node-1
username: alice
password: password123
outputs:
access_token: access_token
refresh_token: refresh_token

Refresh a node’s cached access token (POST /auth/refresh). Fields: node, expected_failure?.

- type: refresh
node: calimero-node-1
outputs:
access_token: access_token

Open a WebSocket subscription (/ws), attaching the cached JWT. Alias: ws_subscribe.

Field Required Type Meaning
node yes string Node to connect to.
unauthenticated no bool (false) Connect without a token (negative test).
expected_failure no bool (false) Pass only if the handshake is rejected.
token no string Explicit JWT (overrides the cached token).
message no string Text frame to send once connected.
timeout no float Handshake timeout (seconds).
- type: ws_connect # uses the token seeded by `login`
node: calimero-node-1
- type: ws_connect
node: calimero-node-1
unauthenticated: true
expected_failure: true # no token -> handshake rejected

Upgrade a group to a target application version.

Field Required Type Meaning
node yes string Target node.
group_id yes string Group to upgrade.
target_application_id yes string Application to upgrade to.
cascade no bool (false) Dispatch as a namespace cascade instead of a per-group upgrade.
- type: upgrade_group
node: calimero-node-1
group_id: '{{group_id}}'
target_application_id: '{{new_app_id}}'

Cascade an application to every descendant of a namespace. Fields: node, namespace_id, target_application_id.

- type: cascade_namespace_application
node: calimero-node-1
namespace_id: '{{namespace_id}}'
target_application_id: '{{new_app_id}}'

Poll until a namespace cascade finishes, or fail.

Field Required Type Meaning
node yes string Target node.
namespace_id yes string Namespace whose cascade must complete.
timeout_seconds no number (30) Max seconds to poll.
poll_interval no number (2.0) Seconds between polls.
- type: assert_cascade_complete
node: calimero-node-1
namespace_id: '{{namespace_id}}'
timeout_seconds: 60

Poll until a namespace migration finishes, or fail. Same fields as assert_cascade_complete (node, namespace_id, timeout_seconds?, poll_interval?).

- type: assert_migration_complete
node: calimero-node-1
namespace_id: '{{namespace_id}}'
Step Fields Reads / does
get_group_upgrade_status node, group_id Per-group upgrade status.
get_cascade_status node, namespace_id Cascade subtree status.
get_migration_status node, namespace_id Migration rollup.
abort_migration node, namespace_id Abort an in-flight migration.
retry_group_upgrade node, group_id Retry a failed group upgrade.
resync_context node, context_id, force? Resync a context from a peer (force: true discards local heads).
- type: resync_context
node: calimero-node-2
context_id: '{{context_id}}'
force: true

get_migration_status and assert_migration_complete flatten the response into target_version, expected_members, fleet_completed_at, cohort_pinned_at_hlc, the rollup counters (total, migrated, in_progress, unknown, failed, all_migrated, members_pending_signature), and one members row per cohort member carrying its whole report (schema_version, residue_auto, synced_up_to_hlc, reported_at, authored_remaining, migration_failed).

Any of those the node did not send is left out of the summary rather than offered as null, so capturing it fails the step naming the field. A node that has not converged omits fleet_completed_at, and a member that has not reported omits its whole report. A field the node genuinely sends as null still binds, and the rollup counters are computed rather than passed through, so they are always present.

Alongside those, merobox recomputes these aggregates from the raw member reports rather than reading core’s rollup, so an assertion on them can falsify the rollup rather than confirm it against itself:

Output Meaning
reported_at_target Members whose reported ABI state version is at or above target_version.
reported_below_target Members that reported a version below the target.
reported_missing Members that reported no usable state version at all.
min_reported_schema_version Lowest reported state version, or None.
residue_total Sum of residue_auto across members.
failure_reasons Sorted list of the members’ migration_failed reasons.
stuck_members Sorted peers behind those reasons.

report.schema_version is a u32 ABI state version, unlike an application’s own schema_info, which is a semver string compiled into the binary and reports which WASM is loaded rather than whether the state was migrated. Assert reported_below_target to check the latter.

The two target-relative counters are None when the response carries no target_version (a namespace with no upgrade record): defaulting the target to 0 would classify every member as at-target and report green for a namespace that never migrated.


Read proposals in a context. Each takes node and context_id; proposal-specific steps also take proposal_id.

Step Fields
list_proposals node, context_id
get_proposal node, context_id, proposal_id
get_proposal_approvers node, context_id, proposal_id
- type: get_proposal
node: calimero-node-1
context_id: '{{context_id}}'
proposal_id: '{{proposal_id}}'

Upload a file to a node’s blob store. Fields: node, file_path, optional context_id.

- type: upload_blob
node: calimero-node-1
file_path: ./data/input.json
context_id: '{{context_id}}'
outputs:
blob_id: blobId

Delete a blob via the admin API (cascades chunked blobs).

Field Required Type Meaning
node yes string Target node.
blob_id yes string Base58 (parent) blob id.
missing_ok no bool (true) Treat an already-absent blob as success.
- type: delete_blob
node: calimero-node-1
blob_id: '{{blob_id}}'

Delete a blob directly from a container’s on-disk blob store (for corruption / backfill tests).

Field Required Type Meaning
node yes string Target node (container name).
blob_id yes string Base58 blob id.
data_dir no string (/app/data) CALIMERO_HOME in the container.
blobs_subdir no string (blobs) Blob-store subdir under <data_dir>/<node>.
missing_ok no bool (true) Treat a node that never held the blob as success.
- type: delete_blob_on_disk
node: calimero-node-1
blob_id: '{{blob_id}}'

Pause for a fixed duration. Fields: seconds (int ≥ 0), optional message.

- type: wait
seconds: 5
message: Waiting for gossip to propagate

Poll until state converges across nodes. At least one of context_id / group_id is required.

Field Required Type Meaning
nodes yes list Nodes to wait for.
context_id one of context_id/group_id string Wait for contextStateHash to converge.
group_id one of context_id/group_id string Wait for groupStateHash to converge.
timeout no int (60) Timeout in seconds.
check_interval no float (2) Steady-state polling cap (backoff ceiling).
initial_check_interval no float Starting sleep for adaptive backoff (default ~0.05s).
backoff_factor no float Geometric growth per missed check (default 2.0).
trigger_sync no bool (false) Trigger a sync before waiting.
- type: wait_for_sync
context_id: '{{context_id}}'
nodes:
- calimero-node-1
- calimero-node-2
timeout: 60
trigger_sync: true

Run nested steps N times. Iteration counters (iteration, iteration_index, total_iterations, …) are exported automatically for use in nested steps and can be renamed via outputs.

Field Required Type Meaning
count yes int ≥ 1 Number of iterations.
steps yes list Steps to repeat.
- type: repeat
count: 3
outputs:
current_iteration: iteration # rename the auto-exported counter
steps:
- type: call
node: calimero-node-1
context_id: '{{context_id}}'
method: set
args:
key: 'key_{{current_iteration}}'
value: 'value_{{current_iteration}}'

Run several groups of steps concurrently. Steps within a group still run in order; the groups run in parallel.

Field Required Type Meaning
groups yes list of {name?, steps} Groups to run concurrently.
failure_mode no fail-slow|fail-fast|continue-on-error (fail-slow) How group failures are handled.
mode no burst|sustained|mixed (burst) Group start scheduling.
- type: parallel
failure_mode: fail-fast
groups:
- name: writer
steps:
- type: call
node: calimero-node-1
context_id: '{{context_id}}'
method: set
args: { key: a, value: '1' }
- name: reader
steps:
- type: call
node: calimero-node-2
context_id: '{{context_id}}'
method: get
args: { key: a }

Run a shell script.

Field Required Type Meaning
script yes string Path to the script.
target no image|nodes|local (image) Where to run: inside the image build, on running nodes, or on the host.
description no string Human label.
- type: script
description: Assert container state
script: ./workflow-examples/scripts/assert-container-state.sh
target: local

Stop or (re)start local nodes mid-workflow. nodes accepts a single name or a list. start_node uses the node’s config from the workflow’s nodes: section.

Step Fields
stop_node nodes
start_node nodes, wait_for_ready? (bool, true), wait_timeout? (int, 30)
- type: stop_node
nodes: calimero-node-1
- type: start_node
nodes:
- calimero-node-1
wait_for_ready: true
wait_timeout: 30

pause_container / unpause_container / restart_container

Section titled “pause_container / unpause_container / restart_container”

Docker-level container control. Each takes container. restart_container also accepts wait_healthy (bool, true) and timeout (int).

- type: pause_container
container: calimero-node-1
- type: restart_container
container: calimero-node-1
wait_healthy: true

Disconnect a node from (or reconnect it to) a Docker network. Fields: node, optional network (defaults to the container’s attached network).

- type: disconnect_node
node: calimero-node-1
- type: connect_node
node: calimero-node-1

Cut (or restore) libp2p traffic between a node and specific peers while keeping RPC reachable. Linux + iptables + passwordless sudo only.

Field Required Type Meaning
node yes string Container to isolate / reconnect.
peers yes list (≥ 1) Peer containers to cut / restore.
- type: partition_peers
node: calimero-node-1
peers:
- calimero-node-2
- type: heal_peers
node: calimero-node-1
peers:
- calimero-node-2

Apply a tc-based network fault to a container for a duration.

Field Required Type Meaning
container yes string Target container.
fault yes loss|delay Fault type.
duration yes int ≥ 1 How long to hold the fault (seconds).
percent required for loss float (0, 100] Packet-loss percent.
ms required for delay int ≥ 1 Added delay in ms.
interface no string (eth0) Container network interface.
- type: inject_network_fault
container: calimero-node-1
fault: loss
percent: 20
duration: 10

Evaluate boolean statements. Helpers include is_set(...), contains(a, b), and equality (==). Statements may be strings or {statement, message} mappings.

Field Required Type Meaning
statements yes list Assertion statements.
non_blocking no bool Continue the workflow on failure instead of aborting.
- type: assert
statements:
- 'is_set({{set_result}})'
- "contains({{get_result}}, 'world')"

Assert on JSON structure with json_equal(...) and json_subset(...). Field: statements.

- type: json_assert
statements:
- 'json_subset({{user_data}}, {"email": "user@example.com"})'

Scan node logs for patterns. assert_log_present requires each pattern to hit at least min_matches times; assert_log_absent fails if any pattern appears.

Field Required Type Meaning
nodes yes list Nodes to scan ([] = all running nodes).
patterns yes list Patterns to match.
regex no bool (false) Treat patterns as Python regex.
tail_lines no int Only scan the last N lines per node.
case_sensitive no bool (true) Case-sensitive matching.
min_matches present only int (1) Required hits per pattern (assert_log_present only).
- type: assert_log_present
nodes: [calimero-node-1]
patterns:
- 'context synced'
- type: assert_log_absent
nodes: [] # all running nodes
patterns:
- 'panicked'

GET a path on a node’s admin API and assert against the JSON the node actually sent. Every other step reads its response through calimero-client-py, whose compiled DTOs silently drop any key the pinned core build does not know about, so a field added to core is unassertable until a client release lands. This step never hands the body to a typed deserializer.

Field Required Type Meaning
node yes string Node whose admin API is queried.
path yes string Admin-API path, e.g. /admin-api/health.
match one of map Dotted paths mapped to expected values.
present one of list Dotted paths that must exist, whatever their value.
absent one of list Dotted paths that must not exist.
token no string Explicit JWT (otherwise the token login cached for the node).

At least one of match / present / absent is required, so a step can never silently assert nothing. Paths address the body verbatim: the admin API serializes its payload directly, camelCase, with no envelope, so a field reads fleetCompletedAt.

- type: assert_api_response
node: calimero-node-1
path: '/admin-api/groups/{{namespace_id}}/migration-status'
match:
rollup.allMigrated: true
present:
- fleetCompletedAt

Core marks optional fields skip_serializing_if = "Option::is_none", so a key being absent is meaningful and distinct from present-and-null. The three assertions separate the two states: present accepts a null value, absent rejects one, and match: {path: null} demands the key be there and null - an absent key fails it and is reported as absent, not as null.

# A namespace whose fleet never converged must not emit the stamp at all.
- type: assert_api_response
node: calimero-node-1
path: '/admin-api/groups/{{namespace_id}}/migration-status'
absent:
- fleetCompletedAt

A failure prints the per-path verdict and then the whole response body, so CI output alone tells “the field is missing” from “the field is wrong”. The step issues one request and does not poll; wrap it in repeat or precede it with wait_for_sync when the assertion has to wait for state to settle. A non-2xx status fails the step and works with expected_failure / expected_error:

- type: assert_api_response
node: calimero-node-1
path: '/admin-api/namespaces/deadbeef/migration-status'
expected_failure: true
expected_error: 'HTTP 404'

Both govern the request outcome only, never the assertion verdicts. An assertion that misses always fails the step: letting a miss satisfy expected_failure would turn a typo in a path into a green negative test.


Create a context and wire multiple nodes into it via group membership in one step.

Field Required Type Meaning
context_node yes string Node to create the context on.
application_id yes string Application to instantiate.
nodes yes list Nodes to join into the mesh (must include one ≠ context_node).
params no string Init params as a JSON string.

outputs: captures from the context response, plus namespaceId folded in:

Source Meaning
contextId The context the mesh was built around.
memberPublicKey The context node’s member key.
namespaceId The namespace the context lives under - needed by create_group_in_namespace.

The per-node ids the joins produce are set directly, since outputs: sees one response and there is one join per node:

Variable Meaning
{{namespace_id}} Same namespace id, for workflows not using outputs:.
{{member_account_<node>}} Account the node’s key speaks for - what list_group_members returns and what every member-addressing step takes.
{{member_identity_<node>}} The node’s member public key.
- type: create_mesh
context_node: calimero-node-1
application_id: '{{app_id}}'
nodes:
- calimero-node-2
- calimero-node-3
outputs:
context_id: contextId
member_public_key: memberPublicKey
namespace_id: namespaceId
- type: create_group_in_namespace
node: calimero-node-1
namespace_id: '{{namespace_id}}'
group_name: private-channel
outputs:
subgroup_id: groupId
- type: update_member_role
node: calimero-node-1
group_id: '{{subgroup_id}}'
member_id: '{{member_account_calimero-node-2}}'
role: Member

Run long randomized load tests with weighted operation patterns. See fuzzy load testing for the full model.

Field Required Type Meaning
duration_minutes yes number > 0 How long to run.
context_id yes string Context to hammer.
nodes yes list of {name, executor_key} Nodes and their executor keys.
operations yes list of {name, weight, steps} Weighted operation patterns.

Random generators ({{random_int(min, max)}}, {{uuid}}, {{timestamp}}, {{random_node}}, …) and auto-captured args ({{fuzzy_key}}, {{fuzzy_value}}) are available inside operation steps.

- type: fuzzy_test
duration_minutes: 30
context_id: '{{context_id}}'
nodes:
- name: calimero-node-1
executor_key: '{{key1}}'
operations:
- name: write_and_read
weight: 70
steps:
- type: call
node: '{{random_node}}'
method: set
context_id: '{{context_id}}'
args:
key: 'k_{{random_int(1, 1000)}}'
value: 'v_{{uuid}}'
- type: assert
non_blocking: true
statements:
- 'is_set({{result}})'

These drive a local mock-TEE fleet against a node’s admin API. They require a merod built with the mock-attestation feature and nodes booted with mock_tee: true.

Set a namespace-root TEE admission policy. Fields: node, group_id, optional accept_mock (bool, true).

- type: set_tee_admission_policy
node: tee-owner
group_id: '{{namespace_id}}'
accept_mock: true

Run a mock-TEE fleet join against a namespace root. Fields: node, group_id. Blocks server-side for one admission window.

- type: tee_fleet_join
node: tee-replica
group_id: '{{namespace_id}}'

Assert presence / absence of an account in a group’s member list. Fields: node, group_id, account.

Takes the account, not the signing key: membership is recorded against the account a key speaks for, so the listing reports accounts and a key matches nothing. tee_fleet_join reports both — capture account.

- type: assert_tee_member
node: tee-owner
group_id: '{{namespace_id}}'
account: '{{replica_account}}'