The worker protocol
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>| Part | What it is |
|---|---|
pk | the device's compressed P-256 public key, lowercase hex, starting 02 or 03 |
ts | the time of signing, milliseconds since 1970 |
sig | ECDSA 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:
{"body":"<sha256 hex of the request body>","chain":"<chain id>","device":"<pk>","method":"POST","path":"/api/worker/hello","ts":<ts>,"type":"mlx-device-request"}bodyis the SHA-256 of the exact bytes sent, as hex. An empty body hashes the empty string.chainis the chain id fromGET /api/stats(chain).pathis the URL path only, without the host or a query.tsis a number, not a string.
High-S signatures are accepted (verifyDevice in lib/chain/device.ts), which is what CryptoKit makes.
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:
| Check | Error |
|---|---|
| The body isn't too large | 413 "Too large" |
The header has a valid key and a number for ts | 401 "Unsigned request" |
ts is close to the server's clock | 401 "Your Mac's clock is off by more than five minutes. …" |
| The signature verifies | 401 "Bad signature" |
| This signature wasn't used before | 409 "Request already used" |
| The Mac is under the route's rate limit | 429 "Slow down" |
| The body is JSON | 400 "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).
| Route | Limit |
|---|---|
/api/worker/job | 240 a minute (WORK_ROUTE_PER_MINUTE) |
/api/worker/result | 240 a minute (WORK_ROUTE_PER_MINUTE) |
/api/worker/hello, /api/worker/feedback, /api/worker/redeem | their 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
https://mlx.app/api/worker/helloThe 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.
| Field | Type | Notes |
|---|---|---|
app | string | MLX Node's version |
os | string | macOS version |
chip | string | as macOS names it, such as Apple M4 Pro. Sets the Mac's class (then checked, see Chip checks) |
hw | string | Apple's model identifier, such as Mac16,10. Kept only if it looks like one |
hwid | string | MLX 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 |
memoryGB | number | unified memory |
cores | number | CPU cores |
state | string | idle-waiting, working, paused, off, calibrating or downloading. Anything else is read as off |
reason | string | what the panel says, such as Ready or Waiting for power. Decides availability while idle-waiting |
thermal | string | nominal, fair, serious or critical |
onAC | boolean | on power |
limits | object | the owner's limits; see Limits |
calibration | object | { tokPerSec, model } from the Mac's own speed test |
pod | object | { jaccl, rdma }: whether the Mac could join a Thunderbolt pod |
stops | object | { count, p50, max }: how fast work stopped when the owner came back, in ms |
today | object | { jobs, tokens, passRate } on this Mac today |
tps | number | live tokens a second |
ack | string[] | 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
| Field | Type | Notes |
|---|---|---|
account | string | null | the wallet this Mac is linked to |
invited | boolean | whether it redeemed a beta invite |
jobs | boolean | whether it may ask for jobs: true when linked |
calibration | object | the speed test to run: { modelId, prompt, maxTokens } |
latest | object | null | the public build: { version, notes, size }, size of the disk image in bytes |
rewards | boolean | always true |
earning | object | null | what this Mac earns; null when it isn't linked. Below |
commands | array | remote commands waiting for this Mac: { id, action, limits?, at, by }. See Remote commands |
catalogRev, inboxRev | string, number, or null | change when MLX's app catalog (12 hex characters) or the announcements (the newest one's time) change. See Apps and updates |
msgRev | number | null | changes when there is a new message for this Mac's wallet (MLX Node 0.16+) |
storeRev | string | null | MLX Node 0.17+: the AI App Store catalogue's rev; it changes when a listing is added, changed, flagged or removed. See Catalogue API |
next | number | seconds 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.
| Field | Notes |
|---|---|
seat | free or dedicated |
throttled | true on a free seat |
units | the 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 |
weight | units, or 0.125 on a free seat |
nodeUnits | the units of all the wallet's seats |
carries | the seat tier this Mac carries (S, MX …), or null |
macTier | the biggest tier this Mac's checked class carries in full |
macName | its name, such as Pro Latest |
fullUnits, nodeName | the units and name of the wallet's biggest seat on a Mac of its own class |
chip | the chip on record; empty when unknown, and then the Mac counts as a base Mac |
perDayCap | MLX a day at most, at weight |
perDayCapSmall | the same for a Core seat |
freePerDay | the same for a free seat |
today | MLX paid to the wallet today, UTC |
mined | the wallet's mined MLX still locked |
score | this Mac's score so far this hour, as a number |
started | whether compute rewards have started |
freeJobGapS | the average throttle wait, in seconds, for older apps |
offers | every 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, maxTimes | the biggest seat that earns in full here, and how many times a free seat it earns |
seatTier, seatName, seatNumber, seats | on a dedicated seat: the wallet's biggest seat and how many it holds |
POST /api/worker/job
https://mlx.app/api/worker/jobAsks for the next job.
{ "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.
| Reply | When |
|---|---|
{ 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
https://mlx.app/api/worker/resultReturns a finished or interrupted job.
| Field | Type | Notes |
|---|---|---|
job | string | the id of the job this Mac holds |
outHash | string | SHA-256 hex of the generated token ids joined with commas |
tokens | number | tokens generated; capped at the job's maxTokens |
promptTokens | number | |
tokPerSec | number | the Mac's own measure; shown only |
ttftMs | number | time to first token |
ms | number | run time |
stop | string | stop or length for a finished job. Anything else, such as cancelled, means it was cut short: the Mac keeps the job |
text | string | the start of the output, for the admin's agreement view |
ids | array | MLX Node 0.10: the generated token ids. Must hash to outHash. |
commit | object | MLX 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. |
| Reply | When |
|---|---|
{ 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
https://mlx.app/api/worker/openMLX 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.
| Field | Type | Notes |
|---|---|---|
job, j, l | exactly as asked | |
x, h, o | string | base64 of the three rows (896 values each, in dtype): the layer's input, after attention, after the MLP |
path | array | the 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
https://mlx.app/api/worker/feedbackFeedback 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
https://mlx.app/api/worker/redeemRedeems 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
https://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.
{"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.
Related
POST /api/log/cosignis signed the same way: a Mac cosigning a checkpoint. See Witnesses.- What the server does with the work: Jobs and checks and Compute rewards.