Goose Swarm 3.0: local models on every Mac, joined by LeanZero Link
Mihai Perdum
Author
18 min readSeptember 23, 2026
Key takeaways
Goose Swarm 3.0.11 installs as a Developer-ID signed, Apple-notarized app. It runs local models in its own MLX engine (Rapid-MLX 0.14.3-lz.1) and treats that engine, LM Studio and fourteen cloud providers as nodes in one swarm.
LeanZero Link joins your Macs into a private mesh with no password: an emailed six-digit code gets you a 180-day identity token, and the app fetches a single-use mesh key the service mints for it. I signed in and joined the mesh live on 3.0.11. Accounts are isolated from each other, and remote runs are off on every Mac until its owner turns them on.
Link and the swarm router arrived in 2.0.3. What 3.0 adds on top is Agent Work (recurring agent desks), memory that is indexed and recalled rather than pasted, a live check for every provider, and an OpenAI-compatible endpoint.
The memory change alone cut the prompt from 344,605 bytes of pasted memories to a 40,687-byte index, measured on this machine's 171 memories. Even so, one chat completion through the endpoint still carried about 31,450 prompt tokens with my setup.
Not everything worked first time. After a tree listing, the 9B model ran a find over my entire home directory, which I had to stop. And Connect failed on the one Mac that also hosts our sign-in service, which cannot reach its own public address. Both are written up here as they happened.
Goose Swarm 3.0 is our desktop build of the goose agent for running local models on Apple Silicon. It has its own MLX engine, the MCP servers come bundled, and it can put every Mac you own to work through LeanZero Link. Version 3.0.0 shipped on 19 September. Eleven more releases followed in four days; 3.0.11, a UX audit pass, landed this morning while I was testing 3.0.10, so I installed that too.
The first version of this post, published earlier today, covered one of those features, the OpenAI-compatible endpoint, and skipped most of the rest. That was the wrong call for a 3.0 write-up, so this is the full version. I downloaded the release DMGs, installed them on our Mac Studio M3 Ultra, and went through the app screen by screen. Every screenshot below comes from those installs (each caption says which version), and every number was measured here unless I say whose it is.
If you have not met the fork: it is LeanZero's local-first edition of goose (aaif-goose/goose, formerly block/goose), published as goose Local Edition, with releases on the leanzero-srl/goose-local-edition GitHub feed.
Installing Goose Swarm 3.0.11
Each release ships a DMG (Goose-Swarm-3.0.11.dmg is 498 MB) alongside the ZIPs the in-app updater uses. I mounted the DMG, copied Goose Swarm.app into /Applications and asked Gatekeeper what it thought:
bash
1spctl -a-vv"/Applications/Goose Swarm.app"
text
1/Applications/Goose Swarm.app: accepted
2source=Notarized Developer ID
3origin=Developer ID Application: Mihai Perdum (ZZ8MTZ6NRZ)
3.0.10 gave the same answer. That is what "Developer-ID signed and Apple-notarized" means in practice. You never need to disable Gatekeeper or strip quarantine attributes to run it, and if you ever find yourself doing that, download it again. Updates come from the same GitHub releases feed. On a fresh profile the app opens on a "Welcome to goose" screen with two choices, Use Goose Swarm or Connect to a Provider, followed by the upstream "Help improve goose" usage-data prompt, which I declined. In 3.0.11 the home screen is "Pick up where you left off": recent sessions, desks waiting on you, and a new session in the last project.
MLX runtime: running local models on Apple Silicon
The first tab under Providers is LeanZero MLX, the in-house engine. It is a supervised Rapid-MLX process that runs next to LM Studio rather than replacing it. 3.0.0 moved it to upstream Rapid-MLX 0.14.3, keeping our LoRA extension, published as v0.14.3-lz.1. The app watches the engine through its /v1/models endpoint and restarts it with capped backoff if it dies.
I picked mlx-community/Qwen3.5-9B-MLX-4bit (the model list shows its real download size, 5.6 GB) and pressed Mount. In 3.0.11 the page leads with a status card:
LeanZero MLX serving Qwen3.5-9B, Goose Swarm 3.0.11 on our Mac Studio
The context length is 262,144, the tool-call parser is qwen3_coder_xml, it listens on port 8090, and 64.1 GB of the machine's 96 GB was still free. The spawn command at the bottom shows the pin: rapid-mlx[mtp] from our Rapid-MLX fork at v0.14.3-lz.1. Since 3.0.0 the model browser shows the repository's actual download size and keeps tracking a download when you navigate away, so you know a model is 30 GB before you start rather than halfway through.
3.0.0 also bundles two of our MCP servers with the app: LeanZero Web Search and Documents. The Documents server includes the browser it uses for PDF generation and page extraction. The 3.0.0 release notes count 17 document tools and 11 web tools. Web search needs a Serper API key; the document tools and page extraction do not.
The swarm: one router for local and cloud nodes
Goose Swarm is also a provider. Choose it in a chat and each turn goes to an idle node in your pool. Swarm Settings is where that pool lives. Mine has one node, the MLX engine above. Its Share value is how much of a swarm build it takes relative to other nodes.
The node pool. Share steers swarm builds, not chat routing, 3.0.11
The router behind it arrived in 2.0.3 and has not changed in the 3.0 line, but it explains a lot of what you will see. Each node has a fixed number of slots. A conversation sticks to the node it started on while that node has a free slot. Otherwise the turn goes to the node with the most free slots, with ties going to the node's configured weight (a separate setting from Share, which only steers builds). If every slot everywhere is taken, the turn waits in a queue: "no clock, no cap", as the source puts it. The pool is re-read from config on every turn, so edits apply without a restart. For chat, the pool's context limit is the smallest window among the nodes that can serve.
Queued and refused are different outcomes. If no node can serve the turn at all, the router refuses it, and until 3.0.10 that refusal appeared as raw assistant text wrapped in "Ran into this error: … Please retry". 3.0.10 renders it as a proper notice instead. It lists each node and why it cannot serve, with Retry, Open Providers and a Mount button. The button only appears when your saved model matches what the device actually serves, because a mismatch is shown to you, "never guessed".
Cloud nodes sit in the same pool. 3.0.6 wired up fourteen API-key providers, and saving a key now makes a brief authenticated model request. The app rechecks connections on startup, and a provider that fails the check stays out of the pickers. The release notes add that those brief requests "can incur normal provider charges". 3.0.9 then dropped the hard-coded test model each provider used to carry. The key is checked against a default model you pick from the provider's own list, and "a key is proven only when the chosen model runs".
Fourteen providers, each checked with a real model call before it can join the swarm, 3.0.11
A real turn through the swarm, including the bad one
On 3.0.10, I opened a new session in my home-directory project, on the swarm provider, and asked the 9B model a deliberately loose question: list the files in this directory and tell me which is the largest.
It listed the tree, then called find /Users/workhorse -type f -exec du -ch {} +, which walks every file in my home directory. On a machine with tens of gigabytes of project data in it, that is not a question you want answered. I stopped the turn and killed the leftover process.
Then I asked something bounded: run wc -l on the release notes and tell me which is longest. It ran exactly that and said 3.0.8-goose-swarm.md, 36 lines. I checked with wc myself, and that is right.
Two turns on a local 9B: one I had to stop, one that was right, 3.0.10
Two things in that screenshot are worth pointing at. The first is the small "recalled: skills docs" line above the second answer. That is the recall feature, which I come back to below. The second is the counter at the bottom right: 40k of 262k tokens used after two short turns. A small local model with shell access will do what a small model does with a loose question. The answer is to ask narrower questions, not to trust it with broad ones.
LeanZero Link: AI agents across multiple Macs
This is the feature that makes Goose Swarm feel different from a chat app. LeanZero Link joins the Macs you run Goose Swarm on into one private mesh. From the laptop in front of you, you can see your other devices, whether they are free, and their sessions. You can also hand one of them a prompt to run in its own workspace, on its own compute, while you keep working.
Link first shipped in 2.0.3 on 5 September, and nothing in the 3.0 line changed it (git log v2.0.3..v3.0.11 -- crates/leanzero-link is empty). It is still the foundation the 3.0 features sit on, so here is how it works, and what happened when I signed in with it today.
Signing in: no password, a code by email
Open Providers → LeanZero Link. The screen says it plainly: "Sign in to link your devices into a private mesh — no password, just a code by email."
Signed out, on a fresh 3.0.11 profile: an email address is all it asks for
Behind that screen are three calls to the Link sign-in service, and each one mints something:
Request a code. You give your email address, and the service emails you a six-digit code. It stores only a SHA-256 hash of the code. The code expires after ten minutes, works once, allows five attempts, and a new request invalidates the old code. Requests are rate-limited per IP address and per email.
Verify it. The service returns an identity token valid for 180 days, plus your account's node secret: 32 random bytes, created on your first sign-in and shared by every device on your account. It cannot be derived from your email, and the service only returns it to someone holding a valid code or token. A wrong, expired, used or exhausted code all get the same 401, so a guesser cannot tell which case they hit.
Connect to mesh. The app uses that token to ask the service for a join key. The service mints an ephemeral, single-use key for your account on our Headscale server, and the app's own copy of tailscaled joins with it. The key never appears on a command line: it is written to a file readable only by you, passed as --auth-key=file:, and deleted after the join.
There is no key to copy anywhere. The app asks for everything it needs. You bring an email address and read one code.
Why it needs your email
The email is the account. With no password, it is the only thing that says two Macs belong to the same person. Sign in with the same address on each Mac and they join the same mesh. A different address is a different mesh, and the two cannot see each other: the server's policy only lets a device reach devices on its own account. Before every key, the sign-in service checks that policy, puts the isolation policy in place if it is missing, and refuses to mint if it cannot.
Each account is a Headscale user named acct- followed by the first 16 hex characters of a SHA-256 hash of the email. The mesh server never sees your address, but it does keep a record for each Mac you connect (its device name, mesh IP and last-seen time) under that hashed name. The sign-in service keeps the code's hash for up to ten minutes, rate-limit counters keyed by your email and your IP address (gone within about three hours), and your node secret filed under your email. The codes are sent through Resend, the same provider that delivers this site's newsletter, and Resend keeps the sent email. The sign-in service also writes an operations log: your email on sign-in events, and the client IP (including any forwarded-for address) on code requests. On our deployment that log is rotated daily and archives are deleted after 30 days. The LeanZero Link page and our privacy policy spell out what is kept.
The code can also add sign-ins to a mailing list, if whoever runs the service configures it. Ours does not: the live health check this morning returned "audience":false, and when I signed in, the service logged "audienceSync":"skipped". Signing in to Link does not subscribe you to anything.
It stays out of your own Tailscale
If you already use Tailscale, personally or at work, Link does not touch it. Goose runs its own tailscaled in userspace networking mode, with its own state directory (~/.leanzero/tailscale/), its own socket, no TUN device and no root. The source says it keeps clear of /var/run/tailscale* or any personal or system Tailscale daemon, and the config validation refuses system paths.
Remote runs are off until you turn them on
Every Mac on the mesh can be seen, but by default none can be told what to do. A node is observe-only until its owner sets one key in that Mac's goose config:
yaml
1# ~/.config/goose/config.yaml on the Mac that should accept work2LEANZERO_LINK_ALLOW_REMOTE_EXECUTION:true
If that Mac is already connected, the change takes effect the next time it connects to the mesh. Without it, the node answers 403 to a remote run and to every remote model-management request. With it, a Mac that is busy still refuses the work with a 409: it does not queue it. A run lands in the working directory you give it, or the target's home directory by default. The panel that sends the work is called "Run a prompt on a linked device", and a device that is busy or offline is disabled in its picker with the reason shown.
Link also has limits. It does not sync your project files. The target has to be awake and running Goose Swarm. If the target uses a cloud provider, the inference still goes to that provider. It is also not an LM Studio product. LM Studio's LM Link does a similar job for models, but the two are separate networks.
Signing in and joining the mesh, live
I did this on 3.0.11 with a fresh profile, so the app started signed out. One wrinkle first: this Mac Studio is also where our sign-in service and Headscale server run, published over Tailscale Funnel, and a Funnel host cannot reach its own public address. On 3.0.10 I pressed Connect from the Studio's normal profile and got "Couldn't reach the LeanZero Link service — the auth worker may not be deployed yet." The service was up; the hint was wrong. So for the fresh profile I pointed the app at the local service with the LEANZERO_LINK_WORKER_URL environment variable, which is exactly what you would do to use your own sign-in service.
I typed our team address and pressed Send code. The screen switched to "Code sent to g*******@leanzero.net" with a ten-minute countdown, a six-digit field, Send a new code, and Use a different email.
The code screen: the address is masked, and the code expires in ten minutes, 3.0.11
The email arrived with the code in its subject line ("… is your LeanZero Link sign-in code"). I entered it, pressed Verify, then Connect to mesh. The service logged these lines, and they match the design exactly. There was also a fourth, client_ip_unresolved, because I came in over localhost, so there was no client IP to record:
Then the panel read "mesh Running · online · 1 node", with this Mac on mesh IP 100.64.0.1, idle, and an empty Linked devices table: "No other devices linked yet. Sign in on another Mac to see it here."
Signed in with our team account and on the mesh, 3.0.11
That is as far as one machine goes. I did not have a second Mac on hand for this session, so I have not shown a device appearing in that table or a remote run landing on it. The public route itself was flaky this morning: probes from Sweden, Iran, Hong Kong, Portugal and Israel got "Broken pipe" around 08:40, and a re-check at 09:00 got OK from all eight locations. If you would rather not depend on our hosting at all, the sign-in service is a single worker you can run yourself (Cloudflare Workers or Node, next to your own Headscale). Set the LEANZERO_LINK_WORKER_URL environment variable for the app to use it; a config.yaml entry will not do it.
Agent Work: recurring agent desks
Agent Work is the biggest thing 3.0 adds on top of 2.0.3. A normal goose session builds something and ends. An Agent Work desk keeps a recurring assignment. In the source's words, it "polls, investigates, keeps ledgers and a scratchpad, drafts, has its drafts attacked, posts through one gated script, and asks the human when only the human can decide".
Creating a desk in 3.0.11: a charter, a schedule, and only the tools it should use
A desk is a folder with an agent.yaml and a CHARTER.md, which holds the desk's rules: who it speaks as, what it may touch, and what it must ask about first. It also has a schedule: a cadence such as 30m, a timezone, and working hours and days. Each tick runs a fixed sequence of phases:
Guard: a check script can hold the tick.
Poll: read-only scripts that act as the inbox.
Orient: plan the tick.
Lanes: the desk's specialist "surgeon" calls are spread across the swarm's slots.
Review: every draft is attacked by refuting lenses. The default lenses are factual, duplication and voice.
Synthesis: the lane results are combined.
Post: drafts approved in earlier ticks go out through the desk's single write command. Approval defaults to a human.
Close: the tick finishes.
The cadence sets when the next tick starts; it is never a cap on how long a tick runs.
Across the 3.0 line the desk got more usable. 3.0.1 made results readable handoffs, marked stopped workers as interrupted, and only lets a draft into the approval queue if a real worker actually produced it; 3.0.2 replaced the raw JSON tick reports with readable fields. 3.0.11 gave the form cadence presets and a searchable timezone list. 3.0.10 rebuilt the desk page around the result: a status header with the one action that fits the state, the tick's phases timed from their events, and a "needs you" section only when something is waiting. One caveat from the 3.0.1 notes, as written: existing private agent workflows still need their own authentication and approval adapters, and adding a directory does not migrate them automatically.
Self-assessment and the memory extension
If you used goose's memory extension before, you know its two failure modes. It forgets what matters, or it drags everything into every prompt. 3.0.0 fixed the second, and 3.0.8 addressed the first.
The old extension pasted every saved memory into the system prompt at startup. The commit that changed it measured this machine: 171 memories, 344,605 bytes, roughly 86k tokens, a third of a 262k local window, in every session. Now startup injects an index, one line per memory, and a search_memories tool returns the full entries when needed. On the same 171 memories the instructions dropped to 40,687 bytes, an 88% cut.
A local model rarely thinks to search, so 3.0.0 also added recall. On each turn where you type something, it takes your words, searches memories and skills, and adds up to three matching memories in full to that turn's context, and names up to three skills the model should load. That is the "recalled: skills docs" line in the chat screenshot above. If nothing matches, nothing is added.
3.0.8 then added the end-of-turn assessment. After a turn ends, a background task (the turn never waits for it) asks the model for one small JSON judgement: {worth, polarity, memory, why}. It sees what you said this turn, the final reply, and your nearest existing memories, so it merges with them instead of duplicating. Anything out of shape is thrown away whole, "never defaulted". The shapes it rejects are an unknown polarity, worth=false, an empty memory, or prose instead of JSON. You then see a card under the last message, "Save this as a memory?", with Save, No and Edit. Nothing is saved until you press Save. The feature is on by default, and assessment_model lets a different model make the judgement.
Two related changes landed at the same time. Research the swarm has grounded can become project-local knowledge, which you also get to accept or decline. And benchmarks are now "knowledge-blind": a run being measured may not read or write memories, skills or knowledge, and the build refuses the result if any knowledge folder changed. Otherwise a score would depend on one machine's history. That rule sits beside the scorer we described in goose Local Edition: a scorer that runs the app. It keeps future runs on the agentic benchmarks page comparable with each other. The runs published there today, including gemini-3.8-flash's 0.4992 on SB-7.1, predate the rule.
3.0.9 also lets an AI session change what it is discussing. Start a session about a skill, a memory or an MCP and it can edit that item, after asking first. The release notes say plainly that a model doing the write was not exercised, because no local node was up at release time.
Serving an OpenAI-compatible endpoint from goose
3.0.7 added GET /v1/models and POST /v1/chat/completions, and 3.0.8 moved them onto goose serve, the engine the desktop runs. Anything that speaks OpenAI can now call a goose agent on your own Mac. A few behaviours matter before you point a client at it:
Each call is its own session. A completion runs as one ephemeral goose session, deleted afterwards unless you send x-goose-keep-session: 1.
Model ids name the provider. An id is provider/model, split on the first slash. swarm and swarm-build route to the swarm.
Auth is on these two paths only. A bearer token is accepted there, compared in constant time.
Your tools are ignored. A caller's tools are accepted and ignored; the agent uses its own MCP tools. If you need OpenAI-style function calling, this is not it.
I ran it from a source build of the same commit (f2e237ad6e, tag v3.0.10), against oMLX on port 8000:
bash
1GOOSE_SERVER__SECRET_KEY=lzsmoke goose serve --port33992curl-H'Authorization: Bearer lzsmoke'-H'content-type: application/json'\3 http://127.0.0.1:3399/v1/chat/completions \4-d'{"model":"omlx/Qwen3.5-9B-MLX-4bit","messages":[{"role":"user","content":"In one sentence: why is the sky blue?"}]}'
text
1{"object":"chat.completion","model":"omlx/Qwen3.5-9B-MLX-4bit","choices":[{"index":0,"message":{"role":"assistant","content":"The sky appears blue because of Rayleigh scattering: sunlight's shorter blue wavelengths scatter far more strongly off nitrogen and oxygen molecules than longer red ones, …"},"finish_reason":"stop"}],"usage":{"prompt_tokens":31454,"completion_tokens":107,"total_tokens":31561}}
(I trimmed the id and created fields and cut the answer short.) Without the secret, goose serve refuses to start and says why. An unauthenticated call gets a 401, an unknown model a 404 not_found_error, and a body with no model a 400. Streaming works as ordinary chat.completion.chunk deltas, with usage on the last chunk.
Look at prompt_tokens: 31454 for a one-sentence question. That is goose's system prompt and tool definitions, which ride along on every turn. It varies with setup: the 3.0.7 notes record about 44k with the developer extension loaded. The first call took 55.5 seconds; the next two took 10.4 and 8.0 seconds. I did not separate model load from prefill on that cold call. The practical reading: this endpoint is for when you want an agent with tools behind a familiar API. For a raw model's latency, call the model server directly. How much context a local model keeps using over a long run is its own story, told in how a 27B re-read the same file during compaction.
The live test before 3.0.7 shipped caught two defects the unit tests had missed. Both were fixed before packaging. The non-streaming body glued the streamed words together with newlines, so an answer read "A\n clear\n daytime\n sky". A failed model call came back as "200 + finish_reason stop with an MLX stack trace as content", which a client would have shown to a user as the answer. That second case is now a proper 500, and I saw it behave by accident before I had a model server up: {"error":{"message":"Network error: Could not connect to localhost:8000 …","type":"server_error","code":500}}.
CogniRunner task mode and the benchmark view
Two more pieces round out the line. 3.0.8 added CogniRunner task mode. POST /cognirunner/tasks on goose serve accepts a task, pushes HMAC-signed receipts back to a callback as the work progresses, and supports steer and cancel. It is the route built for CogniRunner, our Jira app, to hand work to a goose agent. It is proven offline with a mock model and a local receiver; I have not run it against a live CogniRunner.
3.0.2 to 3.0.4 brought SB-7.1 into the app's Benchmark view. It bundles the scorer, records browser evidence, lets you publish a run to leanzero.net from the app, and retries scoring from the receipt without re-running the model. Graded recordings play at original speed and full length. 3.0.0 also added SB-8, a compact 3D gantry challenge, shipped as experimental and uncalibrated. It has no scores yet, and I am not quoting any. How we score is on the benchmark methodology page.
The rename that nearly emptied the app
For anyone shipping Electron: in 3.0.8 the app was renamed from Goose to Goose Swarm. We pinned the bundle id, com.electron.goose, so installed copies would keep updating. What we missed at first is that Electron derives the user-data folder from the product name. The packaged 3.0.8 "had created 'Goose Swarm/' beside the 1.2 GB 'Goose/' dir", so every updated install would have opened as an empty app. The fix pins the path to ~/Library/Application Support/Goose.
The same rename broke two more things. The fallback updater looked for an asset called Goose and would never have found Goose-Swarm.zip. And the menu code that found the app menu by the literal label "Goose" silently stopped inserting Settings. None of these showed up in a unit test. The empty-app one only showed up when a real package was launched.
Every 3.0 release to date
release
date
what it shipped
3.0.0
19 Sep
Agent Work, memory index and recall, Rapid-MLX 0.14.3-lz.1, bundled Web Search and Documents MCPs, real download sizes, SB-8 (experimental)
3.0.1
20 Sep
Agent Work setup and readable handoffs, MCP config fields with connection tests and live tool discovery
3.0.2
21 Sep
SB-7.1 in the Benchmark view with bundled scorer and publishing, mixed local and cloud swarm wording
3.0.3
21 Sep
Benchmark view runs only the latest stable benchmark
3.0.4
21 Sep
Full-length graded recordings, seekable player on the site
3.0.5
21 Sep
Only configured providers in the node pickers (a saved-config check, not a health check)
3.0.6
21 Sep
Fourteen API-key providers with a live authenticated check
Provider default models, projects by working directory, AI sessions that can edit skills and memories
3.0.10
23 Sep
The "no node can serve" notice, Agent Work desk page rebuilt
3.0.11
23 Sep
UX audit pass: "Pick up where you left off" home, a status card on the MLX page, a no-model strip in chat, memories showing scope and source with Edit and Delete, a rebuilt New agent form
13 rows × 3 columnsHeader row enabled
What I did not test
Here are the things I did not test, stated plainly:
A Link remote run between two Macs. I signed in and joined the mesh live, but had no second Mac on hand, so no linked device and no remote run.
A running Agent Work desk. I created none, and the 3.0.10 notes list "a live ticking desk" as not exercised.
The model-write in 3.0.9 and CogniRunner task mode against a live CogniRunner. Neither has been run.
SB-8. It has no scores.
The version string. A CLI built from source prints 3.0.2, because the workspace Cargo.toml lags the tags. Trust the tag and the DMG name, not goose --version.
People also search for goose vs Claude Code on local models. I have not run that comparison in a way I would put numbers on, so it is not in this post either.
The DMG is on the latest Goose Swarm release page. The full LeanZero Link walkthrough, including what the sign-in service keeps, is on the LeanZero Link page. If you install it, mount a small model first, keep your first questions narrow, and check the token counter after your first turn. And if you have a second Mac, sign in to LeanZero Link on both with the same email.
Convert a Fine-Tuned MLX Model to GGUF: Proving Quantization Parity with KL Divergence
Converting a fine-tuned MLX model to GGUF for llama.cpp is one merge script and one convert_hf_to_gguf.py away. Nothing in that path tells you the GGUF actually matches the model you fine-tuned. This is the three-link method we used to prove it for a 27B model, with the exact commands, the real KL-divergence numbers, and the flag-collision bug that silently broke the measurement the first time we ran it.