Reference
Changelog
What changed in each release of Pooled and of the pooled serve CLI.
Two things get released: the site at pooled.run, which deploys from main (so Unreleased changes are usually live), and the @pooled/cli package.
Releases
Section titled “Releases”| Version | Date | Summary |
|---|---|---|
| 1.0.0 | 2026-09-27 | The first stable release. Qwen 3.6 35B MoE in rooms, Code mode, long context (16K on the 27B, 32K on the MoE), room checkpoints, the new landing page and the room at /room. |
| 0.2.0 | 2026-09-07 | Demo release: Qwen 3.8 27B across a MacBook and an iPhone in browser tabs at 10.7 tok/s. |
| 0.1.0 | 2026-08-29 | First public demo: Qwen 3.8 27B split across browser tabs over WebRTC, on Pooled’s own WebGPU engine. |
Pooled was called SwarmLLM before September 2026. Entries from before the rename keep the old name.
pooled serve
Section titled “pooled serve”@pooled/cli 0.2.1 is on npm: Chat Completions, Responses and Messages with tool calling, six-character room codes and #k= invite links.
npx @pooled/cli@latest serve 4TKG9PThe full log below still calls @pooled/cli “not published yet”. That line is out of date.
Full log
Section titled “Full log”From CHANGELOG.md in the repository.
Changelog
All notable changes to Pooled (called SwarmLLM before September 2026). Format follows Keep a Changelog. Entries before the rename keep the old name.
[Unreleased]
Security
- Every device checks who runs the model; the facts come from the room’s creator. A device used to take a new model host from anyone in the room (an
ai-start-reqnaming aboss, anai-loadnaming its sender), so an admitted member could name any device, itself included, as another device’s host and write into its chat. Now any device may press Start, and each device runs the same pick (room/plan.jspickModelHost) over the creator’s roster (membership, join order and the metas the pick uses; metas heard on a device’s own links don’t count) and follows only when it agrees. A device that wasn’t there for the Start takes a host only from the creator (itself, or one it names inai-modelhostafter checking that Start). A device takesai-loadand host messages only from the host it follows, and answers refused layers withai-error, so a wrong start fails at once; a second Start while its host deals is refused (ai-start-refused, forwarded by the creator to whoever pressed it), and a replay is ignored (room/authz.js, room page and room node). A requester gives up only on the creator’s own refusal or the would-be host’s, forwarded under the refuser’s real name. Still trusted: the creator’s roster. Still unverified claims: each device’s pledge, device kind and GPU speed (held only to the room’s existing caps), so a member that overstates them can win the pick until it fails to load its share.pooled hostruns its own model (meta.fixedHost, taken only from the creator). A device takes a Start only from the creator (its own, or a member’s it checked and relays); a member’s copy sent straight to another device is ignored. A followed host that doesn’t start dealing within 60 s, or leaves the room, is dropped (a guest of a member host that left goes back to the picker), and a failed start frees every device, the pick included, for the next Start. A resumed creator restarts the model on itself only if it ran it before. Mixed versions: a new creator refuses an older member’s Start (it has noids), so older members must update to press Start; older devices ignoreai-start-refusedand follow as before. See docs/protocol.md, “Who runs the model”. - The invite key and passes are proved, never sent, and every link proves who is on it. Rooms find their devices through a signaling server (the public PeerJS cloud by default), which carries the certificate fingerprints each WebRTC link is checked against; a joining device used to send the room’s invite key (or its pass) in its first message. Now each side proves the key or pass with an HMAC bound to the link’s DTLS fingerprints, both peer ids and a nonce from each side, and the host proves it back (
room/chanauth.js); links between devices and the hidden-state stripes prove a room key the host hands out with the admit, and a link that proves nothing is closed. A device let in by Allow shows a six-digit code on its waiting screen that matches the one beside the host’s prompt (room page,pooled host,pooled serve/pooled chatlogs). A new device never sends the raw key (an older host lets it in by hand;?legacyauth=1/POOLED_LEGACY_AUTH=1restores the old way and is unsafe). For one release a new host still takes the raw key from an older device, with a warning;?legacyauth=0/POOLED_LEGACY_AUTH=0on the host refuses it. Room page, room node, CLI and OpenClaw plugin. See docs/protocol.md, “Proving who is on a link”.
Added
--disk: MoE experts read from the SSD when the GPU and RAM are short (roadmap 32, steps 3-5).pooled join --diskandpooled host --disk(with--heretoo) let a computer with a discrete GPU and--ramleave some layers’ routed experts in the model’s files and read them when a token picks them, through a RAM cache of per-expert upload buffers (at least 2 GB of the--ram; segmented LRU, an expert is cached on its second miss). The room uses it only when parking experts in RAM can’t hold the model, and a room that fits without it deals exactly as before. Outputs stay bit-exact (the 35B with every layer on disk gives main’s hashes).- The CLI uses the TURN relay too.
pooled host,pooled join,pooled chatandpooled serve(and the OpenClaw plugin’s room node) used to try direct links only (public STUN), so two devices behind strict NATs or CGNAT could not reach each other from the terminal, though the room page could through pooled.run’s relay. They now fetch the same short-lived relay credentials (cli/lib/relay.js) and use the relay only when no direct path works, refreshing the credentials at 3/4 of their lifetime./api/turnlets a request with noOriginthrough when it names itself inX-Pooled-Client(a browser on another site always sendsOrigin, so other pages still can’t mint credentials; the per-IP limit applies).POOLED_RELAY=0/1turns the relay off / forces it,POOLED_TURN(+POOLED_TURN_USER,POOLED_TURN_CRED) uses your own,POOLED_TURN_APIanother deployment’s. Tested with every link forced through Cloudflare’s relay:pooled hostandpooled chaton a GB10 answered over relayed links only. @pooled/cli0.4.0 and@pooled/openclaw0.3.0: MoE experts in system RAM, and the Qwen3.5 122B MoE. Apooled host/pooled joindevice with a discrete GPU keeps some layers’ routed experts in system RAM when the room’s pledges can’t hold a MoE model, and copies the ones each token uses into a cache on its GPU (#319, #320).pooled host qwen3.6-35b-moe --herenow runs on a 12 GB GPU (about 38-45 tok/s on an RTX 5070), and a Mac Studio lending 25 GB with an RTX 5070 PC keeping 42 GB of experts in RAM runs the 122B at about 17 tok/s.--ram Nsets how much RAM (default: the total less 16 GB, at most what is free less 4 GB, and 64 GB;--ram 0turns it off); unified memory and browsers never offload. While a device offloads, the room decodes without speculation (faster there;POOLED_OFFLOAD_SPEC=1turns it back on), and that device keeps GPU and RAM headroom past its cache (#323). Qwen3.5-122B-A10B (a two-file GGUF, 72.5 GB) is inpooled pullandpooled host’s list, with what it needs, and in the room page’s?dev=1list (#316). On Windows the room node runs V8 without Maglev, which crashed Dawn (#317). The plugin bundles the same room node, so its rooms deal offload topooled joindevices (the gateway itself holds its layers whole), and its README says Windows is tested (#322).
Changed
@pooled/openclaw0.3.2: a ClawHub listing with pictures. README and images only, no code changes. The README (ClawHub’s and npm’s plugin page) opens with the Pooled icon, the tagline and a still from the demo’s last frame (OpenClaw’s inbox triage beside the room panel: an RTX 5070 PC and a Mac Studio at 36 tok/s) that links to the demo video, then what it does in three lines, the install command, a three-step quick start with onboarding and Control UI screenshots, a short loop of the room coming online and the demo’s numbers (10 + 14 GB, Qwen3.6 35B MoE, about 36 tok/s). Security gets a one-line summary with links to the security model and privacy pages; the capability and privacy lists stay, tightened. New images insite/img/openclaw/(hero.webp,onboarding.webp,room-online-loop.webp, an animated WebP, andicon.png), linked through raw.githubusercontent.com: ClawHub’s image proxy passes animated WebP through and ignores<img width>, so each image is stored at the size it should show.
Fixed
pooled hostdeals again when a device refuses its expert offload. A tab that keeps the model off its disk, or a 0.4.1 room node that has no Flash offload, used to stop a node host’s start, and only the next Start left its offload out. Now the node host deals again once without that device’s offload, as the room page does; a later refusal from that device answers a deal already replaced and no longer stops the start.@pooled/cli0.4.1 and@pooled/openclaw0.3.1:pooled chatandpooled serveno longer hang after the invite-key proof. With an invite link, about 1 in 6 joins on 0.4.0 waited for nothing and ended with “has no model started yet” (9 of 57 in a Spark + PC room). The host sendsadmitand thenai-ready-allstraight away; the bridge (cli/lib/room.js, whichpooled chat,pooled serveand the plugin’s ask bridge share) checks the host’s proof inadmitwith an await, and a message that arrived before the check finished was dropped. What the host sends during the check now waits and is handled in the host’s order once the client is in (dropped if the proof is wrong). The same audit of the other places the proofs (#325) made admission async: a room node device’s and a room page’s link to the host now keep everything afteradmitbehind it (abyeused to overtake the messages ahead of it); a held link that gets more than 256 messages closes and starts over instead of dropping the rest; and a stripe opens its hidden-state channel while it proves the mesh key, so slices the other end sends as soon as its own check passes are kept instead of lost. Tests reproduce each race with a slow proof check. The docs now give the 35B on one RTX 5070 as about 38-45 tok/s (37.5-44.9 measured on the published 0.4.0, about 40 on long generations), not 41-54.@pooled/cli0.3.6 and@pooled/openclaw0.2.3: rooms send the prompt in wide frames, so OpenClaw’s cold first turn on a 2-device MoE room drops from ~196 s to ~51 s (#303); plainopenclaw onboardworks on a 2-device room without –skip-health (#304); layer fusion keeps its attention fusions under attention v2 (#301); dense prefill uses the f32 wide GEMM by default, DP4a opt-in (#302).@pooled/openclaw0.2.3: plainopenclaw onboardfinishes with a room that waits for devices. OpenClaw 2026.9.7’s onboarding ends the model step with a live test completion (90 s) and goes back to the provider picker if it gets no answer; a 2-device room can’t answer until the other device joins, and the plugin used to open a second copy of the room in the onboarding process to try. That check is now answered at once with the room’s state (“opens when OpenClaw’s gateway starts and answers once 2 devices are in …”), the room opens only with the gateway, and--skip-healthis no longer needed.@pooled/cli0.3.5 fixes 0.3.4’s Windows joins of the MoE/27B (with@pooled/openclaw0.2.2): decode layer fusion’smatvec_coop_n(the attention norm folded into the [k | v] GEMV, #276) had a thread-dependent loop inside its barrier loop, which Windows’ FXC compiler rejects, so every D3D12 join of the 35B MoE and 27B failed. The loop now has a trip count all threads share (same bits), the barrier lint reads the generated GEMV variants and catches this shape, a layer-fusion flag’s kernels are compiled only when it is on, and a fused kernel a GPU can’t compile turns layer fusion off instead of failing the join.
Added
@pooled/cli0.3.4 and@pooled/openclaw0.2.2:pooled join/pooled hostand the OpenClaw plugin run the 35B MoE and 27B on Windows (D3D12/FXC:topk_brewritten with uniform barriers, same picks), a re-deal never deals a device past its pledge (a short room stops and waits),pooled hostnames itself from the hostname, and a shader that a GPU’s compiler can’t build gives a one-line “join from Chrome or Edge” message (#291).@pooled/cli0.3.3:pooled host/pooled joinfollow the room page’s context rule for Qwen3 1.7B: 16K when the room’s memory holds it, 8K when it is short (and they say so), instead of always 16K. The picker showsneeds 6 GB (4 GB at 8K);--ctxstill wins.@pooled/openclaw0.2.1: the CLI’s look, and a ClawHub listing that reads well. Onboarding,/pooledand the room’s notices now look likepooled(packages/openclaw/src/ui.js, oncli/lib/style.js). In a terminal, onboarding prints the dot mark with the tagline and this GPU, the room code as the blue pill, label rows on the CLI’s grid and one sentence on trust, between OpenClaw’s own prompts (behind its guide bar); the option labels are short and line up (the model list readsQwen3.6 35B MoE needs 23 GB downloaded); the download is one line in OpenClaw’s spinner with a thin bar, size, MB/s and time left; joining with a code showsWaiting for <host> to let this device in · 0:08. NO_COLOR keeps the layout with bold, dim and reverse video, and a hosted wizard (the Control UI, the macOS app) gets the same lines as plain text./pooledand the notices are markdown, since the TUI strips escape codes and the Control UI and channels render markdown: a first linePooled · 4TK-G9P · <state>(the code in a code span, the pill’s stand-in), the invite link, then the devices in a code block (DEVICE, GPU, LENDS, HOLDS, the memory row, the download) and plain sentences; notices drop the ⚠️ (0.2.x notices in a conversation are still dropped on replay). The README, which ClawHub shows as the plugin’s page, now opens with the tagline (Peer-to-peer inference engine for your claw), links to the website, docs and GitHub, a screenshot of OpenClaw’s Control UI answering through a Pooled room on the 35B MoE, and three steps (install, start a room, invite a second device), then real timings (first answer about 3 min while OpenClaw’s ~18.7k-token prompt is read once, then 14 s, 46 s in a new chat), a model table, and the requirements. What it does on the machine and who can see what keep every disclosure, as short lists further down; setup details,/pooled, config and development sit in collapsed sections. The images live insite/img/openclaw/and the README links them through raw.githubusercontent.com, since ClawHub’s image proxy only fetches from GitHub hosts. The package and manifest descriptions (ClawHub’s summary line) lead with the tagline.- Qwen3 1.7B: 16K context by default, 8K when the room is short of memory. The room page,
pooled host/pooled join(the room node) and the OpenClaw plugin now open the 1.7B the same way (room/models.jsCTX.fallback+pickCtx): at 16K when the room’s pledges hold it there (5.6 GB: the dense engine’s f32 KV cache is ~224 KB per position), else at 8K when they hold it there (4 GB), so one 8 GB laptop lending its default 4 GB still runs it alone. It says so where the model is shown:Qwen3 1.7B · 8K context: the room's memory is short for 16K(the room page’s need line, picker row4 GB · 8K fits, chat note and model band, sent to every device withai-ready-allctxWant; thepooled hostroom screen and online line). The needs read5.6 GB (4 GB at 8K)(room page) and6 GB (4 GB at 8K)(pooled host); a room short even for 8K is short for 8K (“This room is 0.3 GB short”, as #271 counts it). An explicit?ctx=or--ctxis taken as asked and never lowered. “Run it here” lends what 16K needs when this computer has it, else what 8K needs. The OpenClaw plugin hosts the 1.7B with the same rule, onboarding saysfits on this machine alone at 8k (OpenClaw needs 16k), and a room it opens at 8K warns that OpenClaw needs 16k; a joined room at 8K says more memory on the host gets 16k. Was: 8K by default on the room page, 16K always (5.5 GB) inpooled host0.3.2 and the plugin. @pooled/cli0.3.2: fixes from the starter-kit run, and how to run a model.pooled host’s picker is one list (smallest first), then How do you want to run it?: Pool with devices (first and preselected: the lend question, then the room, split across all devices) or Run it here (lends what the model needs on this computer alone and starts; offered only when it fits, otherwise dimmed with why).--pool/--hereanswer it. Every model opens at its longest room context, the 1.7B at 16k (was 8k) as the OpenClaw plugin, which needs it; the 1.7B then needs 5.5 GB (--ctx 8192: 4). It picks up #278’s dense speculation: apooled hostroom splitting the 1.7B drafts by prompt lookup (the room node’s host used only MTP drafts). Fixes:pooled joinasks how much to lend before it knocks, so the host’s prompt shows the real amount; whenpooled hostquits it tells the room (bye {closed: 1}), andpooled joinexits with “The host closed the room.” instead of knocking for a minute, and leaves within a second on Ctrl-C; the picker stops at its ends instead of wrapping, and “waiting for the download” clears when the download ends; the serve banner’s Claude Code line hasANTHROPIC_API_KEY(the README’s recipe);--ctxabove a model’s most says so (“the 1.7B’s context is 16384; using that”);pooled pull, host and join take the models folder’s.pull.locklike the OpenClaw plugin, so two downloads never write one.part.- Speculation for models without a draft head (the dense Qwen3 0.6B / 1.7B / 4B,
engine/dense.js). In a room of two or more devices, prompt lookup (room/lookup.js) drafts up to 7 tokens when the answer repeats text in the context, andDenseEngine.specStepDraftsverifies them in one lap round the chain, as the MTP models do. The verify’s batched kernels use the single-token kernels’ workgroup shape, so every verified column is bit-identical to a plain step. Output is plain decoding’s under any sampler (tests/test_dense_spec.js: greedy bit-exact per column, sampled same draws, solo and split; answers identical in the 2-device room at every delay). Qwen3 1.7B on 2 devices with an injected one-way delay, 0 / 5 / 20 / 50 ms: editing code 36.9 / 26.5 / 14.8 / 7.3 → 58.4 / 44.2 / 26.9 / 14.6 tok/s (1.6-2.0×), writing code +5-7%, chat unchanged (nothing to copy).?densespec=0turns it off. Experimental?draft=qwen3-0.6b: the host also loads Qwen3 0.6B and drafts with it when lookup finds nothing, only when the measured lap is long enough to pay for the drafts (room/draftmodel.js). @pooled/cli0.3.1: a first run that isn’t clunky.pooledalone opens a short menu in a terminal (Host a room / Join a room / Chat with a room / Use a room from your tools / Manage models; ↑/↓ and Enter or a number) and each item runs that flow; without a terminal it prints a 9-line usage.pooled --helpis a 13-line overview with an example per command (the details stay underpooled <command> --help). A mistyped command or option is one line with the nearest real one (pooled: unknown command "hsot". Did you mean "host"?, alsojion,pul,models->list;pooled host: unknown option "--gbb". Did you mean "--gb"?) and a missing argument says what to type (pooled serve needs a room: pooled serve 4TK-G9P, or paste the invite link in quotes.): no more 30-line help walls. The GPU driver’s own stderr lines when Dawn starts (MESA: error: Opening /dev/dri/card0 failed,TU: error: … VK_ERROR_INCOMPATIBLE_DRIVER,Warning: maxDynamicUniformBuffersPerPipelineLayout artificially reduced …on a GB10; all printed synchronously inside the firstrequestAdapter()) are hidden by pointing fd 2 at a file around Dawn’s native calls and back (close + open takes fd 2 again; Linux and macOS), shown with--verbose, and quoted when no GPU is found.pooled joindownloads the host’s model to~/.pooled/modelsfirst (the pull progress bar) when it isn’t there, instead of a long “loading layers 1%” while it streamed from Hugging Face;--no-pullstreams, and the status line says what a load is doing (downloading 312 of 900 MB · 18 MB/s · from Hugging Face,loading onto the GPU). Pooled keeps its models in one folder,~/.pooled/models, made on first use and shown once on the host screen (--models/POOLED_MODELSare advanced overrides underpooled pull --help). The host screen shows the room’s header once (code, invite link, thepooled joincommand, GPU and models folder, one privacy line) instead of twice, the model picker reads plainly (“22.8 GB · runs here alone”, “needs 2+ devices”, no footnote), and when the pledges split a model this computer could hold alone the panel says so before Start (“Qwen3 1.7B would run on spark alone if it lends 4 GB (now 2 GB): p raises it. Split across 2 devices, each token waits for the network: expect roughly 14 tok/s.”, from the links’ round trips), and the online line gives tok/s and the network’s share. Fixed:pooled chat’s prompt could be redrawn over a streaming answer on a keypress or a resize (def add_numbers(a, b):came out as>>> _numbers(a, b):); the prompt now exists only between answers. The screens use plain ANSI (no terminfo), so they work over ssh from Ghostty (xterm-ghostty), in tmux, withNO_COLOR, and withTERM=dumb(numbered questions, one line at a time). A new look for the menu,pooled host,pooled join,pullandchat, in the site’s language (cli/lib/style.js): the 3x3 dot mark, the room code as a blue pill, one accent (#2A45E0, #7F93FF on dark backgrounds; 256-color and NO_COLOR fallbacks, light or dark from OSC 11 /COLORFGBG/POOLED_THEME), thin━bars, words for states, key hints in the footer (llends,icopies the invite link over OSC 52), no green/yellow/cyan. Asplitsetting on the host screen (s,--split speed|spread): fastest first (the room page’s speed split, the default) or across all devices (its memory split), with a HOLDS column showing what Start would deal each device; the room node’s deal now takes the same two modes (split/setSplit(), planForSpeed or planSplit). Streaming layers from Hugging Face (--no-pull, and a host that streams) is 4-5x faster: the room node plans the shard’s byte ranges, merges them into chunks of up to 64 MB and fetches 6 at a time ahead of the loader, at most ~512 MB buffered (the 1.7B’s layers 13-27, 765 MB on the Spark: 39-45 s -> 8.5-8.8 s);pooled pulldownloads files of 64 MB or more in 4 ranges at once (resume per range from<file>.part.json; size and SHA-256 checked as before).@pooled/openclaw0.2.0: the OpenClaw plugin as a package, with the join gate [preview, not on npm yet] (packages/openclaw).openclaw plugins install @pooled/openclaw(or the tarball fromnpm pack) works without a Pooled checkout: esbuild bundles the room node and engine intodist/index.js,webgpuis an optional dependency, and a missing Dawn says how to add it; installing the tarball into a clean OpenClaw passes on 2026.9.6 (Linux) and 2026.9.7 (macOS 27). The plugin now follows the room page’s gate (#268): links arehttps://pooled.run/r/<CODE>#k=<key>(the old/room/<CODE>404ed) with 6-character codes; Start a room asks before new devices join, keeps its invite key and passes in~/.openclaw/pooled/room.json(0600) so a gateway restart keeps the link, and/pooledin any OpenClaw chat shows the room and who is waiting (/pooled allow [n|all],/pooled deny,/pooled link,/pooled pledge <GB>); Join a room takes a pasted link or code, knocks during onboarding without the GPU (“Waiting forto let this device in…”), keeps the pass so the gateway walks straight in, and learns the host’s model and context (no more 32K guess); an ask still held in the lobby says so after 15 s instead of hanging (room problems like this come back as visible ⚠️ Pooled: …text the model never sees, since OpenClaw hides a provider’s error text and retries it three times; a full context is worded so OpenClaw compacts and asks again), and the ask bridge shows the host the device’s pass, so the host sees one join request, not two. Models come from~/.pooled/models, shared withpooled pull: onboarding shows downloaded / download size / memory needed / context, recommends a model and a pledge the waypooled hostdoes, and offers Download now with progress; a hosting gateway downloads a missing model in the background (one writer at a time) while devices join. A hosting gateway also replays the last system prompt and tools once when the room comes online, so the first question after a restart starts from their checkpoint.createRoom({ gate: true, gateState })in@pooled/room-noderestores a saved gate (docs/openclaw.md).@pooled/cli0.3.0:pooled pull/list/rmand the interactivepooled hostandpooled join(#270) ship on npm. The WebGPU addon (npmwebgpu0.6.1, ~95 MB) is now an optional dependency, sonpm i -g @pooled/cliornpx @pooled/cli hostworks without installing it by hand; if it fails to install,pooled serveandpooled chatstill work andhost/joinsay how to add it.- OpenClaw plugin and a headless room node [proof of concept, not published] (
packages/openclaw,@pooled/openclaw;packages/room-node,@pooled/room-node). Pick Pooled inopenclaw onboard(“Start a room on this device” or “Join a room”) and the OpenClaw gateway becomes a device in a Pooled room: it runs its share of the layers with Pooled’s own engine on Dawn (npmwebgpu) in the same process, with WebRTC from node-datachannel, and browser tabs and phones on pooled.run join the same room. OpenClaw’s requests, tool calls included, go through the same path aspooled serve(cli/librequest checks,room/api.json the host,cli/lib/answer.jsanswer checks). The room node speaks the current protocol (serve v2, the faster-host pick, one submit per token, devices coming back into their slot,ai-linked,ai-wake); The node keeps the browser room’s checkpoints (packages/room-node/ckpt.js): OpenClaw’s system prompt + tools and its own STABLE cache boundary are pinned, answer states are kept by GreedyDual so session titles and compactions do not push out the conversation, and every session on the host shares them. On the 35B MoE split over two nodes on one GB10, OpenClaw’s 11.3k-token first request takes 84 s cold; a new session then answers in 2.0 s, the next day’s in 6.9 s, a follow-up in 0.3 s (answers identical to prefilling everything). The dense engine gets GPU slots for this (a browser host still keeps checkpoints to the qwen35 engine), and a node opens MoE rooms at 128k (27B at 64k), lowered to what the smallest GPU binding limit in the room holds, like a browser host (a node reportsmaxBindMBtoo). Fixed with it: after a single-token frame a worker’s engine position stayed at that token, so a checkpoint saved before the next frame left out its KV row (both engines; batched frames were right).cli/lib/room.jsBridge takes an optional PeerJS class, so one process never loads two WebRTC stacks. Turn checkpoints: the host also saves the state where a prompt’s last user turn starts, because OpenClaw ends every call with per-call context that the next call drops; each step of a tool loop now reads only its new tokens (Spark + Mac, OpenClaw’s long-file task 813 -> 306 s, feature task 298 -> 139 s).packages/room-node/join.mjslends a machine’s GPU to a room from the command line. Real agent tasks on the MoE across a Spark, a Mac Studio and an iPhone: docs/openclaw-poc.md. Review fixes: closing a room node no longer counts its own links as departures (it used to arm the 60 s re-deal and reload the whole model on the closing machine); the node takes avisibilityoption and the plugin hosts withasker, so other devices’ screens get the “answering…” stand-in instead of OpenClaw’s prompts, answers and tool calls; API asks are only answered from devices that joined as API clients, andallowApi(pluginallowApiClients,POOLED_ALLOW_API) turns them off; host room codes are checked against the room page’s alphabet; the plugin’s state-file timer stops with its service and an abort before the ask is honored. Overview, setup on Linux and macOS, results and limits (including who can see and steer what in a room): docs/openclaw.md. - Rooms at work: a default TURN relay (docs/rooms-at-work.md). When the site’s owner sets a provider in the Vercel environment (Cloudflare Realtime TURN:
TURN_KEY_ID+TURN_KEY_API_TOKEN; or coturnuse-auth-secret:TURN_SECRET+TURN_HOST/TURN_URLS),POST /api/turn(api/turn.mjs) hands each device relay credentials that expire (6 h by default, refreshed by the page) for UDP 3478, TCP 3478 and TLS 443, so rooms connect on networks that block UDP. Links still go direct when they can;?relay=1forces the relay,?relay=0skips it. Only this site’s own pages may ask (Origin check), with a per-IP limit. Model weights never go over a relayed link (both sides refuse; the device downloads them instead), so the relay only carries tokens. A device on a network that blocks UDP and has no relay says so (the join fails with the reason and a docs link instead of a generic error). Not configured: the room works exactly as before. - Rooms: 6-character codes and Allow / Deny for new devices. New rooms get codes like
4TK-G9P(30^6 instead of 30^4; four-character codes and/r/ABCDlinks of rooms opened before still work). A device that types the code waits (“Waiting for the host to let you in”) until the host presses Allow on “otter wants to join (Mac, 8 GB)”; Deny sends it back with a message. The host’s invite link and QR code carry a random key in the fragment (/r/4TKG9P#k=…) that lets a device in without asking, and a device the host let in comes back after a reload or a lock without asking again, also after the host reloads. Room settings: “Ask before new devices join” (on).pooled servetakes the invite link (or waits for Allow with the code alone). An older tab joining a newer host is told to reload; a newer tab joining an older host joins as before.PROTOCOLstays 4 (docs/protocol.md). pooled pull,pooled list,pooled rmand an interactivepooled host[preview] (cli/, on npm from 0.3.0). Likeollama pull:pooled pull <model>(aliasdownload) downloads a model’s GGUF (and the dense models’ config and tokenizer) to~/.pooled/models/<model>(--models/POOLED_MODELS), with a one-line progress bar (size, %, MB/s, ETA; plain lines without a terminal), into a.partthat resumes with a Range request after Ctrl-C or a dropped network, checked against its exact size and SHA-256 (new in room/models.jsFILES, Hugging Face’s own numbers) before it is renamed.pooled list(ls) shows what is here and what is not (with partial downloads),pooled rmdeletes one; a part of a name works (pull 35b). The room node reads<dir>/<model>/<file>first, sopooled hostandpooled joinstart from disk instead of streaming the layers from Hugging Face on every start (1.8 GB 1.7B pulled on the Spark at ~106 MB/s, SHA-256 checked).pooled host [model]: in a terminal the room opens at once (code and invite link at the top), then a model picker (each model’s need over the room, downloaded or its download size, the one this GPU holds alone preselected; a model not downloaded downloads right in the screen while devices join), a pledge prompt (half the GPU by default, as the room page; ←/→ or type), and a live room panel redrawn in place: every device with its kind and pledge, the lobby (a / d), and whether the pledges hold the model with the room page’s own math (room/plan.jsroomFitoverroomBytes, #271), “This room is X GB short for …” and Start off until they do; then each device’s load progress, “online” with the layer split, andcto chat right there (pooled chat’s REPL,/exitback to the panel). Every step has a flag that skips it ([model]/--model,--gb,-y,--no-pull,--start,--wait N,--allow-all,--deny-unknown,--chat,--name), sopooled host qwen3.6-35b-moe --gb 64 --start --yesasks nothing; without a terminal the room pulls a missing model first and starts once the pledges fit.pooled joinalone asks for the code or link, shows the host’s model and asks how much to lend (a node sendspledgeas the room page does; a node host’s hello names its model), and says once thatpooled pull <model>makes the next join faster when it streamed. No new dependencies (a small terminal toolkit incli/lib/tui.js, NO_COLOR respected). Driven through a pty on the Spark: pick Qwen3 1.7B, lend 2 GB (“1.8 GB short”), an interactivepooled joinwith the code alone waits in the lobby,a, the room fits, Enter, online in ~4 s;pooled chatandcin the host both answered (54 tok/s).pooled join,pooled hostandpooled chat: rooms from the terminal [preview] (cli/, on npm from 0.3.0).pooled join <CODE | link>lends this computer’s GPU to a room without a browser: the headless room node (packages/room-node, Pooled’s engine on Dawn) holds the layers the host deals it, with a memory rule for how much to lend, a status line, reconnects (it knocks for a minute when the host link drops, rejoins only a host of the same name) and errors that say what to do.pooled hostopens a room on this computer (Enter deals the layers,--devices Ndeals when N are in).pooled chat <CODE | link>talks to a room’s model likeollama run: it joins as an API client overpooled serve’s bridge (v2 asks), streams the answer, keeps the conversation (each turn sends all of it, so the host’s cache reuses the earlier turns: 34 of 60, then 70 of 92 prompt tokens reused on the Spark), shows reasoning dimmed (/think),/clear,/exit, Ctrl-C stops the answer being written, and prints room, model, tok/s and cache reuse under each answer; with a prompt or piped input it asks once, for scripts. Joining follows the room page’s gate (#268,room/joingate.js): six-character codes (4TK-G9P, four still work), the invite link’s#k=key lets a device in at once, a code alone waits in the host’s lobby (“waiting for the host to let you in”), and the pass from the host lets it back in after a dropped link;pooled hostprints the invite link, asks about each device that comes with the code alone (a / d in the terminal) and has--allow-all. Against a host from before the gate all three work as before. Qwen3 1.7B split overpooled host+pooled joinon the Spark: 51-61 tok/s inpooled chat, Ctrl-C stops an answer in 0.2 s; Spark + Mac Studio with the 35B MoE: 45-53 tok/s (cli/README.md).- Room checkpoints on disk: every device keeps a copy of its part of the room’s checkpoints in OPFS, so a device or host that reloads reads it back and the next question resumes from the last saved answer instead of prefilling the whole conversation. A device lists the copies it read back when it says it is ready, and the host forgets any checkpoint a device lacks, so it prefills instead of asking for it. No new message;
?ckptdisk=0turns it off. pooled serve: a room as a local OpenAI and Anthropic endpoint (roadmap 04,cli/, package@pooled/cli, not published yet).npx @pooled/cli serve ABCDjoins the room as an API client with no layers and servesPOST /v1/chat/completionsandPOST /v1/messages(streaming and not),GET /v1/modelsand/healthon 127.0.0.1, so Continue, Open WebUI, LiteLLM, theopenai/anthropicSDKs and curl run on the room’s model. Requests share the room’s one queue; a client’s follow-up turn reuses the room’s caches (exact sampled ids), so only the new turn is prefilled.temperature,top_k,max_tokens, stop sequences and thinking map onto the room’s sampler (tool calling and the Responses API: next entry). Host and Origin checks against web pages, an optional token (POOLED_TOKEN). Prompts go to the room’s host and follow the room’s visibility (see cli/README.md, “Who sees your prompts”).pooled serve0.2.0 [preview]: Chat Completions, Responses and Messages with tool calling, so coding agents run on a room: Codex CLI (Responses, customapply_patchincluded) reads a file with its own tool on Qwen3.6 35B MoE and Qwen3 1.7B, and Claude Code (Messages) on the MoE (its ~15 k-token prompt leaves the 1.7B’s 16 k context too little room). On all three APIs: function tools (up to 1024), everytool_choiceform, parallel calls, streamed call arguments, tool results, reasoning carried through the history (Anthropic thinking signatures and Responsesencrypted_contenthold it), JSON mode and JSON schemas. Every call and structured answer is constrained to its schema by a grammar on the room’s sampler, for Qwen3’s JSON calls and Qwen3.5+’s XML calls, so it always parses. Responses addsprevious_response_idand stored responses (in memory, an hour). Images become a note;max_tokensdefaults to 16384 and is capped at 65536; the banner,/healthand/v1/modelsgive the room’s context and the Codex / Claude Code settings for it. No legacy/v1/completions. Protocol: a v2ai-askwithai-callmessages, negotiated through the host’shello(an older host keeps answering plain requests; an older bridge keeps working);PROTOCOLstays 4 (docs/protocol.md, docs/design/serve.md 11-15).- In the room: API clients get their own card (“API client · Continue”) and are not counted as devices; the host can disconnect one or switch API clients off; the black Serve API button in the room’s header (the one with the dots) opens the Serve API page: this device’s layers and passes beside the command for the room, the base URLs, examples and who is connected; once a client is connected the steps fold under “How to connect” and the page leads with what it serves (“Serving Continue via API · 12 requests answered”). API exchanges show in the chat marked “via API · not part of this chat’s memory” and follow the room’s visibility (the asking client always gets its whole answer). Protocol: new fields on existing messages,
PROTOCOLstays 4 (docs/protocol.md).
Changed
- DP4a prefill is the dense default on NVIDIA (~1.8x faster 27B prompt processing on the GB10;
?dp4a=0turns it off, MoE stays opt-in): it passed a pre-registered accuracy evaluation against llama.cpp on the 27B and Qwen3.5-2B (KL and top-1 agreement over 256-token generations up to 8K-token prompts, 40 scored downstream items: same answers as the f32 path) (#309). - 128K context on the 35B MoE (
?ctx=131072, was 64K): a needle is found at 32K, 64K, 96K and 128K on the GB10. A cold 128K prompt takes ~15 min to prefill there, so it is for sessions that reuse their checkpoints. The 27B goes up to 64K (was 32K). Each device reports its GPU binding limit and the host keeps the room’s context within it; the engine refuses a KV buffer a device cannot bind with a readable error. - Code mode can run on
pooled serve’s core (?hcore=1, opt in: on the Code mode eval it holds on Qwen3.6 35B MoE, 19/24 against 18/24, and is faster there, but Qwen3 1.7B drops from 4/24 to 2/24 on the template’s tool wording): every step goes through the same in-process code that answers API clients: the model’s own chat-template tool prompt, the strict tool-call grammar (tool names and typed arguments, required parameters enforced), the streaming call parser and the room’s exact-id reuse. History is kept as content plus calls, so a session renders right for either call style and survives switching back (?hcore=0). A cutwrite_filestill keeps its complete lines, calls written outside the format still run with a hint, and a model failure now shows as an error instead of ending the request quietly. The eval runner gets--codemode,--dense,--hcore 0|1,--samplingand--label(docs/design/harness-core.md). - Code mode keeps the system prompt cached through compaction (#73): the prefill pauses after the system prompt + tools and saves a checkpoint there (on every device in a room; it is never evicted by the answer checkpoints), so when compaction rewrites old turns the next step prefills only what follows it instead of the whole prompt. Same tokens at the same positions, so answers do not change. The eval runner shows tokens reused next to tokens prefilled for each task.
- Rooms with phones (device matrix, Spark + M5 Max + iPhone 14 Pro Max):
- The layer split now defaults to For speed: the fastest devices fill first, each up to what it lends, and devices that aren’t needed join to ask. Before the first answer measures each device, computers go before phones (an unmeasured phone counts as 20x slower), so a phone only takes the layers the computers can’t hold. By memory is still in the room settings;
?split=memory|speedpicks one at load (the test harnesses pinmemory). - A room split by memory no longer gives a phone layers when the other devices can hold the model; the phone joins as an ask-only guest (
?phonelayers=1restores the old split). Qwen 3.6 35B on a GB10 + iPhone room: 1.6-1.9x faster, the same speed as the GB10 alone. - Keep-alive while an answer runs: each end sends 1 byte every 10 ms of silence on its own unordered channel (id 78), so Wi-Fi power save doesn’t park the radio between laps. No protocol bump;
?ka=0turns it off. Qwen3 1.7B, GB10 + iPhone: +17% plain. - Test rig for rooms with the iPhone (
tests/e2e/xroom_phone.mjs,xroom_cluster.sh) and the measured matrix in docs/bench-log.md.
- The layer split now defaults to For speed: the fastest devices fill first, each up to what it lends, and devices that aren’t needed join to ask. Before the first answer measures each device, computers go before phones (an unmeasured phone counts as 20x slower), so a phone only takes the layers the computers can’t hold. By memory is still in the room settings;
- Metal (Apple GPUs):
- The MoE’s default prompt processing (wide prefill at ubatch 256 plus grouped experts) no longer loses the device under Deno on an M5 Max. Wide prefill now submits every 8 layers, which also makes GB10 MoE prefill about 5% faster, with bit-identical results.
- Apple GPUs in Chrome and Safari get wider fused MoE expert kernels (
moeFusedLayout; M5 Max kernel time: gate/up 0.49x, down 0.55x). Qwen 3.6 35B decode in Chrome on an M5 Max: +5.6% plain (86.1 -> 90.9 tok/s), +11% spec. moe_routeis 1.6-1.9x faster on every GPU (GB10 and M5 Max) and gives the same bits.- Other GPUs keep their output bits. MoE output on Apple still matches llama.cpp, and spec == plain.
- First run, accessibility and speed: a laptop alone now gives the 4 GB the smallest model needs (and a short room offers “Give N GB from this device”), Start says what it will download, a Start/Join tap made before the room script loads is kept, and the landing page and join screen say what a room needs; the room is a labelled radio group with real headings, a live-announced conversation and AA contrast, and the landing demo has a Pause button; the room no longer loads the inference engine or blocks on PeerJS before the join screen works (JS before join 271 -> 135 KB, fonts self-hosted), Lighthouse accessibility 91 -> 100 (landing) and 98 -> 100 (room).
- The model host is the device whose GPU moves memory fastest, when it is clearly faster (1.5x a copy’s GB/s, measured at page load in
room/gpuspeed.js, about 12-450 ms) and lends at least half the memory of the device that would host by memory. Before, it was the device that lent the most memory. GB10 + M5 Max room (the Mac copies at about 390 GB/s, the GB10 at about 190): the Mac hosts, and the MoE’s speculative decode is +20-39% (plain +9-11% on a calm link), the 27B’s speculative +17-19% (plain unchanged). Same tokens for a given split; the GB10 alone is unchanged.?gbps=Npins the value (0 = unknown, which leaves the pick by memory). - A room’s host runs its share of each lap in one GPU submit (engine
hostFuse): in plain greedy decoding the head of the returned hidden, the pick’s embedding and the host’s layers on it are one command buffer with one readback, and a speculative step’s draft chain, draft embeddings and host layers are another. GB10 host + M5 Max guest, MoE over Wi-Fi: plain +7-11%, speculative two-sum +4.5%. Same kernels in the same order, so the same tokens; one device is unchanged.?hostfuse=0for A/B.
Fixed
- Rooms: a device the deal leaves out can ask (starter-kit run). With the “For speed” split, a device the deal left out sat on the Loading card at 0% when the model ran on a device other than the room’s creator (the biggest pledge, or any of them on a tie), and after a reload it showed the model picker. That model host now links every device before it deals and welcomes devices that join later; the creator tells a newcomer which device runs the model (
ai-modelhost); and a left-out device says “Not needed for this model: the other devices hold it. You can still ask.”tests/e2e/room_state.mjs(synthetic model, SwiftShader, in CI’s browser job). - Rooms: a device back on a new link gets its input back (#265, reported by Pointhairedboss). A replacement link that reached the host before the old link’s close was never sent
ai-ready-all, so the worker’s chat input stayed hidden. The host sends the room’s state (ai-layers+ai-ready-all) on every hello, devices take it idempotently, “leaving” closes a link at once, and a hello withback: 1probes an old link under the same name, so the device is not listed (or dealt) twice. - Code mode cards in call order: when one answer holds several tool calls, each call’s card now takes the place of its own live card, so a “skipped: the same call as the one before it” card no longer shows above the call that ran (host and peers).
pooled serveand Code mode on a model with draft tokens (Qwen3.6 MoE): after the answer’s end token, the tokens a speculative step writes to the caches are no longer masked by the tool-call grammar, so the next turn reuses the cached conversation instead of prefilling it again from the system prompt (8 re-prefills to 0 on a 6-task Code mode check).- Rooms never use more memory than a device pledged. A laptop hosting Qwen3 1.7B with 3 GB next to an iPhone with 1 GB held all 28 layers (~3.7 GB) and the phone none: the deal counted a dense layer’s weights (51 MB) but not its f32 KV cache (64 MB at 8k context), nor the host’s second copy of the embedding, so the laptop’s 3 GB “held” 54 layers. The deal now counts both (Qwen 3.5+: the draft chain’s copy of the embedding too), and neither split spreads layers past a pledge when a room is short (both used to, by capacity). The same room now deals laptop 21 layers (2.97 of 3 GB) and iPhone 7 (0.79 of 1 GB). A room whose pledges can’t hold a model keeps Start off and says “This room is X GB short for
. Add a device or raise a pledge: Laptop could give X GB more”; a device left out says why on its card and Lend screen (“Not needed: the computers hold the model”, “joined after the start: waits for a re-deal”); the pledge box says what a pledge covers. pooled serve0.2.1: the banner’s agent settings match the room’s model: a bridge that joined before the host started the model printed Codex / Claude Code settings for a stale context (2048 tokens) and none once the model was ready; they now appear only when it is ready, and again when its context changes. The banner also prints a ready-to-paste opencode provider (opencode.json; setup and tips in cli/README.md), and the bridge log gives a tool-call turn’s finish reason astool_callsinstead ofstop.- iPhones in rooms no longer crash while downloading their layers (#207). Measured on an iPhone 14 Pro Max (iOS 26.6): Safari gives a tab a 1536 MB soft limit (WebGPU buffers count against it) and its networking process 840 MB; after back-to-back loads the tab was killed at 1534-1537 MB. The crash came from downloads the tab never read, which piled up in Safari’s networking process until iOS killed it and the page with it.
- The prefetcher no longer downloads tensors that are already loaded, and cancels what nobody read when a load ends (a MoE layer wasted about 450 MB this way).
- Weights taken from another device in the room stream with flow control (at most 8 MB in flight, new
ai-wackmessage) instead of being buffered whole in JS; the sender streams from its cache, and desktop cache writes stream too. Q4_1/Q5_0/Q5_K/Q6_K tensors convert a few rows at a time on their way to the GPU (same bytes). A phone-shaped tab taking its layers from another device peaked at 307 MB, against 505-546 MB before. - Phones and tablets lend what their tab survives: iPhones and iPads 0.5 GB by default and at most 1 GB (the host also holds them to it, older code included); Android by
navigator.deviceMemory(0.5 GB up to 2 GB of RAM, 1 GB below 8, 2 GB at 8, 1 GB when unknown). The Lend screen and the join box say why. - A device whose tab is killed while it loads gets a smaller share on its own re-deal (about half, new
ai-sharemessage); a second kill, or one at a single layer already, re-deals without it and it stays to ask.
- A phone that locks, goes to the background or reloads comes back into its slot (#207): every device holding layers keeps the screen awake (Screen Wake Lock, a sun in the header shows it), a phone back from the background reconnects by itself and keeps its layers when its slot is unchanged, a reloaded tab rejoins under its name, and an answer or Code run waits for the device and carries on from the last token shown. If it is not back within 60 s the host re-deals over the devices still there (the “Re-deal on its own” setting, experimental). iPhone, 30 s in the background: back and answering in 67 s with no download; reload: 53 s.
- Phone GPU wake: the host tells a phone worker when a decode lap starts (
ai-wake), and the phone keeps its GPU clocked up until its frame arrives. Qwen 3.6 35B, GB10 + iPhone: +4.6% and +11.6% over two runs, same answers;?wake=0turns it off. - Rooms on bad networks: signaling falls back through a list of servers (
window.POOLED_SIGNAL_SERVERS, or?signal=as a comma list; the public PeerJS cloud stays the default), keeps reconnecting after a blip so new devices can join again (before, the host’s registration never came back), and says so on the join screen when no server answers (docs/self-host-signaling.md). Late frames on lossy links are waited for instead of skipped after 5 s, and small frames go out twice (15% loss: 1.4 -> 4.1 tok/s, 5%: 4.4 -> 7.3, Qwen3 1.7B on 3 devices). The host shows within 3.5-5 s which device stopped responding mid-answer and holds the answer for it. A dead link is redialed with the same layers, and an optional TURN relay (?turn=, the Network box) helps behind strict NATs. No protocol bump:ai-linklostis new, and older hosts ignore it. Before/after table in docs/bench-log.md. - Room chat: emoji and non-Latin text no longer show broken characters (U+FFFD) while an answer streams: a character split across two tokens is sent once it is whole.
- The tokenizer no longer freezes the host on one very long word: merging was quadratic in a word’s length (a 32,000-letter run took 18 s on the host’s main thread); it now takes 42 ms, with the same tokens.
[1.0.0] - 2026-09-27
The first stable release: peer-to-peer inference in the browser at pooled.run.
Added
- Demo video (September 27, 2026): Qwen 3.6 35B MoE across a Mac and an iPhone; the phone chats, both devices drive Code, which builds a Tetris game, fixes its own bug and restyles it. Attached to this release.
- Syntax colours in Code as the agent writes, in its diffs and on the landing demo.
- The load card turns to the Pooled mark (“Starting
”) once every device has its layers, while a big model gets onto the GPUs. - The band’s live pill changes word every two seconds while the room writes (“Twinkling…”, “Weaving…”).
- Anyone in a room can drive Code (not only the host): requests queue with the asker’s name, the asker or the host approves or stops, and the model’s heavy role goes to the strongest device (WebGPU, a computer before a phone, then memory).
- Code on phones as Agent / Preview / Files tabs, with the prompt above the keyboard and approvals that can’t be missed; other devices’ previews run by themselves.
- The landing demo matches the room (download card with each device’s layers, the chat band’s token sweep, Code cards and the Editing overlay) and starts at Chat.
- Device colours are the same on every screen (the host’s order) and don’t repeat: 16 swatches, then generated shades; each download row and the device screen show the layers being downloaded.
- The device screen says Serving / Working with the device’s layers, a hop dot per pass, tok/s and ms per hop.
- First Apple GPU results (M5 Max, Metal) and the first two-machine room over Tailscale in docs/bench-log.md.
- Sampling on the GPU (
gpuSample, on by default;?gpusample=0/GPU_SAMPLE=0for the logits path): the head’s argmax or top-k runs in the same submit (topk_a/topk_b), so 16 bytes come back per greedy token instead of the 1 MB logits vector, and the draft chain’s argmax is a two-stage multi-workgroup kernel. Chrome on the GB10, 35B MoE: plain 48 -> 50.5 tok/s (+5%), speculative 72 -> 80 (two-sum) and 65 -> 71.5 (hash map), +10-12%; 27B +1% plain, +3-4% speculative. In a room (MoE,japanprompt): one device +7% plain / +9% speculative, two devices +6% speculative. Same tokens as before (sampling never changes the logits; the 27B bit fingerprint is unchanged), MoE matches llama.cpp, split rooms equal one device. A masked sampler (Code mode’s tool-name constraint) still gets full logits. - MoE prompt processing 2-3x faster by default: tiled prefill attention, the wide prefill GEMM and expert-grouped MoE FFN are on for Qwen 3.6 35B (Chrome, 2048 tokens: 168 -> 402 tok/s). Checked: a split room equals one device, spec equals plain, phone-class limits fall back cleanly; MoE prefill logits tolerance is 2e-2 (the batched path’s own baseline).
- Qwen3 1.7B kernels (bit-exact): parallel attention softmax, fused residuals and norms, one QKV GEMV, encode-ahead. Chrome: prompt processing at 4K 22 -> 177 tok/s, decode at 4K context 10.4 -> 32.9 tok/s, short chats ~52 -> ~61 tok/s.
- 27B decode +5% in Chrome (10.5 -> 11.1 tok/s) from encode-ahead and faster norms, bit-exact; tiled prefill attention makes 16K prompts ~1.9x faster.
- Code mode for small models (
harness/argfix.js): tool calls are repaired before they run (tool and parameter aliases, paths, string booleans and ports, content arrays, stray JSON);serveaccepts a file as its dir; write/edit guards, a loop card and a clean finish after a successful serve. Real Qwen3 1.7B eval: 4/8 -> 7/8 tasks. - Device screen: “Loading 40%” / “Working” / “Ready” / “Standing by”, a “hop” dot that flashes each time a pass reaches the device, tok/s, tokens and ms per hop.
- Room: join and leave toasts, a context circle in Chat, a working indicator until the first token, a floating editor over the preview while the agent edits a served app, per-layer blocks in the folded band; a host that reloads goes straight back into its room.
- Merged projection GEMVs (
fuseProj): the projections that read the same input run as one GEMV over stacked rows; bit-identical. With the fused MoE and the one-submit speculative step, Chrome decode on the GB10 went up 16–24% plain and 26–32% speculative on the MoE (docs/bench-log.md, 2026-09-26). - Draft vocabulary cap (
draftVocab, room default 65536 with an automatic fallback to the full vocabulary): the draft head scores only the first 65536 tokens, which cuts its LM head read; the verify still uses the full vocabulary, so output is unchanged. - Qwen3 1.7B gets 8K of context in rooms (was 2K; Code mode’s prompt alone is ~620 tokens), and JSON-call models decode greedily so their tool calls stay well formed.
- Emulated-latency room benchmark (
tests/e2e/room_latency.mjs): one browser per device, 0 to 50 ms one-way delay, plain and speculative (docs/bench-log.md, 2026-09-27). - Code mode: an in-browser coding agent on the room’s model. It writes files, serves them on a virtual localhost in a preview frame, and reads its own errors (
harness/, docs/design/harness-app.md). - A new landing page at pooled.run and the room app at
/room. - Qwen 3.6 35B MoE in rooms, next to Qwen 3.8 27B and Qwen3 1.7B.
- Long context: the 27B defaults to 16K tokens (up to 32K with
?ctx=), the 35B MoE to 32K (up to 64K), with flash attention and an f16 or int8 KV cache (docs/long-context-and-sessions.md). - Fused MoE FFN (
moeFuse, on by default for MoE models;moeFuse: false/ room?moefuse=0for A/B): per layer the router GEMV carries the shared-expert gate as row nExp (one launch for both),moe_routedoes softmax + top-K by rank (the same ids and weights asmoe_router’s K argmax rounds, no barrier rounds) and writes sigmoid(shared gate) as slot K’s weight,moe_gus_*runs gate/up for the K routed experts plus the shared expert as slot K (Q8_0 shared next to Q4_0 routed is handled per slot), andmoe_dnc_*runs every slot’s down rows and folds the combine and the residual add into its epilogue. 5 dispatches per MoE layer instead of 9. Same kernel per column on the one-token and batched paths; MoE bits change (the shared expert now goes through the expert kernels).moeDnRows(1, 2, 4; default 1, fastest on the GB10) sets output rows permoe_dncworkgroup. CPU check of the kernels:tests/e2e/moe_fused_cpu.mjs. - Long context for the 27B, first step (docs/long-context-and-sessions.md): f16 KV cache (half the memory) and split-K flash attention (
attn_flash/attn_combine; the query heads sharing a KV head read each row once; fixed-order merge), used by decode, verify and prefill alike so speculative decoding and split rooms stay exact. 27B rooms got 8192 tokens of context (was 2048; now 16K, see Long context above). Checked against the f32 path bytests/e2e/flash_synth.mjs, including past 2048 tokens.attnFlash: falsefor A/B. - Tiled prefill attention (
attn_flash_t2): batched passes run two columns per workgroup, so each K/V row is read once per pair instead of once per column (at 8K context, a 16-column prefill chunk otherwise reads each attention layer’s 32 MB of K/V 16 times). Bit-identical to the one-column kernel (tests/e2e/fusion_synth.mjs, with a mutation check);attnTile: falsefor A/B. - Session state:
exportState/importStateand GPU slots (saveSlot/loadSlot) resume a conversation with bit-identical logits;harness/statecache.jskeeps states on disk (OPFS) keyed by model, layers and tokens;harness/prefix.jspicks the longest reusable checkpoint (tests/e2e/state_synth.mjs). - Tool calls (
harness/tools.js): both Qwen formats (XML for Qwen 3.5+/3.8, JSON for Qwen3), prompts, results, and a streaming parser that never shows half a tag;harness/constrain.jskeeps tool and parameter names to the declared ones. - Prompt-lookup drafts search the whole context (16K tokens, was 4K), and after a lookup run is accepted in full the next one may run to
engine.maxDrafts= 15 tokens (replay rollback’s limit at 16 batch columns; was 7): a file being copied back goes in verifies of 16 tokens (tests/e2e/drafts_synth.mjs: 15-token runs, rejections at draft 1 and 9, 4 and 16 columns, all == plain). - int8 KV cache (
kvQ8: true, room?kv=q8): K/V as int8 with one scale per 32 values (~56% of f16’s memory; 32K context on the 27B: 1.2 GB instead of 2.1 GB, spread over the devices). Off by default: same greedy tokens as the f32 path intests/e2e/flash_synth.mjs --q8, logit differences ~3e-5 of the range (f16: ~1e-6);engine_synth.mjs --q8checks spec == plain and split == solo with it. - Agent: tool-name constraint applied while sampling (
engineModel({ tools }), speculative steps included); context budget that cuts the oldest tool outputs first (Agent({ budget })). - Mixture of experts (
qwen35moe: Qwen3.6-35B-A3B, Qwen3.5-122B-A10B): router, top-k experts and shared expert on the GPU (engine/wgsl/moe.js), on the one-token and batched paths. Checked against a float64 reference per layer and exact under speculative decoding (tests/e2e/moe_synth.mjs,engine_synth.mjs --moe). Timed on real hardware in docs/bench-log.md (2026-09-26). - Agent harness core:
harness/agent.js(tool loop with approval for edits),harness/codetools.js(list / read / search / edit / write),harness/workspace.js(a local folder via the File System Access API, or memory),harness/engine-model.js(the loop on a Qwen35Engine with prefix reuse between steps). - Sessions (
harness/sessions.js): several agent conversations on one engine; parked ones wait in GPU slots, the least recently used spill to disk (OPFS); switching back is bit-exact. - Room checkpoints (
?ckpt=N, default 2): every device saves its state after each answer (keys ride the frames like reset / rollback), so a regenerate or branch resumes from the longest saved answer instead of re-prefilling. - Register-resident DeltaNet kernels (
dn_delta_mcfor batched passes,dn_deltafor decode): each thread keeps its column of the recurrent state in registers for the whole pass instead of two global read-modify-write sweeps per token (docs/deltanet-prefill-spec.md, RG=1). Bit-identical to the previous kernels at the 27B’s shape (tests/e2e/dn_delta_synth.mjs: 0 of 786,432 state values differ); the spec’s GB10 microbenchmark: 1.24× at 1 column, 2.03× at 16 for the kernel. - Attention softmax on a workgroup per head, bit-identical (max and exponentials in parallel, the same sequential sum from workgroup memory): it used to be one thread per head doing three serial passes over global memory, a cost that grew with the context.
- Fused attention glue (
attn_glue): the q/gate split, both head norms and the partial rope of an attention layer in one dispatch instead of five, on the decode and batched paths; bit-identical (tests/e2e/fusion_synth.mjs, also at the 27B’s shape).attnGlue: falsefor A/B. Aimed at Metal, where each dispatch costs 32–71 µs; GPU numbers pending. - Fused DeltaNet delta rule and gated norm for decode (
dn_delta_gn): both already ran one 128-thread workgroup per value head, so the gated norm now follows in the same workgroup and the head’s outputs never go through global memory; bit-identical (tests/e2e/fusion_synth.mjs).dnFuse: falsefor A/B. With the attention glue, 112 fewer dispatches per 27B token. - Batched attention for verify and prefill passes: scores, softmax and output for every column in one dispatch each (per-column score rows), instead of three dispatches per column run in turn over a shared scratch; bit-identical (
tests/e2e/fusion_synth.mjs). −144 dispatches per K=3 verify on the 27B.attnMC: falsefor A/B. - One-submit draft chain (on by default;
?draftchain=0turns it off): the K drafts of a speculative step in one command buffer, the next draft’s embedding gathered on the GPU from the argmax; identical drafts. - Q8_0 prefill GEMM (
engine/wgsl/gemm.jskernel8):ffn_down,ssm_outandattn_output, which the 27B file keeps in Q8_0 (~40% of a DeltaNet layer’s bytes), now go through the row-stationary GEMM in full-width prefill passes instead of the batched GEMV. On a 27B-shaped synthetic model: relative error vs the GEMV 2.7e-7 (prefill tolerance 2e-3).gemm8: false/engine.gemm8 = falsefor A/B. GPU numbers pending. - Replay-based rollback for speculative decoding: one pre-verify state plus each column’s inputs instead of 7 full state snapshots per DeltaNet layer; a rejection re-runs the same kernel over the accepted columns (bit-identical). Frees ~6/7 of ~1 GB of snapshot memory on a device holding the whole 27B.
replayRollback: falsefor the old path. - Batched draft-cache fill in prefill: one batched pass per chunk instead of one submit (and one pass over the draft layer’s weights) per prompt token; rows bit-identical to the per-column fill.
- Wire guard: every frame is scanned for NaN/Inf and refused beyond f16’s range instead of sending Inf; the layer map shows each boundary’s largest activation.
- Research survey of kernel and scheduling techniques ranked against this engine:
docs/research/kernels-2026-09.md. Synthetic models with the 27B’s real shapes (synth.mjs --shape 27b) and of the dense Qwen3 family (synth_dense.mjs) for SwiftShader runs. - Prompt-lookup drafts: when an answer repeats something already in the conversation, the tokens that followed the same n-gram earlier are verified as drafts (up to 7 at once at first, now up to 15; free to produce); otherwise the draft head drafts. New
Qwen35Engine.specStepDrafts. Output unchanged (synthetic room: answers byte-identical to lookup off; 2-device decode 3.9 → 4.7 tok/s on a deliberately repetitive model; real-model numbers pending). - Speed-aware layer split: “for speed” fills the fastest devices first (ms per layer measured on the room’s own answers) and leaves out devices that aren’t needed; they stay as ask-only guests and free their GPU.
- Weights from the room: before dealing, the host collects which byte ranges each device has cached; a device takes missing ranges from another device over WebRTC and only falls back to the model host on failure (a re-deal in the test room: 26 → 1 requests to the model host).
- Tensor prefetch: the next 4 tensors of a shard are requested while one loads, hiding the model host’s per-request latency (test room with 300 ms time-to-first-byte: online in 40.8 s instead of 65.6 s; phones keep 1).
- The room survives a host reload: the host keeps the conversation in localStorage and a reloaded page offers “resume room”; guests keep knocking for a minute; the room deals again and the conversation continues where it was.
- Question queue instead of “busy, try again”; Continue a capped answer (byte-identical to an uncapped one) and Regenerate; answer length setting.
- Reactions, “… is typing”, read aloud (the device’s own speech synthesis), and the room card (called the swarm card at first): a shareable PNG of the room’s model, devices and tok/s.
- The prompt’s last 1–4 tokens go round the chain as one frame instead of one lap each; f16 unpack through a lookup table (13× faster per frame).
- Harness:
room_synth.mjsgained--isolate(one cache per device),--netlag,--ttfb,--max-new,--continue,--regen,--queue,--social,--split,--redeal-after,--reload-after,--card,--screenshot. - Multi-turn chat (roadmap 13): the room keeps its caches between questions and prefills only what is new, so follow-ups work (“what did I just ask?”) and a long conversation stops paying for its history on every turn. The host tracks the exact tokens every device holds (
room/conversation.js); anything that is not a strict prefix (a new persona, older turns dropped to fit the context) resets and re-prefills, so correctness never depends on guessing. A “new chat” button, a live context meter, and older exchanges are forgotten (and said so) instead of overflowing. - Stop (roadmap 12): Send turns into Stop while an answer streams, on the host and on the asker’s screen (Esc works too); decoding ends after the lap in flight and every screen unlocks.
- Fail fast and re-deal (roadmap 12): a device leaving mid-answer fails every lap in flight at once (tabs announce
leavingonpagehide, so it takes one lap rather than an ICE timeout), the room goes “degraded”, and the host gets a Re-deal button that splits the model over whoever is still there (cached layers reload in seconds; the conversation is kept). The same button brings in devices that joined after the start. Guests whose host left see “room over” with a way to start a new one. A phone whose tab was killed says so when it comes back. - Pipelined prefill: up to 6 prefill rounds in flight round the chain, so round r+1 runs on the host while round r is on a worker. Same frames in the same order on every device, same output.
- Draft cache filled from the room’s prefill (roadmap 25): the hidden states the chain returns during prefill now feed the host’s draft block, so the first speculative steps after a prompt draft from a warm cache instead of a cold one.
?fill=0turns it off for A/B runs. - Join links and QR (roadmap 14):
/r/ABCDlinks (nowpooled.run/r/ABCD) open the room and joins with no typing; a share sheet with a QR code (a dependency-free encoder inroom/qr.js, checked against jsQR), copy and Web Share. - Model ladder (roadmap 14): every model with what the room still needs for it (“27B +7.5 GB”); the select follows the largest model the room can run until someone picks one. Only devices with WebGPU count; the others join as ask-only guests instead of 0 GB peers that break the split.
- Pre-flight (roadmap 14): the landing page and join screen say whether this browser can hold layers and, if not, the exact remedy (Linux Chrome flag, Firefox, iOS version, insecure page, blocklisted driver); the landing page redesign later dropped its copy of the check. iPads are no longer classified as Macs.
- Layer map (first called the swarm map) and per-hop telemetry (roadmap 25): workers report compute per frame kind; the host splits each lap into GPUs and wire and shows the chain live on every screen, with a pulse per lap, tok/s and draft acceptance.
- Show drafts: tint the words the draft head guessed right and the whole room confirmed in one lap.
- Answer styles: personas (concise, explain-like-I’m-five, pirate, haiku, “the room speaks”), sampling presets (creative, focused, exact/greedy) and a thinking mode for Qwen models whose think block folds away. Save the chat as Markdown.
- Protocol version in
hello; a tab from another deploy is told to reload instead of failing mid-answer (PROTOCOLinroom/transport.js, 4 today). - A synthetic Qwen-3.5-architecture model and SwiftShader harnesses (
tests/e2e/synth.mjs,engine_synth.mjs,room_synth.mjs) that run the real engine and the real multi-tab room on a machine with no GPU.
Changed
- Landing: the hero and demo fit on the first screen; the demo follows the real room (join, lend, pick, split, with a phone) and flows from Chat into Code; switching to Code sparkles.
- One-word lowercase device names; no yellow or orange text; the black dots button opens this device’s screen (no “Lend” wording); the GitHub mark in the header; dots favicon everywhere.
- Guests (phones included) get the Chat/Code switch once the model is ready.
- SwarmLLM is now Pooled, at pooled.run. swarmllm.ai and www.swarmllm.ai redirect to pooled.run for good, and join links like
/r/ABCDkeep working. The repo moved to github.com/Nehanth/pooled; the old GitHub URLs redirect. Browser caches are per site, so the first visit to pooled.run downloads model weights again. Rooms now use the PeerJS id prefixpooled-room-, so a pooled.run tab and an old swarmllm.ai tab never land in the same room.
Fixed
- Code follow-ups printed their tool calls as text (
<tool_tool_calls>): re-tokenized answers now keep<tool_call>and friends as single tokens, a garbled opener can’t be sampled, and a stray<function=…>block still runs. - Code previews fit their box: an app with a fixed layout (a 300x600 tetris board and a side panel) is scaled down to fit the preview on a phone or a narrow pane instead of being cropped; clicks and keys still reach it, and apps that already fit are untouched.
- The folded layers bar sizes each device by its share of the layers, so a phone holding one or two layers never drops out.
- Landing on phones: one device at a time in the join scene, notices in the room bar instead of over the cards, and a chat that follows the answer as it is written.
- A crisp favicon at tab size, light on dark tab strips.
- The 35B MoE on Apple GPUs: the expert-grouped prefill kernel wrote single components of shared vec4s, which Metal can lose; it gave wrong output when a Mac hosted the MoE. Now a scalar array, same arithmetic order.
- Rooms could not start a model: the load-time kernel check read a merged q/k/v view as a buffer.
- Code in a tab opened before a deploy failed to link a newer module; it now falls back or asks to reload.
- Peer names can’t inject markup; a reloaded host goes straight back into its room.
- Rooms could not start a model: the load-time kernel check read the dense engine’s merged q/k/v view as a buffer, so every device failed “GPU kernel FAILED” (PR #91;
tests/test_selftest.jsnow runs first intests/run.sh quick). - Split rooms: a speculative rollback pending when the room reset was dropped, so workers saved checkpoints with rejected drafts in their state and a resumed turn went wrong (
tests/test_moe_split.js). - Peer names can no longer inject markup (quotes escaped, names cleaned on join).
- swarmllm.ai’s bare domain now redirects to pooled.run like every other path.
- CI unit tests run without a local node_modules.
- Dense models (Qwen3 0.6B/1.7B/4B) produced gibberish whenever a device’s autotune picked 8 rows per workgroup. The 4-column batched kernels are built with 4 rows per workgroup, but
engine/dense.jsdispatcheddOut / coopRowsworkgroups, so at ROWS=8 half of every batched GEMV’s output rows were never written (stale but finite, so no NaN check fired). Every prompt prefill on such a device was corrupted; since each device autotunes on its own, a room broke as soon as any device in the chain picked ROWS=8 (solo on such a device broke too for prompts of 5+ tokens). The dispatch now uses the batched kernels’ own rows.tests/e2e/engine_dense_synth.mjschecks batched vs one-token hiddens for WG {64, 256} x ROWS {4, 8} (all bit-identical now; ROWS=8 differed by up to 7.1 before), androom_synth_dense.mjs --rows-per-tab 4,8reproduces the room (different from solo at the first character before, identical after). - Speculative steps could draft past the answer cap, so a capped answer forced the next question to re-prefill the whole conversation.
- A review of the new room code: a degraded room queued questions forever; the transport’s gap timer could skip a frame that was only late and then run it out of order; host-only messages were accepted from any peer; a malformed ask could lock the host busy; layer labels were not escaped; Continue/Regenerate could run during a re-deal.
- Workers never reset their recurrent state between questions. Since the module split,
onDatarouted everyai-*message toaiOnData, whose switch had no case forai-reset,ai-next,ai-layersorai-start-req. In a split room every question after the first ran the workers’ DeltaNet layers from the previous conversation’s state; pressing start on a device that was not the biggest did nothing; a reloaded device was re-seated without its upstream neighbour learning its new id. Resets now ride on the frames and the routing is fixed. - Chains with two or more workers corrupted their state on the first rejected draft. A worker forwarded verify frames without the
specflag, so the next worker never snapshotted its recurrent state and restored a stale snapshot on rollback. The flag now travels with the frame. - A rollback and a new-conversation reset could be overtaken by the next frame (separate channels, one lost packet); both now ride in the frame header and the transport delivers frames in send order.
- Lap timers were never cleared: a stale 30 s / 90 s timer could delete a newer waiter with the same position key.
- Device names from other peers went into the load card as raw HTML.
- Solo prefill filled the draft cache at position
i + 1in its single-token tail, which is only right when the prompt starts at position 0.
[0.2.0] - 2026-09-07
Demo release: Qwen 3.8 27B across a MacBook and an iPhone in browser tabs at 10.7 tok/s.
Added
- Room topology is linear, not a mesh: every device keeps one link to the host; chain neighbours connect only when the layers are dealt (
ensureLink). A 64-device room opens 126 links instead of 2,016. Workers draw the device list from the host’s roster.?signal=host:portpoints the room at a self-hosted PeerServer. - Room emulator (
npm run e2e):--devices N --phones K, local PeerServer, 27B from local disk, room-log error capture, fail-fast on load errors. 16 devices on the 27B verified on GB10. - Hidden-state wire (
room/transport.js): activations travel on a dedicated data channel as ≤4.6 KB slices, striped over several peer connections (?wire=stripeN, default 4;?wire=offto disable). On a 100 ms link a token’s hidden state now crosses a hop in 51 ms instead of 152, and a depth-5 verify block in 52 ms instead of 355 (docs/bench-log.md, transport section). Bytes only, output unchanged. - Multi-token-prediction speculative decoding using the model’s built-in draft layer; output is bit-identical to plain decoding. Solo host and device chains.
- Adaptive draft depth (3/5/7) chosen by measured tokens per second; 8 batch columns so a depth-5 verify is a single pass.
- Batched prefill: 4 (now up to 8) tokens per GPU pass, 16 tokens per network round.
- Device autotune for the cooperative GEMV shape at load.
- Binary WebRTC frames and f16 wire format for activations (one third of the original bytes per hop).
- Fused gate/up GEMV with in-kernel SiLU; fused DeltaNet gate + L2-norm pre-pass; accumulate-into-residual matvec variants.
- Bench log, kernel-family profiler, GEMM prefill prototype, research plans and four implementation designs under
docs/anddocs/research/. - A roadmap (
roadmap/) of 27 items, each mirrored by a tracking issue.
Changed
- Repository restructured for open source:
engine/split into focused modules behind a compatibleengine.jsbarrel (dense, qwen35, wgsl/*, gguf, tokenizer, sampling, quant, autotune, selftest, safetensors); the room page split intop2p.html(markup) +room.js+room/*helpers; tests, benchmarks, goldens and references moved undertests/andbenchmarks/;BelloEnginerenamedDenseEngine. - Cooperative GEMV kernels (64 threads per row, coalesced loads, shared-memory reduction): 27B decode 3.6 → 9.0 tok/s plain, 16 tok/s with speculation on a GB10.
- f16 block scales end to end;
unpack4xdequantization with runtime probe.
Fixed
- Generation no longer runs past the context window (#31): the room stops before the KV cache would overflow and says “stopped: context full”, refuses prompts that leave no room for an answer, and the context is 2048 tokens (was 512). Cache size does not change logits (
tests/test_ctx.js, bit-identical). f32ToF16dropped the rounding carry, silently halving ~0.03% of all values (e.g. -1.9999911 to -1.0): about 1.4 corrupted numbers per 5,120-float activation frame on every hop of a split room, plus occasional halved Q4/Q8 block scales. The carry now propagates into the exponent; all 65,536 representable f16 values round-trip exactly.- Tall matvec dispatches over 65,535 workgroups (the LM head at 2 rows per workgroup) were silently dropped; all matvecs now dispatch in 2-D.
- Draft depth selection by lap time could lock a Mac-hosted room at maximum depth (1.5 tok/s); replaced by throughput-based selection.
[0.1.0] - 2026-08-29
Initial public demo: Qwen 3.8 27B split across browser tabs over WebRTC, from-scratch WebGPU engine, Qwen3 and SmolLM support.