Skip to content

Repository files navigation

Arbiter

Arbiter is an AI-driven issue tracker and autonomous coding agent harness. It coordinates work across your projects through a CLI (arb), MCP tools for a coordinator agent, and a LiveView dashboard. Tasks are tracked as issues, dispatched to worker agents for autonomous execution in isolated git worktrees, and merged through a ReviewGate and merge queue for code quality and safety.

Prerequisites

  • Elixir 1.19+ / Erlang 28+mise is the recommended way to install. With mise installed:

    mise install
    

    (The .tool-versions file in this repo pins the exact versions.)

Arbiter's datastore is SQLite — no database server or Docker is required.

Install (development)

# 1. Clone
git clone <repo-url> arbiter
cd arbiter

# 2. Install dependencies, run migrations, seed the default workspace
mix setup

# 3. Build and install the arb CLI onto your PATH
arb install cli

arb install cli builds the CLI escript from apps/arbiter_cli and installs it to ~/.local/bin/arb. If ~/.local/bin isn't in your PATH yet, add this to your shell profile (.bashrc, .zshrc, etc.) and restart your terminal:

export PATH="$HOME/.local/bin:$PATH"

Since arb install cli itself depends on arb already being on your PATH the very first time, bootstrap it once by hand:

cd apps/arbiter_cli && mix escript.build && cd ../..
mkdir -p ~/.local/bin && cp apps/arbiter_cli/arb ~/.local/bin/

Re-run arb install cli any time you pull changes to apps/arbiter_cli.

Architecture

Workspaces — isolated coordination scopes. A workspace holds a set of issues, dispatch policies, and tooling configuration. An installation can run multiple workspaces side by side, each with its own repos, tracker, and merge settings.

Repos — registered git repositories. Workers check out code on repos to work on issues.

Issues — tasks to be worked. Can be tracked in an external system (Jira, GitHub, Linear) or managed locally. Status flows from creation through ready → in_progress → done.

Workers — autonomous agents spawned via Claude Code (or future adapters) to work an issue. Each worker receives an issue, works it in an isolated git worktree, and reports completion with a PR or notes. Their full transcript is retained for audit and learning.

ReviewGate — optional quality checkpoint. A second Claude agent reviews the worker's work before merging. Can be disabled per-issue.

Merge queue — batches approved changes and applies them to the repo in sequence. Handles merge conflicts, CI status checks, and rollback on failure.

PRPatrol — polls open PRs per repo and dispatches follow-up workers when a review needs a response (changes requested, unresolved review threads, or failing required checks).

Watchdog — polls a merge request's fate for a worker parked at :awaiting_review and drives the worker to its terminal state (merged, closed/rejected, or auto-merged).

Escalation mailbox — the coordinator's inbox for anything that needs a human or coordinator ruling (e.g. a ReviewGate escalation, a worker failure). Read it via arb message inbox or the inbox_check / notify_list MCP tools.

/events — a server-push event stream (GET /events) for the coordinator: newline-delimited JSON, one event per line, for topics like inbox, review_gate, worker_failed, worker_done, and (opt-in) task_state / external_review. Every event carries a "cursor"; pass since=<cursor|timestamp> to replay what was missed since a disconnect before rejoining the live stream (see ArbiterWeb.Api.EventController moduledoc).

Coordinating via MCP

The primary integration path for a coordinator agent (e.g. a dedicated Claude Code session) is the arbiter MCP server, which exposes tools like task_show, task_create, task_list, worker_dispatch, worker_resume, worker_review, worker_list, worker_log, inbox_check, message_send, notify_list, workspace_show, workspace_config_get/set, quota_get, run_log_list, transcript_capture_stats, and usage_summarize, plus whole tool categories beyond one-off issue dispatch:

  • Skillsskill_list/skill_get/skill_create/skill_update/skill_delete for managing reusable skill content.
  • Dependencies + schedulerdep_add/dep_remove/dep_list to wire issues together with depends_on/blocks/conflicts_with edges, and scheduler_pause/scheduler_resume/scheduler_status to control the board scheduler (Autopilot) that auto-dispatches Ready cards in edge order. Chains of issues run by declaring the edges, not by building a separate graph object.
  • ExternalReviewexternal_review_list, external_review_show, external_review_transcript, review_greenlight for inspecting and unblocking worktree-backed external code review. external_review_transcript is worker_log's counterpart for a review: the prompt it was given, the raw transcript its reviewer emitted, and every tool call paired with its result — keyed on the review record id, since an external review is not task-linked.

See apps/arbiter/lib/arbiter/mcp/catalog.ex for the full, current catalog and which tier (worker vs. coordinator) can call each tool.

To mint a token for a coordinator to use:

arb mcp token mint --tier coordinator

The server speaks MCP over Streamable HTTP at http://127.0.0.1:4848/mcp, so configure a client with "type": "http" (Claude Code), httpUrl (Gemini CLI) or a plain url (Codex CLI), and pass the token as Authorization: Bearer <token>:

{
  "mcpServers": {
    "arbiter": {
      "type": "http",
      "url": "http://127.0.0.1:4848/mcp",
      "headers": { "Authorization": "Bearer <coordinator-token>" }
    }
  }
}

Streamable HTTP is the only transport /mcp serves. The deprecated HTTP+SSE transport (2024-11-05, Claude Code's "type": "sse") is not served: a client configured that way POSTs initialize and then waits for the reply on the SSE stream, which never arrives, so it times out and reports the server as down. Use "type": "http" — which is what arb init and worker dispatch write. The GET /mcp SSE stream still exists, but only as Streamable HTTP's server → client channel (server-initiated messages and keepalives); it requires a coordinator token.

Quick-start

1. Start the server

arb server start

The dashboard is at http://127.0.0.1:4848.

2. Configure a workspace

Visit the dashboard's Workspace page and configure:

  • Repos (projects to work in)
  • Worker/agent settings — model tier map, per-thinking-level args, provider overrides, and credentials (agent.config.*)
  • Security policy (agent.security.*)
  • Rate-limit throttling (quota.*) and per-workspace worker concurrency (conductor.max_concurrent — the key name is historical)
  • Optionally a tracker (Jira, GitHub, Linear) and merge strategy

Or edit config/dev.exs directly and restart the server, or use arb config set.

3. Dispatch your first issue

Create and dispatch an issue via the dashboard or CLI:

arb issue create "Fix typo in README"
arb issue dispatch <id> my-project

Watch the worker in the dashboard, or tail the transcript:

arb worker log <task-id>

Once complete, the merge queue picks it up (if configured) or you can merge manually via the dashboard.

Running as a systemd service

To run Arbiter as a self-contained OTP release under a systemd user unit, install and enable it with:

arb install service

This writes ~/.config/systemd/user/arbiter.service (ExecStart=~/.arbiter/current/bin/arbiter start), enables it via loginctl enable-linger for machine-boot startup, and starts the release. Manage it with systemctl --user status arbiter.service and view logs with journalctl --user -u arbiter.service -f. Pass --system to install a system-wide unit instead (needs root). Secrets and PATH configuration live in ~/.arbiter/arbiter.env. Uninstall with arb install service --uninstall.

arb install service only writes the release-shaped unit above. There is no CLI command yet for a dev-mode unit (one whose ExecStart runs mix phx.server instead of a release binary) — on a source checkout you hand-write that unit file yourself; see Deploying: pick the path for your install shape below.

Deploying: pick the path for your install shape

Arbiter supports two install shapes, and the deploy command differs between them:

  • Source checkout (dev mode) — a git clone with mix phx.server run directly (by hand, or supervised by a systemd user unit that points ExecStart at mix phx.server rather than a release binary). This is how the "Install (development)" section above sets things up, and how this repo's own coordinator instance runs today — the releases strategy is scoped but not yet built (see #754 for the history: arb server deploy used to hard-require ARB_RELEASE_REPO and dead-end on a dev-mode install; it now detects the missing env var and falls back automatically).
  • OTP release install — a built release artifact unpacked under ~/.arbiter/releases/<tag>/, installed via arb install service. No source checkout or Elixir/Mix toolchain on the box.

If you're on a source checkout, use the sequence below. If you have a release artifact installed, skip to OTP-release installs.

Source checkout (dev mode)

git fetch origin main
git diff --name-only HEAD origin/main -- mix.lock   # non-empty → deps changed; see note below
arb server deploy --git-pull
arb server doctor     # confirm CLI and server report the same version

arb server deploy --git-pull does the pull → rebuild → restart → migrate sequence for you: git pull --ff-only on main, rebuild and install the CLI escript if apps/arbiter_cli changed, then restart Phoenix — via systemctl --user restart arbiter when the systemd unit is present, a plain process bounce otherwise. The restart's boot sequence (Boot.Migrator) applies any pending migrations before the endpoint opens, so migrations are never run against the live server. Do not git pull by hand first — the command diffs before_sha/after_sha itself and treats an already-current tree as "nothing to deploy," skipping the restart and escript rebuild entirely.

It also does not run mix deps.get for you. If the pre-check above printed a mix.lock line, the pulled tree needs new deps that the restart will not have — running mix deps.get before the pull is a no-op, since git fetch doesn't move HEAD and the old lockfile is still checked out. Instead, either run mix deps.get && arb server restart immediately after the deploy, or skip --git-pull and do the whole thing manually: git pull --ff-only origin mainmix deps.get(cd apps/arbiter_cli && mix escript.build && cp arb ~/.local/bin/arb) if apps/arbiter_cli changed → arb server restartarb server doctor. The manual form is the safer choice when deps changed, since it never restarts against unfetched deps.

A bare arb server deploy (no flags) does the same git-pull fallback automatically whenever ARB_RELEASE_REPO is unset, but prefer the explicit --git-pull form on a source checkout so the command's behavior doesn't depend silently on an environment variable you may not have set on purpose.

Production deploys: OTP-release installs

This subsection only applies once you have a built release artifact installed (arb install service, or manually under ~/.arbiter/releases/<tag>/) — not to a source checkout; use the sequence above for that. ~/.arbiter/current is symlinked atomically to the active release. Deploy a specific version (or latest) with:

arb server deploy --version v1.2.3

This downloads the release tarball + checksum from GitHub Releases (ARB_RELEASE_REPO), verifies the SHA-256, unpacks it, atomically swaps current, restarts the service, and health-checks it — auto-rolling back to the last-known-good release if it doesn't come back green.

Migration ordering, and rollback across a migration

The deploy does not run migrations. SQLite allows exactly one writer, so a bin/arbiter eval Arbiter.Release.migrate from the new release while the old server is still serving would be a second writer racing the live one. Instead the new release migrates during its own boot: Arbiter.Boot.Migrator is a synchronous supervision-tree child that brings the schema to head before ArbiterWeb.Endpoint binds its port, gated on the single-instance advisory lock so only one node ever migrates. The real ordering is therefore:

stop the old server  →  new release boots  →  migrate  →  serve

— one writer at every instant, and the same path arb server restart, arb server migrate and dev mix phx.server already take. The dev-mode fallback (arb server deploy --git-pull) obeys the same ordering: with the server up it pulls and restarts, and the pulled migrations are applied by that boot; it only migrates standalone when the server is already down.

Auto-rollback stops at a schema change. Re-pointing current back at the prior release after the new one has migrated would run old code against a schema it has never seen. Before swapping the symlink, the deploy compares the migrations packaged into the new release tree (lib/<app>-<vsn>/priv/repo/migrations) against those in the release it would roll back to — a pure filesystem comparison, no database connection. If the new release adds any:

  • it says so up-front, naming them, and notes that automatic rollback is disabled;
  • on a health-check failure it refuses to roll back, leaves current on the new release, names the crossed migrations, and exits non-zero with the operator's options (fix forward, or roll the schema back first with bin/arbiter eval "Arbiter.Release.rollback(Arbiter.Repo, <version>)" and then arb server deploy --version <prior> --force);
  • --allow-cross-migration-rollback overrides the refusal, rolling back anyway with a loud warning that the prior release is now on a newer schema.

A deploy that adds no migrations keeps the automatic rollback unchanged — but only when detection actually worked. Because an arbiter release always ships migrations, an empty migration set from the new release tree means the globs no longer match the packaging layout, not that the deploy is migration-free. That case fails closed: the deploy warns up-front, refuses the automatic rollback the same way a crossed migration does, and reports migrations_detected: false in --json so the empty crossed_migrations list can't be mistaken for "safe". --allow-cross-migration-rollback overrides it.

When the refusal comes from a failed swap (/api/version still reports the old release) rather than a green-wait timeout, the message says the new release's migrations may have been applied rather than claiming they were — a release that never booted never ran its boot migrator.

Server bind address (ARB_BIND_ADDRESS)

The dashboard's auth model is "a loopback peer is trusted; there is no login" — LiveView pages, including a terminal into every worker session, have no credential check beyond "did this request come from the same box". Because of that, the server binds to 127.0.0.1:4848 by default in both dev and prod/release, in dev and runtime config alike.

Upgrade note: before this change, the default was 0.0.0.0 (dev)/[::] (prod) — reachable from the LAN, or the internet on a cloud host with a permissive security group. An install that relied on that for remote access will stop being reachable after upgrading. Use an SSH port-forward instead (ssh -L 4848:127.0.0.1:4848 <host>), which needs no server-side change; or, if off-loopback really is required, set ARB_BIND_ADDRESS explicitly (below) — arb server doctor will keep warning about it every time as a reminder of the exposure.

To bind elsewhere, set ARB_BIND_ADDRESS to any address :inet.parse_address/1 accepts (e.g. 0.0.0.0, ::, a specific interface IP):

echo "ARB_BIND_ADDRESS=0.0.0.0" >> ~/.arbiter/arbiter.env

Any non-loopback value logs a boot WARNING naming the exposure, and arb server doctor reports a non-fatal warning check pointing back here.

Remote arb — access Arbiter over VPN

By default, arb talks to a local server on http://127.0.0.1:4848 (loopback). To point arb at a remote Arbiter server:

  1. Set ARB_HOST to your server's URL over VPN:

    export ARB_HOST="http://arbiter.internal.example.com"
    # or export ARB_HOST="https://arbiter.example.com" for HTTPS
  2. Mint a token on the server:

    arb mcp token mint --tier coordinator
  3. Export the token on the client:

    export ARB_TOKEN="<token-from-step-2>"
  4. Verify the connection:

    arb prime
  • Local loopback (ARB_HOST unset or http://127.0.0.1:4848) requires no ARB_TOKEN — the server exempts localhost.
  • Remote access requires both ARB_HOST and ARB_TOKEN, and the server must be reachable at that address in the first place — since the server now binds loopback-only by default (above), that means either ARB_BIND_ADDRESS set server-side, or reaching it through an SSH port-forward / VPN tunnel that terminates on 127.0.0.1 locally. The API's own token check is unaffected either way; only the dashboard's login-free LiveView pages are the reason to prefer a tunnel over a raw ARB_BIND_ADDRESS override.

Encryption key (ARBITER_CLOAK_KEY) — required

Arbiter encrypts workspace secrets (tracker / merger credentials) at rest using AES-256-GCM via ash_cloak. The server refuses to start without an encryption key. Generate a 32-byte Base64 key and add it to your environment before deploying:

echo "ARBITER_CLOAK_KEY=$(openssl rand -base64 32)" >> ~/.arbiter/arbiter.env
# (for a non-service run, put it in the project-root .arbiter.env or export it)

arb install service forwards ARBITER_CLOAK_KEY from the installing shell into ~/.arbiter/arbiter.env automatically if it is set. Treat this key like a database password: back it up and keep it stable — rotating it (re-encrypting existing secrets) is a separate runbook and is not yet automated. Losing it makes existing encrypted secrets unrecoverable.

Claude CLI authentication (CLAUDE_CODE_OAUTH_TOKEN) — recommended

Real worker spawns and the CredentialWatchdog's health probe both authenticate the claude CLI via Arbiter.Agents.Claude.spawn_env/1. By default that falls back to whatever OAuth session is active for the account running the Arbiter server (~/.claude/.credentials.json) — which means an operator's personal session expiring blocks every workspace's dispatch until it's manually re-authenticated.

To avoid that, set a long-TTL Claude Code OAuth token once, install-wide, in .arbiter.env (same file/trust model as ARBITER_CLOAK_KEY / SECRET_KEY_BASE above — loaded via EnvironmentFile= when running as a service):

echo "CLAUDE_CODE_OAUTH_TOKEN=<your-long-ttl-token>" >> ~/.arbiter/arbiter.env
# (for a non-service run, put it in the project-root .arbiter.env or export it)

spawn_env/1 exports this under its own literal name (never remapped to ANTHROPIC_API_KEY — the two are not interchangeable to the CLI) whenever it's present in the OS environment, so the probe and real dispatch always agree. Prefer this single install-wide var over configuring the same token as a per-workspace credentials_ref/worker_env secret in each workspace — a per-workspace copy only reaches real worker spawns, not the Watchdog's probe, and duplicating an identical token across workspaces risks copy-drift if one copy is rotated and the others aren't.

arb install service also forwards CLAUDE_CODE_OAUTH_TOKEN from the installing shell into ~/.arbiter/arbiter.env automatically, same as the other captured secrets above.

Precedence when both are set: a spawn can end up with both CLAUDE_CODE_OAUTH_TOKEN (install-wide) and ANTHROPIC_API_KEY (workspace credentials_ref/api_keys rotation) in its environment at once. Which one the claude CLI honours is decided by the CLI itself, not by Arbiter — if it prefers the OAuth token, a workspace that deliberately configured its own key would silently authenticate against the install-wide account instead. If a workspace's ANTHROPIC_API_KEY must win, verify the CLI's actual precedence before relying on it, or unset the install-wide token for that install.

Redaction: Arbiter.Worker.ClaudeSession.start/1 adds CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY values to the session's redaction list alongside the workspace's secret-flagged worker_env values — both the values carried in the spawn's explicit :env (the Dispatch/ ReviewGate path) and, for the start/1 callers that pass no :env at all (the conflict-resolver and CI fix-pass workers, which still inherit the install-wide OS environment via Port.open's {:env, …} extension semantics), the value read directly from the OS environment. So the token is scrubbed from worker output (worker_runs.output_lines, the live dashboard stream, the durable output log) across all ClaudeSession.start/1 callers, even after the per-workspace worker_env copies are removed.

Storing tracker / merger credentials in the database

Instead of pointing credentials_ref at an environment variable (credentials_ref: "env:SHORTCUT_API_TOKEN"), you can store the token directly on the workspace, encrypted at rest, and reference it with a secret: ref. This needs no server-side env var and no restart:

# 1. store the encrypted secret on the workspace
arb workspace secret set tracker_token sct_rw_...

# 2. point the tracker config at it
arb config set tracker.config.credentials_ref secret:tracker_token

# inspect / remove (values are never shown — only key names)
arb workspace secret ls
arb workspace secret rm tracker_token

Existing env: refs keep working unchanged, so you can migrate one workspace at a time. Secret values are never returned by the API or CLI — only the key names are listed.

Initialize your coordinator session

A coordinator is a dedicated Claude Code session that directs work across a workspace — creating and dispatching issues, reviewing worker output, and resolving escalations — typically via the MCP tools above. It has its own working directory with persistent memory and a notes folder.

mkdir ~/coordinator && cd ~/coordinator
arb init         # initializes the current directory

Or pass a path to initialize elsewhere:

arb init ~/my-coordinator
cd ~/my-coordinator

Then open Claude Code:

claude

The coordinator session will check whether the server is running, start it if needed, orient itself with arb prime, and be ready to coordinate your work.

Adding repos (projects)

A repo is a local git repository that workers check out and work in. Register your projects via the dashboard (Workspace → Repos), or directly in config/dev.exs:

config :arbiter, :repo_paths, %{
  "my-project" => Path.expand("~/dev/my-project"),
  "another-project" => Path.expand("~/dev/another-project")
}

After editing config/dev.exs, restart the server for changes to take effect.

CLI Reference

The CLI uses an arb <resource> <verb> grammar: arb [resource] verb [args]. Run arb help (or arb --help) for the exhaustive, always-current usage text (apps/arbiter_cli/lib/arbiter_cli/main.ex); the table below covers the commands you'll reach for most.

Key commands

Command Purpose
arb prime Mission briefing — run at session start to check workspace status
arb issue list List issues in the workspace
arb issue show <id> View an issue's details and history
arb issue create <title> Create a new issue
arb issue dispatch <id> [repo] Dispatch a worker to work on an issue
arb worker list List running and completed workers
arb worker log <task-id> Read a worker's full transcript (durable)
arb worker review <task-id> Dispatch a review-only worker against a task
arb message inbox Read (and mark read) the coordinator's escalation mailbox
arb server start Boot the stack (no-op if already up)
arb server deploy [--version vX.Y.Z] Deploy an OTP release from GitHub Releases (auto-rollback on failure, refused across a migration)
arb server deploy --git-pull Source-checkout deploy: git pull --ff-only, rebuild the CLI if changed, restart (see Deploying)
arb server doctor Health-check the server and database
arb config get/set [workspace] Read/edit workspace configuration (tracker, merger, etc.)
arb mcp token mint --tier coordinator Mint an MCP token for a coordinator session

All commands accept --help and --json for structured output. Pre-<resource> <verb> flat commands from earlier CLI versions (arb list, arb start, arb doctor, …) still run — they print a one-line note pointing at the new form. (arb dispatch <id> is a permanent top-level shortcut for arb issue dispatch <id>, not a legacy alias.)

Documentation

Architecture and design decision records live in docs/:

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages