Skip to main content

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.

A group target needs device:read as well

Resolving 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 groupVersion to 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.

A group target is frozen at fire time

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:

allowPartialIf any device cannot receive the command
falseThe 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.
trueBest 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 it

It 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.

Check rejection, not just for errors

createCommandBatch 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.

codeMeaningRetry?
BATCH_PARTIAL_REFUSEDAt 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_EXCEEDEDThe batch needs more room than the tenant has for undelivered commands.Yes — it clears as the backlog drains
BATCH_TARGET_AMBIGUOUSBoth targets were given, or neither, or a groupVersion with no group.No
BATCH_TOO_LARGEMore devices than one batch may command — named explicitly, or resolved from the group.No — split the operation or narrow the group
BATCH_GROUP_UNUSABLEThe 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_JSONThe string is not valid JSON.No
EXPIRES_AT_INVALIDexpiresAt 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.
On a rejection, resolved is nullable — and null is not zero

null 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 now

They 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_FOUND
  • COMMAND_NOT_IN_VOCABULARY
  • PAYLOAD_SCHEMA_VIOLATION, relayed from the device's profile
  • HELD_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
}
}
FieldMeaning
cancelledThe authoritative number. That many commands moved from QUEUED, HELD or PARKED to CANCELLED and will not be delivered.
alreadySentCommands already dispatched to their devices. Those devices will still act on them.
alreadyFinishedCommands that had already reached a terminal state: SUCCESSFUL, FAILED, TIMEOUT, EXPIRED or CANCELLED.
matchedHow 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 allowPartial on, a large fan-out can be admitted only partially. The devices that did not fit come back as per-device HELD_CEILING_EXCEEDED refusals.
  • 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.