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.
Synopsis
Section titled “Synopsis”stalwart-cli apply ( --file <path> | --stdin ) [--dry-run] [--continue-on-error] [--quiet] [--json] [--progress]| Option | Effect |
|---|---|
--file <path> | Read the plan from an NDJSON file. |
--stdin | Read the plan from standard input (NDJSON). |
--dry-run | Parse and validate the plan, then print it; no requests are sent. |
--continue-on-error | Do not abort on the first failed operation; report all errors at the end and exit non-zero. |
--quiet | Suppress per-operation log lines; print only the final summary. |
--json | Emit 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. |
--progress | Print one extra line per request batch during large destroy, create, upsert, and reconcile operations. |
Exactly one of --file and --stdin must be supplied.
How it works
Section titled “How it works”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:
-
Destroy pass (reverse order). Every
destroyoperation 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 listsDomain, thenAccount, thenDkimSignature(parents first), the reverse pass tears them down inDkimSignature,Account,Domainorder, satisfying foreign-key constraints. Most plans contain no destroys at all; the pass is a no-op for them. See Teardown with destroy. -
Apply pass (plan order). All
upsert,reconcile,update, andcreateoperations run in the order they appear in the plan. Destroys are skipped during this pass, and the delete half of eachreconcileis deferred to pass 3. This ordering is what allows objects to reference each other: aDomaincan be upserted in operation 1 and then referenced by anAccountin operation 2. -
Reconcile cleanup pass (reverse order). The objects each
reconcilemarked 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 noreconcileoperations 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.
Stop on first error (default)
Section titled “Stop on first error (default)”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.
Idempotent re-runs
Section titled “Idempotent re-runs”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 sameDomain,AllowedIp,Certificate, etc. trips the server’s primary-key constraints, and re-creating types that have no such constraint produces duplicate rows. - A
destroy+createplan 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: aDomainreferenced by anAccount, aDkimSignature, or the non-nullableSystemSettings.defaultDomainIdcannot be destroyed while the reference exists (the destroy fails withobjectIsLinked).upsertreconciles 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).
Matching
Section titled “Matching”The match key for an upsert is resolved in this order:
- Explicit
matchOn. If the operation carries amatchOnlist of property names, those properties are the key. An object in thevaluemap matches an existing object when every listed property is equal. Every property inmatchOnmust be present in each object body, or the operation errors. - The schema’s label property. If
matchOnis omitted, the CLI uses the type’s label property (the field the WebUI lists objects by, for examplenameonDomain).snapshotwrites thismatchOnexplicitly so plans are self-describing. - Value match. If the type has no label property and no
matchOnwas given, the CLI matches by comparing every non-secret scalar field (string, number, boolean, enum, datetime) of the body against existing objects. Awarningis 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 explicitmatchOn(or rely on a label property) rather than value matching.reconcilenever 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.
Teardown with destroy
Section titled “Teardown with destroy”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"}(novalue) 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.
Reconciling to exact state
Section titled “Reconciling to exact state”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.
reconciledoes not silently fall back to the by-value matching thatupsertallows: an operation with nomatchOnon a type that also has no label property is rejected at plan time. Value matching treats any drifted field as a non-match, which underreconcilemeans 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 withprimaryKeyViolation. Value matching is dependable for re-applying an unchanged plan, not for absorbing drift; an explicitmatchOnis 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
scopeto 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
Domainpinned by anAccount), the destroy fails withobjectIsLinkedand the operation is reported as failed, exactly as adestroywould.reconciledoes not force-delete.
Scoping an operation with scope
Section titled “Scoping an operation with scope”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
@typevalues are validated. A key must be@typeor a property the type declares (in any of its variants), and an@typevalue 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 fromdestroy:{"nameContains": "spam-"}is rejected rather than silently matching nothing.destroy’s filter andscopeare not the same language. nullvalues are rejected. A comparison treats an absent property and anullone 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
Mxbody 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.
Cross-operation references
Section titled “Cross-operation references”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-levelcreatedIdsmap. The server resolves#dom-ato the real id assigned during the matchingcreate. This works across separate JMAP requests, including across batches. -
Refs as the
idof anupdate("id": "#dom-a"): JMAP does not resolve#-prefixed update keys server-side. The CLI resolves these client-side from the id map populated during earliercreateandupsertoperations. 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
idfield of anupdateoperation (resolved client-side as described above).
Refs do not work for:
- The
idfield of anupdateoperation when no matchingcreateorupsertexists in the same plan (the CLI surfaces a clear error in this case).
Dependency ordering
Section titled “Dependency ordering”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
Domainupsert must appear before anAccountupsert that references the domain via"domainId": "#...". This also covers references in areconcilematch key: the parent must be produced before the child op that keys on it. The delete half of areconcileneeds 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
SystemSettingspointing atdefaultDomainId) 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.
Batching
Section titled “Batching”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.
File format
Section titled “File format”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.
Annotated example
Section titled “Annotated example”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.
Per-line JSON Schema
Section titled “Per-line JSON Schema”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" } ] } } } }}Per-operation field reference
Section titled “Per-operation field reference”upsert
Section titled “upsert”| Field | Type | Required | Notes |
|---|---|---|---|
@type | "upsert" | yes | |
object | string | yes | Object type name (x: prefix optional). Singletons are rejected: use update. |
matchOn | array of string, or "*" | no | 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 must be non-empty. |
scope | object | no | Flat 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. |
value | object | yes | Map 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.
reconcile
Section titled “reconcile”| Field | Type | Required | Notes |
|---|---|---|---|
@type | "reconcile" | yes | |
object | string | yes | Object type name (x: prefix optional). Singletons are rejected: use update. |
matchOn | array of string, or "*" | no | 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: a type with no label property must supply matchOn or "*", or the operation is rejected. A list must be non-empty. |
scope | object | no | Flat 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. |
value | object | yes | Map 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.
create
Section titled “create”| Field | Type | Required | Notes |
|---|---|---|---|
@type | "create" | yes | |
object | string | yes | Object type name (x: prefix optional). |
value | object | yes | Map 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.
update
Section titled “update”| Field | Type | Required | Notes |
|---|---|---|---|
@type | "update" | yes | |
object | string | yes | |
id | string or null | required for non-singletons | Top-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. |
value | object | yes | JMAP 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).
destroy
Section titled “destroy”| Field | Type | Required | Notes |
|---|---|---|---|
@type | "destroy" | yes | |
object | string | yes | Singletons cannot be destroyed. |
value | object or null | no | JMAP 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.
Output
Section titled “Output”Human (default)
Section titled “Human (default)”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.
NDJSON (--json)
Section titled “NDJSON (--json)”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"}Dry run
Section titled “Dry run”stalwart-cli apply --file plan.ndjson --dry-runPlan: 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.
Integrating with infrastructure-as-code
Section titled “Integrating with infrastructure-as-code”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.
Ansible
Section titled “Ansible”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.
Terraform
Section titled “Terraform”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
Section titled “Pulumi”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.
CI / CD pipelines
Section titled “CI / CD pipelines”apply reads from stdin and emits NDJSON, so it slots into any CI environment.
GitHub Actions
Section titled “GitHub Actions”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 }}GitLab CI
Section titled “GitLab CI”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.Operational guidance
Section titled “Operational guidance”- Prefer
upsertfor anything re-applied. A plan ofupsertand singletonupdateoperations converges on every run. Reservecreatefor one-shot plans anddestroyfor deliberate teardowns. - Reach for
reconcileonly when the plan owns the whole type.reconciledeletes 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, preferupsertplus explicitdestroyoperations so you delete only what the plan owns. Always givereconcilean explicitmatchOn, and review a--dry-runbefore 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-runin pull requests. Every plan change should pass a--dry-runbefore 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
destroyoperations, list them parents-first (the same order as the corresponding upserts/creates); the reverse pass takes them down children-first. If a destroy fails withobjectIsLinked, an earlier entry is too far down the tree (children-first ordering): re-order so parents come first.
See also
Section titled “See also”- Exporting server state for the inverse operation (generating an apply plan from a live deployment).
- Overview for installation and connection setup.
- Exploring the schema to discover what objects, fields, and filters are available.
- Creating objects and Updating objects for the single-shot equivalents.
- Removing objects for id-based deletes outside a plan.