Sending commands to devices with the API
Everything else in the API edits records. This sends an instruction to a physical device: force a check-in, reboot it, shut it down, restart its agent, or run a script.
Device commands need the k12panel Windows agent. Chromebooks and MDM-managed devices have no agent, so they cannot receive commands — see Which devices can take a command.
Permissions
Commands use three separate permissions, because the three things they cover carry very different risk:
| Permission | Allows |
|---|---|
commands:read |
List and read commands, including the output a device returned |
commands:execute |
Force poll, reboot, shut down, restart agent, run a command from your Quick Command library by name, and cancel |
commands:script |
Send arbitrary command text |
These are not a ladder — each type needs its own permission. A key with commands:script cannot reboot a device unless it also has commands:execute.
Most integrations should never need commands:script. Put the script in your Quick Command library (Settings → Quick Commands), where an administrator reviews and names it, and let the integration run it by name with commands:execute. Output is a separate permission again, because a script can print anything.
Queue a command
POST /api/public/v1/assets/{id}/commands
{ "type": "reboot", "expires_in_minutes": 60 }
{ "type": "library", "library_command": { "name": "Clear Print Spooler" } }
{ "type": "custom", "command": "Get-Service Spooler" }
The response is 202 Accepted, not 201 Created — the command exists, but nothing has happened on the device yet:
{
"id": 88213,
"asset": { "id": 4471, "name": "LIB-1042" },
"type": "reboot",
"status": "pending",
"command": "PREROLL_REBOOT",
"library_command": null,
"created_by": { "type": "api_key", "name": "Ticketing System" },
"expiration": "2026-08-15T15:30:00Z",
"started_at": null,
"completed_at": null,
"results": "",
"created_at": "2026-08-15T14:30:00Z"
}
expires_in_minutes is how long the device has to pick the command up — default 10, maximum 1440 (24 hours). A device that is asleep, off, or off-network when the window closes never receives it, and the command ends as expired.
Retries: the Idempotency-Key header
A network timeout does not tell you whether the command was queued. Send an Idempotency-Key header with a value you generate, and repeating the same request returns the original command (200) instead of queueing a second one.
The header is required for reboot, shutdown, and restart_agent — the three where a retry does the damage twice — and optional everywhere else.
curl -X POST -H "Authorization: Bearer k12_live_..." \
-H "Idempotency-Key: ticket-55012-reboot" \
-H "Content-Type: application/json" \
-d '{"type":"reboot"}' \
https://<your-panel-domain>/api/public/v1/assets/4471/commands
Running a command from your library
A library command is referenced by name (or id), and only enabled entries are runnable:
{ "type": "library", "library_command": { "name": "Clear Print Spooler" } }
Every library command carries a revision that increases each time an administrator edits it. The command record keeps the revision that actually ran, so history can tell you which version of "Clear Print Spooler" executed on a device — not just the name.
If you want a call to fail rather than silently run an edited script, pin the revision:
{ "type": "library", "library_command": { "name": "Clear Print Spooler" }, "expect_revision": 7 }
A mismatch returns 409 library_command_changed and tells you the current revision:
{
"error": "library_command_changed",
"message": "Library command 'Clear Print Spooler' is now at revision 9 (you pinned 7). Re-review the script and re-pin.",
"current_revision": 9
}
Pinning is optional on purpose — without it you always get the latest approved version, which is what most integrations want.
Check first with ?dry_run=true
Add ?dry_run=true to run every check — permission tier, device capability, device state, library lookup, expiry limits — and get back the command that would be queued, without queueing it:
POST /api/public/v1/assets/4471/commands?dry_run=true
This answers "would this device accept a reboot?" without rebooting anything, which is worth doing before a large batch.
Which devices can take a command
A device must have the k12panel agent (version 0.2 or newer), must have reported its encryption key, and must be active or inactive (not archived, in the trash, or awaiting enrollment approval).
When it can't, the API says so instead of accepting work that will never happen:
| Status | error |
Meaning |
|---|---|---|
| 422 | device_cannot_receive_commands |
No agent, an agent older than 0.2, or no encryption key reported yet — the message says which |
| 422 | asset_state_not_commandable |
The device is archived, trashed, or awaiting enrollment |
For a Chromebook-heavy district this is the common answer, and it is not an error in your code: those devices are managed through their MDM, not through the agent.
Watching what happened
GET /api/public/v1/commands?asset=4471
GET /api/public/v1/commands/88213
Filters: asset, status, type, job, created_after, created_before. Newest first.
status |
Means |
|---|---|
pending |
Queued; the device has not picked it up yet |
running |
The agent has retrieved it — not that it is executing right now |
completed |
The agent reported a result |
timed_out |
Delivered, but the device never reported back |
expired |
Never picked up before expires_in_minutes ran out |
canceled |
Cancelled in k12panel before the device ran it |
running is worth reading twice. It is stamped when the agent collects the command, so a dashboard that shows "running" as "in progress right now" will be subtly wrong — a device that collects a command and then loses power sits in running until the recovery job (every five minutes) resets or times it out.
Output is previewed in lists and complete on a single command. A list gives you results_preview (the first 200 characters) and results_truncated; fetch the command by id for the full results. Script output has no size limit, so a page of complete outputs could be enormous.
Finished commands are kept for 90 days and then removed.
Cancelling
DELETE /api/public/v1/commands/88213
Cancelling needs commands:execute — stopping a fleet operation is not a read.
If the agent already retrieved the command, the cancel still succeeds but the response carries a warning that it may run anyway:
{
"id": 88213,
"status": "canceled",
"warnings": [
{ "code": "command_already_retrieved",
"message": "The agent has already retrieved this command; it may still execute on the device." }
]
}
A command that already finished is left exactly as it is (its status is history), with a command_already_finished warning.
Many devices at once
POST /api/public/v1/commands/bulk
{
"op": "create",
"items": [
{ "asset": { "id": 4471 }, "type": "force_poll" },
{ "asset": { "serial": "5CD1234ABC" }, "type": "force_poll" }
]
}
Batches follow the same rules as every other bulk endpoint — up to 25 items run inline and return the completed job; larger batches return 202 with a job_id to poll at GET /jobs/{id}; the cap is 1,000 items; ?dry_run=true validates the whole batch. See Doing things in bulk.
Two things are specific to commands:
- A batch needs the highest permission any item needs. One
customitem makes the whole batch requirecommands:script. - A finished job means the commands were queued, not that the devices did anything. That is the point of keeping the two separate: rows are written in seconds, devices report back in minutes, hours, or never. Poll
GET /commands?job={job_id}for what actually happened.