simbroker
A machine-global broker for a scarce pool of iOS simulators and Android emulators.
simbroker hands out devices to whoever asks — a terminal, a build script, a
CI job, an AI agent, several agents at once — and makes sure the machine never
boots more than it can handle. It coordinates across projects, because the
real limit (how many simulators/emulators your Mac can run before it thrashes) is
a property of the machine, not of any one repo. Each class (iOS, Android) has its
own capacity, since an Android emulator is a full VM and costs far more RAM than
an iOS simulator. See docs/ANDROID.md for the Android design.
It does one thing: allocation — "which device may I use, and is it free?"
It deliberately does not build or install your app. That stays in your
project, where it belongs.
┌─────────────┐ claim --class ios ┌─────────────────────────┐
│ project A │ ─────────────────────▶ │ simbroker │
│ 1 terminal │ ◀───────────────────── │ • pool + capacity │
└─────────────┘ {claim_id, udid} │ • claims + liveness │
┌─────────────┐ │ • flock'd state file │
│ project B │ ─────────────────────▶ │ ~/.simbroker/ │
│ 3 agents │ ◀───────────────────── │ │
└─────────────┘ └─────────────────────────┘
each agent boots/builds on ITS granted UDID
Why this exists
Running iOS simulators in parallel has one hard limit: RAM. Three or so
booted simulators is about all a Mac takes before it crawls. So whenever more
than one thing wants a simulator — parallel git worktrees, several AI agents
bug-hunting at once, two projects open side by side — something has to track
who holds what, or they collide on the same device (and, for a single app, the
same bundle id).
That bookkeeping is the same no matter who's asking, so it shouldn't live inside
any single project. simbroker is that shared bookkeeper.
What it replaces
This generalises the per-project, per-worktree simulator registry that projects
tend to grow (a wt.sh that maps worktrees to devices) into a standalone,
project-agnostic tool. The key change is the lease key: such registries are
keyed by worktree path (one worktree → one sim). simbroker is keyed by an
opaque claim id, so anything can hold a claim — a worktree, a subagent, a
CI step — and a single worktree can hold several claims at once (e.g. three
agents, three sims).
Requirements
- macOS. Device discovery shells out to
xcrun simctl (iOS) and the Android
SDK's emulator / adb.
- iOS: Xcode or the Command Line Tools, so
xcrun simctl list works. Nothing
else — simbroker never opens Simulator.app.
- Android (optional): an Android SDK containing
emulator/emulator and
platform-tools/adb. simbroker finds it via $ANDROID_HOME, then
$ANDROID_SDK_ROOT, then ~/Library/Android/sdk. With no SDK present, the
Android class simply reports no devices and iOS works as normal.
- Building from source: Go 1.25+.
No daemon, no background service, no root. Coordination is a lock file.
Install
go install github.com/RyanJThompson/simbroker/cmd/simbroker@latest
Make sure Go's bin directory is on your PATH (export PATH="$(go env GOPATH)/bin:$PATH").
Or grab a prebuilt binary from Releases
and drop it anywhere on your PATH. The binaries aren't code-signed, so if you
download one through a browser macOS will quarantine it — clear that with
xattr -d com.apple.quarantine simbroker (downloading with curl avoids it).
Then check the machine:
simbroker doctor # xcrun + Android SDK, config, per-class pool resolution
doctor is the one command to run when something looks wrong: it prints the
resolved capacity per class, how many devices are discoverable, and how many of
them actually match your configured pool.
Set it up for your AI agents
The point of simbroker is that every agent on the machine claims instead of
picking. That takes a few small pieces of wiring — an MCP registration, a pool
config, a note in your project's CLAUDE.md, a permissions entry. Rather than
do it by hand, paste the prompt below into Claude Code (or any coding agent)
from inside your project, and let it do the whole thing:
Set up simbroker in this project so every AI agent working here claims a
simulator/emulator instead of picking one, and hands it back when it's done.
simbroker is a machine-global broker for a capacity-capped pool of iOS
simulators and Android emulators: https://github.com/RyanJThompson/simbroker
It ONLY allocates devices — building, installing and launching the app stays in
this project's own scripts.
Do all of the following, then tell me what you changed:
1. Check it's installed: run `simbroker doctor`. If the command is not found,
install it with
go install github.com/RyanJThompson/simbroker/cmd/simbroker@latest
and make sure "$(go env GOPATH)/bin" is on PATH. Re-run `simbroker doctor`
and show me the output.
2. Register the MCP server once for this machine (user scope, so every project
gets it):
claude mcp add simbroker --scope user -- simbroker mcp
For a non-Claude MCP client, add the equivalent stdio server entry:
{"command": "simbroker", "args": ["mcp"]}
3. Decide the device pool and write ~/.simbroker/config.json:
- "capacity" is how many devices of each class may be claimed AT ONCE. This
is the machine's RAM ceiling, not a wish — iOS defaults to 3, Android to 1.
- "models" is the list of exact device NAMES eligible for the pool.
IMPORTANT: if you omit "models", EVERY simulator on this machine joins the
pool, including ones on the wrong OS version. If this project needs a
specific OS or device set, create purpose-named simulators (e.g. via
`xcrun simctl create`) and list those names, so the pool can't drift.
Example:
{
"capacity": { "ios": 3, "android": 1 },
"models": { "ios": ["MyApp-iPhone17Pro", "MyApp-iPhone16e"] },
"default_ttl_seconds": 1800
}
Verify with `simbroker devices` (it marks pool membership) and
`simbroker doctor`. Every device this project needs must show as in-pool.
4. Add a "Simulators / emulators — use simbroker" section to this project's
CLAUDE.md so every future agent follows the same rules:
- Before building, running, testing or driving the app on a device, claim
one: call the `claim_simulator` MCP tool (pass class:"android" for an
emulator, boot:true to power it on), or run
`simbroker claim --label "<task>" --pid $$ --json`.
- Drive the EXACT udid you were granted, and nothing else. For iOS that's a
simulator UDID; for Android it's an AVD name you boot with `emulator @<name>`.
- Never pick, boot or reset a device that wasn't granted to you — another
agent is probably on it.
- Claim one device per concurrent task, and don't hold one you aren't using.
- Teardown: MCP claims are released automatically when the session ends, so
normally you do nothing. Release early with `release_simulator` /
`simbroker release <claim_id>` as soon as the work is done. A shell script
that claims MUST release on exit:
read CLAIM UDID < <(simbroker claim --label "$PWD" --pid $$ --json | jq -r '.claim_id,.udid')
trap 'simbroker release "$CLAIM"' EXIT
Pass --pid so a crashed holder's device is freed immediately instead of
waiting out the TTL. `simbroker gc` reclaims strays.
- If a claim fails with "capacity reached" or "no free device", that class is
full: release something, retry briefly, or tell the user. Do NOT loop
forever, and do NOT work around it by grabbing an unclaimed device.
5. Add these to .claude/settings.json under permissions.allow so agents aren't
prompted for routine broker calls:
"Bash(simbroker claim:*)", "Bash(simbroker release:*)",
"Bash(simbroker renew:*)", "Bash(simbroker list:*)",
"Bash(simbroker devices:*)", "Bash(simbroker doctor:*)"
6. Finally, remind me to restart my agent session: the MCP server reads
~/.simbroker/config.json once at start-up, so any config you just wrote will
not reach an already-running session.
If you'd rather wire it up by hand, docs/CLAUDE.snippet.md
is the CLAUDE.md block on its own.
Configuration
State and config live in ~/.simbroker/ (honours $XDG_STATE_HOME). Config is
~/.simbroker/config.json; every field is optional.
{
"capacity": { "ios": 3, "android": 1 },
"models": {
"ios": ["MyApp-iPhone17Pro", "MyApp-iPhone16e"],
"android": ["Pixel_8_API_35"]
},
"default_ttl_seconds": 1800,
"grace_seconds": 5,
"android_keep_emulator_on_release": false
}
| Field |
Meaning |
capacity |
Max concurrent claims per class — the RAM ceiling. Default: iOS 3, Android 1. |
models |
Exact device names eligible for the pool. Empty/omitted = every device of that class qualifies. |
default_ttl_seconds |
Lease length when a claim doesn't ask for one (default 1800). |
grace_seconds |
Slack added to a TTL before a pid-less claim can be reclaimed (default 5). |
android_keep_emulator_on_release |
Keep the emulator running on release instead of adb emu kill-ing it to reclaim RAM. |
capacity and models also accept the legacy scalar/array shorthand
("capacity": 3, "models": [...]), which means iOS.
Two things that bite people:
- Pool membership is matched on the device name, and nothing else. There's
no OS filter and no UDID list. Leave
models out and every simulator on the
machine is fair game — including stale ones on an old runtime. A stock name
like iPhone 17 Pro can exist on two OS versions at once, so if a project
needs a specific runtime, create purpose-named devices and list those.
- The MCP server reads config once, at start-up. Editing
config.json does
not reach a running server — it keeps handing out the old pool until the agent
session restarts. The CLI re-reads on every invocation, so simbroker devices
and simbroker doctor always show the truth.
CLI
simbroker claim [--class ios|android] [--label X] [--pid N] [--ttl 15m]
[--device ID] [--wait[=30s]] [--boot] [--json]
# → {claim_id, udid, device, expires_at}
# (udid = iOS UDID, or Android AVD name)
simbroker renew <claim_id> [--ttl 15m]
simbroker release <claim_id> # idempotent
simbroker list [--json] # pool per class: free / held-by-whom / ttl left
simbroker devices # discoverable devices vs configured pool
simbroker gc # reclaim expired / dead claims
simbroker doctor # check xcrun + Android SDK, config, per-class pools
simbroker mcp # run the MCP server over stdio
simbroker version
Consuming it from a script — claim, always release:
read CLAIM UDID < <(simbroker claim --class ios --label "$PWD" --pid $$ --json | jq -r '.claim_id,.udid')
trap 'simbroker release "$CLAIM"' EXIT
# ... your existing build/install/launch on $UDID ...
--pid $$ is the important part: it ties the claim to a live process, so an
ungraceful death frees the device immediately instead of parking it until the
TTL lapses.
MCP server
simbroker mcp speaks the Model Context Protocol over stdio, so an AI agent can
claim and release devices directly:
claim_simulator · release_simulator · renew_simulator · list_simulators
Register it once per machine:
claude mcp add simbroker --scope user -- simbroker mcp
Any other MCP client wants the same stdio entry:
{ "mcpServers": { "simbroker": { "command": "simbroker", "args": ["mcp"] } } }
Pass class: "android" to claim_simulator for an Android emulator (the default
is iOS); the returned udid is a simulator UDID for iOS or an AVD name for
Android. Run one server per agent session.
Teardown is automatic. The server releases every claim it created when its
own process exits — session ends, devices come back. Claims also auto-renew while
the session is alive, so long jobs don't need to think about the TTL. Release
early with release_simulator when a device is genuinely finished with; the TTL
and PID-liveness rules below backstop everything else.
Design
Two concerns that per-project tools tend to conflate, kept apart here:
| Concern |
Who owns it |
| Allocation — which device, is it free, RAM ceiling, liveness |
simbroker (this repo) — project-agnostic |
| Provisioning — build / install / launch your app on a device |
your project — irreducibly app-specific |
simbroker grants you a UDID. What you do with it (boot it, install your build,
drive it) is yours.
Concepts
- Device — a real simulator/emulator:
{id, name, class, os, version, state}. iOS devices come from xcrun simctl list -j devices available (id =
UDID); Android from emulator -list-avds + adb (id = AVD name).
- Class —
ios or android. Each class is allocated independently with its
own capacity (an Android emulator is a full VM; its ceiling is usually
lower). One AVD name = one allocatable Android slot.
- Pool / capacity — the max number of concurrent claims per class (the RAM
ceiling). Defaults: iOS 3, Android 1. Optionally a per-class curated list of
preferred device models.
- Claim — a lease on one device:
{claim_id, udid, class, label, pid?, hostname, created_at, expires_at, last_renewed_at}.
- Liveness — a claim is reclaimable when it has expired (
now > expires_at) or its holder process is dead (pid given and not alive).
renew pushes expires_at forward. Garbage collection runs implicitly before
every claim and on demand via gc.
State & concurrency
- State:
~/.simbroker/state.json (honours $XDG_STATE_HOME).
- Every mutation is a locked read-modify-write: take an exclusive file lock,
load state, mutate, atomically replace (temp file + rename), release the lock.
Multiple
simbroker processes — CLI, MCP server, other projects — coordinate
only through this file + lock. No daemon required.
Liveness model (the part that's actually hard)
A claim can be reclaimed two ways:
- TTL expiry — every claim has a deadline. Long-running holders must
renew
(or use a TTL longer than their work). This is the universal fallback and the
only thing that survives a kill -9.
- PID liveness — if a holder passes its
--pid, the broker frees the claim
the moment that process is gone, without waiting for the TTL. Faster recovery
for the common case (process exited cleanly or crashed).
The MCP server adds a third, convenience layer: it releases the claims it
created when its own process exits (session ends) — connection-liveness without a
daemon. TTL still backstops the cases where that doesn't fire.
The reclaim-out-from-under race. If a live holder lets its TTL lapse, GC
could hand its device to someone else while it's still working — two claimants,
one device. simbroker mitigates this with (a) a conservative default TTL, (b)
renew, and (c) PID liveness so a live holder's claim is never considered
expired-and-dead. Guidance: pass --pid whenever you can, and renew at ~⅓ of
the TTL for long jobs. See docs/DESIGN.md for the full analysis.
Status
Early but in daily use. Built in Go (single static binary, no dependencies at
runtime). macOS-only by nature of what it brokers. See docs/DESIGN.md for
rationale and the open design questions.
License
MIT — see LICENSE.