Every call needs a key from an approved account — see Accounts. Three AI API versions are live: v3 is current, v1 and v2 keep working. mPAKT Compute (boxes that run your code on volunteers' devices) is a separate service at version 0, beta. The same reference as plain text is at /llms.txt.
Compute endpoints take an API key in the x-api-key header (Authorization: Bearer mpakt_... also works). Keys belong to accounts. Accounts are by invitation: the grid's owner sends you a temporary username and password, and you replace the password the first time you sign in. Signing in gives a session token, which manages the account; the key is what compute calls send.
| Secret | Looks like | Used for |
|---|---|---|
| password | 12+ characters | Signing in. Stored as a slow salted hash. |
| session token | mps_… | Account endpoints, as Authorization: Bearer. Lasts 7 days. |
| API key | mpakt_… | Every compute endpoint. Shown once when created; only its hash is kept. |
Ask the owner of the grid for an account. They send a temporary username (guest-…) and password, valid for 7 days. Signing in with them opens a one-hour setup session that can do one thing: choose your own password (and, if you like, your own username).
curl -X POST https://YOUR-HOST/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"guest-amber-x7k2","password":"tmp-amber-birch-…"}'
# { "token": "mps_...", "setupRequired": true, ... }
curl -X POST https://YOUR-HOST/api/auth/complete-setup -H "Authorization: Bearer mps_..." \
-H "Content-Type: application/json" -d '{"newPassword":"a long password of your own","username":"alice"}'
# { "token": "mps_...", "expiresAt": ..., "account": { ... } } a full 7-day session
curl -X POST https://YOUR-HOST/api/account/keys -H "Authorization: Bearer mps_..." \
-H "Content-Type: application/json" -d '{"label":"laptop"}'
# 201 { "key": "mpakt_...", "id": "k_...", "prefix": "mpakt_Ab12Cd34" }
The new password must be 12+ characters, must not contain the temporary username, and cannot be the temporary password or anything shaped like one. A username of your own is optional: 3–32 letters, digits, _ - ., not starting guest-. An expired invitation answers 403; ask for another. The account page does all of this in a browser, and also signs in, creates and revokes keys and changes passwords later.
Everything an account loads — models, sessions, compiled graphs — is shared by all of its keys, so revoking one key strands nothing.
Keys from before accounts still exist. Each was given an account of its own, which the owner approves like any other; until then the key answers 403. Its holder can attach a username and password with POST /api/auth/claim and from then on manage it like any account.
Invited accounts start unverified; the owner verifies the ones they trust (GET /api/account shows verified). Unverified accounts can use the AI API, the chat (without tools) and Compute boxes without internet. Only verified accounts can:
Dockerfile/image) or connect their own domains to boxes;The endpoints involved answer 403 with "verifiedOnly": true. If an account loses verification, its boxes' internet and custom domains are switched off and its container boxes are disabled.
| Endpoint | Purpose |
|---|---|
| POST /api/auth/login | Returns a session token. With a temporary password, the token is a setup session ("setupRequired": true). 403 while pending, rejected or disabled, or when an invitation has expired. For an account with no password yet: 403 with "passwordNotSet": true and "setWith" — "setupCode" (the owner: use /api/auth/setup) or "apiKey" (use /api/auth/claim). |
| POST /api/auth/complete-setup | With a setup session: newPassword, optional username. Returns a full session. |
| GET /api/account | The signed-in account. |
| POST /api/account/password | currentPassword, newPassword. Signs out every other session. |
| POST /api/account/logout | Ends this session. |
| GET /api/account/keys | Ids, labels, prefixes and last use — never the keys. |
| POST /api/account/keys | Create a key (at most 20 per account). |
| DELETE /api/account/keys/:id | Revoke it; effective immediately. |
| POST /api/keys | The original way to get a key, now needing a session token. Returns { "ok", "key" }. |
| POST /api/keys/revoke | { "key" } — revoke a key by presenting it, no sign-in. Its account's resources go with its last key. |
| POST /api/auth/claim | { "key", "username", "password" } — give the account of a key from before accounts a password, and optionally a new username (omit it to keep the current one). Once. |
The owner account, tnme (OWNER_USERNAME), exists from the first start with no password. The server writes a one-time setup code to <data dir>/owner-setup-code, readable only by the service user, and the password can only be set by presenting it. On the account page, signing in as the owner asks for the code and a new password; the same over the API:
curl https://YOUR-HOST/api/auth/setup
# { "needed": true, "owner": "tnme" }
curl -X POST https://YOUR-HOST/api/auth/setup -H "Content-Type: application/json" \
-d '{"code":"<contents of owner-setup-code>","password":"a long owner password"}'
# signs in as the owner; the code file is deleted
Or on the server itself: npm run admin -- setup, then npm run admin -- invite "for alice" prints a temporary username and password to send her, and npm run admin -- verify alice verifies her once you trust her. A signed-in owner gets the same, plus approve, reject, disable, enable and delete, on the account page.
| Endpoint | Purpose |
|---|---|
| POST /api/admin/invites | { "note"? } — creates an approved, unverified account under a temporary username and password. Both are in this response and nowhere else; they stop working after 7 days if unused. |
| GET /api/admin/accounts?status=pending | Accounts, pending first, with each one's note. |
| POST /api/admin/accounts/:user/approve | Let it sign in. :user is a username or id. |
| POST /api/admin/accounts/:user/verify | Let it download the volunteer programs, give boxes internet, run containers, use its own domains and use chat tools. /unverify takes that back. |
| POST /api/admin/accounts/:user/reject | Refuse a pending registration. |
| POST /api/admin/accounts/:user/disable | Stop its keys and sessions and release everything it holds in the network. |
| POST /api/admin/accounts/:user/enable | Undo a disable. Its keys work again. |
| DELETE /api/admin/accounts/:user | Delete it, its keys and everything it holds. |
v2 could express a matrix multiply but not a transformer. Running two decoder layers of a real model meant pulling activations back to your machine between every pair of steps it could offload, and getting the weights out there meant uploading them one HTTP request at a time into a coordinator-side quota. v3 fixes both.
| Problem in v2 | What v3 does |
|---|---|
| Weights uploaded from your machine, base64 in JSON, against a 128 MB per-key quota | You name a model. The coordinator streams it from the model host straight to volunteers. Nothing is uploaded and nothing is stored, so there is no quota to hit. |
| bf16 weights widened to f32 before upload, doubling the bytes | bf16 and f16 travel as-is and are widened on the volunteer's device. |
No rmsnorm, rope, grouped-query attention or swiglu, so a layer needed 3+ round trips | All of them are operations. A whole decoder layer is one request. |
| Nothing persisted between calls, so a KV cache had to be resent every token | Sessions keep the cache resident on one worker and update it in place. |
Look before you load. This reads only the tensor index — one range request, no weight data:
curl "https://YOUR-HOST/api/v3/models/inspect?source=hf:Qwen/Qwen3-0.6B" \
-H "x-api-key: YOUR_KEY"
# { "count": 311, "totalBytes": 1191000000, "dtypes": { "bf16": 311 },
# "tensors": [ { "name": "model.layers.0.self_attn.q_proj.weight",
# "shape": [1024, 2048], "dtype": "bf16", "bytes": 4194304 }, ... ] }
Then load the parts you want. include and exclude take glob patterns; names takes an exact list.
curl -X POST "https://YOUR-HOST/api/v3/models" \
-H "x-api-key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"source":"hf:Qwen/Qwen3-0.6B",
"include":["model.layers.26.*","model.layers.27.*"]}'
# { "modelId": "m_...", "tensors": [ { "name": "...", "tensorId": "t_..." }, ... ],
# "bytesTransferred": 71300000, "throughputMBs": 24.6, "storedOnCoordinator": 0 }
Sources. hf:Org/Model, hf:Org/Model@revision/file.safetensors, or any https:// URL to a safetensors file whose host honours range requests.
Sharded models work. Most models past a couple of gigabytes ship as an index plus numbered shards rather than one file. If model.safetensors is absent, the loader reads model.safetensors.index.json beside it, takes the shard names from weight_map, and range-requests each one. You can also name the index outright. Shard names are never guessed — the usual model-00001-of-000NN convention is not universal, and real repos are zero-indexed — so weight_map is the only source of truth. inspect reports sharded and shards.
Large tensors are streamed in pieces. An embedding table is vocab x hidden and is the largest tensor in essentially every model — 134 MB in a 350M model, 525 MB in a 2B one. Tensors are fetched and forwarded in 16 MB pieces, so coordinator memory is bounded by the piece size rather than by the model. There is no per-tensor ceiling here; a tensor does still have to fit on one worker, since it cannot span them.
Preflight without transferring anything. GET /api/versions carries a limits block, and /api/v3/status reports device.maxTensorBytes — the largest single tensor any one connected worker could hold. Between those and inspect you can decide whether a load will succeed before starting it.
Weights stay packed. A bf16 model occupies its published size on a volunteer, not twice it — the operations that consume weights read the packed form in place. limits.widenFactor reports 1 for every dtype when the whole pool does this, and 2 for the half-precision ones when a worker in the pool predates it, since a tensor could land on any of them.
limits.byteUnits says so explicitly. With packed weights the two coincide for bf16; without, resident is twice stored. Compare device.maxTensorBytes against the resident figure, or you will accept a model that cannot be placed and find out only after transferring it.Quantizing on load. A model that is too big for the pool can be held in a smaller form. Add "quantize" to the load:
{"source":"hf:HuggingFaceTB/SmolLM2-135M-Instruct", "quantize":"q8"}
# { ..., "quantize": "q8", "residentBytes": 143000000, "bytesTransferred": 270000000, ... }
quantize | |
|---|---|
| "none" | As published (bf16, f16 or f32, kept packed). The default, unless the operator has chosen another default for the grid. |
| "q8" | Matrices held as 8-bit integers with a 16-bit scale per block of 32 weights: 8.5 bits per weight against 16 for bf16. |
| "q4" | The same with 4-bit integers and a scale and offset per block: 5 bits per weight. The embedding table, the output head and the value and down projections stay at q8, where 4 bits hurts most. |
| "auto" | The least aggressive of none, q8 and q4 whose resident size fits the pool right now. |
Nothing changes on the wire: the published bytes travel as before and each volunteer converts them as they arrive, so the results do not depend on where the conversion ran. Norms, biases, small tensors and anything whose rows are not a multiple of 32 wide are left as published. The ops that read weights (matmul, linear, moe, embedding) use the quantized form directly. The load response reports quantize and residentBytes. A volunteer whose program predates this keeps the published dtype, so a request is never answered wrongly, only less approximately; capacity is planned at the quantized size only when every connected volunteer can do it. This costs accuracy. On SmolLM2-135M-Instruct, over 49 teacher-forced positions against bf16, q8 agreed with the top token on 48 (KL divergence 9e-4; weights 270 to 143 MB) and q4 on 43 (KL 0.07; 110 MB). Larger models lose less, but test yours; the chat page uses q8 or q4 only when a model does not otherwise fit, and says which.
Capacity is checked first. If the selection does not fit in the volunteers currently connected, the request is refused with the numbers, before anything is transferred:
{ "error": "model needs 1420 MB but only 890 MB is free across 6 worker(s)
— load fewer layers or wait for more volunteers" }
Durability. Weights are not stored on the coordinator, so if a volunteer holding one disappears it is re-fetched from the same URL and byte range and pushed to someone else. The model host is the backing store. Your graphs do not notice.
/api/v3/status reports backends — a count of what the pool is made of:
| Backend | |
|---|---|
| webgpu | WGSL compute shaders. The usual case, and the fastest for most work. |
| npu | A neural processing unit through WebNN — Intel AI Boost, Qualcomm Hexagon, the Apple Neural Engine. Silicon that is otherwise idle, so it does not compete with the browser's rendering and draws less power for the same arithmetic. |
| npu+webgpu | Both present. The worker times the first few matmuls on each and keeps the faster; an NPU is not automatically quicker for a given shape. |
| cpu | Plain JavaScript kernels. Always available, and the fallback for anything a device declines. |
You do not choose. The scheduler ranks workers on measured end-to-end time, so a backend earns work by being fast rather than by being named.
TPUs are not reachable from a browser — Cloud TPUs have no client-side API and a Coral Edge TPU needs a native driver — so they are not offered.
Same shape as v2, with inputs that can name something already in the network instead of carrying data.
| Input form | Meaning |
|---|---|
| { name, shape, data | dataB64 } | Uploaded with this call. Use for activations. |
| { name, model, weight } | A weight from a loaded model, by its name in the file. Nothing transfers. |
| { name, session } | A tensor carried over from a previous step in this session. |
| { name, tensorId } | A tensor uploaded with the v2 tensor endpoint. |
Add "encoding":"base64" beside graph to get outputs back as base64 f32 rather than a JSON number array — about a quarter the bytes, which matters for logits.
Every worker ships all of these. Nothing is compiled or downloaded per job. Shapes are row-major f32; ops that speak of rows and columns fold every leading dimension into rows, so [batch, seq, channels] behaves as [batch*seq, channels]. GET /api/ops describes each one, with its parameters, as JSON.
Parameters marked feature are optional capabilities. A step using one is routed only to volunteers that implement it, and a graph that no connected volunteer can run is refused with the missing op or feature named — never computed without it.
| Op | Inputs | Params | Meaning |
|---|---|---|---|
| matmul | a, b | transB | Row-major product. transB reads the right operand as [out, in] — the layout checkpoints store weights in — so no transposed copy is needed and no transpose steps are spent. It is also the faster arrangement: both operands are then walked contiguously. A weight is read as it is held on the volunteer (f32, bf16, f16, or the 8- and 4-bit forms below) without being widened to f32 first; a product with a few rows, such as a decode step, has a kernel of its own. |
| linear | x, w, bias | transB | x·w + bias, fused. transB as for matmul. |
| rmsnorm v3 | x, gamma | eps; features weightOffset, groupSize | RMS normalisation over the last dimension. weightOffset: 1 multiplies by 1 + gamma (Gemma). groupSize normalises each group of channels separately — per-head q/k norm (Qwen3, Gemma 3) without a reshape; gamma then has groupSize values. |
| rope v3 | x | headDim, offset, base; features rotaryDim, interleaved, scaling (invFreq, attentionFactor) | Rotary embedding. offset is the absolute position of the first row. Half-split layout by default (Llama, Qwen, Mistral, NeoX); interleaved pairs adjacent channels (GPT-J, Cohere, GLM). rotaryDim rotates only the first channels of each head. scaling takes config.json's rope_scaling as-is — llama3, yarn, longrope, linear, dynamic — computed as transformers computes it. |
| attention v3 | q, k, v, bias? | heads, headDim, kvHeads, vHeadDim, causal, posOffset; features scale, softcap, window, alibi, bias | Fused scaled dot-product attention with grouped-query support; the score matrix stays on the worker, and only the keys a row can see are read. Computed in one pass with a running softmax; for a few query rows (a decode step) the keys are also split across the device and combined, with the same result as one pass. vHeadDim for value widths that differ (MLA). window is sliding-window attention, masked exactly as HF does. softcap and scale cover Gemma 2; alibi: true BLOOM, alibiMax: 8 MPT. A fourth input [T,S] or [heads,T,S] is added to the scores (T5 position bias, custom masks — use -1e9 to mask). |
| swiglu v3 | gate, up | silu(gate) * up. | |
| geglu v3 | gate, up | gelu(gate) * up — the Gemma feed-forward. | |
| moe v3 | x, logits, bias?, experts… | topK, scoring, normalize, scale, nGroup, topKGroup, groupTopK, selectionBias, layout, activation | A sparse mixture-of-experts feed-forward as one op: routes every token to its top-k experts, runs only those, sums the weighted results. Expert weights as stored, either one gate/up/down per expert (layout: "separate", as on the Hub) or 3-D "fused"/"stacked" tensors. Covers Mixtral, Qwen-MoE, OLMoE and DeepSeek-V2/V3 routing — see Architectures. |
| embedding v3 | ids, table | Row lookup; ids are integers stored as f32. | |
| short_conv v3 | x, w_in, w_conv, w_out, state | A whole gated short-conv block as one step. Weights passed as stored [out, in]. state is advanced in place. See below. | |
| conv1d v3 | x, weight, state | Depthwise causal 1-D convolution. x [T,C], weight [C,1,K], state [K-1,C] holding the positions immediately before x. Pure — see below for carrying the state. | |
| argmax v3 | a | Index of the maximum over the last axis. This is how a token is chosen without sending the logits home — four bytes instead of 600 KB. Ties resolve to the lowest index. | |
| reduce_max v3 | a | Maximum over the last axis, one per row. | |
| sample v3 | a | temperature, topK, topP, seed, step | Draws a token on the device. temperature: 0 is exactly argmax. The RNG is counter-based on (seed, step), so a generation replays exactly and the worker holds no sampler state. |
| cache_write v3 | cache, values | offset | Writes rows into a cache in place and returns it. The only op with a side effect. |
| zeros v3 | — | shape | Allocates zeros in the network, so a cache costs no upload. |
| concat / slice v3 | varies | axis, start, length | Work on the leading axis or the last (feature) one; axis: -1 means the last. Middle axes are not supported. |
| reshape v3 | a | shape | Same bytes, new shape. |
| add / sub / mul | a, b | Elementwise; b may be the same shape, a row vector, or a scalar. | |
| scale | a | factor | Multiply by a constant. |
| relu / silu / tanh / sigmoid | a | Activations. | |
| gelu | a | GELU, tanh approximation — HF gelu_new and gelu_pytorch_tanh (GPT-2, Phi-2, Gemma). | |
| gelu_exact v3 | a | GELU with erf — HF gelu (BERT, Falcon, GPT-NeoX). Not interchangeable with gelu. | |
| gelu_quick v3 | a | x·sigmoid(1.702x) — HF quick_gelu (CLIP). | |
| softcap v3 | a | cap | cap·tanh(a/cap) — Gemma 2 final-logit softcapping. |
| softmax | a | Over the last dimension, max-subtracted. | |
| layernorm | x, gamma, beta | feature eps | Over the last dimension. eps defaults to 1e-5 (BERT uses 1e-12). |
| transpose | a | 2-D transpose. | |
| causal_mask | a | Sets the strict upper triangle of each square block to -inf. | |
| reduce_sum / reduce_mean | a | Over every element. |
{"graph":{
"inputs":[
{"name":"x","shape":[6,1024],"dataB64":"..."},
{"name":"ln","model":"m_...","weight":"model.layers.26.input_layernorm.weight"},
{"name":"wq","model":"m_...","weight":"model.layers.26.self_attn.q_proj.weight"},
{"name":"wk","model":"m_...","weight":"model.layers.26.self_attn.k_proj.weight"},
{"name":"wv","model":"m_...","weight":"model.layers.26.self_attn.v_proj.weight"},
{"name":"wo","model":"m_...","weight":"model.layers.26.self_attn.o_proj.weight"}],
"steps":[
{"op":"rmsnorm", "in":["x","ln"], "out":"h", "params":{"eps":1e-6}},
{"op":"matmul", "in":["h","wq"], "out":"q0"},
{"op":"matmul", "in":["h","wk"], "out":"k0"},
{"op":"matmul", "in":["h","wv"], "out":"v"},
{"op":"rope", "in":["q0"], "out":"q", "params":{"headDim":128,"offset":0}},
{"op":"rope", "in":["k0"], "out":"k", "params":{"headDim":128,"offset":0}},
{"op":"attention","in":["q","k","v"], "out":"a",
"params":{"heads":16,"headDim":128,"kvHeads":8,"causal":true}},
{"op":"matmul", "in":["a","wo"], "out":"ao"},
{"op":"add", "in":["x","ao"], "out":"y"}],
"outputs":["y"]}}
Most differences between transformer families are a parameter, not a different graph. Map config.json onto them rather than approximating — several of these give plausible wrong numbers, not errors, when left out.
| config.json / model | Parameter |
|---|---|
| rope_scaling / rope_parameters | rope scaling, the object as-is. Llama 3.1, 3.2 and 3.3 use llama3 and are wrong without it even at short context. For longrope include max_position_embeddings unless attention_factor is set. |
| partial_rotary_factor, rotary_dim | rope rotaryDim (GPT-NeoX/Pythia, Phi-2, StableLM, GPT-J). |
| GPT-J, CodeGen, Cohere, GLM | rope interleaved: true. |
| sliding_window | attention window, on the layers that slide (Mistral v0.1, Gemma 2/3, Cohere 2). |
| attn_logit_softcapping · final_logit_softcapping | attention softcap · a softcap op on the logits (Gemma 2). |
| query_pre_attn_scalar · attention_multiplier | attention scale = value-0.5 (Gemma) · the multiplier (Granite). |
| ALiBi | attention alibi: true (BLOOM, Baichuan-13B) or alibiMax: 8 (MPT). |
| q_norm / k_norm per head | rmsnorm groupSize: head_dim (Qwen3, Gemma 3). |
| Gemma norms and embeddings | rmsnorm weightOffset: 1; scale the embeddings by √hidden_size. |
| hidden_act | gelu → gelu_exact; gelu_new, gelu_pytorch_tanh → gelu; quick_gelu → gelu_quick; Gemma's MLP → geglu. |
| layer_norm_eps | layernorm eps. |
| Mixtral | moe with scoring: "softmax", normalize: true. |
| Qwen2 / Qwen3-MoE, OLMoE | moe softmax, normalize = norm_topk_prob. Qwen's shared expert is an ordinary MLP scaled by sigmoid of its gate. |
| DeepSeek-V2 / V3 | moe with scoring: "sigmoid" (V3), selectionBias = e_score_correction_bias, nGroup/topKGroup, normalize, scale = routed_scaling_factor. MLA attention uses vHeadDim and feature-axis slices. |
Tensors stored as f64, int64 or bool — buffers such as position_ids and masks that many checkpoints carry — are converted to f32 as they stream, so loading a whole checkpoint no longer fails over them.
Still refused: quantised checkpoints (GPTQ, AWQ, bitsandbytes, int8, fp8) — there is no dequantisation path, and guessing one would give plausible wrong numbers. The error says so and names the tensors.
A session is a named tensor namespace pinned to one worker. Tensors named in retain stay there under a name of your choosing instead of being returned, which is what makes a decode step one round trip: the cache never travels.
POST /api/v3/sessions {"modelId":"m_..."} -> { "session": { "sessionId": "s_..." } }
GET /api/v3/sessions/<id> -> resident tensors, bytes, step count
DELETE /api/v3/sessions/<id> -> releases everything it holds
Allocate the cache once, in the network:
{"graph":{"session":"s_...","inputs":[],
"steps":[{"op":"zeros","in":[],"out":"kc","params":{"shape":[2048,1024]}},
{"op":"zeros","in":[],"out":"vc","params":{"shape":[2048,1024]}}],
"retain":{"kcache":"kc","vcache":"vc"}}}
Then each token writes into it at its position and attends over it. cache_write mutates in place, so the cache is one allocation no matter how long the context grows:
{"graph":{"session":"s_...",
"inputs":[{"name":"x","shape":[1,1024],"dataB64":"..."},
{"name":"kc","session":"kcache"},
{"name":"vc","session":"vcache"},
{"name":"wk","model":"m_...","weight":"...k_proj.weight"}],
"steps":[
{"op":"matmul", "in":["x","wk"], "out":"k0"},
{"op":"rope", "in":["k0"], "out":"kr", "params":{"headDim":128,"offset":41}},
{"op":"cache_write","in":["kc","kr"], "out":"kc2","params":{"offset":41}},
{"op":"attention", "in":["q","kc2","vc2"], "out":"a",
"params":{"heads":16,"headDim":128,"kvHeads":8,"causal":true,"posOffset":41}}],
"outputs":["a"],
"retain":{"kcache":"kc2","vcache":"vc2"}}}
You do not need retain for a cache. cache_write mutates the tensor the session already holds, so the session still points at it afterwards — listing all 56 cache tensors in retain on every call achieves nothing. Use retain only for a value that is genuinely a new tensor you want to keep.
Decoding several independent conversations on the same model can share one pass: the weights are read once for all of them. A graph may list more than one session, all pinned to the same worker, and an input may name which one it reads:
{"graph":{"session":"s_a", "sessions":["s_a","s_b","s_c"],
"inputs":[{"name":"kc_a","session":"kcache","of":0},
{"name":"kc_b","session":"kcache","of":1}, ...],
...}}
session is the primary one (of counts from 0 along sessions; an input without of reads the primary), at most 64 are listed, and a session that lives on a different worker is refused. The graph runs there and cache_write updates each cache in place, so each conversation's tokens are those it would have had alone. The chat page does this for conversations that happen to decode at the same time.
A session's cache is mutated in place, so unlike an ordinary tensor it cannot be rebuilt from lineage. By default, losing the worker holding it means the next call fails and you must re-prefill. Checkpointing avoids that:
POST /api/v3/sessions/<id>/checkpoint -> { "tensors": 56, "bytes": 469762048 }
POST /api/v3/sessions/<id>/restore -> puts it back on another volunteer
With a checkpoint in place, a disconnect is handled automatically — the coordinator restores onto another volunteer and the next call proceeds. Worth doing once after prefilling a long prompt.
conv1d is the depthwise causal convolution used by hybrid conv/attention stacks — LFM2, and Mamba- or Hyena-style gated convs. It is deliberately pure: the state it reads is not modified. Carrying the state forward uses ops that already exist, in the same graph, so a decode step is still one round trip:
{"op":"conv1d", "in":["x","w","st"], "out":"y"},
{"op":"slice", "in":["st"], "out":"tail","params":{"start":1,"length":K-2}},
{"op":"concat", "in":["tail","x"], "out":"next"},
{"op":"cache_write","in":["st","next"], "out":"st2", "params":{"offset":0}}
Prefilling T ≥ K-1 rows at once needs only slice(x, T-K+1, K-1) for the new state. Two extra ops rather than a second in-place mechanism to get right — and cache_write is the one already carrying every KV cache.
A fused projection splits with the ops you have. slice accepts a model weight directly, so an LFM2 conv.in_proj [3C, C] or a Phi-3 qkv_proj becomes three parts on axis 0, each transposed, with no new ops:
{"op":"slice", "in":["Win"], "out":"Wb","params":{"start":0, "length":C}},
{"op":"slice", "in":["Win"], "out":"Wg","params":{"start":C, "length":C}},
{"op":"slice", "in":["Win"], "out":"Wx","params":{"start":2*C, "length":C}},
{"op":"transpose","in":["Wb"], "out":"Wbt"},
{"op":"matmul", "in":["x","Wbt"], "out":"B"}
Because slice and concat work on the feature axis, a head can be cut apart, partially rotated and put back — which is what DeepSeek-style low-rank attention needs, since it ropes only the last 64 of each 192-wide query head:
{"op":"reshape","in":["q"], "out":"qf","params":{"shape":[T*heads,192]}},
{"op":"slice", "in":["qf"], "out":"qn","params":{"start":0, "length":128,"axis":1}},
{"op":"slice", "in":["qf"], "out":"qp","params":{"start":128,"length":64, "axis":1}},
{"op":"rope", "in":["qp"], "out":"qr","params":{"headDim":64,"offset":41}},
{"op":"concat", "in":["qn","qr"],"out":"qc","params":{"axis":1}},
{"op":"reshape","in":["qc"], "out":"qq","params":{"shape":[T,heads*192]}}
Six ops, one graph, one round trip. Pair it with vHeadDim on attention when the value width differs from the query/key width, and the whole MLA block is expressible with no operation specific to it.
short_conv does the whole thing in one step — nine ops down to one, which in a 16-layer model with ten conv layers is eighty fewer steps for the worker to dispatch:
{"op":"short_conv","in":["x","w_in","w_conv","w_out","state"],"out":"y"}
| Input | Shape | |
|---|---|---|
| x | [T, C] | the layer input |
| w_in | [3C, C] | in_proj as stored; transposed on the device |
| w_conv | [C, 1, K] | as stored, no reshape needed |
| w_out | [C, C] | out_proj as stored |
| state | [K-1, C] | the last K-1 rows of Bx; advanced in place |
The computation, in order:
BCx = in_proj(x) # [T, 3C], chunked in order B, C, x
Bx = B * x # gate BEFORE the convolution
conv = conv1d(Bx) # depthwise causal, over the carried state
y = C * conv # gate AFTER the convolution
out = out_proj(y)
Because state is written in place, a session carrying it needs no retain clause, and it stays one allocation however long the sequence runs.
A decode step for a 28-layer model is around 650 operations. Sent inline that is roughly 78 KB of JSON per token to move four bytes of payload — re-serialised, re-uploaded, re-parsed and re-validated every time. Compile it once instead:
POST /api/v3/graphs
{"label":"decode","graph":{
"inputs":[
{"name":"ids","shape":[1],"bind":"ids"}, # contents vary per call
{"name":"wq","model":"m_...","weight":"...q_proj.weight"}],
"steps":[
{"op":"rope","in":["q0"],"out":"q",
"params":{"headDim":128,"offset":{"$bind":"pos"}}}, # value varies per call
...],
"outputs":["tok"]}}
# { "graphId":"gt_...", "steps":653, "bindings":["ids","pos"],
# "bodyBytes":79417, "perCallBytes":88, "savedPerCall":79329 }
Then each call carries only what changed:
POST /api/v3/graph
{"graphId":"gt_...","session":"s_...","bind":{"ids":[4097],"pos":41}}
Two kinds of hole can be left in a template. An input with "bind" keeps a fixed shape and takes new contents per call. A parameter written {"$bind":"pos"} takes a number per call, optionally with "add" or "mul" applied. Everything else — weights, session tensors, the step list — is fixed at compile time, because that is the part that never changes between tokens.
Because every shape is known at compile time, a template that compiles will not fail shape validation later. Validation happens once per template rather than once per token.
This is the largest single win available. Generating 64 tokens by calling the graph endpoint 64 times is 64 crossings of the open internet, and the latency of each dwarfs the arithmetic it carries. POST /api/v3/generate runs the loop next to the worker and streams the tokens back over one request.
POST /api/v3/generate
{"graphId":"gt_...","sessionId":"s_...",
"ids":[9707], # the last prompt token
"startPos":41, # where the prompt ended
"maxNewTokens":64,
"stopIds":[151645],
"temperature":0.8,"topK":40,"seed":7}
event: token
data: {"token":3838,"index":0,"pos":41,"ms":38}
event: token
data: {"token":11,"index":1,"pos":42,"ms":36}
...
event: done
data: {"ok":true,"tokens":[...],"stopped":"stop token",
"elapsedMs":2410,"tokensPerSecond":26.6,"roundTrips":1,"workerLoop":true,"nextPos":105}
The compiled graph must bind both ids and pos, and must end in a single token id from argmax or sample. Sampling parameters passed here override whatever the template baked in, so one compiled graph serves greedy and sampled decoding alike; step is supplied automatically so a seeded run is reproducible.
Prefill is your own single call — run the prompt through the graph once, then start generating from the last token with startPos set to the prompt length. Pass "stream": false to get the finished list in one JSON body instead of an event stream.
With a session, the loop can run on the volunteer itself. When the session's worker holds everything the graph reads and supports it (the downloadable volunteer program does), every token after the first is produced there and streamed back, with no round trip through the grid per token — typically several times faster. workerLoop in the result says whether that happened. The tokens, the cache writes and nextPos are exactly what the ordinary path produces; pass "workerLoop": false to insist on the ordinary path. It applies to graphs whose only bindings are ids, pos, step and sampling parameters, whose inputs are model weights or session tensors, and that retain nothing.
Each pass can also carry a few drafted tokens, checked in the same pass. The drafts are not from a second model: they are the continuation of the most recent earlier occurrence, in the context so far, of the last one to four tokens. The pass is the decode step over several positions, each sampled exactly as the one-token step would sample it (same seed, step + row); the longest agreeing prefix plus the first disagreeing row is kept. A rejected draft costs nothing but the rows it took, so greedy and seeded output are the tokens plain decoding gives, only sooner when the text repeats (code, quotations, structured output). How many tokens to draft is chosen per pass from how often drafts have been right and how much longer a longer pass takes.
To use it with /api/v3/generate, compile verify graphs yourself: the decode step over k+1 positions, ids bound with shape [k+1], every position sampled with {"$bind":"step","add":row} in its sample step, ending in the k+1 token ids. Then:
{"graphId":"gt_...", "sessionId":"s_...", "ids":[...prompt tokens...], "startPos":41, "maxNewTokens":64,
"speculative":{"graphIds":["gt_verify4","gt_verify2"], "context":[...token ids so far...]}}
event: done
data: {..., "speculation":{"verifies":14,"drafted":47,"accepted":31,"produced":45,"plain":9}}
Up to 16 verify graphs; context defaults to ids; "always": true drafts on every pass instead of letting the scheduler back off. Each verify graph must bind ids (two or more positions) and pos. speculation in the result counts verify passes, drafted and accepted tokens, tokens produced and plain steps. When the session's worker supports the worker-side loop, it drafts and verifies itself, with no round trip in between. The chat page does all of this by itself and is where it is most useful; "speculative": false in a chat message turns it off.
Small inputs (a step's token ids, a stage's activations) are sent together with the work that reads them instead of ahead of it, which removes a round trip per step on volunteers that keep messages in order.
clients/mpakt_grid.py is dependency-free and builds the graph for you. Account covers sign-up and keys.
from mpakt_grid import Account, VirtualGPU, Graph
acct = Account("https://YOUR-HOST")
acct.login("alice", "a long password") # once the account is approved
gpu = VirtualGPU("https://YOUR-HOST", key=acct.create_key("script")["key"])
gpu.wait_for_workers(2)
model = gpu.load_model("hf:Qwen/Qwen3-0.6B",
include=["model.layers.26.*", "model.layers.27.*"])
print(f"{model.bytes_transferred/1e6:.0f} MB into the network, 0 bytes uploaded")
MAXLEN, KVW = 2048, 1024
with gpu.session(model, label="chat") as s:
g = Graph()
kc, vc = g.zeros([MAXLEN, KVW]), g.zeros([MAXLEN, KVW])
s.run(g, retain={"kcache": kc, "vcache": vc})
for pos, hidden in enumerate(stream_of_hidden_states):
g = Graph()
x = g.input("x", [1, 1024], hidden)
kc = g.session_tensor("kcache", [MAXLEN, KVW])
vc = g.session_tensor("vcache", [MAXLEN, KVW])
w = {k: g.weight(model, f"model.layers.26.{n}")
for k, n in LAYER_WEIGHTS.items()}
y, kc, vc = g.decoder_layer(x, w, heads=16, head_dim=128, kv_heads=8,
pos_offset=pos, kcache=kc, vcache=vc)
out = s.run(g, outputs=[y], retain={"kcache": kc, "vcache": vc})
hidden = out[y.ref] # one round trip for the whole layer
That is still one request per token. A real generation loop compiles the graph and lets the server run the loop:
from mpakt_grid import VirtualGPU, Graph, bind
g = Graph()
ids = g.bound_input("ids", [1], "ids")
h = g.embedding(ids, g.weight(model, "model.embed_tokens.weight"))
for L in range(n_layers):
h, kc, vc = g.decoder_layer(h, weights[L], heads=16, head_dim=128, kv_heads=8,
pos_offset=bind("pos"),
kcache=g.session_tensor(f"k{L}", [2048, 1024]),
vcache=g.session_tensor(f"v{L}", [2048, 1024]))
tok = g.sample(g.matmul(h, lm_head), temperature=0.8, top_k=40, seed=7)
decode = gpu.compile(g, outputs=[tok], label="decode")
print(f"{decode.body_bytes} bytes -> {decode.per_call_bytes} per call")
with gpu.session(model) as s:
for token in s.generate(decode, [last_prompt_token],
max_new_tokens=64, start_pos=len(prompt),
stop_ids=[eos], temperature=0.8, top_k=40, seed=7):
print(tokenizer.decode([token]), end="", flush=True)
The client keeps one connection open across calls. Opening a fresh one per request costs a TCP and, over HTTPS, a TLS handshake each time — around 110 ms per call through a CDN, which for anything token-by-token is most of the wall clock.
| Endpoint | Purpose |
|---|---|
| GET /api/versions | What versions exist and what each supports. No key required. |
| GET /api/v3/status | Workers, backends, ops, and free bytes — check before loading. |
| GET /api/v3/models/inspect?source=… | Tensor index only; loads nothing. |
| POST /api/v3/models | Stream a model into the network. |
| GET /api/v3/models | Your loaded models and network capacity. |
| DELETE /api/v3/models/:id | Release a model everywhere. |
| POST /api/v3/sessions | Open a session. |
| GET /api/v3/sessions/:id | Its resident tensors and step count. |
| POST /api/v3/sessions/:id/checkpoint | Copy its tensors here so a lost worker is survivable. |
| POST /api/v3/sessions/:id/restore | Put a checkpointed session back on a volunteer. |
| DELETE /api/v3/sessions/:id | Close it and free what it holds. |
| POST /api/v3/graph | Run a graph — inline, or a compiled one with graphId and bind. |
| POST /api/v3/graphs | Compile a graph; returns a graphId. |
| GET /api/v3/graphs | Your compiled graphs and their bindings. |
| DELETE /api/v3/graphs/:id | Delete one. |
| POST /api/v3/generate | Decode on the server; streams tokens over SSE. |
| GET /api/v3/random | True random integers: min, max, count (up to 10000). Default range 84 – 2147483646, the one generation seeds come from. |
Limits: 4 models, 16 sessions and 32 compiled graphs per key. Graphs time out after 5 minutes; a generation is capped at 4096 new tokens.
rmsnorm, rope, attention, swiglu, embedding, concat, slice, reshape, cache_write or zeros — for those, see v3.Submits a graph of operations, keeps the intermediates out in the network, and returns only the outputs you ask for. Base path /api/vgpu, also reachable as /api/v2.
curl -X POST "https://YOUR-HOST/api/vgpu/graph" \
-H "x-api-key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"graph":{
"inputs":[{"name":"x","shape":[2,3],"data":[1,2,3,4,5,6]},
{"name":"w","shape":[3,2],"data":[1,0,0,1,1,1]},
{"name":"b","shape":[2],"data":[0.5,0.5]}],
"steps":[{"op":"linear","in":["x","w","b"],"out":"h"},
{"op":"gelu","in":["h"],"out":"y"}],
"outputs":["y"]}}'
matmul, linear, transpose, add, sub, mul, scale, relu, gelu, silu, softmax, layernorm, causal_mask, reduce_sum, reduce_mean.
Sending a v3 operation to a v2 endpoint is rejected with a message naming v3 rather than silently ignored.
Upload a tensor once and reference it by id. Limited to 128 MB per key, held in coordinator memory — this is the limit that makes v2 impractical for whole models, and the reason v3 loads them server-side instead.
POST /api/vgpu/tensors {"shape":[256,256],"dataB64":"...","label":"layer0.w"}
GET /api/vgpu/tensors -> everything stored under your key, and your quota
DELETE /api/vgpu/tensors/<id>
# reference it in a graph:
{"inputs":[{"name":"w","tensorId":"t_..."}], ...}
Mark an input "split": true and pass "microbatches": n; its leading dimension is divided into n slices that flow through the network independently and are reassembled on return.
| Endpoint | Purpose |
|---|---|
| POST /api/vgpu/graph | Run a graph. Also /api/v2/graph. |
| GET /api/vgpu/status | Worker count, backends, ops. |
| POST /api/vgpu/tensors | Upload a persistent tensor. |
| GET /api/vgpu/tensors | List them and your quota. |
| DELETE /api/vgpu/tensors/:id | Release one. |
Submit two matrices, get the product. The job goes to three independent volunteers and the majority answer wins, so a faulty or dishonest device cannot change your result. v2 and v3 trade that check for speed and do not replicate.
POST /api?wait=1 blocks until the result is ready. Also reachable as /api/v1.
| Field | Meaning |
|---|---|
| A | Left matrix, flat row-major, length M x K |
| M | Rows of A (and of the result) |
| K | Columns of A / rows of B |
| B | Right matrix, flat row-major, length K x N |
| N | Columns of B (and of the result) |
curl -X POST "https://YOUR-HOST/api?wait=1" \
-H "x-api-key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"data":{"A":[1,2,3,4],"M":2,"K":2,"B":[5,6,7,8],"N":2}}'
# { "ok": true, "task": { "status": "completed", "result": [19,22,43,50],
# "verified": true } }
verified: true means the replicas agreed. Matrices are capped at 4,000,000 elements per side, and the result at 699,050 elements (M*N): a volunteer returns it in one message. A larger task is refused when it is submitted; split it by rows of A, or use /api/v3/graph. A task that volunteers repeatedly fail to compute ends with "status": "failed" and an error instead of waiting indefinitely.
POST /api -> { "task": { "id": "...", "status": "queued" } }
GET /api/task/<id> -> { "task": { "status": "completed", "result": [...] } }
GET /api/status -> all your tasks
GET /api/versions lists it under services.compute.A box is a public GitHub repository, a memory range, a CPU filter and a hostname. When a request arrives for the hostname, the grid picks a volunteer's device, hands it the request (and, the first time, your code), and relays the answer. Between requests nothing runs. Static sites are served straight from the grid's memory. The dashboard at /compute has a tutorial and does everything below; so does the API.
Decided at deploy time from the files, or from an optional mpakt.json at the repository root.
| Files | Runtime | Memory floor |
|---|---|---|
index.html | static | none — served from memory; GET and HEAD only; 404.html, or with "spa": true index.html, for unknown paths |
A Vite project: index.html beside vite.config.*, or beside a package.json that uses vite | static, built | none — built by a volunteer, then served from memory like any static site |
worker.js, index.js, main.js, index.mjs, worker.ts, index.ts, main.ts, src/index.ts, src/worker.ts, src/index.js…; the main of wrangler.toml/wrangler.json(c); an api/ folder of functions | javascript | 32 MB |
main.py, app.py, handler.py | python | 128 MB |
Dockerfile, or "image" in mpakt.json | docker | 64 MB — verified accounts only |
If the files don't say what a repository is, the grid goes by the languages GitHub shows for it (JavaScript/TypeScript → a server from package.json's start script or server.*/app.*, Python → a Flask/FastAPI app, HTML → a static site). A Vite app in a sub-folder (a monorepo) is found the same way. You can always set the server type (including vite), entry file and folder yourself in the box's settings (or type, entry, root in the API); that wins over everything else.
mpakt.json, all fields optional: runtime (static, javascript — typescript means the same —, python, docker), entry (the file to load, or api/), root (a subdirectory to treat as the project), port (containers; default 8080, or, for a box a browser runs from a registry image, the one port the Dockerfile's EXPOSE or the image names), image (a ready-made image from Docker Hub or a well-known registry, instead of a Dockerfile), spa (static: single-page-app fallback), build and output (a static site's build command and the folder it writes). runtime may also be vite.
A Vite project (npm create vite: React, Vue, Svelte, vanilla…) is built by a volunteer and served like a static site, the way Vercel does it: the volunteer installs the npm packages (devDependencies too) from the registry and runs npm run build (or vite build) with no network at all; its output folder (build.outDir, else dist/) comes back to the grid, is checked file by file against the list of hashes the volunteer sent, and is served from memory. Unknown page paths get index.html (single-page apps; "spa": false turns it off), a missing script or picture gets 404, and content-hashed files under assets/ are cached for a year. While a new commit builds, the last good build stays up. The deploy answers at once: the box's buildState says building, waiting (no volunteer that can build is connected, and why), ready or failed with the error, and buildLog has the last lines of the build's output; the dashboard shows both.
A repository that commits its built site (a dist/ with the built index.html) is served from that instead; choose the server type vite to build it anyway. Any static site can have a build step: "runtime": "static", "build": "npm run site", "output": "out" in mpakt.json (or build and output in the box's settings). A build is billed like a request that runs as long as the build, at the memory it was given (1 GB on installed volunteers); serving it afterwards costs what a static site costs. Builds run in browsers first — Vite itself in the browser's own engine (no VM), with the WebAssembly builds of its bundlers: a React app in 20–30 seconds from cold, most of it downloading packages — then on installed volunteers (with Docker). Vite 6 and 7 build in every browser; Vite 8 needs WebAssembly threads, which a browser has only on a cross-origin-isolated page: your own dashboard is one — while /compute is open, your browser builds your sites (and only yours: it runs nothing else), so a deploy or a push never waits for a volunteer — and Chrome 137+ should be on any site (it isolates the frame on its own say; not yet tested); Firefox and Safari as volunteers on other sites build Vite 6/7 only. When nobody that can build is connected and your dashboard is closed, the build waits, says so, and starts when one connects. In a browser, type checks (tsc, vue-tsc, svelte-check) are skipped (installed volunteers run them), and a node step that needs what a browser lacks (brotli, starting programs) is a warning — the site Vite built is kept; a Vite 7 plugin that needs such things is built in the browser's Linux VM instead. npm workspaces work (a monorepo's app is built from the workspace's top, its packages installed once from package-lock.json); with the type vite and no folder the grid finds the Vite app in the repository. Untick web in the box's CPUs to keep builds on installed volunteers. Limits: 24 MB and 2,000 files of output, 15 minutes (30 in a browser's VM). Not supported: frameworks that need a server (Next.js, Nuxt, SvelteKit SSR — only a static export), network access during the build, packages that need install scripts (sharp), Yarn/pnpm workspace: dependencies, build-time environment variables.
export default {
async fetch(request, env) { // a standard Request
const url = new URL(request.url)
if (url.pathname === '/files') return Response.json(env.files.list()) // the repository's own files
return new Response('hello ' + url.pathname) // or a string, or an object (sent as JSON)
}
}
A default-exported function, export function fetch and export function handler work too, and so does CommonJS module.exports. Relative imports are fine. fetch(url) inside a box works only when the box's internet is on. A JavaScript entry that is a Node.js server (it calls .listen() or requires http, express… and has no export default) is run as node <entry> and must listen on $PORT (8080); only browser volunteers run these, in a Linux virtual machine, with npm ci/npm install done from your package-lock.json (install scripts are not run, so packages that compile native code do not work).
Push a Workers or Vercel project as it is; every kind of volunteer runs it, a browser alone is enough.
.ts .tsx .mts .cts .jsx) are compiled on the volunteer (Sucrase), file by file: types and import type disappear, enums and namespaces work, imports may leave out the extension, name a folder (index.ts) or say ./x.js for ./x.ts; tsconfig paths/baseUrl and jsx/jsxImportSource are honoured; JSON imports work. There is no type checking. Not supported: decorators, const enum across files.dependencies in package.json (not devDependencies) are installed from the npm registry — exactly your package-lock.json when there is one — and checked against their integrity hashes; install scripts are not run. Packages resolve the way Workers bundlers do (exports conditions workerd, worker, browser, import, default). A Hono app (import { Hono } from 'hono', export default app) works as it is. In a browser, of Node's built-ins only events, process, util, crypto, async_hooks, path, url, timers are there; for fs, net, http write a Node.js server instead. At most 600 packages / 96 MB.export default { fetch(request, env, ctx) }; the entry is main from wrangler.toml (or wrangler.json/.jsonc) and its [vars] are in env. Bindings (KV, D1, R2, Durable Objects…) are not available; ctx.waitUntil is accepted but does not keep the box running after the answer.api/ — api/hello.ts is /api/hello, api/index.ts is /api, api/users/[id].ts, [...slug] and [[...slug]] for dynamic segments; files and folders starting with _ are helpers. A function is export default function (request) returning a Response, named GET/POST/… exports, or the classic (req, res) with req.query (route parameters included), req.body, req.cookies, res.status(), setHeader, json, send, redirect, end. Every other path is your static files, served by the grid without a volunteer: public/ if there is one, otherwise the repository's top folder minus code and configuration (.ts files, src/, package.json, dotfiles…). vercel.json and frameworks that need a build (Next.js…) are not supported.def handler(request): # or async def
# request: dict with method, url, path, query, headers (lower-case), body (bytes), text
return "hello" # str, bytes, dict (JSON), (body, status[, headers]) or {"status","headers","body"}
# or any WSGI callable named app or application (Flask, Bottle, ...)
requirements.txt is installed for the box: in browsers that means pure-Python and Pyodide-built packages; on installed volunteers, pip runs only if the box has internet. In an async def handler on a browser volunteer, import mpakt; r = await mpakt.fetch(url) makes a request through the grid (internet on); on installed volunteers use the standard library, which is pointed at the grid's proxy.
A Dockerfile (or an image) for a server that listens on port. Installed volunteers (Linux with Docker) build the Dockerfile with no network, so it must be self-contained: FROM an image on Docker Hub, ghcr.io, quay.io, gcr.io, mcr.microsoft.com, public.ecr.aws or registry.k8s.io; no ADD from a URL, no # syntax=, no RUN --mount/--network/--security. Browser volunteers run containers in an emulated 32-bit x86 Linux, and this is slow, especially the first request. A box whose image (image, or the one image its Dockerfile is FROM) has a linux/386 build on a supported registry (Alpine, Debian, Ubuntu, BusyBox, Python, nginx, httpd, Redis and others do) runs from that image's own layers: the grid reads the manifest at deploy, passes the layers to the browser, which checks each against its digest; images up to 160 MB compressed. Otherwise FROM picks one of three prebuilt bases by the image's name — node, python, or alpine/busybox/debian/ubuntu (the tag is ignored) — and RUN, COPY/ADD from the repository, ENV, ARG, WORKDIR, USER, EXPOSE, CMD, ENTRYPOINT, multi-stage builds and .dockerignore are understood. The box's vm and vmNote fields say whether browsers can run it and, if not, why.
64-bit-only images in a browser. An image with no linux/386 build runs from its linux/amd64 build (so does FROM image@sha256:…, a digest pin: that exact image). The VM is 32-bit, and one process cannot mix 32- and 64-bit code, so each program is decided on its own: a 64-bit program becomes the 32-bit Alpine build of the same program when there is one — busybox, bash, dash, GNU coreutils, grep, sed, findutils, tar, gzip; Node.js 22, 24, 26 (same major); Python 3.12, 3.14 (same minor; the image's site-packages stay on the path); Redis 7, 8 (same major) — unless that process would load 64-bit native code (Node addons, Python C extensions, Redis modules). Everything else — Go and Rust programs, other tools, interpreters with 64-bit native code — runs under Blink, an x86-64 emulator, which the kernel starts for each such program, so a 32-bit shell or Node.js can run an emulated helper in the same box. Scripts and bytecode are the same on both, so a Node.js or Python app keeps the VM's usual speed (node:22-alpine, python:3.12-slim, redis:7 in Firefox: first request 1–2.5 min, then 0.1–1 s). Emulated programs are much slower: a Go server about 2 minutes to start and 4–7 s a request; an emulated Node.js or CPython tens of minutes or more. For a 64-bit image the box's vmProcs lists which programs are expected to run 32-bit and which emulated, and vmReport what the last browser that started it decided, with its timings; the dashboard shows both. Installed volunteers run amd64 images natively and go first.
What still cannot run in a browser: images with neither a 386 nor an amd64 build, Python packages with C extensions Pyodide does not provide, packages that compile native code on install, and boxes larger than roughly 350 MB in Firefox. Those need an installed volunteer. Where both are connected, installed volunteers are preferred for containers.
Base /api/compute/v0. A session token (Authorization: Bearer mps_…) or an API key (x-api-key) both work. 240 calls per minute per account, 20 deploys per 10 minutes. Errors are { "error": "…" }.
| Endpoint | Purpose |
|---|---|
| GET / | No key needed. Runtimes, CPU classes, limits, pricing and the live pool (volunteers, memory range and runtimes per CPU class). |
| POST /boxes | { name, repo, ref?, minMb?, prefMb?, cpus?, internet? } → 201 { ok, box }. name 2–40 letters, digits, space, _ - .. repo: owner/name or a github.com URL. ref: a branch or tag (default branch if omitted). minMb: what a volunteer must have free to be chosen. prefMb: what the box gets, and pays for, when available (16–8192). cpus: any of web (a browser tab: cheap, plentiful), x86_64, arm64, other (installed volunteers); default all. internet: false by default. At most 20 boxes per account. |
| GET /boxes, GET /boxes/:id | The box: id, name, repo, ref, url, subdomain, runtime, entry, commit, deployedAt, minMb, prefMb, cpus, internet, enabled, domains, warmVolunteers, stats { requests, errors, ms, credits }. runtime is null until deployed. |
| PATCH /boxes/:id | Any creation field, and enabled (false switches it off; requests answer 503). Changing repo clears the deployment. |
| POST /boxes/:id/deploy | Resolve the branch to a commit, work out what the repository is, go live. Redeploy to pick up new commits; until then the box keeps serving the old one. |
| DELETE /boxes/:id | Remove it. |
| GET /boxes/:id/log | { requests: [{ at, method, path, status, ms }] } — the last 50, newest first. Never headers, bodies or query strings; kept in memory only. |
| GET /wallet | { credits, spent, earned, tier, usdPerThousandCredits, payUrl, payments, purchases }. earned is what volunteering has paid; tier is your contributor tier (below); each purchase lists its bonus. |
| GET /volunteer | Your volunteer code (vc_ and 12 characters; null until issued), tier and progress, today's and the last 30 days' accepted work, earnings, connected devices, the rules, and ready-made snippets (embed tag, volunteer.ini line, command). POST /volunteer/rotate issues a code or replaces it (the old one stops earning at once); DELETE /volunteer turns crediting off. |
| POST /wallet/requests | { credits, note? } — ask the owner for credits (three open requests at most). |
| POST /checkout | { credits } → { url, credits, bonus, amount, currency } (bonus: extra credits from your tier, on top of the amount asked for at the same price). Buy credits by card when the grid has payments set up (payments.enabled in the wallet); open url, pay, and the credits arrive within moments. Multiples of 10. |
| GET /tutorial, POST /tutorial/tour | Eleven quests that pay XP and credits as you do the things: set your own password, read the tour, create and deploy a box, open its address, change its memory or CPUs, turn internet on or off, connect a domain, run a volunteer with your code, embed the script on a site. |
Verified accounts can serve a box on a domain they own, in three steps, so nobody can claim a name they do not control:
curl -X POST https://YOUR-HOST/api/compute/v0/boxes/box_…/domains -H "x-api-key: mpakt_..." \
-H "Content-Type: application/json" -d '{"domain":"app.example.com"}'
# 201 { "domain": { "host": "app.example.com", "status": "pending",
# "records": [ { "type": "TXT", "name": "_mpakt.app.example.com", "value": "mpakt-verify=…" },
# { "type": "CNAME", "name": "app.example.com", "value": "<the box's assigned address>" } ] } }
# add both records at your DNS provider, then:
POST /boxes/:id/domains/app.example.com/verify # 409 until the TXT record is visible; it can take minutes
POST /boxes/:id/domains/app.example.com/approve # now the box answers on it
GET /boxes/:id/domains lists them, DELETE /boxes/:id/domains/:host removes one. Five per box. The grid's own domain cannot be claimed, and a name that is already serving one box cannot be added to another.
| Limit | |
|---|---|
| Request body / response body | 1 MB / 6 MB |
| A box's code | 8 MB (a static site: 24 MB unpacked, 2,000 files) |
| Time per request | 30 s. The first request on a volunteer may take up to 2 minutes (runtime download, image pull), on a browser's virtual machine up to 5 |
| Rate | 20 requests per second per box, 50 across the grid (the operator can change both) |
| Waiting | When every suitable volunteer is busy a request waits up to 5 seconds for one before it fails with 503 |
| Internet (when on) | 16 fetch calls per request, 4 MB per answer, 64 MB per hour per box, no mail ports, no private addresses |
| Per account | 20 boxes, 5 domains per box |
Every box response carries X-Mpakt-Box. The grid itself answers 402 (the box's owner is out of credits), 413, 429, 502 (the box or its volunteer failed), 503 (switched off, not deployed, the grid is busy after waiting, or no volunteer with enough free memory and the right CPU is connected right now) and 504 (timed out). A failure reported by the box's own code is returned as it happened; a failure of the volunteer is retried on up to two others.
Credits: 1,000 credits = US$1. New accounts start with 1,000; the tutorial pays more. A request costs:
| What | Price |
|---|---|
| Each request | 0.1 credit per thousand |
| Run time × memory | 0.004 credit per GB-second on browser volunteers, 0.008 on installed ones — at the memory the box was given, for the time the code ran |
| Internet traffic | about 20.5 credits per GB, both directions |
Static sites cost only the per-request part. A box whose owner has no credits left answers 402. GET /api/compute/v0 carries the exact figures.
The grid sends each request where it is expected to finish soonest, counting the work already queued on a volunteer, a cold start if the code is not yet loaded there, and how busy that volunteer has been lately. A browser is kept to roughly half its time on boxes and runs at most two boxes at once; an installed volunteer to roughly 90%. Busy ones are passed over unless the alternative is a long cold start. A box under steady load is warmed on more volunteers (two or more from about 12 requests a minute, up to eight) one at a time, and extra copies are let go after three idle minutes. GET /api/health reports compute.spread (mean and maximum volunteer duty, spare virtual machines, measured cold starts).
Devices that volunteer under your account's code earn credits: 25% of what the same work would have cost a paying user, for work the grid accepted, up to a daily cap, never for your own boxes, models or tasks. The value of accepted work over the last 30 days sets your tier:
| Tier | From (credits of work) | Boxes cost | Card purchases add |
|---|---|---|---|
| Bronze | 100 | 5% less | 5% bonus credits |
| Silver | 1,000 | 10% less | 10% bonus credits |
| Gold | 4,000 | 15% less | 20% bonus credits |
Higher tiers are also admitted a little further into a busy moment. Use the code in data-account on the script tag, account = or --account in volunteer.py, or a copy of volunteer.py downloaded while signed in, which has it filled in (its SHA-256 then differs from the listed one only by that line). The code is public and identifies no one but the account.
Browsers of participating sites (in a sandboxed frame that cannot see the site, its cookies or its storage) and installed volunteers on Linux with Docker, each box in a container with no network of its own. A volunteer takes a box's code from GitHub itself, by commit, and refuses any file that does not match the size and SHA-256 the grid recorded; if that fails, the grid sends the code. See the participation agreement and downloads.
/chat is a chatbot on the grid's models for signed-in accounts. It uses the session token, not an API key; its endpoints are under /api/chat/ and are not part of the keyed API, but you can call them yourself with Authorization: Bearer mps_…. What matters if you do:
| Endpoint | Purpose |
|---|---|
| GET /api/chat/models | Models the chat offers (checked against what the connected volunteers can run), loaded ones first. |
| POST /api/chat/models | { repo } — load any Hugging Face model by id (Org/Model). The same check as below runs first; a refusal carries the verdict. Answers 202; loaded models are shared. |
| POST /api/chat/models/check | { repo } — check a model without loading it: { ok, problems, warnings, facts, placement, resources } — its config, tokenizer, chat template and weights, the operations the volunteers must implement, and memory against the pool. 12 checks a minute. A model too big for one volunteer is split into stages across several. |
| GET /api/chat/models/:org/:name/prompt | What a page needs to build the prompt and tokenize it itself: the chat template, defaults, stop ids, context size and the hash of the tokenizer ({ offload: false } when the page must send text). GET …/tokenizer/:hash returns that tokenizer.json, cacheable for good. |
| GET /api/chat/e2e | { algorithm, publicKey, fingerprint } for end-to-end encryption. |
| GET /api/chat/mcp | The MCP tool servers a conversation can use (add ?discover=1 for their tools). |
| POST /api/chat/conversations | { repo, system?, history?, tools?, mcp?, thinking? }. tools and mcp: ["deepwiki", …] are for verified accounts only; for others they are ignored. |
sample operation takes the token history as an optional second input with repetitionPenalty, presencePenalty, penaltyWindow and penaltyEnd (the sample.penalty feature, see /api/ops).publicKey, derive an AES-256-GCM key with HKDF-SHA-256, send X-Chat-E2E: <your public key>.<salt> (both base64) and conversation bodies as { "e2e": { "iv", "ct" } }; answers and stream events come back sealed the same way. It keeps chats from anything between you and the grid, such as the CDN in front of it. It does not hide them from the grid itself or from the volunteers whose devices run the model. When the page tokenizes, the grid sees token ids rather than text, but ids can be turned back into text by anyone with the model's tokenizer, so treat them as readable; the volunteers see activations and, on some stages, the ids. Compare fingerprint with the one shown in the chat page./chat-kit.js (the grid's tokenizer, template engine and stream filter), builds the whole prompt itself and posts it as { "prompt": [token ids] } instead of text; the reply streams back as ids events ({ ids: [...] }) for the page to decode. The final done event says tokenized: "client". Text turns still work, and are needed for tools, pictures, video and audio. The grid still enforces the context and reply limits, by counting ids.done.batched counts the steps taken in a batch), and prompt-lookup speculative decoding is on ("speculative": false in a message turns it off); the tokens are the ones each conversation would get alone. A model that does not otherwise fit is held quantized (quantize in the model list says none, q8 or q4).| Capability | v1 | v2 | v3 |
|---|---|---|---|
| Base path | /api | /api/vgpu | /api/v3 |
| Status | supported | supported | latest |
| Single matrix multiply | yes | yes | yes |
| Replicated + majority-verified | yes | no | no |
| Multi-op graphs | no | yes | yes |
| Intermediates stay in the network | no | yes | yes |
| Microbatching | no | yes | yes |
| Uploaded persistent tensors | no | yes, 128 MB/key | yes |
| Server-side model loading | no | no | yes |
| Weights stored on coordinator | n/a | yes, against quota | never |
| bf16 / f16 transfer | no | no | yes |
| bf16 / f16 held packed in memory | no | no | yes |
| transB on matmul / linear | no | no | yes |
| Sessions / resident KV cache | no | no | yes |
| rmsnorm, rope, attention, swiglu | no | no | yes |
| embedding, concat, slice, reshape | no | no | yes |
| cache_write, zeros | no | no | yes |
| Base64 outputs | no | no | yes |
| short_conv (fused LFM2-style block) | no | no | yes |
| Sharded safetensors | no | no | yes |
| Feature-axis slice / concat | no | no | yes |
| attention with a separate vHeadDim | no | no | yes |
| conv1d (hybrid conv/attention stacks) | no | no | yes |
| argmax / reduce_max / sample | no | no | yes |
| Compiled graphs | no | no | yes |
| Server-side decode loop | no | no | yes |
| Session checkpoint / restore | no | no | yes |
| SSE streaming | no | no | yes |
| Round trips for one decoder layer | n/a | 3 or more | 1 |
| Round trips for 64 generated tokens | n/a | 64+ | 1 |
POST /api/vgpu/graph becomes POST /api/v3/graph; the body is the same.POST /api/v3/models and inputs of the form { name, model, weight }.retain for anything you were sending back and forth between calls.argmax or sample so a step returns a token id, not a logit vector./api/v3/generate and let the server run the loop.None of these versions is deprecated and none has an end date. v1 and v2 are separate code paths from v3, so work on the newer one does not put them at risk. If that ever changes it will be announced here first, with a migration window.
The same information, machine-readable and always current:
curl https://YOUR-HOST/api/versions
All versions share one key. Keys idle for over 30 days are deactivated — request or revoke one here.