---
title: "Command and configure your devices"
description: "Define a command type, send it to a device or a group, approve it, undo it, roll out a signed policy in stages and let a playbook tighten it for a while."
section: "guides"
status: "Planned for 1.4"
spec: "device-commands"
canonical: "https://mainsdk.dev/docs/guides/command-your-devices"
updated: "2026-10-28"
---

# Command and configure your devices

> Define a command type, send it to a device or a group, approve it, undo it, roll out a signed policy in stages and let a playbook tighten it for a while.

Status: Planned for 1.4 · Canonical: https://mainsdk.dev/docs/guides/command-your-devices · Updated: 2026-10-28

You will define a command, send it to devices that already enrolled, watch the receipt come back, undo it, then publish a policy bundle that rolls out in stages. It takes about 20 minutes. Devices always pull; nothing here opens a port on a device.

## You need

- Devices enrolled with `mainsdk/device` (spec `device-identity`), each in a group such as `lab`.
- A `device` section in `main.yaml`; every key has a default, so an empty one works:

```yaml
device:
  commands: { ttl: 15m, max-targets: 100, reaction-max: 50 }
  policy:
    rollout: { stages: ["5%", "25%", "100%"], hold: 1h, rollback-if: { failed: 5% } }
    approve: 1
    tighten: { report.interval: higher, upload.enabled: false }
```

## 1. Define a command type

```ts
import { device } from "mainsdk/device";
import { mountCommands } from "mainsdk/device/commands";
import { s } from "mainsdk/schema";

mountCommands(device); // once, where the app starts: builds `device.commands` and serves its routes

device.commands.define("prices.clear", {
  input: s.object({ region: s.string().max(16) }),
  risk: "write",
  undo: "prices.restore",
  reaction: true,
});
```

`mountCommands` is the one call that adds commands to `device`; `mainsdk/device` carries none of that code until you make it, so apps that only enrol devices stay small. A type is data and a name. There is no built-in command that runs a program or a script, and a type under the `mainsdk.` prefix is refused. Bound every string: an unbounded field named `command`, `cmd`, `script`, `code`, `shell`, `exec` or `args` fails `mainsdk check` with `MAIN-DEVICE-0067`.

## 2. Register the handler on the device

```ts
import { deviceClient, memoryKeyStore } from "mainsdk/device/client";
import { mountCommands } from "mainsdk/device/commands/client";
import { s } from "mainsdk/schema";

const client = deviceClient({ url: "https://app.example.com/mainsdk/device", keys: memoryKeyStore() });
mountCommands(client); // once, before `client.start()`: builds `client.commands`
async function dropLocalCopies(region: string): Promise<number> {
  return region.length;
}

client.commands.handle("prices.clear", {
  input: s.object({ region: s.string().max(16) }),
  risk: "write",
  run: async (input) => ({ cleared: await dropLocalCopies(String(input.region)) }),
});
```

A handler that passes its input to `system.run`, `eval`, `new Function`, `vm`, `child_process` or a dynamic `import()` fails `check` with `MAIN-DEVICE-0066`. The device refuses a command it has no handler for, one signed by a key it does not trust, one past its expiry and one whose risk differs from its handler's; each refusal is a signed `refused` receipt with `MAIN-DEVICE-0063`.

## 3. Send it

```ts
import { device } from "mainsdk/device";

const batch = await device.commands.create("prices.clear", {
  to: { group: "lab" },
  input: { region: "eu" },
  reason: "stale prices",
});
```

You get one signed command per device. A group over `max-targets` needs `confirm: <count>` and otherwise fails with `MAIN-DEVICE-0061`. A `destructive` type waits for the people its `approve` names; an agent's create always waits for one person.

## 4. Read, cancel and undo

```ts
import { device } from "mainsdk/device";

const id = "cmd_01JC8Q4Z7N0ABCDEFGHJKMNPQR";
await device.commands.get(id);                               // state, result, approvals
await device.commands.cancel(id, { reason: "wrong group" }); // only before delivery
await device.commands.revert(id, { reason: "restore" });     // sends the type's undo command
```

Every step writes an audit record with the input's digest, never the input. Each device keeps a hash-chained log of what it ran; if its head differs from the one the server computes from receipts, you get `MAIN-DEVICE-0070` and a high `device.command_log_mismatch` event.

## 5. Publish a policy

```ts
import { device } from "mainsdk/device";
import { mountPolicy } from "mainsdk/device/policy";
import { s } from "mainsdk/schema";

mountPolicy(device); // once, where the app starts: builds `device.policy` and serves its route

device.policy.define(s.object({ report: s.object({ interval: s.number() }) }), {
  tighten: { "report.interval": "higher" },
});
await device.policy.publish({ report: { interval: 60 } }, { reason: "baseline" });
```

The bundle is signed and versioned. Devices only ever move forward, keep the last good policy when the app is unreachable and report what they run in every heartbeat. A version waits for `approve` people (`MAIN-DEVICE-0051`), rolls out stage by stage and backs itself out when the failed share passes `rollback-if` (`MAIN-DEVICE-0049`). `rollback` publishes an old version's content as a new version.

## 6. Let a playbook tighten, never loosen

```yaml
playbooks:
  rules:
    quiet-uploads:
      when: { signal: attack.suspected }
      run: auto
      do:
        - device.policy.tighten: { path: upload.enabled, value: false }
      for: 30m
```

The overlay lasts at most 24 hours, is carried inside the signed bundle and is checked again on the device. A step that would publish, roll back or loosen a policy is refused by `check` (`MAIN-GUARD-0002`). A `device.command` step takes only `reaction: true` types, at most `reaction-max` devices (the rest are refused with `MAIN-GUARD-0003`), and never a `destructive` one in a rule that runs `auto` (`MAIN-GUARD-0001`). Register the playbook actions once, after the two `mount` calls of steps 1 and 5:

```ts
import { app } from "mainsdk";
import { device } from "mainsdk/device";
import { deviceReactionActions } from "mainsdk/device/commands";

for (const def of deviceReactionActions({ commands: device.commands, policy: device.policy })) app().action(def);
```

## Test it

`createCommandsRig()` and `createPolicyRig()` from `mainsdk/testing` wire the app to simulated devices on the test clock, so rollouts, expiry and restarts run in milliseconds.

## Next

- Spec `device-commands` and `device-policy` for the criteria and wire formats.
- [Device](https://mainsdk.dev/docs/interfaces/device) for every call.
