MLXDocs
MLX Node

The worker protocol

Markdown

MLX Node talks to mlx.app with JSON POSTs signed by the Mac's device key. A heartbeat says what the Mac is doing and gets back what it may do; a job request gets the next job; a result returns the hash of what the Mac generated.

The server side is lib/worker/core.ts, lib/worker/route.ts and app/api/worker/*. The client side is mac/MLX/Core/API.swift. Where the two disagree, the server is what counts.

Signing a request

Every request carries one header:

x-mlx-device: <pk>|<ts>|<sig>
PartWhat it is
pkthe device's compressed P-256 public key, lowercase hex, starting 02 or 03
tsthe time of signing, milliseconds since 1970
sigECDSA P-256 over SHA-256 of the message below: raw r‖s, lowercase hex

The signed message is canonical JSON (keys sorted, no spaces), built by deviceRequestMessage:

Signed message
{"body":"<sha256 hex of the request body>","chain":"<chain id>","device":"<pk>","method":"POST","path":"/api/worker/hello","ts":<ts>,"type":"mlx-device-request"}
  • body is the SHA-256 of the exact bytes sent, as hex. An empty body hashes the empty string.
  • chain is the chain id from GET /api/stats (chain).
  • path is the URL path only, without the host or a query.
  • ts is a number, not a string.

High-S signatures are accepted (verifyDevice in lib/chain/device.ts), which is what CryptoKit makes.

A signed POST
import { p256 } from "@noble/curves/p256";
import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";

// lib/chain/crypto.ts `canonical`: sorted keys, no whitespace
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)));

export async function devicePost(path, body, secretKey) {
  const { chain } = await (await fetch("https://mlx.app/api/stats")).json();
  const pk = bytesToHex(p256.getPublicKey(secretKey, true));
  const raw = JSON.stringify(body);
  const ts = Date.now();
  const message = canonical({ type: "mlx-device-request", chain, device: pk, method: "POST", path, body: hex(raw), ts });
  const sig = p256.sign(sha256(utf8ToBytes(message)), secretKey).toCompactHex();
  return fetch("https://mlx.app" + path, {
    method: "POST",
    headers: { "content-type": "application/json", "x-mlx-device": `${pk}|${ts}|${sig}` },
    body: raw
  });
}

What the server checks

signedDevice and deviceRoute check, in this order:

CheckError
The body isn't too large413 "Too large"
The header has a valid key and a number for ts401 "Unsigned request"
ts is close to the server's clock401 "Your Mac's clock is off by more than five minutes. …"
The signature verifies401 "Bad signature"
This signature wasn't used before409 "Request already used"
The Mac is under the route's rate limit429 "Slow down"
The body is JSON400 "Body must be JSON"

Once only. Each signature is accepted once (wk:sig:<sig>, kept for a while). Resending the same signed request is refused, so every request needs a new ts and signature.

Anyone can make a P-256 key and sign. An unknown key gets answers, but its status isn't kept until it is linked to a wallet (or has redeemed a beta invite), and it gets no jobs.

Rate limits

Limits count requests per Mac and per route, a minute at a time, on each server instance (limited in lib/worker/core.ts).

RouteLimit
/api/worker/job240 a minute (WORK_ROUTE_PER_MINUTE)
/api/worker/result240 a minute (WORK_ROUTE_PER_MINUTE)
/api/worker/hello, /api/worker/feedback, /api/worker/redeemtheir own, lower limits, set in each route file

A device route that names no limit gets 30 a minute (DEVICE_ROUTE_PER_MINUTE). Results have a second limit of their own: 300 a minute per Mac, counted across instances (see Results).

POST /api/worker/hello

POSThttps://mlx.app/api/worker/hello

The heartbeat. MLX Node sends it on a timer, at once when something changes (a pause, leaving the wallet), and shortly after it carries out remote commands. Every field is cleaned on the way in (hello in lib/worker/core.ts): strings are cut short and control characters removed, numbers are clamped, and anything of the wrong type is dropped.

FieldTypeNotes
appstringMLX Node's version
osstringmacOS version
chipstringas macOS names it, such as Apple M4 Pro. Sets the Mac's class (then checked, see Chip checks)
hwstringApple's model identifier, such as Mac16,10. Kept only if it looks like one
hwidstringMLX Node 0.14.4+: SHA-256 of "mlx-hw-v1|" + the Mac's hardware UUID (IOPlatformUUID, upper case), 64 hex digits. The UUID itself is never sent; the server hashes this again before keeping it. A device key keeps the first one it reports. See Fair use
memoryGBnumberunified memory
coresnumberCPU cores
statestringidle-waiting, working, paused, off, calibrating or downloading. Anything else is read as off
reasonstringwhat the panel says, such as Ready or Waiting for power. Decides availability while idle-waiting
thermalstringnominal, fair, serious or critical
onACbooleanon power
limitsobjectthe owner's limits; see Limits
calibrationobject{ tokPerSec, model } from the Mac's own speed test
podobject{ jaccl, rdma }: whether the Mac could join a Thunderbolt pod
stopsobject{ count, p50, max }: how fast work stopped when the owner came back, in ms
todayobject{ jobs, tokens, passRate } on this Mac today
tpsnumberlive tokens a second
ackstring[]ids of remote commands carried out since the last heartbeat

Of these, only chip, hw, hwid, state and reason can change what the Mac earns (hwid: one Mac is paid once an hour, however many device keys it has). tokPerSec, calibration, today and tps are shown in apps and never paid on.

The heartbeat also marks the current minute as available when the state is eligible, removes the acknowledged commands, and, for a linked Mac, stores its approximate city for MLX World (from the request's location headers; no IP address is kept).

The reply

FieldTypeNotes
accountstring | nullthe wallet this Mac is linked to
invitedbooleanwhether it redeemed a beta invite
jobsbooleanwhether it may ask for jobs: true when linked
calibrationobjectthe speed test to run: { modelId, prompt, maxTokens }
latestobject | nullthe public build: { version, notes, size }, size of the disk image in bytes
rewardsbooleanalways true
earningobject | nullwhat this Mac earns; null when it isn't linked. Below
commandsarrayremote commands waiting for this Mac: { id, action, limits?, at, by }. See Remote commands
catalogRev, inboxRevstring, number, or nullchange when MLX's app catalog (12 hex characters) or the announcements (the newest one's time) change. See Apps and updates
msgRevnumber | nullchanges when there is a new message for this Mac's wallet (MLX Node 0.16+)
storeRevstring | nullMLX Node 0.17+: the AI App Store catalogue's rev; it changes when a listing is added, changed, flagged or removed. See Catalogue API
nextnumberseconds until the next heartbeat. Shorter while commands are waiting

earning (earningView in lib/compute/rewards.ts). Amounts are MLX base units as strings unless noted.

FieldNotes
seatfree or dedicated
throttledtrue on a free seat
unitsthe units this Mac carries for its wallet, counting it as the wallet's only Mac with the wallet's biggest seat; 0 on a free seat
weightunits, or 0.125 on a free seat
nodeUnitsthe units of all the wallet's seats
carriesthe seat tier this Mac carries (S, MX …), or null
macTierthe biggest tier this Mac's checked class carries in full
macNameits name, such as Pro Latest
fullUnits, nodeNamethe units and name of the wallet's biggest seat on a Mac of its own class
chipthe chip on record; empty when unknown, and then the Mac counts as a base Mac
perDayCapMLX a day at most, at weight
perDayCapSmallthe same for a Core seat
freePerDaythe same for a free seat
todayMLX paid to the wallet today, UTC
minedthe wallet's mined MLX still locked
scorethis Mac's score so far this hour, as a number
startedwhether compute rewards have started
freeJobGapSthe average throttle wait, in seconds, for older apps
offersevery seat this Mac could carry, smallest first. Each: tier, name, price (USD), units, perDay (on this Mac), fullPerDay (on a Mac of its class), times (× a free seat), lockDays, fits (full or reduced), fit (see Fit badges) and earnsAs when reduced
best, maxTimesthe biggest seat that earns in full here, and how many times a free seat it earns
seatTier, seatName, seatNumber, seatson a dedicated seat: the wallet's biggest seat and how many it holds

POST /api/worker/job

POSThttps://mlx.app/api/worker/job

Asks for the next job.

Request body
{ "memoryGB": <the Mac's unified memory, GB> }

memoryGB decides which fixed test jobs fit. MLX Node 0.10 also sends open: the slice its last result was asked to open (the body of POST /api/worker/open), so it costs no extra round trip. The server may hold the request before it answers: for a free seat's throttle wait, and for the hand-out pacing (200 ms between hand-outs). See Jobs and checks.

ReplyWhen
{ job, reason: null, throttled, seat }a job: { id, model, modelId, prompt, maxTokens, temperature: 0, kind: "known-answer" }
{ job: null, retryIn, throttled: true, seat: "free", reason }a free seat with a long wait left; ask again after retryIn seconds
{ job: null, reason: "Link this Mac to your wallet first." }the Mac isn't linked

The job is recorded as handed to this Mac. Asking again before answering returns the same job.

A dedicated seat may get a paid job from the Compute API instead: id paid-<18 hex>, kind: "paid", the same fields and the same model. The Mac runs it like any other job and returns it with POST /api/worker/result; its text becomes the paying request's answer.

POST /api/worker/result

POSThttps://mlx.app/api/worker/result

Returns a finished or interrupted job.

FieldTypeNotes
jobstringthe id of the job this Mac holds
outHashstringSHA-256 hex of the generated token ids joined with commas
tokensnumbertokens generated; capped at the job's maxTokens
promptTokensnumber
tokPerSecnumberthe Mac's own measure; shown only
ttftMsnumbertime to first token
msnumberrun time
stopstringstop or length for a finished job. Anything else, such as cancelled, means it was cut short: the Mac keeps the job
textstringthe start of the output, for the admin's agreement view
idsarrayMLX Node 0.10: the generated token ids. Must hash to outHash.
commitobjectMLX Node 0.10: { v: 1, root, steps, layers, dtype }, the Merkle root over the run's hidden states (see Execution checks). steps is ids.length − 1, layers 24, dtype f16 or bf16. A commitment that doesn't match is ignored, not refused.
ReplyWhen
{ ok: true }an interrupted job, or a finished one with no verdict yet
{ ok: true, match: true | false }a finished job whose majority is clear: whether this Mac agreed
…, open: { j, l }the result carried a commitment: open decode step j, layer l with the next job request
403 "Link this Mac to your wallet first"the Mac isn't linked
429 "Slow down"over 300 results this minute, or over the route limit
409 "This job wasn't handed to this Mac"the job isn't the one this Mac holds
409 "Too fast for this job"faster than 1 ms a token; the job is dropped
409 "Result already received"this hand-out was already answered
400 "Unknown job", "Missing output hash"

MLX Node treats a 409 like a lost reply: it asks for the next job. Verdicts for the other Macs on the same job are written after the reply, so the Mac doesn't wait for them.

POST /api/worker/open

POSThttps://mlx.app/api/worker/open

MLX Node 0.10: the slice of its last run that the result's reply asked for. MLX Node sends the same object as open in its next job request instead; this endpoint takes it on its own.

FieldTypeNotes
job, j, lexactly as asked
x, h, ostringbase64 of the three rows (896 values each, in dtype): the layer's input, after attention, after the MLP
patharraythe Merkle path from that leaf to the root, hex

Replies { ok: true }, or 409 "Nothing to open for that job" when it isn't the slice this Mac was asked for (or it was already opened). A wrong path is recorded as a failed check at once; the MLP is checked by the server within a few minutes.

POST /api/worker/feedback

POSThttps://mlx.app/api/worker/feedback

Feedback from MLX Node's Settings: { text, diagnostics? }. Diagnostics are opt-in, and never contain prompts, outputs or file paths. Replies { ok: true }, 400 "Write something first" for an empty text, or 403 when the server has never kept a heartbeat from this Mac.

POST /api/worker/redeem

POSThttps://mlx.app/api/worker/redeem

Redeems a Mac beta invite code on this Mac: { code }. Replies { ok: true, wave } or 400 with the reason. An invite is no longer needed to get jobs.

Device lookup

GEThttps://mlx.app/api/device/<pk>

Unsigned and public: which wallet a device is linked to. MLX Node polls it after it opens the wallet to link.

Response
{"linked":false,"account":null,"at":null}

at is the time of the link (ms) when there is one. MLX Node waits for it to grow.