← back

herds

2025

give your agents real macs

herds

connect any Mac you own and it becomes a programmable cloud runtime, driven by agents, SDKs & CLIs from anywhere. real macOS, not emulated: xcode, simulators, codesigning, homebrew and native apps. build & ship iOS/macOS apps autonomously, automate real Safari, manage a fleet of Macs in parallel, and run agent-driven workloads & CI/CD 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 problemThere is no cloud for the Mac you already own

Anything that needs real macOS is stuck. Apple's licensing means nobody rents dense macOS capacity the way Modal or Fly rent Linux, and most developers already have a Mac doing nothing for twenty hours a day.

SSH is the obvious answer and it fails for boring reasons. It gives you a shell, not a runtime, and the machine is behind NAT anyway.

xcodereal toolchainsimulatorssimctl, boot, screenshotcodesigningkeychain identitiesnotarizationapple idssafariwebkit, not a shimnative appsaccessibility api
what a Mac gives you that a container cannot
ssh gives youa ttytext on stdoutwhatever the env wasone shared home dirno limit on anythinga runtime needsa job recordstructured exit codesa declared imageisolation per joban admission gate
a shell versus a runtime

the ideaThe Mac dials home, and the work comes back down the same pipe

One decision shapes everything: the Mac opens a single outbound WebSocket to a control plane and never listens for anything. Self hosted GitHub runners, Cloudflare Tunnel and Temporal workers all make the same move, for the same reason.

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

control plane
FastAPI, holds agent sockets, dispatches exec, fans out logs
daemon
asyncio process on the Mac, one outbound WebSocket, runs the work
sdk + cli
synchronous Python, httpx to start a job, websockets to stream it
relay
gives a host a stable subdomain without any inbound port
inbound portport forwarding on the routera firewall exceptiona static address or ddnsvpn for the corporate caseconfig from the Mac's owneroutbound 443traverses NAT unchangedsurvives a corporate proxyworks on hotel wifino vpnzero config, it just dials
one outbound socket vs an inbound port
1herds authaccount token2herds hostclaim file, control pl…3daemon dialsoutbound ws4registerid, tags, specs5metrics framescpu every few sec6onlineschedulable
enrolling a Mac into the fleet
the maccontrol planesqlite storemachine_idwss, tokenregisteredmetrics_reportherds connecttoken carries the linkmachine.gathername, chip, memoryrun_foreverdials, then backs offmetrics heartbeatevery 5 seconds/agent/wsmachine_id, device tokenhub.add_agentlive socket registryupsert_machinemachines row, onlinelast_seen_msoffline past 60s
a Mac joins the fleet

how it worksFrom mac.run() to a process on somebody's desk

The control plane stores almost nothing: machines, keys and job rows in SQLite, while volumes, sandboxes, images and every byte of build output stay on the Mac. It is a router with a job table attached.

The Hub buffers every frame per request and replays it to late subscribers, so a client that connects a hundred milliseconds in misses nothing. Fan out is an in-memory map of request_id to asyncio queues, with exactly two methods, which is the seam where Redis goes the day there is a second process.

sandbox
~/.herds/sandboxes/<id>/{workspace,tmp,home}, HOME and TMPDIR redirected into it
seatbelt
sandbox-exec fences writes to the tree plus mounted volumes, network is a toggle
why caches move
two Xcode builds sharing DerivedData corrupt each other and it reads as a compiler bug
why not xcode-select
--switch is global and would repoint a concurrent job
callercontrol planethe macrequest_idexec framestdout, seqmac.run()blocking pythonlog websocketreads until exitPOST /v1/execpicks a machineHubbuffer, replay, fan outsqlite storemachines, keys, jobsdaemon socketoutbound, multiplexedexecutorsandbox + killpgxcodebuild, simctl
one command, end to end
the maccontrol planecallerstdout bytesstdout, seqlive framesreplay on joinframe jsonchild processstdout pipe_pump64KB reads, not readlinestdout_frameseq per request_id/agent/wsone frame loophub.publishbuffer, then fan outhub.buffersreplay for late readershub.subscribean asyncio queue/v1/jobs/{id}/logssends until exitmac.stream()yields chunks
a log line, the other direction
own treeworkspace, tmp, homebuilt envallowlist, not inheritedown cachesderived data, npm, cargokillpgnew session per command
four layers of isolation, because macOS has no cgroups

the hard partsFailures I only found by running a real fleet

Every interesting bug was about something going away at the wrong moment, and I found all of them by running two Macs against one account for days rather than by reading the code.

A hang is a worse failure than an error, because an error has a message in it. That one sentence is the fix in all three cases below.

retry policy
forever for a dropped link, never for a rejected token, because that fails identically a million times
64KB reads
readline() dies on a 1.3MB base64 screenshot and takes the log pump with it
admission
8 concurrent, 32 queued behind it, exit 75 past that, plus a cpu high water mark
reapers
idle sessions at 30 min, sandbox trees at 24h, a machine with no metrics for 60s is offline
killed daemon57 jobs stuck dispatchedadmission wedged8/8 live, 32/32 queueddropped socketsdk blocked on a dead read
three ways a fleet strands itself
the maccontrol planecallerconnection closedconn.inflightexit frameretry foreverregisteredlink diessleep, wifi, kill -9run_forever1.7x backoff, capped at 10s_connect_onceregisters again/agent/wsone loop per sessionhub.remove_agentstatus offlinesynthetic exit -1machine disconnectedupdate_jobjobs row failedlog websocketbreaks on exitResultan error, not a hang
the socket drops mid-job
1both armsame instant2both checkis a host serving?3neither isstill negotiating4both bind7 processes, 2 ports5claim fileO_CREAT | O_EXCL
the startup race between herds host and launchd

the surfaceWhat a caller actually gets

The SDK is synchronous on purpose. User code is ordinary blocking Python, so all the asyncio lives inside the control plane and the daemon where it belongs.

Driving real HID events needed a trick: macOS no longer ships PyObjC with the system interpreter, so the input driver is a ctypes script against CoreGraphics, kept Python 3.9 clean because that is what every Mac has. AppleScript can type but cannot move a mouse to a coordinate.

run, stream, map
blocking Result, chunk generator, or a callable fanned across the fleet
48 cli commands
connect, run, shell, machines, logs, volume, token, schedule, mcp
tokens
scoped read < run < admin, revocable, checked on every route
SKILL.md
shipped in the package, every call in it checked against the live SDK by a test
Mac24 methodsmac.ui23 methodsSandbox13 methodsImage9 methodsVolume7 methodsApp7 methods
107 public sdk methods, by class
callercontrol planeexecutorimage, basesandbox_createseatbelt profilesandbox_readysandbox_terminateSandbox.create()a context managersbx.terminate()or __exit__POST /v1/.../sandboxessandbox_id mintedsandboxes tableid, machine, imageDELETE /v1/sandboxes/{id}one frame downget_or_createworkspace, tmp, home_build_envHOME, TMPDIR, DerivedData_wrap_commandsandbox-exec -p profileSandbox.destroykillpg, then rmtree
a sandbox, made and unmade
callercontrol planethe macfs_write, tar_b64into workspacerequest_idsandbox_exec framethe built .appfs_getfs_result, base64sbx.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_get
an iOS build, and the artifact back

the stackWhat runs where, and what it cost

FastAPI and websockets rather than gRPC. Bidirectional gRPC is the better long term wire and the documented graduation path, but a browser dashboard and a pip install with no build step both want plain HTTP.

The daemon installs as a launchd LaunchAgent rather than a root daemon, which is the whole point: it runs as the user, with their toolchains, signing identities and keychain. inherit_home=True is a named opt out of the sandbox for tools like git and gh, and I would rather that be a flag than a surprise.

callerpython sdk (sync) · typer + rich climcp server · agent tokenrelay (herds.run)fastapi · neon postgres accounts · host-header subdomainscontrol planefastapi · sqlite store · in-memory hubdaemonasyncio + websockets · launchd LaunchAgent · admission gatethe macsandbox-exec · xcodebuild, simctl · osascript, coregraphics
what sits on what
sqlitecontrol plane, single processpostgresrelay accounts, on neonthe mac diskvolumes, caches, outputnothingwhat a restart loses
the storage split, and why
publicrelaythe macsHost headersubdomainhttp_requesttokenreplaces the oldinstance, peer urlstep down, reconnectyou.herds.runcaddy, on-demand tlsroute_by_subdomainhost header to socket/relay/connecttoken to accounthosts[account]one socket per accountclaim_hostinstance id, leaseclose 4409closed, not orphanedherds host, mac Aholds the claimherds host, mac Bsame token, later dial
two Macs, one subdomain

where it standsShipped, and what is still wrong

It is on PyPI at 0.9.12 and it works end to end: a remote agent holding only a URL and a token can build an iOS app, boot a simulator, expose a port and stream logs back. Many of the 369 tests are regressions written after a real two-Mac failure, with the observed symptom in the docstring.

Isolation here is workspace confinement, not an adversarial jail. Real multi-tenancy means Tart VMs, and Apple's EULA caps that at two macOS VMs per host, which makes VM slots a scarce resource the scheduler would have to model.

python (sdk, daemon, control pla…14,186 linesnext.js dashboard, ts/tsx9,916 lines
where the code is
works369 tests, 41 files36 wire frame typesscoped revocable tokensdashboard bundled into the wheelnot yetrelay is one process, one zonepeer forwarding built but offherds shell is -c, not a ptyrun scope is still full shell
shipped versus still wrong