simbroker

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: MIT

README

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).
  • Classios 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:

  1. 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.
  2. 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.

Directories

Path Synopsis
cmd
simbroker command
Command simbroker brokers a machine-global pool of iOS simulators across projects, terminals, and AI agents.
Command simbroker brokers a machine-global pool of iOS simulators across projects, terminals, and AI agents.
internal
avd
Package avd is the Android counterpart to internal/simctl: a thin, read-only discovery wrapper over the Android SDK's `emulator` and `adb` for finding the AVDs (Android Virtual Devices) installed on this machine.
Package avd is the Android counterpart to internal/simctl: a thin, read-only discovery wrapper over the Android SDK's `emulator` and `adb` for finding the AVDs (Android Virtual Devices) installed on this machine.
broker
Package broker is simbroker's allocation logic: it hands out simulators from a capped pool and reclaims them when their holders are gone.
Package broker is simbroker's allocation logic: it hands out simulators from a capped pool and reclaims them when their holders are gone.
cli
Package cli is simbroker's command-line surface: a thin layer that parses flags and renders broker results.
Package cli is simbroker's command-line surface: a thin layer that parses flags and renders broker results.
device
Package device is the class-neutral vocabulary the broker speaks: a Device is "one allocatable thing" and a Controller is "how to discover/boot that class of thing." iOS simulators (internal/simctl) and, later, Android emulators (internal/avd) each provide a Controller; the broker never learns the per-platform details (xcrun JSON vs adb plaintext, UDID vs AVD name).
Package device is the class-neutral vocabulary the broker speaks: a Device is "one allocatable thing" and a Controller is "how to discover/boot that class of thing." iOS simulators (internal/simctl) and, later, Android emulators (internal/avd) each provide a Controller; the broker never learns the per-platform details (xcrun JSON vs adb plaintext, UDID vs AVD name).
mcpserver
Package mcpserver exposes the broker over the Model Context Protocol (stdio), so an AI agent can claim and release simulators as tool calls.
Package mcpserver exposes the broker over the Model Context Protocol (stdio), so an AI agent can claim and release simulators as tool calls.
proc
Package proc answers one question for the broker: "is the process that holds this claim still alive?" — the signal that lets a live holder's lease survive TTL expiry (so we never hand a busy agent's simulator to someone else) while a dead holder's lease is reclaimed immediately.
Package proc answers one question for the broker: "is the process that holds this claim still alive?" — the signal that lets a live holder's lease survive TTL expiry (so we never hand a busy agent's simulator to someone else) while a dead holder's lease is reclaimed immediately.
simctl
Package simctl is a thin read-only wrapper over `xcrun simctl` for discovering the iOS simulators installed on this machine.
Package simctl is a thin read-only wrapper over `xcrun simctl` for discovering the iOS simulators installed on this machine.
store
Package store is simbroker's machine-global state: the set of live claims, persisted to a JSON file and guarded so that concurrent simbroker processes (CLI invocations, the MCP server, other projects) never corrupt it or race on capacity.
Package store is simbroker's machine-global state: the set of live claims, persisted to a JSON file and guarded so that concurrent simbroker processes (CLI invocations, the MCP server, other projects) never corrupt it or race on capacity.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL