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
PAYLOAD_TOO_LARGE / METADATA_TOO_LARGEThe payload or metadata string is over 64 KiB.No
COMMAND_NAME_TOO_LONGThe command name is over 128 bytes.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
  • DEVICE_DISABLED
  • 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
  • WINDOW_AFTER_EXPIRY, for devices whose type's maintenance window next opens after the command expires

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.

In the console, a batch's detail page lists these commands and has an Export CSV button. It exports every command matching the status filter in force, not only the visible page, up to 10,000 rows; if the batch is larger the console says so and you narrow the filter to get the rest.

Retry the devices that did not succeed​

A fleet write that partly fails should not make you rebuild the device list by hand. Every command that finishes carries an outcome, recorded by the write that finished it, and a retry is selected by that outcome rather than by reading error text:

outcomeWhat happenedSending it again
SUCCEEDEDThe device reported success.Not retryable.
NOT_DELIVEREDThe platform can prove the command never reached the device: it expired or was cancelled while the platform still held it, the transport has no command path, or the platform gave up publishing it.Cannot act twice.
REJECTEDThe device received the command and answered that it did not do it.A deliberate re-ask.
UNKNOWNThe command may have reached the device and acted: it was sent and never answered (TIMEOUT), or the device answered and the platform lost the answer.May act twice. Only on explicit acknowledgement.

A command that has not finished has no outcome. status alone cannot give you this, because FAILED covers all of never-delivered, rejected and unknown. Search by outcome:

query {
commands(criteria: {
pageNumber: 1, pageSize: 100,
batchToken: "nightly-reboot-2026-08-14",
outcomes: ["NOT_DELIVERED", "REJECTED"]
}) {
results { deviceToken status outcome error }
}
}

retryCommandBatch re-issues the original batch's command to the devices you name, as a new batch linked to the original:

mutation {
retryCommandBatch(request: {
token: "nightly-reboot-2026-08-14-retry-1",
of: "nightly-reboot-2026-08-14",
deviceTokens: ["gw-4471", "gw-4472"],
includeUnknown: false,
allowPartial: true
}) {
batch { token retryOf resolved accepted }
rejection { code reason refusals { deviceToken code reason } }
}
}
  • The command is not in the request. The platform copies the name and payload from the original, so a retry cannot turn into a different command.
  • Every device must be a member of the original batch and unsuccessful: NOT_DELIVERED or REJECTED. A device whose command succeeded or is still in flight is refused (RETRY_NOT_ELIGIBLE), a device that was not in the batch is refused (RETRY_NOT_A_MEMBER), and a device that already has a live or successful command in another batch of the same retry family (the original, any retry of it, and any retry of those) is refused too, so a device is never sent the command twice because two retries were open at once.
  • UNKNOWN needs includeUnknown: true. Without it those devices are refused with RETRY_UNKNOWN_NOT_ACKNOWLEDGED. Set it only after confirming that acting twice is harmless.
  • Devices the original refused can be retried only when the batch record holds that refusal. The record keeps at most 100 refused devices per code (refusalCounts is exact), so a device from a larger set is refused with RETRY_NOT_A_MEMBER.
  • A retry goes through the same checks as a new batch: the device's current command vocabulary, a disabled device, a command the platform refuses to send raw, the held-command ceiling and allowPartial. Retrying is not a way around any of them.
  • Idempotency runs both ways. The retry has its own token; sending it again returns the retry and sends nothing more. Sending the original token to createCommandBatch still returns the original batch and executes nothing. A token that already names a batch which is not a retry of this one is refused (RETRY_TOKEN_IN_USE).
  • The link is on the batch: retryOf names the batch a retry re-attempts, and retries on the original lists the batches that retried it. A retry of a retry names the retry.

Retrying needs command:write, and a group-targeted original also needs device:read.

In the console, a batch's detail page has a Retry… action. It lists the unsuccessful devices in groups: never delivered and device rejected are selected for you, refused when fired is offered (when the record holds it) but not selected, and outcome unknown is not selected and stays locked until you tick an acknowledgement that those devices may already have acted. Commands still in flight are counted and cannot be selected. The new batch opens when it is created, and the two batches link to each other.

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.