Commanding a Fleet
A command batch sends one command to many devices as a single, recorded operation. You either name the devices or let the platform resolve them from an entity group. What comes back is a persisted record of what the platform tried to do: how many devices the target resolved to, how many were actually enqueued, and which ones were refused and why.
Each device is still handled exactly as a single command would be. Its command is validated against that device's capability contract, held if the device is away, tracked through the same lifecycle, and expires on the same TTL. Read Sending a command first; this guide covers only what changes when the target is a fleet.
A loop of createCommand calls can command the same devices. What it cannot do is:
- leave a record of what was attempted;
- pin the group's membership, so that a selector edit mid-loop does not change the target;
- be called off as one operation.
Batches live on the command-delivery endpoint, https://<your-host>/api/command-delivery/graphql,
and take a tenant access token. Firing and cancelling a batch need command:write. Reading batch
records needs command:read.
device:read as wellResolving a group to its members reads the device registry under the platform's own identity, and
the answer comes back to you: the refusal list names device tokens, and resolved discloses the
group's size. So targeting a group, reading a group-targeted batch record, and cancelling one each
require device:read on top of the command authority. Naming devices explicitly needs only the
command authority, because a caller doing that already knows them.
Choose the target: devices or a group
deviceTokens and groupToken are alternatives, and you supply exactly one. Both or neither is
refused with BATCH_TARGET_AMBIGUOUS rather than resolved by a precedence rule, because a caller
that sent both does not know which fleet it just actuated.
Naming devices
- You can name at most 10,000 tokens in one request. More is
BATCH_TOO_LARGE, and you must split the operation. - Order matters. A partially admitted batch admits devices in the order you gave, so put the devices you care about most first.
- A token you name twice is counted once.
Naming a group
- The group must collect devices.
- A dynamic group must have been published. A batch resolves the published selector, never the draft, because a fleet actuation must not follow whatever someone last typed into the editor.
- Pass
groupVersionto pin a specific frozen version, or omit it to use the active published one. Naming a version for a static group is refused rather than ignored, as is naming one with no group at all. - A group that resolves to more than 10,000 devices is
BATCH_TOO_LARGE. The platform refuses rather than commanding the first 10,000 and reporting success.
The record stores the group version the target set was resolved against. An audit can therefore answer what the group meant when the batch fired, even after someone edits the selector. The stored version is null for a static group, which is never versioned, and for a device-list batch.
Editing a dynamic group after a batch fires changes nothing about what already went out. See Facets and dynamic groups.
Decide what a partial fan-out means
On a real fleet, some devices will not be able to receive the command: one is not in the registry,
another's profile does not declare the command, a third does not fit under the tenant's ceiling.
allowPartial says what happens then:
allowPartial | If any device cannot receive the command |
|---|---|
false | The whole batch is refused and nothing is created, including the batch record — nothing happened, so there is nothing to record. The refusal names the devices responsible. |
true | Best effort. The devices that can receive the command get it. The rest get no command row at all and appear in the record's refusal list. |
The flag has one meaning across every refusal reason. It is not a tolerance for capacity problems only: opting in also accepts that a device whose profile rejects the command is silently left out.
allowPartial has no default — you must send itIt is a non-null Boolean with no default value, so a request that omits it is invalid. This field decides whether a physical actuation may reach some of a fleet but not all of it, so you state your intent rather than inheriting one from a schema you may not have read.
Fire the batch
mutation {
createCommandBatch(request: {
token: "nightly-reboot-2026-08-14",
name: "reboot", # the commandKey, not the display name
payload: "{\"delaySeconds\":5}",
groupToken: "pumps-arid-us",
allowPartial: true
}) {
batch {
token targetKind groupToken groupVersion
resolved accepted
refusals { deviceToken code reason }
refusalCounts { code count }
}
rejection {
code reason resolved
refusals { deviceToken code reason }
refusalCounts { code count }
}
}
}
name is the commandKey from the device's vocabulary, exactly as in createCommand — see
commandKey is the identifier. Every
targeted device receives the same key and the same payload, which is what makes validating a fleet
write affordable in the first place.
expiresAt sets the TTL on every command the batch creates. Without it, the platform default of
seven days applies to all of them. metadata is recorded on the batch record; it is not copied onto
the individual commands.
rejection, not just for errorscreateCommandBatch returns exactly one of batch or rejection. A refused batch is a
successful GraphQL response carrying a rejection, not a GraphQL error. A GraphQL error instead of
either means the batch could not be decided at all: nothing was created, the token is unspent, and
you can retry the request.
The token is an idempotency key
You choose token, and it names the whole operation afterwards. Re-issuing a token that already
names a batch returns that batch, unchanged. It is never topped up with more devices, because
admitting more under the same token would make accepted a moving number and the record
un-auditable.
A retry after a network failure is therefore safe. That matters more here than for a single command: the request you are unsure about may have rebooted ten thousand pumps.
There is no TOKEN_IN_USE refusal for a batch. A token already in use is not a conflict; it is a
replay.
When a batch is refused
Branch on code, never on reason. The reason is prose for a person, and its wording may
change.
code | Meaning | Retry? |
|---|---|---|
BATCH_PARTIAL_REFUSED | At least one device cannot receive the command and allowPartial is off. Nothing was created. | Read the refusals — each device's own code says whether it will still be refused next time |
HELD_CEILING_EXCEEDED | The batch needs more room than the tenant has for undelivered commands. | Yes — it clears as the backlog drains |
BATCH_TARGET_AMBIGUOUS | Both targets were given, or neither, or a groupVersion with no group. | No |
BATCH_TOO_LARGE | More devices than one batch may command — named explicitly, or resolved from the group. | No — split the operation or narrow the group |
BATCH_GROUP_UNUSABLE | The group does not exist, collects something other than devices, was never published, or the named version does not exist (or a version was named for a static group). The group service's own code travels in the reason. | No |
PAYLOAD_NOT_JSON / METADATA_NOT_JSON | The string is not valid JSON. | No |
EXPIRES_AT_INVALID | expiresAt is not an RFC3339 timestamp. | No |
The list is open. Treat a code you do not recognize as a refusal you cannot classify, never as a success.
BATCH_PARTIAL_REFUSED is the one code that cannot answer the retry question by itself, which is
why the offending devices travel with it. A device missing from the command vocabulary needs a
profile change, while one refused for headroom will succeed once the backlog drains. A single code
cannot say both, so it says neither and defers to the list.
The rejection's refusals list is populated for exactly one code, BATCH_PARTIAL_REFUSED, and is
empty for every other, including HELD_CEILING_EXCEEDED. The asymmetry is deliberate:
- A partial refusal is caused by specific devices, so naming them saves you from bisecting a fleet by hand.
- A ceiling refusal is caused by the tenant's backlog. No device in the request is at fault, and
nothing would change if you swapped its members; a list there would invite fixing devices that are
fine. What to do about it is in
reason.
resolved is nullable — and null is not zeronull means no target set was ever established: the refusal happened before anything was resolved.
0 means a target that genuinely resolved to no devices, which is a real and successful batch rather
than a refusal.
Read the record
query {
commandBatchesByToken(tokens: ["nightly-reboot-2026-08-14"]) {
token name targetKind groupToken groupVersion allowPartial
resolved accepted
refusals { deviceToken code reason }
refusalCounts { code count }
}
}
You can also search by command key, by group, or by targetKind (DEVICE_LIST or GROUP):
query {
commandBatches(criteria: {
pageNumber: 1, pageSize: 25,
groupToken: "pumps-arid-us"
}) {
results { token name resolved accepted createdAt }
pagination { totalRecords }
}
}
resolved and accepted describe the moment the batch fired, not nowThey are stored facts, not live counts. For present-tense delivery state, search the commands instead (see Follow the commands it created).
Command rows are not immortal: they can be soft-deleted, or erased with a tenant. Deriving accepted
from a live query would let it drift below the creation-time truth with no refusal explaining the
gap, which is why the record stores it.
refusals is a sample; refusalCounts is complete
refusals keeps at most 100 entries per code, so a batch fired at a large group refuses more
devices than the record names. refusalCounts is the complete per-code total and is never truncated,
which keeps the record self-auditing:
resolved = accepted + the sum of refusalCounts
That identity always holds. The sample may be short; compare its length against the counts to tell whether it was capped.
The per-device code uses the same open vocabulary as a single enqueue rejection:
DEVICE_NOT_FOUNDCOMMAND_NOT_IN_VOCABULARYPAYLOAD_SCHEMA_VIOLATION, relayed from the device's profileHELD_CEILING_EXCEEDED, for the devices that did not fit under the tenant's remaining headroom
See When an enqueue is refused for what each one means.
Follow the commands it created
The batch record deliberately does not move. To ask what the fleet write is doing — "of the 5,000
queued, how many have gone out?" — search the commands with batchToken:
query {
commands(criteria: {
pageNumber: 1, pageSize: 50,
batchToken: "nightly-reboot-2026-08-14",
statuses: ["QUEUED", "HELD", "PARKED"]
}) {
results { token deviceToken status queuedTime }
pagination { totalRecords }
}
}
The platform generates the individual command tokens; you chose the batch's token, not theirs. So
you find them with batchToken rather than by constructing a token yourself.
The link works the other way too. A command row carries batchToken as a readable field, so a caller
holding one command — from a device's history, or from a response that arrived with no context — can
ask which fleet write minted it:
query {
commands(criteria: { pageNumber: 1, pageSize: 20, deviceToken: "gw-4471" }) {
results { token name status batchToken }
}
}
batchToken is null for a command issued one at a time, and it is the only field that tells the two
apart. A batch sends the same command key, with the same payload, that the device would have received
individually, so nothing else on the row differs.
This direction matters because a single command row cannot show you the interesting part of a fleet write: the devices it refused. They were given no command, so they appear in no device's history. Only the batch record knows they were targeted at all.
Cancel a batch
mutation {
cancelCommandBatch(token: "nightly-reboot-2026-08-14") {
cancelled
alreadySent
alreadyFinished
matched
}
}
| Field | Meaning |
|---|---|
cancelled | The authoritative number. That many commands moved from QUEUED, HELD or PARKED to CANCELLED and will not be delivered. |
alreadySent | Commands already dispatched to their devices. Those devices will still act on them. |
alreadyFinished | Commands that had already reached a terminal state: SUCCESSFUL, FAILED, TIMEOUT, EXPIRED or CANCELLED. |
matched | How many of the batch's command rows were live at that moment (see below). |
This is the same brake cancelCommand applies to a single command: both cancel QUEUED, HELD and
PARKED, and neither touches SENT. Why SENT is the line is explained in
Cancelling a batch.
Cancelling never refuses. A brake that declined to engage because part of the fleet had already
moved would leave the rest of the fleet commanded, which is the worst available outcome. So a batch
where every command has already been sent is a successful call reporting cancelled: 0. Read the
counts rather than assuming the call did nothing. A token matching no batch is a GraphQL error.
Cancelling needs command:write, and a group-targeted batch also needs device:read, for the same
reason firing one does.
The batch record itself is stamped with cancelledAt and cancelledCount, so the cancellation is as
auditable as the fan-out was. cancelledCount is what that call caught. The stamp is
first-wins: a second cancel does not overwrite what the first recorded.
matched and the other counts
matched is a live count, and the four numbers need not add up.
matched counts the batch's command rows that were live at that moment, not how many the batch
created. Rows removed since — by a purge, or a deletion — are not there to match. So matched below
the batch's accepted is ordinary and says nothing about the cancel.
matched can also exceed cancelled + alreadySent + alreadyFinished. A command whose delivery
failed can return to the queue between the cancel and the count. Such a command is left out of all
three buckets rather than folded into alreadyFinished, because reporting a live command as a
finished one is the single thing this vocabulary exists to prevent. Cancel again and it is caught.
What keeps this rare is the cancellation stamp itself: once a cancellation is committed, a failed delivery retires the command instead of putting it back in the queue. The exception is a command released in the same instant as the cancellation, which stays live inside a batch that has been called off. Cancelling again is the remedy, rather than waiting.
Limits a batch shares with single commands
A batch is bounded by exactly the same limits a loop of single commands would hit. It is admitted against the tenant's ceiling on undelivered commands, minus the share reserved for the platform's own delivery. There is no way around either, and no advantage to one shape over the other.
In practice, when the tenant is near its ceiling:
- With
allowPartialon, a large fan-out can be admitted only partially. The devices that did not fit come back as per-deviceHELD_CEILING_EXCEEDEDrefusals. - With it off, the whole batch is refused with that code and nothing is created.
Either way, this is a temporary condition rather than a defect in the request. Once the backlog drains, a new token will command the rest. Replaying the original token cannot, because a replay returns the batch you already have.