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

```text
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`:

```json title="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.

```js tab=JavaScript title="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
  });
}
```

```swift tab=Swift title="A signed POST"
import CryptoKit

// MLX Node builds `message` with Signing.requestMessage (mac/MLX/Core/Canonical.swift):
// the same canonical JSON, keys sorted by UTF-16 code units like JavaScript.
let key = try SecureEnclave.P256.Signing.PrivateKey()
let hex = { (d: Data) in d.map { String(format: "%02x", $0) }.joined() }
let pk = hex(key.publicKey.compressedRepresentation)
let sig = hex(try key.signature(for: Data(message.utf8)).rawRepresentation)  // SHA-256 inside
request.setValue("\(pk)|\(ts)|\(sig)", forHTTPHeaderField: "x-mlx-device")
```

### 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](/protocol/jobs#results)).

## POST /api/worker/hello {#hello}

```endpoint
POST /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.

| 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](/protocol/jobs#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](/resources/fair-use#hardware) |
| `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](/node/remote-commands#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](/node/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](/node/apps) |
| `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](/store/catalog) |
| `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](/protocol/seats#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 {#job}

```endpoint
POST /api/worker/job
```

Asks for the next job.

```text title="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](#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](/protocol/jobs).

| 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](/compute/how-jobs-run) 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 {#result}

```endpoint
POST /api/worker/result
```

Returns 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](/protocol/jobs#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 {#open}

```endpoint
POST /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](#job) 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 {#feedback}

```endpoint
POST /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 {#redeem}

```endpoint
POST /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 {#device}

```endpoint
GET /api/device/<pk>
```

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

```json title="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.

## Related

- `POST /api/log/cosign` is signed the same way: a Mac cosigning a checkpoint. See [Witnesses](/integrity/witnesses).
- What the server does with the work: [Jobs and checks](/protocol/jobs) and [Compute rewards](/protocol/rewards).
