orchestra

orchestra is a daemon that runs agentic coding tasks. Listeners watch issues, comments, pull request reviews and an issue tracker, turn what they find into tasks, and the daemon runs each one in a sandbox with credentials the agent never gets to hold.

the moving parts

Three pieces carry everything else.

  • A task is the unit of work and the only thing that ever runs an agent: a repository pair (or none, for meta-work), a prompt, a set of tools, a budget → task files
  • The queue is where tasks wait for the daemon that runs them, by priority and in parallel → queue mode
  • Listeners poll an event source and enqueue a task whenever something matches → listeners

It ships as two binaries. orchestrad is the backend — the queue daemon and the HTTP API — and orchestra is the client you type at, which reaches the backend over that API. See the two binaries.

 task file      workflow       listener event      role dispatcher
      \             \                /                  /
       ─────────────► queue (priority, parallel slots) ◄──────
                             │
                             ▼
                        task runner
                             │
          clone + GitHub App token + per-repo hooks
                             │
                             ▼
              landrun sandbox ──── agent backend
                             │           │
                             └► MCP server ◄┘
                              (GitHub + issues)

what it does

  • Sandboxed runs. Every agent runs under landrun: write access to its own clone and /tmp, read+execute on the toolchain, outbound TCP to HTTPS and the MCP port, nothing else. A task can mount its clone read-only, which is what review tasks use.
  • Repository-independent tasks. A task can name no repository at all and run in a scratch workspace instead of a checkout — for maintenance and coordination that spans every project rather than belonging to one → repository-independent tasks
  • Credentials the agent never sees. A GitHub App installation token is minted per task and handed to gh for git transport only. The personal access token used for upstream pull requests, reviews and comments stays in the MCP server process.
  • Four agent backendsclaude (Claude Code), vibe (mistral-vibe), opencode and pi — plus two built-in agent-less backends: merger, which lands an approved pull request, and triage, which applies and removes labels deterministically.
  • GitHub actions as MCP tools. Creating pull requests, comments, reviews with inline annotations, and reading review threads are tools, not gh invocations. Each task is granted a subset of them.
  • A queue daemon with priorities, parallel execution backed by per-repository clone slots, re-enqueueing of unfinished entries, and a graceful drain on SIGTERM.
  • Usage-limit awareness. Every Claude subscription limit is tracked — session, weekly, and the weekly limits scoped to one model family — so work is routed to an account that can still run it instead of into a wall. Several accounts can back one listener, used in order or balanced across, and what each one has spent is kept window by window and graphed → usage limits
  • Listeners that poll GitHub issues, comments, pull request reviews, labels, or an arbitrary shell command, and enqueue a task — or a whole workflow — when something matches.
  • An autonomous project pipeline. Projects and issues live in a taxis tracker; roles are task templates a dispatcher spawns as work appears, with claim locks so two agents never pick up the same issue.
  • Persistent identities. A task can be performed under a named identity: it gets a memory of its own that carries across every run under that name, and — where the identity has a taxis token — writes to the tracker as itself rather than as orchestra → identities
  • Concert workflows: YAML multi-step programs with typed step outputs, loops and conditionals.
  • Per-repository hooks for setup, validation and teardown, with an automatic retry loop when validation fails.
  • Recorded history. Every run is stored, can be grouped into a named series, and resumed with a follow-up prompt.
  • A native app. One client for macOS, Windows, Linux, iOS and Android that holds several backends at once and switches between them — reading the same API the dashboard reads, over a bearer token kept in the OS keychain. Its own repository: orchestra-appnative app
  • Chat sessions the backend holds. A conversation with an agent, in the same sandbox a task gets, reachable over the API from the CLI, the dashboard or a phone — and still running after the client that started it goes away → interactive sessions

prerequisites

It is recommended to run all of orchestra inside a virtual machine or container. Two ready-made environments provide the full set of tools: a NixOS incus image (see the container section) and a Docker image running the queue daemon (see the docker section).

Before starting you will need to create a GitHub App with a private key, installed on the organization owning the fork. Download the private key.

You will also want a personal access token with repo scope. The two are not alternatives: every task mints an installation token from the App and gives it to gh for cloning and pushing, so without the App no task runs at all, while the PAT covers what an installation token cannot — pull requests against the upstream repository, issue comments, PR reviews, and the triage backend. One PAT is enough until orchestra works across repositories no single token can see; then give it several → per-repository PATs.

Inside the container (or VM), the following must be available (all installed automatically when using the provided container image):

  • Lean 4 / Lake to build the tool
  • landrun for sandboxing
  • at least one agent CLI, installed and authenticated: claude (Claude Code), vibe (mistral-vibe), opencode, or pi
  • gh (GitHub CLI) for repository operations
  • pi-mcp-adapterOptional MCP OAuth support for the Pi agent specifically (not needed for the other backends). Tested with the MCP tools defined in this project. Install in the container with pi install npm:pi-mcp-adapter.

In addition, the private key from the GitHub App must be included as a file in the container.

building

lake build

Two binaries land in .lake/build/bin/: orchestra (the CLI) and orchestrad (the backend). lake build orchestra and lake build orchestrad build one each.

the two binaries

orchestrad is the half that runs continuously and holds the credentials: the queue daemon that dispatches agents, and the HTTP API that everything reads and writes orchestra's configuration through.

orchestrad serve                  # the API and the queue daemon, one process
orchestrad queue                  # the queue daemon alone
orchestrad dashboard --site web/dist   # the API and the web UI alone

orchestra is the client. Its configuration commands — orchestra config, orchestra listener — are HTTP clients of orchestrad, so a running daemon picks up a change without a restart and there is never a second writer racing it for the same file. orchestra chat is one too: the session it talks to runs on the backend. Its other commands (run, interactive, prepare) execute locally, because what they do is launch a sandbox on the machine you are sitting at.

orchestra queue start and orchestra dashboard still work and still mean what they meant: they start orchestrad, looked for next to the orchestra binary, then at $ORCHESTRA_SERVER_BIN, then on PATH.

configuration

Create ~/.config/orchestra/config.json:

{
  "github_app": {
    "app_id": 12345,
    "private_key_path": "/path/to/private-key.pem",
    "installation_id": 67890
  },
  "github": {
    "pat": "github_pat_..."
  },
  "plugin_dirs": [],
  "claude_token": "...",
  "authorized_users": ["<GitHub_username>"],
  "default_organization": "my-org",
  "queue": {
    "parallel": 4,
    "parallel_per_repo": 2
  }
}

default_organization is the organisation under which orchestra may create repositories. It is used by every project/role-based task that pushes (the automatic dispatcher and orchestra spawn): a task targets a repository its pull requests must land in, and the agent works on a fork it can push to. When the GitHub App already has write access to the target, the fork is the target itself and nothing is created; when it does not, the target is forked into default_organization and that fork is what the agent pushes to. If the App cannot push to a target and default_organization is unset, the task is skipped rather than dispatched at a repository it cannot push to — so set this whenever the dispatcher works on repositories the App is not directly installed on. The App must be installed on default_organization with permission to create repositories.

It is also the destination of the create_repository MCP tool, which is the only way an agent creates a repository from scratch rather than by forking — and the only owner that tool will use.

The target must be readable by the App. A task's token is minted for the fork's installation, and that token is what fetches the upstream. Public targets are fine. A private target the App is not installed on cannot be fetched — and cannot be forked in the first place — so such a task is skipped, with the reason on stderr under [fork].

Two roles never fork. The merger merges with gh pr merge as the GitHub App, which requires write access to the pull request's own repository, and a fork cannot supply that; when the App cannot push to a pull request's repository — or the check stays inconclusive — no merger is queued and decide_issue approve reports that the pull request has to be merged by hand. The auto-reviewer is read-only and pushes nothing, so it works in the pull request's own repository and needs no writable fork at all.

installation_id is optional; if omitted it is looked up automatically. pat is a personal access token used to create pull requests to the upstream repository. The agent itself never sees this token. When one token cannot see every repository orchestra works on, add github.pats beside it → per-repository PATs.

claude_token is an optional long-lived Claude OAuth token. Claude login sessions lapse frequently; a stable token avoids repeated re-authentication. Obtain one by running claude setup-token and copy the token value here. When set, it is passed to the agent as the CLAUDE_CODE_OAUTH_TOKEN environment variable.

additional_sandbox_paths grants every agent launched by this instance extra filesystem access on top of what its backend already needs — rox/ro/rw for absolute paths, home_rox/home_rw/ home_rwx for $HOME-relative ones. It is the usual fix when a repository's build needs a shared cache directory:

{
  "additional_sandbox_paths": {
    "home_rw": [".cache/mybuildtool"]
  }
}

TCP ports beyond HTTPS and the MCP server are opened per backend, via extra_ports on that backend's entry in the agents array — for a local model server, for instance.

The queue block sets how many tasks the daemon runs at once. Both keys default to 1, which is the serial behaviour, and orchestra queue start --parallel N / --parallel-per-repo N override them for a single run.

parallel_per_repo is capped separately because concurrent tasks on the same repository each need their own clone — a slot — so that two agents can create the same branch name without git refusing. Raising it costs one working tree per slot; the git history itself is hardlinked from a shared cache clone, so it is the checkout and its build output that take the space, not the objects. Run

orchestra prepare <upstream> <fork> --slots 2

with --slots matching parallel_per_repo, so each slot's init hook is paid up front rather than charged to whichever task lands there first.

Repository-independent tasks are pooled the same way and under the same cap, in one pool they share with each other and with nothing else — their workspaces cost a directory each rather than a checkout, and there is nothing to prepare up front.

Two backends always run exclusively regardless of these settings: pi and opencode keep per-run state at a fixed path under $HOME, so a second concurrent run would read the first one's MCP configuration. They start only while the daemon is otherwise idle.

files and directories

orchestra separates configuration from state:

PathContents
$XDG_CONFIG_HOME/orchestra/ (or ~/.config/orchestra/)config.json, secrets.json, prompts/, listeners/, roles/, skills/, identities/
$XDG_DATA_HOME/orchestra/ (or ~/.local/share/orchestra/)clones, task records, queue entries, logs, per-project role overrides, per-identity memory

Installations predating this split keep everything in ~/.agent/; that layout is still read, with a deprecation warning. orchestra migrate moves it into place.

secrets

Values that should not sit in config.json — or that are shared between it and the listener configs — can live in secrets.json next to it:

{
  "github_pat": "github_pat_...",
  "work_pat": "github_pat_...",
  "taxis_token": "..."
}

Every {{key}} occurrence in config.json and in listener configs is replaced with the corresponding value before the file is parsed, so "pat": "{{github_pat}}" works in either. The file is optional; when absent, nothing is substituted.

A {{key}} that secrets.json does not define is left standing verbatim, which for most settings is a visible mistake. For a token it is not: the entry keeps a non-empty, token-shaped value that authenticates as nobody. github.pats therefore refuses to load an entry still holding one — see below for why that particular failure is worth catching early.

per-repository PATs

One personal access token covers one account's repositories. When orchestra works across repositories that no single token can see — a personal account and an organisation, two organisations, a client's repository beside your own — name a token per repository in github.pats:

{
  "github": {
    "pat": "{{github_pat}}",
    "pats": [
      { "label": "work",     "token": "{{work_pat}}",     "repos": ["acme/*", "acme-labs/tooling"] },
      { "label": "consult",  "token": "{{consult_pat}}",  "repos": ["acme/client-work"] }
    ]
  }
}

Each entry needs a unique label (used in diagnostics — orchestra never logs the token itself), a token, and the repos it covers. A pattern is owner/name, owner/*, or * for everything; matching is case-insensitive, as GitHub's own names are. Nothing else is a pattern — acme-*/x and acme/widget* are rejected at load rather than silently matching nothing.

The most specific match wins, so a config reads top-down: the broad line for an account, then the individual repositories that are exceptions to it. Exact owner/name beats owner/* beats *, whichever order they are written in; two entries claiming the same repository at the same specificity resolve to the one written first. A repository no entry covers falls back to github.pat, so adding a second token cannot change where an existing repository's calls go.

The token is resolved per repository at every point a PAT is spent: the MCP server's create_pr, merge_pr, label_issue, comment and get_pr_comments, the triage backend, and — per repository within a single tick — listener polling and the dispatchers' review classification. A listener's repos list may therefore span accounts. Because GitHub's rate limits are per token, splitting a busy listener across two also raises its ceiling; polling is the heaviest PAT consumer there is.

Two things it deliberately does not do. It is not selected per task the way an agent authentication source is: which PAT is correct is a property of the repository, not a choice, and keying it by repository is what reaches the dispatch paths — a role dispatched by a listener, a concert step — that have nowhere to name a label and would otherwise fall back to the wrong account. And it does not extend the GitHub App: cloning, pushing, forking and merging all run on installation tokens, so a repository in a new organisation still needs the App installed there, or default_organization set so orchestra can fork into somewhere it can push.

Get the mapping wrong and the symptom is misleading: GitHub answers a read a token is not entitled to with a 404, the same answer it gives for a repository that does not exist. So a misrouted token reports "no such repository" on a repository that plainly exists. To keep that from happening quietly, a pats block that is present but unreadable fails the whole config rather than being skipped, entries with a blank token or an unsubstituted {{secret}} are refused, and orchestrad prints the coverage — labels and patterns, never tokens — at startup:

GitHub PAT sources (2, most specific match wins; anything uncovered falls back to github.pat):
  work: acme/*, acme-labs/tooling
  consult: acme/client-work

authentication sources

The agents array in config.json lets you configure multiple named authentication sources per backend. Each source carries either an OAuth token or an API key:

{
  "github_app": { "..." : "..." },
  "agents": [
    {
      "name": "claude",
      "auth_sources": [
        { "label": "work", "oauth_token": "sk-ant-oat-..." },
        { "label": "personal", "api_key": "sk-ant-api-..." }
      ],
      "default_auth_source": "work"
    },
    {
      "name": "vibe",
      "auth_sources": [
        { "label": "main", "api_key": "mistral-..." }
      ]
    }
  ]
}

Each authentication source object has the following fields:

  • label — unique name within the backend, used to reference the source
  • oauth_token — an OAuth token (sets CLAUDE_CODE_OAUTH_TOKEN for the claude backend)
  • api_key — an API key (sets ANTHROPIC_API_KEY for claude, MISTRAL_API_KEY for vibe)
  • base_url — optional base URL used with api_key (sets ANTHROPIC_BASE_URL for claude)

Exactly one of oauth_token or api_key must be present per source.

The default_auth_source field selects which source is used when a task does not specify one. When omitted and only one source is configured, that source is selected automatically. It also accepts a list, which pools the accounts it names instead of pinning one — see defaulting to several sources.

To select a specific source in a task, set the auth_source field:

{
  "upstream": "owner/repo",
  "fork": "org/repo",
  "mode": "pr",
  "prompt": "Fix the bug.",
  "auth_source": "personal"
}

The same auth_source field is available on queue entries and listener actions.

The legacy flat fields (claude_token, anthropic_api_key, anthropic_base_url, anthropic_auth_token) still work when no agents array is present, so existing configurations remain valid.

using several sources, and failing over between them

A task, queue entry or listener action can name several candidate sources instead of one, and say how to choose between them:

{
  "upstream": "owner/repo",
  "fork": "org/repo",
  "mode": "pr",
  "prompt": "Fix the bug.",
  "auth_sources": ["work", "personal"],
  "auth_mode": "ordered"
}
  • ordered (default) — use the sources in the order listed, falling through to the next when one is out of quota. Burn the subscription first, then the API key.
  • distribute — spread work across every source that is not limited, preferring the least-consumed one. Two accounts with the same plan end up roughly level rather than one being exhausted before the other is touched. Between sources at the same utilisation it round-robins, so a burst of parallel claims fans out instead of landing on one account.

Which source ran a task is recorded on its queue entry. Under distribute the choice is made and stamped while the daemon holds its claim lock, so parallel workers cannot all read the same pre-dispatch state and pick the same account.

auth_sources takes precedence over auth_source; either can be omitted, in which case default_auth_source applies as before.

defaulting to several sources

Naming candidates per task only reaches the paths that have somewhere to write them. Two do not: a role dispatched by a label-dispatcher or project-dispatcher listener is built from the role template, which has no auth fields, and a concert step is built from its own YAML, which has none either. Both arrive at resolution naming nothing, so both take whatever default_auth_source says — and a single label there means every dispatched agent and every workflow step runs on one account while the others idle.

Give the field a list to pool them instead:

{
  "name": "claude",
  "auth_sources": [
    { "label": "work",     "oauth_token": "sk-ant-oat-..." },
    { "label": "personal", "oauth_token": "sk-ant-oat-..." }
  ],
  "default_auth_source": ["work", "personal"],
  "default_auth_mode": "distribute"
}

default_auth_mode takes the same two values as a task's auth_mode and means the same things. It is what the pool is walked with when the task says nothing — which is the point, since a task that names no mode must not be read as asking for ordered, and walking a pool in ordered picks the first account every time. A task that does set auth_mode still gets it, with or without candidates of its own.

The narrower forms win. A task's own auth_sources beats the pool, a task's auth_source pins past it, and a default_auth_source written as a plain string keeps pinning as it always did.

A pool label that is not among the backend's auth_sources is dropped. Under distribute an unknown label would otherwise be preferred — nothing has been recorded against it, so it looks like the least-consumed account — and the task would then fail against a source that has no credentials behind it. Both keys are parsed strictly when present: a misspelled default_auth_mode fails config load rather than quietly reverting the pool to ordered.

Which source a task runs on is decided when it starts, not when it is queued. An entry can sit in the queue for hours, and the account that was free when a listener created it may be exhausted by the time a worker picks it up. The daemon resolves the list at claim time and records the winner on the entry.

usage limits

Orchestra tracks how much of each subscription is left, so it can route around a limit instead of running into it. For OAuth sources it polls the same endpoint Claude Code itself uses, which reports every limit at once: the rolling session window, the weekly total, and weekly limits scoped to a single model family.

$ orchestra usage --select --model claude-opus-4-8
claude:
  work [oauth]: BLOCKED (weekly_scoped limit for Opus at 100%, resets in 16h 22m)
    session: 13% [normal], resets in 1h 51m
    weekly_all: 77% [warning], resets in 16h 22m
    weekly_scoped (Opus): 100% [critical], resets in 16h 22m
  personal [api-key]: available
    no subscription limits to report (API-key sources are billed per token)
  → would select: personal (ordered)

Two details matter in practice:

  • Model-scoped limits only close one model family. An exhausted weekly-Opus window leaves Sonnet work on the same account perfectly runnable, so availability is a question about (source, model) — not about the account. Pass --model to ask about a specific one.
  • A limited source does not cancel anything. Queue entries that would land on it stay pending and run when the window resets; entries on any other source, backend or model family keep going. Earlier versions cancelled every pending entry sharing a backend, which threw away work that was about to become runnable.

Limits are learned two ways, and the two cover each other: the poll sees a limit coming and knows the exact reset time, while a run that comes back rate-limited is recorded immediately, before anything else can be dispatched to that source. Both write to <data>/usage/<backend>/<label>.json, which is shared across processes — a limit orchestra run discovers in a terminal stops the daemon from dispatching to that source too.

Every poll is also kept, so the account's past is readable and not only its present. It is kept one record per window rather than one per poll: a session window and a weekly total are counters that fill and then reset, so the peak reading inside one is what that session or that week consumed. The records live in <data>/usage/<backend>/<label>.history.json, they are what the dashboard's usage graphs are drawn from, and they are bounded — 240 windows per series, nothing older than six months.

A window is identified by the reset time every poll inside it reports, so a new reset time is a new window; where nothing reports one, utilisation that dropped is what marks the rollover. Only polls are recorded. An observed hit knows that a limit was reached but not where its counter stood, so folding one in would invent a reading rather than keep one — the poll that follows it reports the real number.

API-key sources have no subscription window to poll (they bill per token against an organisation), so they are always considered available until a run proves otherwise.

Polling is an account-metadata call, not an inference call: it costs no input or output tokens and consumes none of the quota it reports. It is metered as requests, though, and the budget is small: about five of them before the endpoint answers 429 with a retry-after of five minutes, so roughly one request per minute per token — shared by everything that polls.

There is therefore one poller, and everything else reads what it wrote. The daemon refreshes each source every five minutes; dispatch goes to the network only if the stored numbers are older than that, which while the daemon is up they never are. A poll that fails backs off before anything retries it — for as long as the server's retry-after asks after a 429, a minute otherwise — because a failed poll leaves the source stale, and a stale source with no backoff is re-polled by every claim decision until the budget is gone. orchestra usage reuses fresh data too; pass --refresh to force a poll or --cached to make none. While a backoff is in effect the command says so, and dispatch falls back to the last known limits — an unreachable endpoint is never treated as an exhausted account.

orchestra usage flags: --backend to narrow to one backend, --model to judge model-scoped limits, --cached to skip polling, --select to also show which source a task queued now would be dispatched to, and --auth_mode to simulate distribute instead of ordered.

System prompts can be placed in ~/.config/orchestra/prompts/. The file ~/.config/orchestra/prompts/default.md is loaded automatically; named prompts can be referenced via the system_prompt field in a task file.

the plan a setup-token cannot state

Claude Code checks a model's entitlement against the subscription it is holding, and it learns that subscription from the account profile it fetches at /login. A long-lived claude setup-token — which is what an oauth_token source carries — has inference scope and nothing else, so that profile is never fetched and the client ends up holding no plan at all. Checks that cannot confirm the subscription covers a model then fail closed: a Max account is told that Fable, a standard part of that plan, requires usage credits (anthropics/claude-code#79597). The server grants the very same token Fable perfectly well — it is the client refusing, in the plan's name.

In that mode the client reads the plan and the rate-limit tier from CLAUDE_CODE_SUBSCRIPTION_TYPE and CLAUDE_CODE_RATE_LIMIT_TIER instead of from a profile, precisely because there is no profile to read. So orchestra sets both beside every OAuth token it passes — an oauth_token source or the legacy flat claude_token: max and default_claude_max_20x, the plan its accounts are on. Nothing to configure, and nothing granted by it — every request is still authorised and priced by the server against the token, so the value can only make the client's local guess right or wrong. API-key sources get neither: they bill an organisation per token and have no subscription to describe.

task files

Tasks are described in a JSON file:

{
  "tasks": [
    {
      "upstream": "owner/repo",
      "fork": "your-org/fork-repo",
      "tools": ["create_pr"],
      "prompt": "Implement feature X and open a pull request.",
      "budget": 8.0
    }
  ]
}

Fields:

  • upstream — upstream repository in owner/repo format. Omit it together with fork for a repository-independent task
  • fork — fork repository the agent has write access to. A task names both repositories or neither; naming one is an error
  • prompt — instruction sent to the agent
  • goal — condition the run is held to: the agent may not stop before it holds, and a second model call decides whether it does. Keep it one short, checkable sentence — it is judged on its own, without the prompt around it. Only the claude backend can enforce one today; the others say so on stderr and run without it → goals
  • tools — optional tools granted to this task on top of the always-available ones; see MCP tools
  • mode — legacy shorthand for tools, kept for compatibility: "fork" grants nothing, "pr" grants create_pr. Ignored when tools is present
  • backend"claude" (default), "vibe", "opencode", "pi", or one of the agent-less backends "merger" / "triage"
  • model — optional model override passed to the agent
  • agent — optional sub-agent name passed to the backend
  • auth_source — label of the authentication source to use
  • auth_sources — candidate authentication sources, tried per auth_mode; takes precedence over auth_sourceauthentication sources
  • auth_mode"ordered" (default) or "distribute"
  • system_prompt — optional name of a file in ~/.config/orchestra/prompts/ (without .md); defaults to default.md if present
  • budget — maximum spend in USD (default 4.0)
  • read_only — mount the clone read-only; used by review tasks
  • memory — which shared memory directories the agent may persist to: "none", "global", "project", or "both" (default)
  • identity — name of the identity this task is performed under. The task gets that identity's own memory, and where the identity has a taxis token, its tracker writes are recorded as coming from it. Naming one that is not configured fails the task
  • priority — queue priority, higher runs first (default 10)
  • series — name of the series this run belongs to
  • issue_number — GitHub issue or PR the task was launched from; enables the comment tool
  • pr_labels — labels applied to every pull request create_pr opens during the task, created on the target repository if missing
  • triage_add_labels / triage_remove_labels — labels the triage backend applies
  • project_id / issue_id / role — set by the project subsystem; see projects, issues and roles
  • spawn_policy — what this task may itself put on the queue through the queue_task tool, and how much of it. Absent means nothing, and the tool is not offered → queueing tasks from inside a task

repository-independent tasks

A task that names no upstream and no fork runs with nothing checked out. It is sandboxed like any other task, but instead of a clone slot it gets an empty scratch workspace, and the tools that act on a repository are withheld from it.

{
  "tasks": [
    {
      "prompt": "Go through the open issues on the tracker, close the ones that duplicate work already merged elsewhere, and open a coordination issue for anything blocked on another project.",
      "tools": ["manage_issues"],
      "budget": 6.0
    }
  ]
}

This is the shape meta-work takes: maintenance across the whole set of projects, coordinating efforts on the taxis tracker, anything where checking out one repository would mean picking an arbitrary one of the several the task is about.

What such a task gets, and what it does not:

  • A scratch workspace at ~/.local/share/orchestra/workspaces/, mounted read-write, and /tmp. It is emptied between tasks rather than cleaned selectively — a clone slot can keep gitignored build output because git says which files those are, and a workspace has nothing that could draw that line. Anything meant to outlive a run belongs in a memory directory.
  • The project, issue and task toolsmanage_issues, work_issues, review_issues, get_task_input, submit_task_output, health. This is what the task is for.
  • create_repository, which creates a repository in default_organization rather than acting on one the task named, so there is nothing about it a repository-independent task lacks. Starting a project that does not exist yet is meta-work like any other.
  • No create_pr, merge_pr, label_issue or comment. Asking for one in tools is reported on stderr and dropped, and the MCP server refuses the call as well. get_pr_comments, which no task has to ask for, is simply not offered — nor is refresh_token when there is no installation behind it.
  • No per-project memory. There is no upstream to name a directory after, so memory: "project" resolves to nothing and "both" to the global directory alone — which is the right home for what work spanning projects learns anyway.
  • A GitHub App token only if the config says which installation. With installation_id or default_organization set, one is minted and reaches gh in the sandbox as GH_TOKEN; with neither, the task runs without GitHub credentials rather than refusing to start.

The merger and triage backends need a repository by definition and refuse to run without one.

Workspaces are pooled the way clone slots are, so --parallel-per-repo bounds how many repository-independent tasks run at once — they share one pool with each other, and with nothing else. A continuation goes back to its predecessor's workspace and keeps the files there, on the same terms as a continuation on a repository.

Listeners and concert workflows can queue these too: a listener whose action names no upstream/fork (and whose event source supplies neither) queues a repository-independent task, and so does a workflow step with no repositories at either the step or the program level.

sandboxing

Agents are confined with landrun, which uses the Linux Landlock LSM — no container or VM is involved, so this works inside the Docker image as well. A task's agent gets:

  • read+write+execute on its own clone (read+execute only when read_only is set) and read+write on /tmp
  • read+execute on the toolchain paths its backend declares, and read+write on the $HOME subdirectories that backend needs for its own configuration and state
  • read+execute on plugin directories, read+write on the memory directories permitted by memory
  • outbound TCP to port 443 and to the local MCP server port, plus any extra_ports configured for the backend
  • GH_TOKEN (the installation token) and the selected authentication source's key in the environment — nothing else is inherited beyond SHELL, PATH, HOME, USER and TERM

orchestra run --debug prints the exact landrun invocation before executing it, which is the quickest way to find out why an agent cannot see a path. A path that does not exist cannot be granted — orchestra warns about missing $HOME-relative paths rather than letting the agent hang.

MCP tools

The agent has access to the following tools via the built-in MCP server. health, refresh_token, and get_pr_comments are always available; create_pr, merge_pr, label_issue, comment and create_repository must be enabled explicitly by adding them to the tools list in the task configuration.

  • health — check that the MCP server is running

  • refresh_token — refresh the GitHub App installation token

  • get_pr_comments — fetch review threads for a pull request

  • create_pr — create a pull request on the upstream repository

  • merge_pr — merge a pull request on the upstream repository, authenticated by the configured PAT. Takes pr_number, plus an optional merge_method (merge | squash | rebase, default squash) and delete_branch (default true) — the same way the merger backend merges. A pull request that is already merged, closed, still a draft, in conflict with its base branch, or held back by branch protection is refused with that reason, for the agent to report back. Grant it deliberately: holding create_pr does not imply it, and most tasks have no business merging anything — a review task that should land what it approves is the case it was added for

  • label_issue — add and remove labels on an issue or pull request of the upstream repository: the triage tool. Takes issue_number plus add and/or remove, each a list of label names. Only labels the repository already defines can be applied — an unknown name is refused with the list of labels that do exist, rather than creating one — and names are matched case-insensitively. An addition the issue already carries and a removal it does not are reported and skipped, so calling twice changes nothing. Unlike comment, it can label any issue, not only the one the task was launched from

  • create_repository — create a new repository in default_organization, the organisation tasks are forked into. Takes name plus optional description, private (default true) and auto_init (default false, i.e. an empty repository ready for a first push). The owner is not an argument: the configured organisation is the one place the operator has already agreed is orchestra's to write to, and the tool refuses when none is set. Unlike forking it is not idempotent — a name the organisation already uses is refused rather than handed back, so a push never lands in a repository the task did not just create. Authenticated by the organisation's own GitHub App installation, which needs the Administration: read and write permission. The result carries a token to push with, scoped to the new repository alone and expiring in an hour like every installation token — the agent is given it as a remote URL to use once, not as a GH_TOKEN to export, since that variable is what authenticates its work on its own fork. A token from refresh_token is minted for whichever installation the task runs under, so it reaches the new repository only when that installation covers default_organization — which is the case when the task works on a fork, and not when the App could push to the target directly. Grant it deliberately, like merge_pr

  • comment — post a comment on the issue or pull request the task was launched from. Supports four modes:

    • regular comment: provide only body
    • PR review: provide body and set review: true; optionally include inline_comments
    • reply to inline comment: provide body and reply_to_comment_id
    • new inline comment: provide body, path, and line

    The comment tool requires the task to carry an issue_number (set automatically by the listener when triggered from an issue or pull request).

  • queue_task — put a task on orchestra's queue for an agent to pick up, optionally bound to a taxis issue and claiming it. This is the one tool the tools list cannot enable: what it may do — which backends, models, tools and repositories a queued task may be given, how many may be queued, whether an issue may be claimed — is written in the task's spawn_policy, and a task without one is not offered the tool at all. Everything the agent omits is inherited from the queueing task, so an empty policy still lets it queue more of itself and nothing else, and a queued task never carries a policy of its own → queueing tasks from inside a task

Three more tool groups — manage_issues, work_issues, review_issues — are available when a task carries them in its tools list, backing orchestra's project/issue/claim workflow (a taxis issue tracker instance, not local files — see examples/projects/README.md for the full concept mapping, the taxis config section, and a CLI/tool cheat sheet).

skills

skills/ is a Claude plugin directory loaded into every agent by default. It carries two skills:

  • orchestra-pull-requests — opening PRs, commenting, reading review feedback.
  • orchestra-taxis-issues — claiming, splitting, attaching PRs, managing tracker issues, and keeping an issue's context notes.

Both exist mainly to state one rule the agent cannot infer: gh must never be used for pull requests or issues. Everything goes through the MCP tools, which select the right credential (PAT vs GitHub App token), record what the task did, and enforce the per-task permission groups. gh is authenticated for git transport only. Plain git is fine.

They also draw the distinction between taxis issues (the tracker; what agents claim and work) and GitHub issues (the thread a task was launched from). The ids look alike and nothing stops you passing one where the other belongs.

Install by copying into the config directory, from where orchestra picks it up automatically:

cp -r skills $XDG_CONFIG_HOME/orchestra/skills      # or ~/.config/orchestra/skills

The Docker image bundles them and seeds /config/orchestra/skills on first start, so nothing is needed there. Local edits survive restarts; delete the directory to get the shipped copy back.

Anything in plugin_dirs in config.json is loaded as well — the skills directory is prepended, not a replacement.

Skills are also editable through the API, which is the way to change one on a machine you are not sitting at — see configuration over the API. A new or edited skill applies to tasks launched after the write; a task already running keeps the skills it started with.

identities

An identity is a persistent someone for a task to be. A task, a role or a listener names one, and the run gets a memory that was there before it started — and, where the identity carries a taxis token, writes to the tracker as itself rather than as orchestra.

An identity is a directory, because it is a bundle rather than a record:

~/.config/orchestra/identities/maintainer/
├── identity.json      # who it is to orchestra
└── AGENTS.md          # how it works — optional
// identity.json
{
  "name": "maintainer",
  "description": "Keeps the tracker in order: triages what comes in and chases what has gone quiet.",
  "taxis_token": "{{taxis_maintainer_token}}"
}
Field
nameMust match the directory name. Every surface refers to the identity by it
descriptionOne or two sentences, shown to the agent as part of who it is
taxis_tokenAPI token for the taxis actor this identity is. Optional; {{secret}} placeholders are substituted from secrets.json

A taxis_token that is present but unusable — blank, not a string, or a {{placeholder}} no secrets.json defines — is refused when the record is read, naming the identity. Two of those would otherwise read as no token, and an identity with no token authors everything as orchestra: the run succeeds and the only way to notice is to look at who signed the comments.

Name it from a task file, a role, or a listener's action:

{ "prompt": "Triage what came in overnight.", "identity": "maintainer" }

A concert step takes identity: the same way. An interactive session takes one too, so a conversation you hold with an agent can be held as somebody in particular:

orchestra chat --upstream owner/repo --fork you/repo --identity maintainer

The identity is fixed for the session's life and is kept on its record, so a session that goes dormant and is woken later comes back as the same one. A session gets the identity's memory and no other: there is no memory field on a session to select the shared ones with.

Naming an identity that is not configured fails the task, rather than running it as the instance: the fallback would use the wrong tracker actor and the wrong memory, and look like it had worked.

what an identity is, and is not

A role says what a task does — its prompt, its tools, its model. An identity says who does it, and it is the half that accumulates: two tasks dispatched for the same role a week apart share nothing, while two tasks run under the same identity share everything the first one wrote down.

An identity is not a permission. What a task may do is still its tools, and where it may write is still its project subtree; an identity narrows neither and widens neither. Per-identity permissions want an authorization service to hold them and are not here yet.

An identity is assigned, never chosen. It is written in configuration, and queue_task has no field for it — a task queued by a task inherits the identity of the task that queued it, so an agent can pass its own on but cannot put on another one.

AGENTS.md

AGENTS.md beside the record is the identity's standing instructions: how this one works, what it always checks, which conventions it holds itself to. It is the half of an identity that does not change from run to run — the task's prompt is the half that does.

It reaches the agent as a section of its system prompt, under a heading naming whose instructions they are, with your file inside it unedited. Deliberately not written into the checkout, where the agent's CLI would find an AGENTS.md on its own: a repository may have one of its own, and overwriting it would be orchestra editing the project's instructions — quite apart from leaving a file in the working tree that the agent then has to remember not to commit. The repository's own AGENTS.md is read by the agent as usual; the identity's is added to it, not swapped for it.

A blank file contributes nothing rather than an empty heading, and the whole system prompt is capped at 120 KB with a warning on stderr if it has to be cut — so an AGENTS.md the length of a manual is a thing you will hear about.

examples/identities/ ships two written out in full.

the memory

Each identity gets $XDG_DATA_HOME/orchestra/identities/<name>/memory/, mounted read-write into the sandbox and named in the agent's system prompt as its own.

It sits outside <data>/memory/ on purpose. That directory is the global memory — a task whose memory is global or both is handed the root itself — so an identity's memory kept under it would be one every ordinary task could read and rewrite. For the same reason "memory": "none" does not switch it off: that field chooses among the shared memories, and an identity's own is part of the identity.

The one-file-per-subject convention of memory applies here too. Two tasks can run under one identity at the same time, and neither can see the other's edits.

Worth knowing: memory directories are also passed to the agent as plugin directories, so a run can leave behind a skill that every later run under that identity loads as instructions. That is how shared memory has always worked; per-identity memory makes it durable and scoped to one name.

acting on the tracker

With a taxis_token, everything the task's issue tools write — comments, reviews, issues created and updated, context notes, labels and assignees — goes out on that token, so taxis records the identity as the author. A reviewer identity's request-changes review is signed by the reviewer, and the next agent reading the thread can see who asked for what.

Two things stay on orchestra's own token, deliberately:

  • Claims. o-claimed is orchestra's bookkeeping, written and read back by the daemon, and it records a task id rather than an author. Keeping it on the instance token means a claim taken under an identity whose token is later revoked is still one the daemon can release.
  • GitHub. Pull requests, comments and reviews on GitHub go out on the App installation token and the configured PAT as before. A pull request an identity opens is opened by orchestra — per-identity GitHub credentials are not part of this.

Mint the token in taxis for an actor of its own (POST /api/me/tokens as that actor, or POST /actors/:id/tokens as an admin — see taxis's README). It needs no admin rights: orchestra creates the labels it maintains on its own token.

One known limit. The token never enters the sandbox — nothing serializes it, no environment variable carries it, and the MCP server that spends it runs in the daemon. But the taxis client passes its bearer as a curl argument rather than through a config file, and /proc is mounted read-only in the sandbox, so an agent that watched /proc/*/cmdline while a tracker write was in flight could read it. That is true of orchestra's own taxis token and of the GitHub tokens today; it is a fix in the taxis client (orchestra's own Utils.Http already uses curl -K - for exactly this reason), not here. Do not treat an identity's token as a secret kept from the agent running as it.

running tasks

orchestra run tasks.json

Run only one task from the file (0-based index):

orchestra run --task 0 tasks.json

Continue a previous agent session:

orchestra run --task 0 --continues <task-id> tasks.json

Group runs into a named series for later resumption:

orchestra run --series my-series tasks.json

To sit in front of the agent yourself, with the same clone, credentials and sandbox a task would get, use its interactive TUI:

orchestra interactive --upstream owner/repo --fork your-org/fork

Leave both flags out for a session with no repository, which is what a repository-independent task sees: an empty workspace, the tracker tools, and none of the ones that act on a repository.

orchestra interactive

That one is local: it hands this terminal to the agent. For the same conversation held by the backend instead — reachable from the dashboard or a phone, and still there after you close the terminal — see interactive sessions.

concert workflows

Workflows are YAML files that describe a multi-step agent program. Steps run sequentially; later steps can receive typed outputs from earlier ones. The workflow is compiled to a Concert program and evaluated step by step.

Pass a .yaml file directly to run:

orchestra run workflow.yaml

Pass initial variable values with --vars:

orchestra run --vars '{"difficulty": 5}' workflow.yaml

See docs/workflow.md for the full workflow DSL reference and examples/concerts/ for ready-made examples:

  • examples/concerts/sequence.yaml — plan / implement / review pipeline
  • examples/concerts/loop.yaml — for-each loop with typed outputs
  • examples/concerts/conditionals.yaml — conditional exit on difficulty score

task history

orchestra tasks
orchestra task <id>
orchestra series
orchestra tag <id> <series>    # append a finished task to a series

resuming a series

orchestra resume my-series --prompt "Now add tests."

This picks up the repository and settings from the latest run in the series and resumes the agent session from where it left off.

queue mode

Start a daemon that picks up tasks from a queue:

orchestrad queue          # or: orchestra queue start, which starts this for you

Add tasks to the queue:

orchestra queue add tasks.json
orchestra queue add --resume my-series --prompt "Next step."

Add a workflow (concert) to the queue:

orchestra queue add workflow.yaml
orchestra queue add --vars '{"key": "value"}' workflow.yaml

Inspect and control the daemon:

orchestra queue                # show queued entries
orchestra queue status         # daemon status and running tasks
orchestra queue cancel         # cancel the running tasks, keep the daemon going
orchestra queue shutdown       # stop after the current task (--force cancels it)

Re-enqueue unfinished or cancelled entries:

orchestra queue retry
orchestra queue retry --series my-series

Unfinished entries resume the partial agent session they died in; cancelled ones start over. On SIGTERM the daemon stops accepting work and drains what is in flight, so a docker compose down does not lose a running task.

per-repository configuration

A repository can provide a .orchestra/ directory with optional hooks and a config file:

  • .orchestra/init.sh — run once after cloning
  • .orchestra/before.sh — run before each agent launch
  • .orchestra/validation.sh — run after each agent launch; non-zero exit triggers a retry
  • .orchestra/after.sh — run after the validation loop completes
  • .orchestra/config.json — validation settings:
{
  "validation": {
    "max_retries": 3,
    "retry_prompt": "Validation failed. Please fix the issues."
  }
}

The failing script's output is available to the retry prompt as {{validation_output}}.

listeners

Listeners poll event sources and automatically enqueue tasks. Listener configs are JSON files placed in ~/.config/orchestra/listeners/, and can equally be written through the API — see configuration over the API.

A listener is named by its file. ~/.config/orchestra/listeners/issue-comments.json is the listener issue-comments, and that is the name its state, its API routes, orchestra listener enable and every line the daemon logs about it use. The config document carries no name of its own; a name field left over from an older version is ignored, and the daemon carries that listener's state across to the file's name the first time it starts.

Either way the running daemon picks them up: it re-reads each listener's config on every tick, and rescans the directory every fifteen seconds, so a listener added, changed, disabled or deleted takes effect without a restart.

Example — issue-comments.json, responding to issue comments containing a trigger word:

{
  "source": {
    "type": "github-comments",
    "repos": [
      {"upstream": "upstream-org/upstream-repo", "fork": "your-org/upstream-repo"}
    ],
    "trigger": "@orchestra",
    "authorized_users": ["alice", "bob"]
  },
  "action": {
    "upstream": "{{upstream}}",
    "fork": "{{fork}}",
    "mode": "fork",
    "prompt_template": "A comment has been left on issue/PR #{{issue_number}}.\n\nAuthor: {{author}}\nURL: {{url}}\n\n{{body}}\n\nPlease read the comment and take the appropriate action.",
    "series": "issue-{{issue_number}}"
  },
  "interval_seconds": 120
}

Fields:

  • source.type — one of "github-issues", "github-comments", "github-pr-reviews", "github-labels", "github-label-count", "shell", or the two auto-dispatchers "project-dispatcher" and "label-dispatcher" (documented in examples/projects/README.md — the first works on one project, the second on every issue carrying a given label, wherever it lives)
  • source.repos — list of {"upstream": "...", "fork": "..."} pairs
  • source.trigger — only events whose body contains this string are processed
  • source.authorized_users — list of GitHub logins that may trigger the listener; empty means allow everyone
  • source.limit_unclaimed_to_open_issueslabel-dispatcher only: bound the caps of roles that do not pre-claim an issue by the work in scope, and skip issues an agent is already on, so one open issue dispatches one agent rather than a capful of them onto the same work (examples/projects/README.md)
  • source.exclude_root_issueslabel-dispatcher only: treat the labelled issues as epics — neither dispatched onto nor counted as work, while still rooting their subtree — so only what inherited the label is worked
  • action.prompt_template — template rendered with event variables (e.g. {{upstream}}, {{fork}}, {{issue_number}}, {{body}}, {{author}})
  • action.upstream / action.fork — the repositories the queued task works on, as templates. Left out, they fall back to the {{upstream}}/{{fork}} variables the event source supplies — every GitHub source supplies both. A shell source supplies neither, so a listener on one that names neither queues a repository-independent task
  • action.workflow_path — path to a .yaml workflow file; when set the listener starts a concert instead of enqueueing a single task
  • action.auth_sources / action.auth_mode — candidate authentication sources for the tasks this listener queues, and how to pick among them. A listener that fires repeatedly is the case several accounts exist for: it keeps producing runnable work after one account's weekly window closes → usage limits
  • interval_seconds — how often the listener looks. What it may do once it has looked is rate_limits, below
  • rate_limits — ceilings on how often this listener may dispatch (see below)

To trigger a multi-step workflow from a listener, replace prompt_template with workflow_path:

{
  "source": {
    "type": "github-issues",
    "repos": [{"upstream": "owner/repo", "fork": "your-org/fork"}],
    "trigger": "@orchestra",
    "authorized_users": ["alice"]
  },
  "action": {
    "upstream": "{{upstream}}",
    "fork": "{{fork}}",
    "mode": "fork",
    "workflow_path": "/path/to/workflow.yaml"
  },
  "interval_seconds": 120
}

Listeners are picked up by the running daemon; they can be inspected and switched on or off without restarting it:

orchestra listener list
orchestra listener enable <name>
orchestra listener disable <name>

how often a listener may fire

interval_seconds says how often a listener looks; it says nothing about how much it may do when it looks. A source can hand a listener twenty events in a single tick, and a listener watching a busy repository can have something to do on every tick of the day — neither is a reason to spend twenty tasks' worth of an account's budget in a minute.

rate_limits is the ceiling on the doing. Each entry is a max and a window, and a dispatch has to fit under all of them:

{
  "source": {
    "type": "github-issues",
    "repos": [{"upstream": "owner/repo", "fork": "your-org/fork"}],
    "labels": ["orchestra"]
  },
  "action": {
    "mode": "pr",
    "prompt_template": "Issue #{{issue_number}}: {{title}}\n\n{{body}}"
  },
  "interval_seconds": 300,
  "rate_limits": [
    {"max": 5,  "per": "hour"},
    {"max": 20, "per": "day"}
  ]
}

The window is either per — a unit ("second", "minute", "hour", "day", "week") or a count and a unit ("6h", "90 minutes") — or per_seconds for the same thing in seconds. A spelling that cannot be read ("hourly", a max with no window at all) fails the config rather than quietly becoming no ceiling at all. "max": 0 is refused on the way in too — through the API or orchestra config set, which is where configs written by a person arrive — because it is an off switch and orchestra listener disable is a better one, keeping the listener's configured pace for when it comes back.

The windows are rolling, not calendar ones: "5 per hour" means five in the last sixty minutes, not five since the top of the hour.

Nothing is dropped. A listener that is already at a ceiling does not poll at all until the window moves — a poll is neither free nor side-effect-free, and for a source that consumes what it reads there would be nothing to come back to. When a tick starts with room and fills up part-way through, what is left over is held: not marked processed, so a later tick offers it again. github-comments, the one source that pages by time rather than re-deriving what it is looking at, keeps its since cursor where it was for that tick, so a held comment is still there to be found. What a rate limit changes is the pace, not the work.

Where each ceiling stands is visible without reading the log:

$ orchestra listener show nightly
...
Rate limits:
  5 per hour (5 used, next at 2026-08-24T21:12:04Z)
  20 per day (11 used)

Listeners with no rate_limits — which is every listener written before the field existed — are unpaced and cost nothing: no clock is read, no timestamps are kept and no check is made. Removing the field from a listener that had one drops the timestamps it had kept, too.

See examples/listeners/ for further listener examples.

projects, issues and roles

This is the autonomous end of orchestra. A project is a repository-independent unit owning a tree of issues; tasks attach to issues, worker agents claim them, reviewer agents approve or reject, and the built-in merger backend lands the pull request. All of it is stored in a taxis instance configured under taxis in config.json — issues created in the taxis UI behave exactly like ones created through the tools.

A role (<config>/roles/<name>.json) is a reusable task template: backend, prompt template, tool permissions, a dispatch policy, and optionally the identity its agents are dispatched under. examples/projects/roles/ ships implementor, reviewer, planner and maintainer. A project-dispatcher or label-dispatcher listener spawns them as work appears, up to per-role caps, taking a claim lock so no two agents land on the same issue.

orchestra project create "API v2" --default-repo myorg/api --default-branch main
orchestra issue add <project-id> --title "Rewrite the auth handler"
orchestra issue list <project-id> --status open
orchestra issue show <issue-id>
orchestra roles list <project-id>
orchestra spawn implementor <project-id> --issue <issue-id>
orchestra project health <project-id>          # find claims whose task is gone

examples/projects/README.md is the full reference: how orchestra concepts map onto taxis, the taxis config block, role templates and their prompt variables, the dispatch triggers, and how the two dispatchers differ.

goals

A taxis issue carries a goal: one sentence saying what must hold for it to be done. Set it in the taxis UI, or through taxis's own tools — orchestra reads it and never invents one.

When a task is launched for an issue that has one — orchestra spawn --issue, orchestra issue continue, or either dispatcher — the goal becomes the task's goal, and the agent is held to it: it may not stop before the condition holds, and a second model call, not the agent itself, decides whether it does. Every hop preserves it, so a queued task that runs after a daemon restart, or a continuation of one that hit a usage limit, is still held to the same bar. orchestra issue continue re-reads it from the issue, so editing a goal changes what the next attempt is judged against.

What the agent is told and what it is judged on are deliberately different things. The prompt is the role template with the issue title, body, comment thread and target rendered into it — thousands of words on a busy issue. The goal is the goal field alone. Nothing derives one from the other, in either direction: a goal assembled out of a prompt would be unjudgeable, and a prompt cut down to a goal would starve the agent of context.

Only the claude backend can enforce a goal (it is what its /goal command does). Give a goal to vibe, opencode or pi and the run says so once on stderr and proceeds without it, rather than failing or pretending.

A task file can set goal directly, for work that has no issue behind it — see task files.

dashboard

A web view of everything above: the queue and concert runs, listeners and when they last checked, task history with the full structured log of each run, projects with their issue dependency graph, and every configured authentication source with the usage limits last reported for it. Pages stream updates over Server-Sent Events, so they stay current without a reload.

It reads, with one exception: a running task's page carries a Cancel button beside its status, which stops that one run without a shell. It asks before it does anything, and it is per task, not per batch — everything else the daemon is running carries on. The queue itself is untouched: pending entries start as slots free up, and the cancelled entry lands as cancelled, which orchestra queue retry re-enqueues.

The UI is a React/TypeScript app under web/, built by Vite; the backend is the Lean server behind orchestrad dashboard, which answers the JSON API, its SSE streams, and — with --site — the built front-end, all on one port:

cd web && npm ci && npm run build && cd ..
orchestrad dashboard --site web/dist --port 8080

web/dist is a build artifact and is not tracked, so npm run build has to run before --site has anything to point at. During front-end work npm run dev is the better loop: it serves the app with hot reload and proxies /api and /sse through to an orchestrad dashboard running on 8080, which keeps the app same-origin in development exactly as it is in production.

Access is gated by a password. The login screen exchanges it for an HttpOnly, SameSite=Strict session cookie, so the secret is never held in localStorage and never rides in a URL — including on the SSE streams, which authenticate with the same cookie. The password comes from --password, $ORCHESTRA_DASHBOARD_PASSWORD, or one generated on first run and persisted to <data>/dashboard.secret; a generated one is printed at start-up. Scripts can skip the login and send Authorization: Bearer <password> instead:

curl -H "Authorization: Bearer $(cat ~/.local/share/orchestra/dashboard.secret)" \
     http://127.0.0.1:8080/api/v1/overview

The same password is what the orchestra CLI authenticates with, so on a single host the two halves need no configuration to find each other: both resolve <data>/dashboard.secret.

The server binds loopback unless --host says otherwise — it is plain HTTP behind that password, so anything wider wants TLS in front, plus --secure-cookie so the session cookie is only ever sent over HTTPS. The docker image builds the front-end in a Node stage and runs the server as its own container.

The Auth page is the one to open when the queue has pending work but nothing is running: it names the limit that is binding on each source and when it lifts — the same data as orchestra usage, read from the usage store rather than polled, so opening the page costs nothing.

It also draws where each source has been: a bar per session window and a bar per week, so a week that ran hot, an account that is always the one at 100%, or a load that is not being spread the way the pool was meant to spread it are visible as a shape rather than assembled from a single percentage. The axis is the limit itself and never the data's own range — half height is half a window spent, in every chart on the page — and the bar still filling is drawn lighter, because its number is not final. A closed window is drawn at the peak it reached, which is what it consumed; the one still filling is drawn at where the source stands now, so it is the same number as the limit track above it, with a mark where its peak was. The two are different numbers whenever a reading inside the window came back down — why upstream's figure falls is not something a poll can see, so the graph reports both rather than choosing. That history has its own endpoint, /api/v1/usage, so it is readable by anything else too.

the API

The web UI is one client of the API, not the reason it has the shape it has. Anything that speaks HTTP can read the same data, and the server describes itself:

curl http://127.0.0.1:8080/api/openapi.json     # needs no credential

That document is embedded in the binary, so it describes the server answering rather than some checkout, and a test fails the build if a route and the spec disagree.

Reads live under /api/v1/, and every one of them is also a Server-Sent Events stream at the same path under /sse/v1/ — identical payload, pushed when it changes and not otherwise, which is what makes it cheap to sit on:

curl -N -H "Authorization: Bearer $PASSWORD" http://127.0.0.1:8080/sse/v1/overview

Four conventions hold everywhere:

  • Instants are RFC 3339 UTC in a ...At field. Never a rendered phrase — "3m ago" cannot be compared or thresholded, and it is only useful if you read English.
  • Durations are integer seconds, in a ...Seconds field.
  • Absent is null. "" means present and empty, which for a name is a different fact.
  • Collections answer in one envelope — items, total, limit, offset — and take limit, offset, and, where they are ordered by time, since. total counts matches before the window, so "50 of 812" needs no second request. A parameter that is malformed, or that a collection cannot honour, is a 400 rather than a shrug.
# the twenty most recent tasks since yesterday
curl -H "Authorization: Bearer $PASSWORD" \
     "http://127.0.0.1:8080/api/v1/tasks?limit=20&since=2026-07-22T00:00:00Z"

writing

Three resources are configuration, and all three are writable: listeners, roles and skills. Everything else the API serves is a record of something that already happened, or is owned by another system, and stays read-only. Nothing here enqueues a task — the queue is fed over the daemon's control socket, which is not on the network at all.

Some routes are actions rather than documents. None of them writes a file; each forwards a single message to that same control socket, which is where the CLI sends its own. POST /api/v1/queue/{id}/cancel stops the one running task that id names, and the five interactive session routes start, drive and end a chat. That second set is the one place this API starts an agent — see the note at the end of that section.

Taking the cancel route as the pattern for all of them: it forwards a message naming the entry to the socket, which is where orchestra queue cancel sends its own. The socket stays off the network; these routes are the only things on the HTTP side that speak to it, and each takes the credential like every other non-GET.

The id may be a queue entry's id or the id of the run it became; both are ids this API hands out for the same piece of work, and the server resolves either.

# stop one run; everything else the daemon is running carries on
curl -X POST -H "Authorization: Bearer $PASSWORD" -H 'Content-Type: application/json' \
     --data '{}' \
     http://127.0.0.1:8080/api/v1/queue/20260819-a3f1/cancel
# → {"id":"20260819-a3f1","taskId":"t20260819T1004"}

404 means no entry and no run carries that id. 409 means it exists and is not running — the message names the status it is in — or that the daemon is not running or did not answer, which is a statement about the daemon rather than about the server answering. The cancelled entry lands as cancelled, which orchestra queue retry re-enqueues, and the pending entries behind it start as slots free up: cancelling stops a run, not the queue.

There is no unaddressed spelling of this route. Stopping everything is orchestra queue cancel over the control socket, where the person typing it is on the host already.

# create or replace a listener; the body is the config document itself
curl -X PUT -H "Authorization: Bearer $PASSWORD" -H 'Content-Type: application/json' \
     --data @listeners/nightly.json \
     http://127.0.0.1:8080/api/v1/listeners/nightly

# turn one off without touching its config
curl -X PUT -H "Authorization: Bearer $PASSWORD" -H 'Content-Type: application/json' \
     --data '{"enabled": false}' \
     http://127.0.0.1:8080/api/v1/listeners/nightly/enabled

The rules, in full:

  • Every non-GET route requires the credential. POST /api/login is the one exception, since it is how the credential is obtained.
  • Every write must be Content-Type: application/json, or it is a 415. That is the second of two locks against cross-site forgery: the session cookie is SameSite=Strict, so a request from another site carries no credential, and an HTML form cannot send JSON, so it cannot form the request in the first place. The reasoning — including why there is no synchroniser token and no Origin check — is written out in the module docs of Orchestra/Dashboard.lean.
  • POST to a collection creates and refuses to overwrite (409), naming the record from the body. PUT to a member creates or replaces, naming it from the path; a body naming a different one is a 400 rather than a silent rename. DELETE is 204, or 404 when there was nothing there. Listeners have no POST: a listener config carries no name of its own, so PUT /api/v1/listeners/{name} is how one is created as well as replaced.
  • A rejected body changes nothing. Validation runs to completion before anything is opened for writing, and every write is a write-and-rename, so the daemon never reads a partial or half-checked file.
  • Documents are stored verbatim, not re-serialised. That is what keeps {{secret}} placeholders — and any field a newer orchestra understands and your client does not — intact across an edit. Fetch the config field of a detail response, change it, PUT it back.

What a write costs: a listener change takes effect on that listener's next tick, and a listener added or deleted within fifteen seconds, which is how often the daemon rescans the directory. A role change takes effect on the dispatcher's next tick, since roles are read per dispatch. A skill change applies to tasks launched after it. Nothing here needs a restart.

native app

The dashboard is a view of the backend that served it: a browser page and its API are the same origin by construction, and the API sends no CORS headers, so a page loaded from anywhere else cannot reach it at all. That is fine for one orchestra and wrong for three — a machine at home, a box in a datacentre, a container on the company network.

orchestra-app is the client for that, and it lives in its own repository: it shares no build with this one — orchestra is Lean and Lake, the app is npm and cargo — so there is no reason to check the two out together.

It holds a list of backends, talks to whichever one is selected, and switches between them without a reload and without logging in again. Everything the dashboard shows, it shows; the one write the dashboard has — cancelling a run — it has too, plus a listener's on/off switch; and the chat pages are the same session routes orchestra chat uses, which is what makes a conversation started on a laptop readable from a phone. One codebase for five platforms — macOS, Windows and Linux on the desktop, Android and iOS on a handset — on Tauri v2, with a Rust core holding the network and the OS keychain, because a webview can do neither across origins.

It authenticates with the bearer half of the scheme above: the same password, sent as Authorization: Bearer, never a cookie. Nothing in this repository has to change for it to work, and nothing in it is aware of the app.

Two things here are its contract, and a change to either is a change the app has to follow: docs/openapi.json, which is the document of record for every payload, and the Json builders in Orchestra/Dashboard.lean that emit them. The app keeps its own copy of those types, which the split makes load-bearing rather than incidental — its design document says so at more length.

interactive sessions

A session is a conversation with an agent, held open by the daemon and reachable over the API. orchestra chat talks to one, the dashboard shows one, and a phone can too — all three are clients of the same five routes, and nothing in the server is specific to any of them.

This is the third shape orchestra runs an agent in. A task is one prompt in and one run out; orchestra interactive is a real conversation but strictly local, because it hands your terminal to the agent's own TUI. A session is that conversation without the terminal: the agent runs on the backend, in the same sandbox with the same credentials and the same MCP tools a task would get, and it stays up between turns rather than being relaunched for each one — the clone slot, the MCP server and the process are acquired once and kept, so a second turn costs a line on a pipe.

orchestra chat --upstream owner/repo --fork your-org/repo

Type a turn, press enter, watch it work. The other three spellings:

orchestra chat --list              # every session, running and finished
orchestra chat --session <id>      # pick one back up, transcript and all
orchestra chat --end <id>          # end it and release what it holds

Detaching and ending are different, and only one of them can be undone. /quit, Ctrl-D and closing the terminal all leave the session on the backend; --end is how you end it. That is what makes it worth having a session rather than a TUI: you can start one at a desk, close the laptop, and pick the same conversation up from the dashboard.

A session does not expire. Its process does — an agent holding a clone slot, an MCP server and a sandbox is expensive to keep up for a conversation nobody is having, so after idle_timeout_seconds without a turn the daemon stops it and hands the slot back. The session goes dormant, which is not an ending: the record and the transcript are untouched, and the next turn posted to it starts an agent again and resumes the agent-side history where it left off. Say something and it comes back, from whichever client is to hand. A daemon restart leaves sessions in the same state for the same reason.

The first turn to a dormant session takes as long as starting one does, because it is starting one. If the working tree it left behind is still in the slot it gets handed back, that tree is kept rather than reset. What ends a session is asking: orchestra chat --end <id>, the dashboard's End session, or spending its budget.

Which model answers is yours to choose when the session starts: orchestra chat --model <name> on the command line, and the box beside the two repositories on the dashboard's chat page. Leaving it empty runs whatever the backend runs by default. The name is passed through to the backend's own CLI untouched, so a family alias (opus) and a pinned id (claude-opus-5) are both fine, and orchestra does not keep a list of what is valid — the backend answers that.

The model is a property of the session rather than of a turn, and it is on the record, so one that goes dormant wakes on the same model it went to sleep on. To carry a conversation to a different one, start a new session that resumes the old — repositories and all, since starting a session always takes them:

orchestra chat --upstream owner/repo --fork your-org/repo --resume-from <id> --model opus

It inherits the old session's history and is launched with the model it was given.

A session can also be held under an identity, which is how a conversation gets a memory that outlasts it:

orchestra chat --upstream owner/repo --fork your-org/repo --identity maintainer

The agent is told who it is, the identity's memory directory is mounted read-write, and where the identity carries a taxis token the session's tracker writes are recorded as coming from it. The record is read when the session starts or wakes, so a rotated token reaches it at its next wake rather than mid-conversation. Like the model it is a property of the session and lives on the record, so a dormant session wakes as the same identity — and, like the model, changing it means starting a new session that resumes the old. A session gets the identity's memory and no other: there is no memory field on a session to select the shared ones with. Naming an identity that is not configured is a 400 listing the ones that are, refused before a clone slot is taken.

What a session may spend is chosen the same way, and is the whole conversation's budget rather than a turn's: orchestra chat --budget <usd>, or the box next to the model on the chat page. Left alone it is 20 USD, and the API will not accept more than 100 — a bound on the one route that spends money, not a policy about what a session should cost. Running out is not a crash: the agent reports it, the session ends saying so, and the transcript is kept.

Only backends whose CLI can read turns from standard input can host a session, which today means claude. Asking for another is refused when the session is created, in a message naming it — never quietly substituted, because a backend that answers the first turn and exits looks exactly like a session that ended on its own.

Two limits bound them, in an interactive block in config.json:

{ "interactive": { "max_sessions": 2, "idle_timeout_seconds": 1800 } }

They are capacity, not access, and they bound processes rather than conversations. A session that is awake pins a clone slot — one of the same slots the queue claims from, so a task can never take it and reset the working tree mid-conversation — and an abandoned browser tab should not hold one forever; idle_timeout_seconds is what takes it back, by putting the session to sleep rather than ending it. max_sessions bounds how many are awake at once, so waking a dormant session can be refused when that many already are: end one, or wait. max_sessions of 0 means a daemon that will not hold sessions at all.

over HTTP

# start one
ID=$(curl -sX POST -H "Authorization: Bearer $PASSWORD" -H 'Content-Type: application/json' \
     --data '{"upstream":"owner/repo","fork":"your-org/repo"}' \
     http://127.0.0.1:8080/api/v1/interactive | jq -r .id)

# say something
curl -X POST -H "Authorization: Bearer $PASSWORD" -H 'Content-Type: application/json' \
     --data '{"text":"why does the queue stall when nothing is running?"}' \
     http://127.0.0.1:8080/api/v1/interactive/$ID/messages

# watch it answer
curl -N -H "Authorization: Bearer $PASSWORD" \
     "http://127.0.0.1:8080/sse/v1/interactive/$ID/events?after=0"

POST /api/v1/interactive/{id}/interrupt abandons the turn in flight without ending the session, and DELETE /api/v1/interactive/{id} ends it.

The transcript is the one stream in this API that is not a whole document re-read. A conversation only grows, so re-sending all of it every time a word is added is quadratic in its length. It carries a cursor instead: every frame holds only what follows the last one, and its id is the last sequence number in it, so a browser reconnecting with Last-Event-ID — or anything else passing the same number as ?after= — resumes exactly where it dropped, with nothing seen twice and nothing missed.

Reads come off <data>/interactive/<id>/, where the daemon writes the session record and an append-only transcript. Writes go the other way, forwarded to the daemon's control socket, because a session is a live process and only the daemon holds one. That split is why the reads answer identically whether the API and the daemon are one process or the two containers the compose deployment runs.

what this changes

POST /api/v1/interactive is the first route in this API that starts an agent. Everything before it read state or edited configuration. It is gated by the same shared secret, the same session cookie and the same Content-Type: application/json rule as every other write, and by nothing else — so a credential that could read the dashboard can now also start an agent with a repository, credentials and tools. On a loopback bind that is the same person who could already type orchestra interactive; on a wider one it is worth knowing before you set --host.

The full design — the session lifecycle, what happens on a crash or a daemon restart, and the schemas — is in docs/interactive.md.

configuration over the API

orchestra config is the CLI front end to the routes above. It talks to the backend over HTTP rather than editing files, so a running daemon sees the change and nothing races it for the file.

orchestra config list   <kind>                 # listeners | roles | skills
orchestra config show   <kind> <name>          # the document as stored
orchestra config set    <kind> <name> [file]   # create or replace; '-' or no file reads stdin
orchestra config remove <kind> <name>
orchestra config list roles
orchestra config set listeners nightly ./nightly.json
orchestra config show skills orchestra-pull-requests
echo '{"name":"planner","permissions":["manage_issues"],"prompt_template":"plan"}' \
  | orchestra config set roles planner

orchestra listener list|show|enable|disable are the same requests with a table in front of them, and are unchanged in spelling from before there was an API.

Both find the backend at $ORCHESTRA_API_URL (default http://127.0.0.1:8080) and authenticate with the same secret the server uses, resolved from --api-token, $ORCHESTRA_DASHBOARD_PASSWORD, or <data>/dashboard.secret. On one host that means no configuration at all; pointing the CLI at a remote orchestra means setting both.

Two things are deliberately not writable through the API:

  • config.json — it holds the GitHub App private key path, the PAT and the agent OAuth tokens. Exposing a write route for the file that holds every credential the daemon has, gated on one shared secret, trades a large amount of blast radius for a small amount of convenience. Edit it on the host.
  • Project-scoped role overrides (<data>/projects/<id>/roles/) — the API writes the global role catalogue. A project's own copy shadows a global role rather than extending it, and belongs with the project.

other commands

orchestra prepare <upstream> <fork>   # clone the fork and configure remotes
orchestra cleanup                     # remove all cloned repositories
orchestra cleanup list                # list clones and their task slots
orchestra chat --upstream <u> --fork <f>   # talk to an agent the backend holds open
orchestra mcp <upstream> <fork>       # start the MCP server standalone
orchestra usage                       # usage limits of every configured auth source
orchestra config list listeners       # read and change configuration through the backend API
orchestra migrate                     # move ~/.agent/ to the XDG directories

orchestrad serve                      # the backend: HTTP API + queue daemon in one process
orchestrad queue                      # the queue daemon alone
orchestrad dashboard --site web/dist  # the API, SSE and UI on one port

container

The container/ directory contains a NixOS image definition for incus. It installs all required tools (claude-code, elan, gh, landrun, mistral-vibe, and others) and creates an orchestra user.

To build the container image you need distrobuilder and incus:

Build the image (from the container/ subdirectory):

distrobuilder build-incus nixos.yaml

Import and start the container:

incus image import incus.tar.xz rootfs.squashfs --alias orchestra
incus launch orchestra my-orchestra --config security.nesting=true
incus exec my-orchestra -- nixos-rebuild switch

The last command installs the software in the container. To login as the orchestra user:

incus exec my-orchestra -- su orchestra

docker

docker/ packages the queue daemon and the same dependency set as an image, for hosts without incus. See docker/README.md for the details.

cd docker
cp .env.example .env      # fill in at least ORCHESTRA_TAXIS_URL and a token
docker compose up --build

Two containers come up off the one image, both running orchestrad: the daemon, and the dashboard on http://127.0.0.1:8080 (docker compose logs dashboard prints the password it asks for). They are separate because the daemon drains in-flight tasks for up to half an hour on every stop, and the web console should not be unavailable for that long.

Both mount /config read-write, because the dashboard is where configuration is now written.

The daemon container runs orchestrad queue; override the command for one-off subcommands against the same volumes:

docker compose run --rm orchestra project list

Config and state live in gitignored host directories — docker/config/, docker/data/ and docker/secrets/ — created on first up, so config/orchestra/config.json can be edited directly and a GitHub App key just gets dropped in secrets/.

It expects an existing taxis instance rather than starting one — ORCHESTRA_TAXIS_URL must be reachable from inside the container, so not localhost. Agents are sandboxed with landrun (Landlock), which works under Docker's default seccomp profile; the entrypoint probes it at start-up and docker/README.md covers what to check if that warning appears.