# Remote commands

The owner can pause, resume or change the limits of any Mac linked to their wallet from another device. The request is signed by the wallet; the server queues it, the Mac picks it up with its next heartbeat and acknowledges it.

MLX.app on iPhone and iPad and MLX World use these endpoints. The code is `app/api/companion/*`, `lib/companion.ts`, and `cleanCommand`, `queueCommand` and `cleanLimits` in `lib/worker/core.ts`.

## Signing with the wallet

Companion requests carry one header, built and checked like the distributor portal's (`lib/distributor/sign.ts`, `signedWallet` in `lib/distributor/core.ts`):

```text
x-mlx-auth: <wallet>|<ts>|<sig>
```

| Part | What it is |
| --- | --- |
| `wallet` | the wallet's name, such as `luca.mlx` |
| `ts` | the time of signing, milliseconds since 1970 |
| `sig` | an Ed25519 signature by the wallet's key, lowercase hex |

What is signed is a SHA-256 hash, as raw bytes:

```text
action = "<METHOD> <path> <sha256 hex of the body>"          (requestAction)
hash   = SHA-256( canonical({ type: "mlx-distributor", chain, account: <wallet>, action, ts }) )   (distributorHash)
sig    = Ed25519.sign(hash, wallet key)
```

`path` is the URL path without a query. A GET signs the hash of an empty body. The wallet key is the one every transaction uses: see [Keys and signing](/transactions/keys-and-signing).

```js tab=JavaScript title="A signed companion request"
import { ed25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";

const canonical = (v) =>
  v === null || typeof v !== "object" ? JSON.stringify(v)
  : Array.isArray(v) ? "[" + v.map(canonical).join(",") + "]"
  : "{" + Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";

const hex = (s) => bytesToHex(sha256(utf8ToBytes(s)));

// secretKey: the wallet's Ed25519 key, from its seed (see Keys and signing)
export async function companion(method, path, body, wallet, secretKey) {
  const { chain } = await (await fetch("https://mlx.app/api/stats")).json();
  const raw = body === undefined ? "" : JSON.stringify(body);
  const ts = Date.now();
  const action = `${method} ${path} ${hex(raw)}`;
  const hash = sha256(utf8ToBytes(canonical({ type: "mlx-distributor", chain, account: wallet, action, ts })));
  const sig = bytesToHex(ed25519.sign(hash, secretKey));
  return fetch("https://mlx.app" + path, {
    method,
    headers: { "content-type": "application/json", "x-mlx-auth": `${wallet}|${ts}|${sig}` },
    ...(raw ? { body: raw } : {})
  });
}
```

The server checks that:

- the wallet name is valid and the wallet exists, has a key and isn't a protocol account;
- `ts` is close to the server's clock;
- the signature verifies against the wallet's key, for this method, path and body;
- for anything but GET and HEAD, the signature hasn't been used before. Replaying a signed POST does nothing.

Any failure answers 401 "Unlock your wallet to continue". A body that isn't JSON is 400, and one that is too large 413.

## POST /api/companion/command {#command}

```endpoint
POST /api/companion/command
```

| Field | Type | Notes |
| --- | --- | --- |
| `device` | string | the Mac's public key; it must be linked to the signing wallet |
| `action` | string | `pause`, `resume` or `limits` |
| `limits` | object | for `limits` only: the keys to change. See [Limits](#limits) |

```json title="Request body"
{ "device": "<the Mac's public key>", "action": "limits", "limits": { "onlyOnAC": false } }
```

| Reply | When |
| --- | --- |
| `{ ok: true, id }` | queued; `id` is the command's id |
| 404 "That Mac isn't linked to this wallet" | the device isn't one of this wallet's |
| 400 "Unknown command" | another action, or `limits` with no valid key left after cleaning |

## What happens next

1. **Queued.** The command `{ id, action, limits?, at, by }` is stored in `wk:cmd:<pk>`, where `by` is the wallet that sent it. A command the Mac doesn't pick up expires.
2. **Delivered.** Every heartbeat reply to that Mac carries its waiting commands, oldest first, in `commands`. While any are waiting, the reply's `next` asks for the next heartbeat sooner.
3. **Applied.** MLX Node carries each one out once (`apply` in `mac/MLX/Core/AppModel.swift`):
   - `pause` sets the owner's pause;
   - `resume` clears it;
   - `limits` applies the keys sent, clamped again on the Mac (`RemoteLimits`).
4. **Acknowledged.** A few seconds later MLX Node sends a heartbeat with the command ids in `ack`, and the server removes them from the queue.

A Mac that is offline gets the commands when it comes back, unless they have expired. The phone sees the result in the next `GET /api/companion/me`.

## Limits {#limits}

Limits use MLX Node's own keys (`Limits` in `mac/MLX/Core/Policy.swift`). `cleanLimits` keeps only these, with the right types:

| Key | Type | Meaning |
| --- | --- | --- |
| `idleMinutes` | whole number | minutes without input before work starts; 0 means manual mode |
| `memoryPct` | whole number | the share of memory a job may use, in percent |
| `hoursFrom`, `hoursTo` | whole number | work only between these local hours; equal values mean any time |
| `onlyOnAC` | boolean | work only while plugged in |
| `maxThermal` | `fair` or `serious` | stop at this thermal state or hotter |
| `paused` | boolean | the owner's pause |

Numbers out of range are clamped to the range MLX Node's Settings allow, and rounded to whole numbers. An hour of 24 is read as 0. Non-numbers, unknown keys and other thermal values are dropped. The server cleans limits on the way in, from commands and from heartbeats, and again on the way out to MLX.app, and MLX Node clamps them once more before using them.

## GET /api/companion/me {#me}

```endpoint
GET /api/companion/me
```

What MLX.app shows: the wallet's Macs with their live status, its seats, and what it mined. Signed like the command. No balances and no transfers.

| Field | Notes |
| --- | --- |
| `account`, `chain` | the wallet and the chain id |
| `network` | `{ name, chain, height }`: the network's name and the newest block |
| `node` | `{ latest }`: the public MLX Node version |
| `live` | `{ at, pending, rate, state }`: MLX from hours not yet settled, its growth a second, and `mining`, `paused`, `waiting` or `idle` |
| `usdPerMlx` | the network price |
| `earning` | `today` (paid today, UTC), `locked` (mined MLX still locked), `perDayMax` and `perHourMax` (what the wallet's online Macs could mine at a full score), `free`, `seat`, `throttled`, `freePerHour`, `lockDays` |
| `nodes` | the wallet's seats: `id`, `tier`, `name`, `reward`, `unlockAt`, `latest`, `perHour`, `lockDays`, `carriedBy` (the Mac's key), `carriedPerHour` |
| `macs` | each linked Mac, below |

Each entry in `macs`: `pk`, `id` (its public MLX World id), `name` (the Mac's own name, shown only to its owner), `kind`, `art`, `model`, `year`, `geo` (approximate city), `chip`, `memoryGB`, `os`, `linkedAt`, `online`, `seen`, `app`, `state`, `reason`, `thermal`, `onAC`, `limits`, `tokPerSec`, `tps`, `today`, `carries` (`{ tier, name, units, full, id }` of the seat it carries), `perHour`, and `pending` (commands still waiting).

Errors: 401 when the signature fails, 404 "No such wallet".

> [!NOTE]
> `carries` and `carriedBy` are a preview. They pair the wallet's seats with its Macs that are online now, by the chip each Mac reports. The hourly settlement pairs seats with the Macs that worked that hour, by score and by checked class, so it can differ. See [Pairing seats with Macs](/protocol/seats#pairing-seats-with-macs).

## Push notifications

`POST /api/companion/push` registers MLX.app for notifications on that device: `{ token, sandbox, lang, on }`, signed the same way. The server then tells the owner when a Mac stops or comes back, and sends a daily summary.
