← back

herds

2025

give your agents real macs

herds

connect a Mac you own and it becomes a runtime that agents, the SDK and the CLI can drive from anywhere. it runs real macOS, so xcode, simulators, codesigning, homebrew and native apps all work. you can build and ship iOS and macOS apps, automate Safari, run jobs across a fleet of Macs in parallel, and run CI or agent workloads on actual Apple hardware.

fastapi control plane, outbound websocket daemon, per-sandbox process isolation

python · asyncio · fastapi · websockets · pydantic · sqlite · postgres · typer · macos · seatbelt · launchd · xcode · next.js · typescript · cloudflared

the problemReal macOS is hard to get on demand

Anything that needs real macOS, like Xcode, simulators, codesigning, notarization, Safari or native apps, is hard to run on demand. Apple's licensing means nobody rents dense macOS capacity the way Modal or Fly rent Linux, while most developers already have a Mac sitting idle twenty hours a day.

SSH is the obvious answer, and it falls short for ordinary reasons. It gives you a shell without any of the things a runtime needs, and the machine is usually behind NAT anyway.

a shell versus a runtime
ssh gives youa runtime needs
a ttya job record
text on stdoutstructured exit codes
whatever the env wasa declared image
one shared home dirisolation per job
no limit on anythingan admission gate

the ideaThe Mac connects out, and the work comes back over that connection

The Mac opens one outbound WebSocket to a control plane and never listens for anything. Self-hosted GitHub runners, Cloudflare Tunnel and Temporal workers do the same, for the same reason, and that one decision shapes the rest of the design.

The cost is that the socket is stateful, so the control plane can't scale by adding processes behind a load balancer. Every frame carries a request_id, and one connection carries exec, filesystem RPC, HTTP proxying and raw TCP.

a Mac's detail page with live CPU, memory and load
a Mac that connected in: M4 Pro, 24 GB, 14 cores, macOS 26.2, with live CPU and load and the 16 sandboxes it is hosting. the Mac opens no port. all of this came up the connection it opened itself.
control plane
FastAPI. holds the Mac sockets, dispatches exec and fans out logs
daemon
an asyncio process on the Mac with one outbound WebSocket, which runs the work
sdk + cli
synchronous Python. httpx to start a job, websockets to stream it
relay
gives a host a stable subdomain without opening any inbound port
one outbound socket vs an inbound port
inbound portoutbound 443
port forwarding on the routertraverses NAT unchanged
a firewall exceptionsurvives a corporate proxy
a static address or ddnsworks on hotel wifi
vpn for the corporate caseno vpn
config from the Mac's ownerno config, it just connects

how it worksFrom mac.run() to a process on the Mac

The control plane stores very little: machines, keys and job rows in SQLite. Volumes, sandboxes, images and all build output stay on the Mac. Its Hub buffers every frame per request and replays it to late subscribers, so a client that connects 100 ms in misses nothing. The fan-out is an in-memory map from request_id to asyncio queues with exactly two methods, which is where Redis would go once there is a second process.

macOS has no cgroups, so each sandbox is isolated in four layers instead. It gets its own directory tree with HOME and TMPDIR pointed into it, an environment built from an allowlist, its own caches, and a new process session per command so killpg stops everything it started. sandbox-exec limits writes to that tree plus any mounted volumes, and network access is a toggle. The caches have to move because two Xcode builds sharing DerivedData corrupt each other in a way that looks like a compiler bug. xcode-select --switch is avoided for a similar reason: it's global, and would repoint a job running at the same time.

a sandbox with live output, execs and an exposed port
one sandbox on that Mac. python3 -m http.server 9123 is still running, four echo tick execs took 11 to 14ms each, and port 9123 is exposed at /p/sbx_d94b44ae2c/9123/ through the control plane, without touching the Mac's firewall.
one command, end to end
callercontrol planethe macmac.run()blocking pythonlog websocketreads until exitPOST /v1/execpicks a machineHubbuffer, replay, fan outsqlite storemachines, keys, jobsdaemon socketoutbound, multiplexedexecutorsandbox + killpgxcodebuild, simctlrequest_idexec framestdout,seq
four layers of isolation, because macOS has no cgroups
  • own treeworkspace, tmp, home
  • built envfrom an allowlist
  • own cachesderived data, npm, cargo
  • killpgnew session per command

the hard partsBugs I only found by running a real fleet

Every interesting bug came from something disappearing at the wrong moment. I found all of them by running two Macs against one account for days.

In each of the three cases below, the fix was turning a hang into an error, since an error at least says what went wrong. When the socket drops mid-job, for example, the control plane now fails every in-flight job with a synthetic exit -1, so the caller gets an error result and isn't left blocked on a dead read.

retry policy
retry forever after a dropped link, never after a rejected token, since that fails the same way every time
64KB reads
readline() fails on a 1.3MB base64 screenshot and takes the log pump down with it
admission
8 concurrent jobs, 32 queued behind them, exit 75 beyond that, plus a cpu high-water mark
reapers
idle sessions at 30 min, sandbox trees at 24h, and a machine with no metrics for 60s is marked offline
three ways a fleet got stuck
  • killed daemon57 jobs stuck dispatched
  • admission wedged8/8 live, 32/32 queued
  • dropped socketsdk blocked on a dead read

the surfaceWhat a caller gets

The SDK has 107 public methods and is synchronous on purpose. User code stays ordinary blocking Python, and all the asyncio lives in the control plane and the daemon.

Sending real mouse and keyboard events took a workaround. macOS no longer ships PyObjC with the system Python, so the input driver is a ctypes script against CoreGraphics, kept compatible with Python 3.9 because that's what every Mac has. AppleScript can type, but it can't move the mouse to a coordinate.

persistent volumes browsed from the dashboard
volumes are directories on the Mac, not blobs in the control plane. four of them, 986.9 KB in total: an .xcarchive in ios-builds, model weights, and the npm and spm caches that make the second build fast.
run, stream, map
a blocking Result, a generator of chunks, or one function run across the fleet
48 cli commands
connect, run, shell, machines, logs, volume, token, schedule, mcp
tokens
scoped read < run < admin, revocable, and checked on every route
SKILL.md
ships in the package, and a test checks every call in it against the live SDK
an iOS build, and the artifact back
callercontrol planethe macsbx.put(project)tarred, then base64sbx.exec(...)stream on, 1200s capmac.pull()the built product_dispatchpicks the frame typejobs rowdispatched, then runninghub.pendingone future per rpcsandbox_execinherit_home, no fencexcodebuildits own DerivedDataxcrun simctlboot, install, launchdaemon files.pyfs_write, fs_getfs_write,tar_b64intoworkspacerequest_idsandbox_exec framethe built .appfs_getfs_result,base64

the stackWhat runs where, and why

FastAPI and websockets instead of gRPC. Bidirectional gRPC is the better long-term protocol and the documented upgrade path, but a browser dashboard and a pip install with no build step both want plain HTTP.

The daemon installs as a launchd LaunchAgent and runs as the user, so it has their toolchains, signing identities and keychain, which a root daemon wouldn't. inherit_home=True is an explicit opt-out of the sandbox for tools like git and gh, so leaving the sandbox is always a visible choice in the code.

what sits on what
  1. callerpython sdk (sync) · typer + rich cli · mcp server · agent token
  2. relay (herds.run)fastapi · neon postgres accounts · host-header subdomains
  3. control planefastapi · sqlite store · in-memory hub
  4. daemonasyncio + websockets · launchd LaunchAgent · admission gate
  5. the macsandbox-exec · xcodebuild, simctl · osascript, coregraphics

where it standsWhat's shipped, and what's still wrong

It's on PyPI at 0.9.12 and works end to end. A remote agent with only a URL and a token can build an iOS app, boot a simulator, expose a port and stream the logs back. Many of the 369 tests are regressions written after a real two-Mac failure, with the observed symptom in the docstring. The code is 14,186 lines of Python and 9,916 lines of TypeScript for the dashboard.

Isolation here confines each job to its workspace, and it isn't built to contain a hostile one. Real multi-tenancy would need Tart VMs, and Apple's EULA allows only two macOS VMs per host, so VM slots would become a scarce resource the scheduler has to manage.

shipped versus still wrong
worksnot yet
369 tests, 41 filesrelay is one process, one zone
36 wire frame typespeer forwarding built but off
scoped revocable tokensherds shell is -c, not a pty
dashboard bundled into the wheelrun scope is still full shell