⚠️ Early-alpha proof of concept. APIs, config schema, CLI flags, and internal behavior will change without notice between versions. Don't depend on it in anything you can't afford to re-wire. Feedback and bug reports are welcome.
Queue-based autonomous task runner for React Native apps. Drop in a markdown prompt, and a background daemon spins up an isolated sandbox (git worktree + iOS simulator or Android emulator + Metro), runs Claude Code headlessly against it, has the agent verify the change live on the device via the ExecBro MCP server, then optionally pushes the resulting branch and surfaces a PR URL.
Fire-and-forget Claude Code runs for your React Native app, with real device verification baked in.
Running Claude Code by hand on a React Native app means babysitting it: starting Metro, picking a simulator, watching it work, then reminding it to actually exercise the UI before declaring victory. This runner automates all of that so you can queue up several tasks and walk away.
Each task gets its own:
- git worktree (so concurrent tasks don't stomp on each other's files)
- iOS simulator UDID or Android emulator (so they don't fight over the same device)
- Metro port (so bundlers don't collide)
- headless Claude session you can resume with
claude --resume <session-id>if you need to inspect or take over
The agent runs under a composed prompt: a preamble describing its sandbox and the ExecBro tools available, the user's task, and a verification suffix that forces it to reload the app, take a screenshot, exercise the affected flow, and check logs before signaling done. When the agent commits, the runner can push the branch and fire a macOS notification with the PR URL.
- macOS with Xcode (for
xcrun simctl) for iOS tasks - Android SDK with
adbandemulatoronPATHfor Android tasks - Node ≥ 18
claude(Claude Code CLI) onPATHand authenticated- A target React Native app — either bare RN or Expo managed workflow (both are supported: Metro, native build/install, and app launch all auto-detect Expo via an
expodependency and useexpo start/expo run:android/run:ios/a dev-client deep link instead of assuming@react-native-community/cli)iosBundleIdandandroidPackageNameare auto-discovered from the nativeios//android/projects, but Expo managed-workflow apps typically don't commit those — set"execbro": { "iosBundleId": "...", "androidPackageName": "..." }inpackage.jsonto override- For Expo apps, also declare a URL
schemeinapp.json(expo.scheme) orapp.config.js/.ts— needed so a rebuilt/relaunched app connects straight to the task's Metro instead of stalling on the dev-client's "Development servers" picker screen
- A GitHub or Bitbucket remote on the target repo if you want auto-push and a PR URL (opt-in via
pushOnDone)
npm install
npm run build
npm link # puts execbro-task and execbro-worker on your PATHTo uninstall the global symlinks: npm unlink -g from this directory.
1. Generate the config. init discovers your booted simulators and Android AVDs and writes them as slots into ~/.execbro/config.json:
execbro-task init # interactive — shows the proposed config and asks to confirm
execbro-task init --yes # non-interactive — write without confirmationRe-run it any time you add or remove devices; it merges new slots in and preserves your other config fields (including a slot's enabled state — see below). If you need to inspect what's available without writing the file, use execbro-task devices; the listing is annotated with each device's current config state (enabled, disabled, or - if unconfigured).
init validates the merged result against the config schema before writing it — a config.json that parses as JSON but fails validation (e.g. a hand-edited field of the wrong type) makes init fail with a clear message instead of silently rewriting it.
Reserving a device for manual use. Every slot has an enabled field (default true). A disabled slot stays in config.json — still listed, still visible in execbro-task devices — but the scheduler never picks it. This is how you keep a device configured for reference while dedicating a different one to automation, without deleting its slot:
execbro-task devices --disable "Pixel_9_API_34" # never scheduled from now on
execbro-task devices --enable "Pixel_9_API_34" # re-activate it(No restart needed — a running execbro-worker watches config.json and picks this up automatically before its next task pickup; a task already running keeps the config it started with.)
2. Start the daemon in a terminal you can leave running:
execbro-worker3. Write a task file. It's just a markdown prompt — the same thing you'd paste into Claude Code interactively:
Add a pull-to-refresh to the orders screen. The list lives in
src/screens/Orders.tsx and is backed by the `useOrders` hook.4. Enqueue it. The repo is auto-detected from the prompt file's git root:
execbro-task add prompts/pull-to-refresh.md # default: iOS
execbro-task add prompts/foo.md --devices android # Android
execbro-task add prompts/foo.md --devices ios,android # both
execbro-task add prompts/foo.md --parallel # let it run alongside other parallel tasks
execbro-task add prompts/foo.md --devices android --device "Pixel_8_API_34" # pin one specific device
execbro-task add prompts/foo.md --force-device # skip the "is this device already in use" check
execbro-task add prompts/foo.md --force-rebuild # rebuild the native app even if nothing changed
execbro-task add prompts/foo.md --eager # boot the device and build the app up frontA task starts deferred: it gets a worktree and node_modules and nothing else — no simulator, no Metro, no pod install, no native build. Most tasks are code changes that never touch a device, and skipping bring-up saves several minutes each.
If the agent decides it needs to verify on a device, it runs:
execbro-task provision <id>That blocks while the runner claims a device slot, boots it, starts Metro, and builds and installs the app pinned to that Metro. If nothing frees up within deferredProvisionWaitSec (default 900), the command exits non-zero, the agent finishes code-only, and it says so in its final message — the task is not failed. A verification that couldn't be attempted is a finding in the report, not a reason to discard a branch.
Pass --eager for a task you know needs a device from its first step; it provisions before the agent starts, which is what every task did before this.
A task no longer has to arrive as a worked-out plan. It can be a prompt and a link to a bug tracker, and the agent works out the rest — so before it edits anything, it declares what it found:
execbro-task verdict <id> --proceed --file plan.md
execbro-task verdict <id> --needs-decisions --file findings.md--proceed attaches a short plan and carries on. Every task writes one, however small; it's the statement of intent a reviewer reads before the diff.
--needs-decisions records that the task turns on choices the codebase cannot settle — not that it was hard, but that it was underdetermined. The command validates that the document names at least two of them, each with its options and why nothing in the repo, the ticket or the conventions decides it. (One is enough when the ticket itself was unreachable.) That bar exists because writing a report is always cheaper than fixing a bug, so the easy exit has to cost something.
Such a task finishes done, not failed — nothing broke, and the agent did the right thing. Its report carries a Decisions required section, the queue list and the macOS notification both flag it, and outcome.json records resolution. Answer the decisions and re-queue the prompt with your answers appended; there's deliberately no channel back into a finished session.
The report also notes when a verdict was declared after the edits it claims to plan — a plan written afterwards is a rationalisation, and that's worth seeing.
Two consequences worth knowing:
- Device-free tasks are exempt from the serial-blocking rule and may start alongside a device task, and past a queue head that's blocked waiting on a busy device.
maxDeviceFreeTasks(default 2) caps how many run at once — each is still a full headless Claude session. - Whether to request a device is the agent's judgement call. Nothing forces it.
By default the scheduler picks the first enabled device slot for the requested platform(s) that isn't already in use (another Metro paired with it, or the app already running there — protecting a manual dev session from being stomped on). --device narrows that search to one specific slot for a single task; --force-device skips the in-use check when you know it's a false positive (it never skips a disabled slot — that's a deliberate exclusion, not a busy heuristic).
Preferring a device by default. Set defaultDevice in config.json to steer the scheduler toward one slot per platform without pinning every task to it:
{
"defaultDevice": {
"ios": "iPhone 15",
"android": "Pixel_9_API_34"
}
}For iOS, the value matches either the simulator's UDID or its display name (case-insensitive); for Android, the AVD name — both compared against each slot's deviceId. It's a soft preference, not a pin: the scheduler tries that slot first, but falls back to the rest in id order when it's disabled, in-flight, or already busy. A task's --device still overrides it outright. An unrecognized name is logged and ignored rather than failing the worker. (No restart needed — like any other config.json edit, a running worker picks this up automatically before its next task pickup; a task already running keeps the config it started with.)
5. Check progress:
execbro-task list # all tasks and their status
execbro-task show <id> # descriptor + log path + session id
tail -f ~/.execbro/logs/<id>.jsonl # follow the agent's transcript live (raw stream-json, one event per line)
cd ~/.execbro/worktrees/<id> && claude --resume <session-id> # attach interactivelyIf pushOnDone is enabled in your config, the finished branch is pushed and you'll get a macOS notification with a PR URL (GitHub or Bitbucket).
If a running task needs a native rebuild — the agent added a pod, a Gradle dependency, or an Expo config plugin — Metro alone won't pick it up, because Metro only reloads JavaScript:
execbro-task rebuild <id> # pod install + rebuild + reinstall + re-pin to the task's Metro portRun this rather than pod install / expo run:ios by hand: reinstalling the app outside this command drops the setting that pins it to the task's Metro port. The agent is told about it in its preamble, and the runner also rebuilds automatically before committing if the native fingerprint changed during the session.
6. Clean up when you're done with a task:
execbro-task retry <id> # re-queue a FAILED task as a fresh one, removing the original
execbro-task clean <id> # one task
execbro-task clean --all-done # everything in the done bucket
execbro-task clean --all-failed # everything in the failed bucket
execbro-task clean --all-running # stop and remove running tasks (then restart execbro-worker)Retrying a failed task. execbro-task retry <id> — or the retry button on a failed task in the dashboard — reads the failed task's prompt and settings, queues them again as a new task, then removes the original. The rerun gets a fresh worktree branched from the same baseBranch, exactly as the first attempt started.
The original's log, report and worktree go with it; its branch is kept, so any commits the failed attempt made are still there. The new task records retryOf, so it stays identifiable as a rerun after the original is gone. Retry refuses anything not in failed/, and refuses if the prompt file has disappeared (a CLI-added task whose file lived in your repo and was since moved) rather than queueing an empty prompt.
{
"maxDeviceFreeTasks": 2,
"deferredProvisionWaitSec": 900
}maxDeviceFreeTasks(default2) — how many tasks holding no device may run at once. Device-free tasks are exempt from the serial rule, so this is the only bound on them, and it bounds headless Claude sessions rather than devices.deferredProvisionWaitSec(default900) — how longexecbro-task provisionwaits for a device before giving up. Generous on purpose: the wait is usually another task finishing, and an agent that gives up early falls back to shipping unverified.
A worktree carries node_modules plus, for repos that gitignore their native dirs, cloned ios/ and android/ trees — several GB per task. The worker therefore deletes a task's worktree once it reaches a terminal state, after the report pipeline has read it. The task/<id> branch and its commit are never touched: the commit lives in the ref, not in the directory it was made in.
The trade-off is that cd ~/.execbro/worktrees/<id> && claude --resume <session-id> no longer has a directory to run in for a cleaned task. Control it in ~/.execbro/config.json:
{
"cleanupWorktree": "always"
}"always"(default) — delete on success and failure."onSuccess"— keep failed worktrees so you can inspect them and resume the session."never"— the old behaviour; worktrees accumulate untilexecbro-task clean.
The agent's preamble, system prompt, verification suffixes, and analysis prompt live in ~/.execbro/templates/ so you can edit them. They're seeded from the packaged copies on first use, and a .shipped.json manifest records the hash of whatever this tool last wrote — that's what lets an upgrade tell "an old copy you never touched" from "a copy you deliberately edited". Untouched copies are refreshed automatically; edited ones are never overwritten, only reported.
execbro-task templates # current / stale / customized / missing, per file
execbro-task templates --refresh # adopt the packaged copies (yours saved to <name>.bak)A file present with no manifest entry (an install predating the manifest) is treated as customized, since it can't be proven unmodified — use --refresh to adopt the packaged version.
By default each task runs serial: it waits until nothing else is running, then runs alone. Pass --parallel to let a task run alongside other parallel tasks (subject to free slots and Metro ports). A serial task acts as an implicit barrier: parallel tasks queued behind it wait until it finishes.
SKILL.md in this repo documents when and how Claude Code itself should use execbro-task/execbro-worker — when to enqueue vs. run interactively, how to read task status and logs, how to diagnose a "device busy" skip, and common mistakes to avoid. Copy or symlink it into ~/.claude/skills/execbro-runner/SKILL.md (Claude Code's user-level skills directory) so it's auto-discovered; a plain relative reference from this repo isn't picked up on its own. If you edit SKILL.md here, remember to re-sync the installed copy — the two aren't linked.
execbro-task add foo.mdwrites a JSON descriptor to~/.execbro/queue/inbox/.execbro-workersees it and claims a slot viaflock, skipping any enabled slot it can't confirm is free (already running the target app, or paired with another live Metro) unless the task set--force-device.- The provisioner creates a worktree at
~/.execbro/worktrees/<task-id>. If the repo gitignoresios//android/(the Expo CNG default), a worktree contains no native dirs at all — so the provisioner copy-on-write clones them from the source repo, which is near-instant on APFS and gives the task its own isolated copy. It then runsnpm install(andpod installif needed), boots the assigned iOS simulator or Android emulator, installs the app, and waits for Metro to come up. - The runner composes the prompt and launches
claudeheadlessly in the worktree with the ExecBro MCP server pre-configured to talk to this task's Metro. The session id is recorded so you can--resumeit. - The runner streams the transcript to
~/.execbro/logs/<id>.jsonland waits for the agent to exit. - Before committing, the runner re-fingerprints the native side. If the agent changed it, the app is rebuilt and reinstalled so the device matches the branch; a failure here is reported, not fatal.
- On success: the agent's commit is on
task/<id>. IfpushOnDoneis set, the branch is pushed and a PR URL is composed; a macOS notification fires either way. - After the report pipeline finishes, the worktree is deleted (see Worktree cleanup); the branch stays.
Every finished task (success or failure) gets a report directory at ~/.execbro/reports/<taskId>/, built by a post-processing pipeline that runs in the worker daemon right after the task's slot is released — it never holds a simulator or emulator busy. Artifacts:
outcome.json— a deterministic per-task outcome: status, timings, the agent's closing message, errored tool calls, the git changes on its branch, and the tail of the runner log. Written for every task, including ones that fail in provisioning and never produce a transcripttask-report.md— a short human summary built fromoutcome.json: what was done, what blocked it, what to do next. Rendered deterministically first, then rewritten as prose by oneclaude -ppass; if that pass fails the deterministic version stays, so there is always a reporttranscript.jsonl— the agent's tool-call stream, split out oflogs/<taskId>.jsonlso that log file stays pure human-readable textanalysis.json— a deterministic extract: per-tool call counts, error counts, latency, and detected act-then-observe round tripsreport.md— a short markdown write-up generated by oneclaude -ppass overanalysis.json. This one reviews the ExecBro tool surface, not the task —task-report.mdis the "what happened to my task" artifactfilmstrip.html— a self-contained page pairing each screenshot the agent took with the actions that led to it; opens standalone in a browser, no server needed<udid>.mp4+<udid>.chapters.vtt— a full-session screen recording per device (when video capture is enabled) with a WebVTT chapter track indexing it by ExecBro tool call
Controlled by the sessionReport block in ~/.execbro/config.json:
{
"sessionReport": {
"enabled": true,
"video": "all",
"model": "sonnet"
}
}enabled— turn the whole pipeline off for a leaner runvideo— which platforms get a screen recording:"all"|"ios"|"android"|"off". This only gates the.mp4/.chapters.vttfiles —analysis.json,report.md, andfilmstrip.htmlare built regardless, on both platforms, since they cost nothing but a JSON parse and a Claude call.model— the model bothclaude -preport passes run on (task-report.mdandreport.md). Accepts an alias ("sonnet","opus") or a pinned id ("claude-sonnet-5"); defaults to"sonnet". Override for one run withexecbro-task report <id> --model <model>.
Every stage (outcome, analysis, report, filmstrip, chapters) is independently fail-open: a failure in one is logged and the rest still run, so a task that already succeeded is never affected by report generation going wrong. The outcome stage runs first and does not need a transcript — that is what makes a provisioning failure produce a report instead of nothing.
Re-run post-processing for a finished task, optionally starting partway through:
execbro-task report <id> # re-run every stage
execbro-task report <id> --from outcome # re-run the task report only, then everything after
execbro-task report <id> --from filmstrip # re-run filmstrip and chapters only
execbro-task report <id> --model opus # one-off model overrideexecbro-task show <id> prints task-report.md inline once it exists, and the dashboard shows it as a panel on the task detail screen.
execbro-dashboard serves the same queue you'd otherwise drive with execbro-task over HTTP — read task status/logs/artifacts, and enqueue new tasks — for a browser-based UI or a script to talk to. It's node:http only (no framework), and phase 1 is read-and-enqueue: there's no cancel, pause, or reorder yet, and DELETE /api/tasks/:id refuses anything whose status isn't done or failed (queued and running tasks can only be stopped today via execbro-task clean --all-running).
⚠️ This API can execute arbitrary code on the host. Enqueueing runs Claude Code with--dangerously-skip-permissionsagainst a real git worktree. Bindingdashboard.bindto anything beyond127.0.0.1exposes that to whatever network can reach it. Only do so behind a private overlay network (e.g. Tailscale) or a tunnel that provides its own authentication — the bearer token here is the only other line of defense.
1. Generate an API token. The dashboard refuses to start without one:
openssl rand -hex 32 > ~/.execbro/dashboard-token && chmod 600 ~/.execbro/dashboard-token- Use an ASCII token (hex or base64, as
openssl rand -hexproduces). Node decodes request headers as latin1 while the token file is read as utf8, so a token containing non-ASCII bytes can never match the one you generated — auth fails closed, but silently, and you'll see nothing but 401s. chmod 600is not optional. This file is the key to arbitrary code execution on the host; a world-readable token is a full compromise. The server checks the file's mode at startup and logs a warning (not a refusal) if it's wider than0600.EXECBRO_DASHBOARD_TOKENin the environment works instead of (and takes priority over) the file, useful for a systemd unit or CI. It's still held to the same 32-character minimum.
2. Configure dashboard and repos in ~/.execbro/config.json:
{
"dashboard": {
"bind": "127.0.0.1",
"port": 8770
},
"repos": [
{ "name": "myapp", "path": "/Users/you/code/myapp" }
],
"repoRoots": [
"/Users/you/code"
]
}dashboard defaults to 127.0.0.1:8770 if omitted.
repos and repoRoots together form the enqueue allowlist — a client never supplies a filesystem path, only a name resolved against it, so this is exactly what the API is trusted to run agent tasks against. With neither configured, reads still work but enqueueing is disabled.
reposnames individual repositories. Use it when you want to be precise about which projects are runnable.repoRootsnames directories, and every immediate child that is a git repository is allowlisted under its own directory name. A project you clone later is enqueueable without editing config or restarting the dashboard — the roots are re-scanned on each request. Children that aren't git repos, and dotdirs, are skipped.
repoRoots widens the boundary to "anything that is, or later becomes, a git repo directly inside these directories." That is the point of it, but it means anything able to write into one of those directories can make itself a target for an agent running with --dangerously-skip-permissions. Point it at directories you control; never at ~/Downloads or a shared/synced location.
Names are unique and first-wins: an explicit repos entry beats a discovered one, earlier roots beat later ones, and within a root the order is alphabetical. GET /api/repos returns the resolved list, so what the UI offers is always what POST /api/tasks will accept.
3. Build the browser UI (optional but recommended):
cd web && npm install && npm run buildThis produces web/dist. The dashboard server serves it automatically at / once it exists — no separate step, no config flag to flip. Without it, / 404s cleanly rather than crashing, and the JSON API under /api/ still works on its own (e.g. from a script, or curl).
The UI itself has no login flow of its own: on first load it shows a form asking for the same token from ~/.execbro/dashboard-token (or EXECBRO_DASHBOARD_TOKEN), and once submitted, stores it in the browser's localStorage and attaches it as Authorization: Bearer <token> on every request from then on. A 401 (wrong or revoked token) clears the stored token; reloading the page brings the form back. There's no server-side session — clearing site data or using a different browser just means pasting the token again.
Like the API it talks to, the UI is read-and-enqueue only: it lists tasks, tails logs, plays back recordings, and can submit a new task from #/new, but there's no cancel, pause, or reorder button anywhere in it — that matches the API's phase-1 scope above.
4. Run it:
execbro-dashboardIt serves web/dist at / when that directory has been built and the JSON API under /api/.
5. Keep it running (macOS). execbro-worker and execbro-dashboard are both plain foreground processes, so on a machine meant to stay up — a Mac mini acting as a home server — install them as launchd agents instead:
npm run autostart # write both plists and start them
npm run autostart:status # show whether launchd has them running
npm run autostart:stop # stop both and remove the plistsBoth are started with RunAtLoad and KeepAlive, so they come back after a crash and after a reboot, logging to ~/.execbro/logs/com.execbro.{worker,dashboard}.log. Installing takes ownership: any hand-started worker or dashboard is stopped first, because a second worker would exit on the worker.pid conflict and be restarted forever.
Two things specific to this being a LaunchAgent rather than a LaunchDaemon:
- A user must be logged in. The worker drives iOS Simulators and screen recording, which need a real GUI session — so a headless mini wants automatic login enabled, and sleep disabled (
sudo pmset -a sleep 0). PATHis rebuilt at install time. launchd hands processes a barePATH, which would leaveclaudeandadbunreachable; the script resolves them when it writes the plist. Re-runnpm run autostartif you later move or reinstall those tools — and note the plists point at this checkout'sbuild/, so re-run it if you move the repo.
Endpoints (all except /api/health require Authorization: Bearer <token>):
| Method & path | Purpose |
|---|---|
GET /api/health |
Liveness check, no auth required |
GET /api/tasks |
All tasks, bucketed by inbox/running/done/failed |
POST /api/tasks |
Enqueue: { repo, baseBranch, title, prompt, devices?, parallel?, forceRebuild?, forceDevice? }. There is no eagerDevice field, so dashboard-enqueued tasks are always deferred and provision on request |
GET /api/tasks/:id |
One task's descriptor, duration, task-report.md body, and signed artifact URLs |
DELETE /api/tasks/:id |
Clean a task — 409 unless it's done or failed |
POST /api/tasks/:id/retry |
Re-queue a failed task as a new one and remove the original |
GET /api/tasks/:id/log?offset=N |
Byte-offset tail of the raw log, for polling |
GET /api/tasks/:id/transcript?offset=N |
Byte-offset tail of the tool-call transcript |
GET /api/tasks/:id/analysis |
analysis.json, whole file |
GET /api/tasks/:id/report |
report.md, whole file |
GET /api/tasks/:id/task-report |
task-report.md, whole file |
GET /api/tasks/:id/artifacts |
Signed, time-limited URLs for every file in the task's report dir |
GET /api/tasks/:id/artifacts/*?t=<token> |
Streams one artifact (images, video with byte-range support) using the signed token from the listing above, not the bearer header — this is what an <img>/<video> tag hits directly |
GET /api/worker |
`{ status: "running" |
GET /api/repos |
The configured repo allowlist |
GET /api/devices |
Configured slots, each with busy and heldBy |
GET /api/devices/discovered |
Simulators and AVDs found on this machine, each flagged configured if already in a slot |
POST /api/devices |
Add a slot: { platform, deviceId }, where deviceId must be one the discovery scan just reported |
PATCH /api/devices/:id |
{ enabled?, headless? } — partial update, an omitted field is left alone; 409 if the slot is busy |
DELETE /api/devices/:id |
Remove a slot — 409 if busy; returns 200 { ok: true } (not 204) |
GET /api/repos/:name/branches |
Local branches of an allowlisted repo, newest first |
Slot changes are written to ~/.execbro/config.json and picked up by a running worker automatically — it watches the file and swaps its config between task pickups, so no restart is needed. A task already running keeps the config it started with. A malformed edit is logged and ignored rather than stopping the worker.
Any change to a slot that is currently running a task (PATCH or DELETE) is refused with 409. If a worker was killed mid-task, its descriptor stays in the running bucket and its slots read as busy indefinitely — clear them with execbro-task clean --all-running.
deviceId is never free text: POST /api/devices only accepts an identifier the discovery scan just returned, because that value reaches simctl/adb directly. To change a slot's device, remove it and add the new one.
Android slots have a headless flag, default true — the emulator boots with -no-window, invisible, matching the default before this flag existed. Setting it to false opens a visible emulator window so a task can be watched; the change takes effect the next time that slot boots an emulator, not one already running. The flag is accepted on iOS slots too but ignored: simulator visibility depends on whether Simulator.app is open, not a simctl flag.
Artifact responses (/api/tasks/:id/artifacts/*) carry X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy, because report artifacts are written by the autonomous agent task itself, not the operator — an .html report or .svg frame is untrusted content and is served defensively.
npm test