MainSDK
Search⌘K
How-to

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.

Planned for 1.4

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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 for every call.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet