MLXDocs
MLX Node

Remote commands

Markdown

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):

x-mlx-auth: <wallet>|<ts>|<sig>
PartWhat it is
walletthe wallet's name, such as luca.mlx
tsthe time of signing, milliseconds since 1970
sigan Ed25519 signature by the wallet's key, lowercase hex

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

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.

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

POSThttps://mlx.app/api/companion/command
FieldTypeNotes
devicestringthe Mac's public key; it must be linked to the signing wallet
actionstringpause, resume or limits
limitsobjectfor limits only: the keys to change. See Limits
Request body
{ "device": "<the Mac's public key>", "action": "limits", "limits": { "onlyOnAC": false } }
ReplyWhen
{ 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 use MLX Node's own keys (Limits in mac/MLX/Core/Policy.swift). cleanLimits keeps only these, with the right types:

KeyTypeMeaning
idleMinuteswhole numberminutes without input before work starts; 0 means manual mode
memoryPctwhole numberthe share of memory a job may use, in percent
hoursFrom, hoursTowhole numberwork only between these local hours; equal values mean any time
onlyOnACbooleanwork only while plugged in
maxThermalfair or seriousstop at this thermal state or hotter
pausedbooleanthe 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

GEThttps://mlx.app/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.

FieldNotes
account, chainthe 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
usdPerMlxthe network price
earningtoday (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
nodesthe wallet's seats: id, tier, name, reward, unlockAt, latest, perHour, lockDays, carriedBy (the Mac's key), carriedPerHour
macseach 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".

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.