Skip to content

Declarative bulk operations

The apply command takes an NDJSON file describing a batch of upsert, reconcile, update, create, and destroy operations, and applies them to the server in dependency-aware order. It is intended as the integration surface for infrastructure-as-code tooling (Ansible, Terraform, NixOS, Pulumi, …) and for one-shot deployments / migrations performed by hand or by CI.

The primary operation is upsert: it matches each object in the plan against the live server (by a natural key such as a domain’s name) and either updates the existing object in place or creates it if absent. A plan built from upsert operations is idempotent: applying it once or many times converges to the same server state, with no deletions and no duplicate objects. This is the operation snapshot emits, and the shape every infrastructure-as-code integration below uses.

reconcile extends upsert with deletion: it does everything upsert does and additionally destroys every existing object of the type that the plan did not match, converging the type to exactly the set described in the plan. Where upsert only ever adds or updates, reconcile also removes, so it closes the one gap upsert leaves open (a renamed or dropped object is not left behind). See Reconciling to exact state.

For interactive use cases see the per-command pages: Creating objects, Updating objects, Removing objects.

stalwart-cli apply ( --file <path> | --stdin )
[--dry-run]
[--continue-on-error]
[--quiet]
[--json]
[--progress]
OptionEffect
--file <path>Read the plan from an NDJSON file.
--stdinRead the plan from standard input (NDJSON).
--dry-runParse and validate the plan, then print it; no requests are sent.
--continue-on-errorDo not abort on the first failed operation; report all errors at the end and exit non-zero.
--quietSuppress per-operation log lines; print only the final summary.
--jsonEmit one NDJSON record per completed operation to stdout, plus a summary record at the end. The plan header and any progress lines remain on stderr.
--progressPrint one extra line per request batch during large destroy, create, upsert, and reconcile operations.

Exactly one of --file and --stdin must be supplied.

The plan is NDJSON: one operation per line, no enclosing array. Blank lines are ignored; surrounding whitespace on a line is tolerated. Each operation is one of five types: upsert, reconcile, update, create, or destroy. The CLI processes the plan in three passes:

  1. Destroy pass (reverse order). Every destroy operation is executed in reverse of its position in the plan. All other operations are skipped during this pass. This is what makes dependency-aware teardowns possible: when a plan lists Domain, then Account, then DkimSignature (parents first), the reverse pass tears them down in DkimSignature, Account, Domain order, satisfying foreign-key constraints. Most plans contain no destroys at all; the pass is a no-op for them. See Teardown with destroy.

  2. Apply pass (plan order). All upsert, reconcile, update, and create operations run in the order they appear in the plan. Destroys are skipped during this pass, and the delete half of each reconcile is deferred to pass 3. This ordering is what allows objects to reference each other: a Domain can be upserted in operation 1 and then referenced by an Account in operation 2.

  3. Reconcile cleanup pass (reverse order). The objects each reconcile marked for deletion (existing objects the plan did not match) are destroyed now, with the reconciles processed in reverse of their plan position. Deferring the deletes until after the whole apply pass, and running them children-first, means a dependent is repointed or removed before the object it referenced is deleted, so a leaked parent is not blocked by a leaked child. A plan with no reconcile operations skips this pass.

The three passes let a single plan file describe a teardown followed by a rebuild, and let a reconcile add, update, and delete in one declarative sweep. For the common non-destructive case (declaring desired state with upsert and re-applying it) the plan is a flat list of upsert and singleton update operations and only the second pass does any work.

If any operation fails (HTTP error, JMAP method-level error, or any per-object SetError such as validationFailed or objectIsLinked), the CLI prints the error and exits non-zero. The remaining operations are not attempted.

This default is chosen deliberately for IaC contexts: a partially-applied plan is usually worse than no apply at all, and a failed step almost always indicates a bug in the plan or a server-side conflict that needs investigation before proceeding.

To override, pass --continue-on-error, in which case every operation is attempted and the final summary reports the count of successes and failures. The exit code is still non-zero when at least one failed.

Use upsert operations to make a plan re-runnable. An upsert matches each object in its value map against the live server using a match key (see Matching); if a match is found the object is updated in place, otherwise it is created. Because nothing is destroyed and an existing object is never duplicated, applying the same upsert plan once or a hundred times converges to the same server state. This is the shape stalwart-cli snapshot emits, and the shape used in the annotated example below.

This sidesteps the problems that a create-only or destroy + create plan has:

  • A create-only plan succeeds on the first run and fails on the second: re-creating the same Domain, AllowedIp, Certificate, etc. trips the server’s primary-key constraints, and re-creating types that have no such constraint produces duplicate rows.
  • A destroy + create plan can be re-run, but it tears down and rebuilds the world on every apply. That cannot work for an object another object depends on: a Domain referenced by an Account, a DkimSignature, or the non-nullable SystemSettings.defaultDomainId cannot be destroyed while the reference exists (the destroy fails with objectIsLinked). upsert reconciles such an object in place instead, leaving its id and its dependents untouched.

apply does not diff the desired value against the current one. An upsert whose match key finds an existing object always issues the update, even when no field changed; the run reports it as updated and the resulting state is identical. Idempotency here means the end state converges, not that an unchanged re-run is a no-op.

A practical consequence worth noting: an upsert preserves the server-assigned id of a matched object, so external systems that cache Stalwart ids keep working across re-applies (unlike a destroy + create, which assigns fresh ids each time).

The match key for an upsert is resolved in this order:

  1. Explicit matchOn. If the operation carries a matchOn list of property names, those properties are the key. An object in the value map matches an existing object when every listed property is equal. Every property in matchOn must be present in each object body, or the operation errors.
  2. The schema’s label property. If matchOn is omitted, the CLI uses the type’s label property (the field the WebUI lists objects by, for example name on Domain). snapshot writes this matchOn explicitly so plans are self-describing.
  3. Value match. If the type has no label property and no matchOn was given, the CLI matches by comparing every non-secret scalar field (string, number, boolean, enum, datetime) of the body against existing objects. A warning is printed, because this match is not convergent: changing any compared field means the body no longer matches the old object, so a new object is created instead of the old one being updated. For any type you re-apply, supply an explicit matchOn (or rely on a label property) rather than value matching. reconcile never selects this mode on its own, since a by-value non-match would delete the old object; request it there with "matchOn": "*", which also overrides the label property on types that have one.

A property the body sets to null compares equal to one the server omits, and vice versa. Under value matching and scope comparison a property the body omits entirely also compares equal to a server null; under an explicit matchOn a body cannot omit a match property at all, since that is an error. Stalwart returns unset nullable fields as explicit nulls, so without this an unchanged plan would read as drifted on re-apply.

If the match key matches more than one existing object the operation errors as ambiguous, rather than guessing which one to update. This holds for value matching too: taking the first of several identical candidates would mean reconcile destroys the others. For multi-variant types, each body must carry its @type, and matching is done within that variant.

A matchOn may include a reference field. When a match-key property is a reference (an ObjectId, such as Account.domainId) and its value in the body is a #<id> reference, the CLI resolves that reference to the real server id before comparing it against existing objects. This makes the effective primary key expressible: matching an Account on ["name", "domainId"] where domainId is #dom-a compares against the domain’s actual id, whether it was created earlier in the plan or matched from the live server. A #-shaped value in a non-reference field is treated as a literal, not a reference. A #<id> in a match key that no earlier operation produced is a hard error (caught upfront, so --dry-run reports it too).

When an upsert updates a matched object, server-set and immutable fields in the body are dropped from the patch (the server would reject them); only mutable fields are sent.

destroy remains available for the cases where deletion is the intent: removing objects a plan no longer owns, or wiping a type before a migration. It is not part of the idempotent re-run flow above. See the destroy reference and Operational guidance.

  • Server-assigned ids change across a destroy-and-recreate, so external systems that cache Stalwart ids must look them up again afterwards.
  • Destroy filters scope the teardown. {"@type":"destroy","object":"Domain","value":{"name":"example.com"}} only removes the named domain. {"@type":"destroy","object":"Domain"} (no value) removes every domain on the server. Choose the filter to match the slice of state the plan owns; an unfiltered destroy in a plan that only declares one domain will silently delete every other domain on the server.

upsert never deletes, so it cannot express “these are the only objects of this type that should exist”. Re-applying an upsert plan after removing an entry leaves the old object in place: if you rename a domain from example.org to example.net and re-apply, you end up with both. reconcile closes that gap. It performs the same match-update-or-create as upsert, then destroys every existing object of the type that no entry in the plan matched. The result is that the type converges to exactly the plan:

{"@type":"reconcile","object":"Domain","matchOn":["name"],"value":{"dom-a":{"name":"example.net"}}}

Applying this leaves example.net as the only domain: it is created (or matched and updated) if present in the plan, and every other domain is destroyed. Re-applying is idempotent. An empty value ("value":{}) is therefore valid for reconcile (it means “this type should have no objects”), whereas upsert rejects it.

Because reconcile deletes, a few rules keep it safe:

  • A match key is required by default. reconcile does not silently fall back to the by-value matching that upsert allows: an operation with no matchOn on a type that also has no label property is rejected at plan time. Value matching treats any drifted field as a non-match, which under reconcile means deleting the existing object and recreating it under a new id. Pass "matchOn": "*" to request value matching explicitly. Be aware of what it costs: on a type with a unique constraint, the recreate collides with the old object (whose deletion is deferred to the cleanup pass) and the operation fails with primaryKeyViolation. Value matching is dependable for re-applying an unchanged plan, not for absorbing drift; an explicit matchOn is almost always the better answer.
  • A reconcile covers the whole type unless you scope it. By default every unmatched object of the type is deleted, including variants the plan does not mention. Add a scope to narrow what the operation owns: a flat map of property/value pairs an object must match (all of them) to be part of the operation at all.
  • Renames are delete-and-recreate. The match key is the object’s identity. Changing the value of a match-key property means the old object no longer matches any entry, so it is deleted and a new object is created with a fresh server id. If you need to preserve the id across a rename, key on a stable property rather than the one being renamed.
  • A blocked delete fails the run. If an object the plan wants to delete is still referenced elsewhere (for example a Domain pinned by an Account), the destroy fails with objectIsLinked and the operation is reported as failed, exactly as a destroy would. reconcile does not force-delete.

scope is what makes upsert and reconcile usable for a type you only partly own. It declares the slice of the type the operation is responsible for, and it governs both halves of that responsibility: an entry may only match an existing object inside the scope, and a reconcile only deletes unmatched objects inside the scope. Everything outside is not the operation’s business, so it is neither matched nor deleted.

That is why scope is meaningful on upsert too, which never deletes anything. Restricting what an entry may match stops an operation reaching across into a slice it does not own. MemoryLookupKey is a good example: its label property is key alone, so a plan with no matchOn matches on key across every namespace and can silently update a row belonging to someone else. A scope makes that impossible regardless of the match key.

It compares property values for equality only: there is no negation, set membership, or comparison operator, so ownership has to be expressible as “every object where these properties equal these values”.

The scope is evaluated client-side, against the objects the operation already fetched to resolve matches. That is deliberate, and unlike the server-side filter accepted by destroy: Stalwart rejects server-side filters on @type for several multi-variant types (MtaRoute, SpamTag, Tracer), which are exactly the types most in need of per-variant scoping. Evaluating locally makes @type work uniformly.

Converge only the Local routes and leave every Mx route alone:

{"@type":"reconcile","object":"MtaRoute","matchOn":["name"],"scope":{"@type":"Local"},"value":{"r-a":{"@type":"Local","name":"internal.example.com"}}}

Combined with an empty value, a scope expresses “this slice should be empty”:

{"@type":"reconcile","object":"MtaRoute","matchOn":["name"],"scope":{"@type":"Mx"},"value":{}}

A scope on several properties requires all of them to match, which is how you converge one namespace of a keyed lookup table without touching the others:

{"@type":"reconcile","object":"MemoryLookupKey","matchOn":["namespace","key"],"scope":{"namespace":"spam-traps"},"value":{"lk-a":{"namespace":"spam-traps","key":"foo@*","isGlobPattern":true},"lk-b":{"namespace":"spam-traps","key":"bar@*","isGlobPattern":true}}}

That single operation replaces the destroy + upsert pair the same intent used to require, and it is better behaved: the destroy half of that pair tore down every row in the namespace and rebuilt it, so unchanged rows got new server ids on every apply. reconcile updates the rows that matched in place and only deletes what the plan dropped.

Scope values on reference properties resolve #<id> references, so a scope can name an object an earlier operation produced ({"domainId": "#dom-a"}). It cannot name one the same operation creates, because the scope is resolved before that operation matches or creates anything; both --dry-run and the real run reject that. A scope on any operation other than upsert or reconcile is rejected rather than silently ignored, and so is any unrecognised key at the top level of an operation: a misspelled scope would otherwise be dropped silently and widen a reconcile to the whole type.

Plan-time checks stop a scope from failing quietly, because a scope that matches nothing looks exactly like a successful reconcile:

  • Keys and @type values are validated. A key must be @type or a property the type declares (in any of its variants), and an @type value must name a declared variant of a multi-variant type. A typo in either is an error, and so is an operator-form key borrowed from destroy: {"nameContains": "spam-"} is rejected rather than silently matching nothing. destroy’s filter and scope are not the same language.
  • null values are rejected. A comparison treats an absent property and a null one as equal, so {"description": null} would match every object that leaves the property unset, widening the scope instead of narrowing it. Scope on a property that is actually set instead.
  • Entries must be inside their own scope. An entry that contradicts the scope (an Mx body under "scope":{"@type":"Local"}) could never match an existing object, so it would be created again on every apply. It is rejected, and belongs in its own operation.

An entry that simply omits a scoped property is fine: it inherits it. The scope is filled into the entry before the duplicate-entry check, before matching and before creation, so an object created by a scoped operation lands inside that scope. A server-derived (ServerSet) property cannot be scoped on, because the create path strips it and the object would land outside the scope it was declared under. {"scope":{"namespace":"spam-traps"},"value":{"lk":{"key":"foo@*"}}} creates the key in spam-traps without repeating the namespace in every entry.

A reconcile’s deletions are computed while the operation runs but carried out in the cleanup pass, so two rules keep the deferred list honest. An object is dropped from it if any other operation in the plan created, matched, or updated it, in either order: a plan never deletes an object it also wrote. And an object already destroyed by an earlier reconcile in the same run is not destroyed twice.

reconcile is not emitted by snapshot, which stays non-destructive (upsert-only) so that restoring a snapshot never deletes objects the snapshot happened to omit. Author reconcile by hand when converging a type to an exact, plan-owned set is the intent.

The plan can express references between objects that have not been created yet by using the JMAP #<id> reference syntax. Two distinct mechanisms cooperate:

  • Refs in values ("domainId": "#dom-a"): the CLI never rewrites these. It collects them recursively from every string value and every object key, and forwards them to the server in the request-level createdIds map. The server resolves #dom-a to the real id assigned during the matching create. This works across separate JMAP requests, including across batches.

  • Refs as the id of an update ("id": "#dom-a"): JMAP does not resolve #-prefixed update keys server-side. The CLI resolves these client-side from the id map populated during earlier create and upsert operations. If the reference does not match any prior create or upsert, the CLI errors before sending the request.

An upsert or reconcile registers a client id (#dom-a) the same way a create does, whether the object was matched or freshly created, so later operations can reference it regardless of whether this run created it or reconciled an existing one. This is what lets a snapshot’s singleton update set "defaultDomainId": "#domain-b" and have it resolve to the already-present domain on a re-apply.

Refs work in:

  • String values, anywhere in the value tree ("domainId": "#dom-a").
  • Object keys (set-of-id and map-of-id forms): { "memberGroupIds": { "#grp-sales": true, "#grp-support": true } }.
  • The id field of an update operation (resolved client-side as described above).

Refs do not work for:

  • The id field of an update operation when no matching create or upsert exists in the same plan (the CLI surfaces a clear error in this case).

Because the CLI does not know the dependency graph in advance, plan authors are responsible for ordering operations correctly:

  • Upserts, reconciles, and creates must be ordered parents-first. A Domain upsert must appear before an Account upsert that references the domain via "domainId": "#...". This also covers references in a reconcile match key: the parent must be produced before the child op that keys on it. The delete half of a reconcile needs no ordering care, because it is deferred to the final children-first pass.
  • Updates must come after the upsert or create of the object they patch (or reference an existing server id directly). Singleton updates that reference other objects (for example SystemSettings pointing at defaultDomainId) belong at the end, after the objects they point to.
  • Destroys, when present, must be listed in the same order as the creates/upserts. The reverse pass will then take them down children-first, matching foreign-key constraints.

A common pitfall with teardowns: writing destroys in reverse-of-creates order (children-first) makes the apply re-reverse them at runtime to parents-first, which fails on objectIsLinked. The fix is to write destroys forwards (parents-first); the apply does the reversal.

The CLI splits large operations into batches sized by the server’s maxObjectsInSet (typically 500), so an upsert or create of 5,000 objects is sent in batches of 500. The first batch of a given object type sees only the previously-tracked createdIds. Each completed batch contributes its newly-assigned ids to the global tracker, so subsequent batches (and subsequent operations) can reference them.

To resolve matches, an upsert or reconcile first reads the existing objects of the type once (paginated by maxObjectsInGet) and caches them for the run, then issues the create and update batches. A reconcile reuses that same cached read to compute which existing objects were not matched, and destroys them in maxObjectsInSet batches during the cleanup pass.

For destroys, ids are first collected by paginating the corresponding query (anchor-based, in maxObjectsInGet increments), then destroyed in maxObjectsInSet batches.

A plan is NDJSON: every non-blank line is a JSON object describing one operation. Each operation has a discriminator @type field and an object field; the remaining fields depend on @type. Lines are processed in file order; blank lines and surrounding whitespace are ignored. There is no enclosing array.

A re-runnable plan: upsert the objects (parents first), then point the singleton at one of them. Applying it twice converges to the same state.

# Upsert objects, parents-first. matchOn names the natural key.
{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{"dom-a":{"name":"example.com","description":"Primary corporate domain"},"dom-b":{"name":"example.net"}}}
{"@type":"upsert","object":"Account","matchOn":["name"],"value":{"grp-sales":{"@type":"Group","name":"sales","domainId":"#dom-a"}}}
# Singleton update, last: it references a domain upserted above.
{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#dom-a"}}

(Annotation lines starting with # are shown above for clarity; the actual NDJSON parser does not accept comments. Every non-blank line must be a JSON object.)

On the first apply, no example.com/example.net domain exists, so both are created and #dom-a resolves to the new example.com. On the second apply, the matchOn: ["name"] key finds the existing domains and they are updated in place; #dom-a resolves to the same id, so defaultDomainId stays valid and nothing is duplicated or destroyed.

A complete obfuscated example plan is included with these docs at example-bulk-plan.ndjson.

A machine-readable schema for a single line of the plan format:

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Stalwart CLI bulk plan operation",
"oneOf": [
{ "$ref": "#/$defs/upsertOp" },
{ "$ref": "#/$defs/reconcileOp" },
{ "$ref": "#/$defs/createOp" },
{ "$ref": "#/$defs/updateOp" },
{ "$ref": "#/$defs/destroyOp" }
],
"$defs": {
"objectName": {
"type": "string",
"description": "Object type name. The 'x:' prefix is optional and case-insensitive."
},
"userId": {
"type": "string",
"description": "Client-assigned id. May be referenced elsewhere in the plan as `#<userId>`."
},
"ref": {
"type": "string",
"pattern": "^#.+",
"description": "Reference to a client-assigned id earlier in the plan."
},
"upsertOp": {
"type": "object",
"required": ["@type", "object", "value"],
"additionalProperties": false,
"properties": {
"@type": { "const": "upsert" },
"object": { "$ref": "#/$defs/objectName" },
"matchOn": {
"oneOf": [
{ "type": "array", "items": { "type": "string" }, "minItems": 1 },
{ "const": "*" }
],
"description": "Property names forming the match key, or \"*\" to match by value explicitly. If omitted, the type's label property is used, falling back to a by-value match (with a warning). A list cannot be empty."
},
"scope": {
"type": "object",
"description": "Flat property/value map restricting the slice of the type this operation owns. An entry may only match an existing object inside the scope. Same rules as the reconcile scope, minus the deletion half.",
"additionalProperties": { "not": { "type": "null" } }
},
"value": {
"type": "object",
"minProperties": 1,
"description": "Map of user-assigned id -> object body. Each body must include every property named in matchOn (and @type for multi-variant types). References inside the body use #<id> syntax.",
"additionalProperties": { "type": "object" }
}
}
},
"reconcileOp": {
"type": "object",
"required": ["@type", "object", "value"],
"additionalProperties": false,
"properties": {
"@type": { "const": "reconcile" },
"object": { "$ref": "#/$defs/objectName" },
"matchOn": {
"oneOf": [
{ "type": "array", "items": { "type": "string" }, "minItems": 1 },
{ "const": "*" }
],
"description": "Property names forming the match key, or \"*\" to match by value explicitly. If omitted, the type's label property is used; unlike upsert, reconcile does not fall back to a by-value match silently, so a type with no label property must supply matchOn or \"*\". A list cannot be empty."
},
"scope": {
"type": "object",
"description": "Flat property/value map restricting the slice of the type this operation owns. An object must match every property to be matched or destroyed. Evaluated client-side, so scoping on @type works for every multi-variant type. Keys must be @type or a property declared by the type; values may not be null, and may use #<id> syntax on reference properties (resolvable from earlier operations only). Every entry in value must itself satisfy the scope. If omitted, the whole type is in scope.",
"additionalProperties": { "not": { "type": "null" } }
},
"value": {
"type": "object",
"description": "Map of user-assigned id -> object body. Same shape as upsert. Existing objects in scope that no entry matched are destroyed. An empty object ({}) means the scope should contain no objects.",
"additionalProperties": { "type": "object" }
}
}
},
"createOp": {
"type": "object",
"required": ["@type", "object", "value"],
"additionalProperties": false,
"properties": {
"@type": { "const": "create" },
"object": { "$ref": "#/$defs/objectName" },
"value": {
"type": "object",
"minProperties": 1,
"description": "Map of user-assigned id -> object body. References inside the body use #<id> syntax.",
"additionalProperties": { "type": "object" }
}
}
},
"updateOp": {
"type": "object",
"required": ["@type", "object", "value"],
"additionalProperties": false,
"properties": {
"@type": { "const": "update" },
"object": { "$ref": "#/$defs/objectName" },
"id": {
"oneOf": [
{ "type": "string" },
{ "type": "null" }
],
"description": "Required for normal objects; may be null or omitted for singletons. May be a #<id> reference to an object created earlier in the plan."
},
"value": {
"type": "object",
"description": "JMAP patch object. Top-level keys may be JSON pointers."
}
}
},
"destroyOp": {
"type": "object",
"required": ["@type", "object"],
"additionalProperties": false,
"properties": {
"@type": { "const": "destroy" },
"object": { "$ref": "#/$defs/objectName" },
"value": {
"oneOf": [
{ "type": "object", "description": "JMAP filter object. {} or null matches all." },
{ "type": "null" }
]
}
}
}
}
}
FieldTypeRequiredNotes
@type"upsert"yes
objectstringyesObject type name (x: prefix optional). Singletons are rejected: use update.
matchOnarray of string, or "*"noProperty names forming the match key, or "*" to match by value explicitly. If omitted, the type’s label property is used, falling back to a by-value match (with a warning). A list must be non-empty.
scopeobjectnoFlat property/value map restricting which existing objects an entry may match. See Scoping an operation. Since upsert never deletes, it only narrows matching. If omitted, the whole type is in scope.
valueobjectyesMap of client id -> object body. Each body must include every property named in matchOn, and @type for multi-variant objects. References use #<id>.

For each object in value, the CLI looks for an existing object matching the match key: if found it issues an update (server-set and immutable fields are dropped from the patch); if not, it issues a create. The map keys are client-assigned ids that may be referenced elsewhere as #<key>, whether the object ends up matched or created. As with create, a single leading # on a map key is stripped, so {"dom-a": {...}} and {"#dom-a": {...}} are equivalent.

An upsert reads the existing objects of the type once to resolve matches, then maps to JMAP Object/set requests with create and update populated. Batching is handled automatically. An empty value, an empty matchOn, an entry outside the operation’s own scope, or an upsert of a singleton each fail before any operation in the plan runs. An ambiguous match (the key matches more than one existing object) and a matchOn property missing from a body are detected while the operation runs, so earlier operations in the plan will already have been applied.

FieldTypeRequiredNotes
@type"reconcile"yes
objectstringyesObject type name (x: prefix optional). Singletons are rejected: use update.
matchOnarray of string, or "*"noProperty names forming the match key, or "*" to match by value explicitly. If omitted, the type’s label property is used. Unlike upsert, reconcile does not fall back to a by-value match silently: a type with no label property must supply matchOn or "*", or the operation is rejected. A list must be non-empty.
scopeobjectnoFlat property/value map restricting the slice of the type this operation owns, for matching and deletion. An object must match every property listed. Evaluated client-side, so @type works on every multi-variant type. Keys must be @type or a declared property; values may not be null, and may use #<id> on reference properties from earlier operations. Every entry in value must satisfy it. If omitted, the whole type is in scope.
valueobjectyesMap of client id -> object body, same shape as upsert. May be empty ({}) to mean “nothing in scope should exist”. Each body must include every property named in matchOn, and @type for multi-variant objects.

reconcile runs the upsert logic (match, then update or create each entry), then destroys every existing object in scope that no entry matched. See Reconciling to exact state for the full semantics. The deletes are deferred to the cleanup pass (run after the whole apply pass, in reverse plan order), so a reconcile can add, update, and remove in one operation without ordering the deletes by hand. A reconcile on a singleton, an empty matchOn, a scope that is not an object or that names an unknown property or a null value, an entry outside its own scope, or a type that would fall back to by-value matching without asking for it each fail with a clear error before any operation in the plan runs. A delete blocked by a live reference fails with objectIsLinked, exactly as destroy does.

FieldTypeRequiredNotes
@type"create"yes
objectstringyesObject type name (x: prefix optional).
valueobjectyesMap of client id -> object body. Each body must include @type for multi-variant objects. References use #<id>.

The map keys are client-assigned ids. They may be referenced elsewhere as #<key>. As a convenience, the CLI strips a single leading # from create-map keys, so {"dom-a": {...}} and {"#dom-a": {...}} are equivalent.

A create operation maps directly to one or more JMAP Object/set requests with create populated. Batching is handled automatically. Unlike upsert, a create is not re-runnable: a second apply trips a primary-key violation or produces duplicate rows. Reach for create only in one-shot plans; prefer upsert for anything re-applied.

FieldTypeRequiredNotes
@type"update"yes
objectstringyes
idstring or nullrequired for non-singletonsTop-level sibling of value, not a key inside value. May be a #<id> reference to an object created earlier in the plan. May be null or omitted for singletons.
valueobjectyesJMAP patch object. Top-level keys may be JSON pointers ("aliases/2/name").

update corresponds to a single JMAP Object/set with update populated. Patches use the JMAP semantics: only changed fields are sent; sub-fields can be addressed with /-separated paths; null removes a value.

A singleton update omits the top-level id field (or sets it to null):

{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#dom-a"}}

A non-singleton update sets the top-level id field to the id of the object being patched. The id may be a #<id> reference to an object created earlier in the same plan:

{"@type":"update","object":"Domain","id":"#dom-a","value":{"description":"Renamed"}}

Or a literal server-assigned id, for patching an existing object the plan does not create:

{"@type":"update","object":"Domain","id":"k1234abcd","value":{"description":"Renamed"}}

Note that id is a top-level field of the operation, alongside @type, object, and value. It is not a key inside value. (The map-keyed shape, where ids are keys of value, is the create convention; conflating the two is a common mistake when writing the first non-singleton update.)

For multi-variant changes (where the entire variant is being switched), pass the new variant’s body as a single value rather than patching individual sub-paths (see Updating objects for the rationale).

FieldTypeRequiredNotes
@type"destroy"yes
objectstringyesSingletons cannot be destroyed.
valueobject or nullnoJMAP filter object. {} or null matches every instance of the type.

Destroys are filter-based, not id-based: the CLI runs a paginated Object/query with the supplied filter, then destroys every returned id in batches. To delete a specific known id, use a filter that matches it (e.g. {"name": "..."}) or the standalone delete command.

The set of filterable properties is whatever the server’s Object/query accepts for the type. Most user-facing properties (name, domainId, etc.) are universally supported. Filtering on the @type discriminator works for some multi-variant types (notably Account) but not all. If a destroy fails with unsupportedFilter: Filter on property @type is not supported or invalid, drop the @type clause and either destroy all variants of the parent (omit value) or filter on a regular property.

Plan: 0 destroy, 1 update, 0 create, 3 upsert, 0 reconcile (7 objects)
✓ upserted Domain (2)
✓ upserted Account (2)
✓ upserted DkimSignature (3)
✓ updated SystemSettings (1)
Done: 0 destroyed, 4 updated, 4 created (0 failed)

An upsert (or reconcile) line shows the total objects handled; in the Done: summary those split into created (no existing match) and updated (matched and reconciled). Here the seven upserted objects landed as four creates and three updates, plus the one singleton update. A reconcile that deletes also prints a cleanup line during the final pass, for example ✓ reconcile removed Domain (2), and those deletions are tallied under destroyed in the summary. The Plan: line and the Done: line are written to stderr so that --json output stays clean for downstream tools.

One record per completed operation, plus a summary record:

{"op":"upsert","object":"Domain","index":0,"count":2,"status":"ok"}
{"op":"upsert","object":"Account","index":1,"count":2,"status":"ok"}
{"op":"update","object":"SystemSettings","index":2,"count":1,"status":"ok"}
{"op":"summary","plan":{"destroys":0,"updates":1,"creates":0,"create_objects":0,"upserts":2,"upsert_objects":4,"reconciles":0,"reconcile_objects":0},"done":{"destroyed":0,"updated":3,"created":2,"failed":0}}

This is the recommended mode for CI pipelines and IaC providers. The plan header and any progress lines remain on stderr; only the records above appear on stdout. The summary’s plan block counts the planned operations (upserts, upsert_objects, reconciles, and reconcile_objects alongside the create/update/destroy counts); the done block counts the actual outcome, where an upsert’s or reconcile’s objects are tallied under created or updated.

A reconcile op emits its create/update record during the apply pass and, if it deleted anything, a second record during the cleanup pass carrying "stage":"cleanup" and the destroyed count:

{"op":"reconcile","object":"Domain","index":0,"count":1,"status":"ok"}
{"op":"reconcile","stage":"cleanup","object":"Domain","index":0,"destroyed":2,"status":"ok"}
Terminal window
stalwart-cli apply --file plan.ndjson --dry-run
Plan: 0 destroy, 1 update, 0 create, 3 upsert, 0 reconcile (7 objects)
(dry run: no changes will be made)

--dry-run validates that the plan parses, that every referenced object type exists in the schema, and that the structural rules are respected (singleton / id rules, no upsert or reconcile of a singleton, non-empty value and matchOn where required, and that a reconcile has a usable match key). It also resolves the plan’s #<id> references in match keys offline, walking operations in plan order: a matchOn reference to an id that no earlier operation produces is rejected at dry-run time, so this class of error no longer waits until apply. It does not contact the server beyond fetching the schema (which is normally cached), so match resolution against live data (whether an object exists, whether a match is ambiguous, what a reconcile would delete) is still only checked at apply time.

The CLI is designed to work as the lowest-level building block under whatever orchestration tool fits the platform. The pattern in every case is the same: render a JSON plan from the platform’s templates / variables, pipe or pass it to stalwart-cli apply, and surface the exit status.

Use ansible.builtin.template to render a plan from a Jinja2 template, then ansible.builtin.command to apply it. A minimal playbook:

- name: Deploy Stalwart configuration
hosts: mail
vars:
stalwart_url: "https://mail.example.com"
domains:
- { name: "example.com", description: "Primary" }
- { name: "example.net", description: "Transactional" }
tasks:
- name: Render plan
ansible.builtin.template:
src: stalwart-plan.ndjson.j2
dest: /tmp/stalwart-plan.ndjson
register: plan
- name: Apply plan
ansible.builtin.command: >
stalwart-cli apply --file /tmp/stalwart-plan.ndjson --json
environment:
STALWART_URL: "{{ stalwart_url }}"
STALWART_USER: "{{ stalwart_admin_user }}"
STALWART_PASSWORD: "{{ stalwart_admin_password }}"
register: result
changed_when: result.rc == 0 and ('"created":' in result.stdout or '"updated":' in result.stdout)
failed_when: result.rc != 0
- name: Show summary
ansible.builtin.debug:
msg: "{{ result.stdout_lines | last | from_json }}"

stalwart-plan.ndjson.j2 (one JSON object per line, no enclosing array):

{"@type":"upsert","object":"Domain","matchOn":["name"],"value":{
{%- for d in domains -%}
"dom-{{ loop.index }}":{"name":"{{ d.name }}","description":"{{ d.description }}"}{% if not loop.last %},{% endif %}
{%- endfor -%}
}}

The single upsert line is idempotent: the first apply creates the domains, every later apply matches them by name and reconciles their fields. No teardown loop is needed.

Use --dry-run in a check task for --check Ansible runs.

Two patterns are supported.

As an external data source (read-only views of state) and a terraform_data resource that runs the apply on changes:

locals {
ops = [
{
"@type" = "upsert"
object = "Domain"
matchOn = ["name"]
value = {
for d in var.domains :
"dom-${d.id}" => { name = d.name, description = d.description }
}
},
{
"@type" = "update"
object = "SystemSettings"
value = {
defaultDomainId = "#dom-${var.default_domain_id}"
defaultHostname = var.hostname
}
},
]
# Render as NDJSON: one JSON object per line, no enclosing array.
plan = join("\n", [for op in local.ops : jsonencode(op)])
}
resource "terraform_data" "stalwart_apply" {
triggers_replace = [local.plan]
provisioner "local-exec" {
command = "stalwart-cli apply --stdin --json"
interpreter = ["/bin/sh", "-c"]
environment = {
STALWART_URL = var.stalwart_url
STALWART_USER = var.stalwart_admin_user
STALWART_PASSWORD = var.stalwart_admin_password
}
stdin = local.plan
}
}

triggers_replace ensures the apply re-runs whenever the rendered plan changes. Because the plan is built from upsert operations, a re-run reconciles the existing Stalwart objects in place rather than recreating them, so re-applying after an unrelated change is safe.

For a more idiomatic Terraform integration (typed resources, real drift detection, partial applies), wrap the CLI in a small custom provider written in Go that shells out to stalwart-cli for the actual operations.

Define a NixOS module that materialises the plan as a derivation and runs it via a systemd.services.<name>.serviceConfig.ExecStart:

{ config, lib, pkgs, ... }:
let
cfg = config.services.stalwart-bootstrap;
# NDJSON: one operation per line, no enclosing array.
planText = lib.concatMapStringsSep "\n"
(op: builtins.toJSON op) cfg.plan;
plan = pkgs.writeText "stalwart-plan.ndjson" planText;
in
{
options.services.stalwart-bootstrap = {
enable = lib.mkEnableOption "Stalwart configuration bootstrap";
url = lib.mkOption { type = lib.types.str; };
credentialsFile = lib.mkOption {
type = lib.types.path;
description = "EnvironmentFile with STALWART_USER and STALWART_PASSWORD (or STALWART_TOKEN).";
};
plan = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
description = "List of stalwart-cli apply operations.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.stalwart-bootstrap = {
description = "Stalwart configuration bootstrap";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
EnvironmentFile = cfg.credentialsFile;
Environment = "STALWART_URL=${cfg.url}";
ExecStart = "${pkgs.stalwart-cli}/bin/stalwart-cli apply --file ${plan}";
RemainAfterExit = true;
};
};
};
}

Consumers then write:

services.stalwart-bootstrap = {
enable = true;
url = "https://mail.example.com";
credentialsFile = "/run/secrets/stalwart-admin";
plan = [
{ "@type" = "upsert";
object = "Domain";
matchOn = [ "name" ];
value = { dom-a = { name = "example.com"; }; };
}
{ "@type" = "update";
object = "SystemSettings";
value = { defaultDomainId = "#dom-a"; defaultHostname = "mail.example.com"; };
}
];
};

The plan is regenerated and re-applied on every NixOS rebuild. Because it is built from upsert operations, each rebuild reconciles the existing objects in place rather than recreating them, so repeated rebuilds converge instead of failing on the second run. Combine with agenix or sops-nix for the credentials file. Use --dry-run in a separate nixos-test to validate plans in CI.

Pulumi’s Command resource (from @pulumi/command) maps cleanly to apply:

import * as command from "@pulumi/command";
import { plan } from "./plan";
// `plan` is an array of operation objects; render as NDJSON.
const planNdjson = plan.map((op) => JSON.stringify(op)).join("\n");
new command.local.Command("stalwart-apply", {
create: `stalwart-cli apply --stdin --json`,
triggers: [planNdjson],
stdin: planNdjson,
environment: {
STALWART_URL: stalwartUrl,
STALWART_USER: stalwartUser,
STALWART_PASSWORD: stalwartPassword,
},
});

The triggers array forces a re-run when the plan content changes.

apply reads from stdin and emits NDJSON, so it slots into any CI environment.

name: Apply Stalwart plan
on:
push:
branches: [main]
paths: ["stalwart/plan.ndjson"]
jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install CLI
run: |
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/stalwartlabs/cli/releases/latest/download/stalwart-cli-installer.sh | sh
- name: Plan (dry-run on PRs would go here)
run: stalwart-cli apply --file stalwart/plan.ndjson --dry-run
env:
STALWART_URL: ${{ secrets.STALWART_URL }}
STALWART_USER: ${{ secrets.STALWART_USER }}
STALWART_PASSWORD: ${{ secrets.STALWART_PASSWORD }}
- name: Apply
run: stalwart-cli apply --file stalwart/plan.ndjson --json
env:
STALWART_URL: ${{ secrets.STALWART_URL }}
STALWART_USER: ${{ secrets.STALWART_USER }}
STALWART_PASSWORD: ${{ secrets.STALWART_PASSWORD }}
apply:
image: alpine:latest
before_script:
- apk add --no-cache curl
- curl --proto '=https' --tlsv1.2 -LsSf https://github.com/stalwartlabs/cli/releases/latest/download/stalwart-cli-installer.sh | sh
script:
- stalwart-cli apply --file stalwart/plan.ndjson --json
variables:
STALWART_URL: "https://mail.example.com"
# STALWART_USER and STALWART_PASSWORD come from masked CI variables.
  • Prefer upsert for anything re-applied. A plan of upsert and singleton update operations converges on every run. Reserve create for one-shot plans and destroy for deliberate teardowns.
  • Reach for reconcile only when the plan owns the whole type. reconcile deletes every object of the type the plan does not list, so use it when the plan is the single source of truth for that type (and when you want a removed entry to be deleted, not left behind). If other tooling or operators also manage objects of that type, prefer upsert plus explicit destroy operations so you delete only what the plan owns. Always give reconcile an explicit matchOn, and review a --dry-run before applying.
  • Give each re-applied type a stable match key. Rely on the type’s label property or set an explicit matchOn; avoid the by-value fallback for anything you re-apply, since a changed field there creates a duplicate instead of updating. See Matching.
  • Generate, review, apply. Treat the plan file as an artifact: render it from templates, commit the rendered version (or its diff) for review, then apply.
  • Use --dry-run in pull requests. Every plan change should pass a --dry-run before merging.
  • Never embed real secrets in committed plans. Use placeholders that the renderer substitutes from a secrets manager (Vault, sops, SSM, …). Plans containing private keys, password hashes, or license tokens should never be checked in unencrypted.
  • For teardowns, keep the destroy list in creates order. When a plan does include destroy operations, list them parents-first (the same order as the corresponding upserts/creates); the reverse pass takes them down children-first. If a destroy fails with objectIsLinked, an earlier entry is too far down the tree (children-first ordering): re-order so parents come first.