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
ghfor git transport only. The personal access token used for upstream pull requests, reviews and comments stays in the MCP server process. - Four agent backends —
claude(Claude Code),vibe(mistral-vibe),opencodeandpi— plus two built-in agent-less backends:merger, which lands an approved pull request, andtriage, 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
ghinvocations. 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-app → native 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, orpi gh(GitHub CLI) for repository operations- pi-mcp-adapter — Optional
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:
| Path | Contents |
|---|---|
$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 sourceoauth_token— an OAuth token (setsCLAUDE_CODE_OAUTH_TOKENfor the claude backend)api_key— an API key (setsANTHROPIC_API_KEYfor claude,MISTRAL_API_KEYfor vibe)base_url— optional base URL used withapi_key(setsANTHROPIC_BASE_URLfor 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
--modelto 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 inowner/repoformat. Omit it together withforkfor a repository-independent taskfork— fork repository the agent has write access to. A task names both repositories or neither; naming one is an errorprompt— instruction sent to the agentgoal— 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 theclaudebackend can enforce one today; the others say so on stderr and run without it → goalstools— optional tools granted to this task on top of the always-available ones; see MCP toolsmode— legacy shorthand fortools, kept for compatibility:"fork"grants nothing,"pr"grantscreate_pr. Ignored whentoolsis presentbackend—"claude"(default),"vibe","opencode","pi", or one of the agent-less backends"merger"/"triage"model— optional model override passed to the agentagent— optional sub-agent name passed to the backendauth_source— label of the authentication source to useauth_sources— candidate authentication sources, tried perauth_mode; takes precedence overauth_source→ authentication sourcesauth_mode—"ordered"(default) or"distribute"system_prompt— optional name of a file in~/.config/orchestra/prompts/(without.md); defaults todefault.mdif presentbudget— maximum spend in USD (default4.0)read_only— mount the clone read-only; used by review tasksmemory— 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 taskpriority— queue priority, higher runs first (default10)series— name of the series this run belongs toissue_number— GitHub issue or PR the task was launched from; enables thecommenttoolpr_labels— labels applied to every pull requestcreate_propens during the task, created on the target repository if missingtriage_add_labels/triage_remove_labels— labels thetriagebackend appliesproject_id/issue_id/role— set by the project subsystem; see projects, issues and rolesspawn_policy— what this task may itself put on the queue through thequeue_tasktool, 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 tools —
manage_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 indefault_organizationrather 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_issueorcomment. Asking for one intoolsis 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 isrefresh_tokenwhen 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_idordefault_organizationset, one is minted and reachesghin the sandbox asGH_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_onlyis set) and read+write on/tmp - read+execute on the toolchain paths its backend declares, and read+write on the
$HOMEsubdirectories 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_portsconfigured for the backend GH_TOKEN(the installation token) and the selected authentication source's key in the environment — nothing else is inherited beyondSHELL,PATH,HOME,USERandTERM
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. Takespr_number, plus an optionalmerge_method(merge|squash|rebase, defaultsquash) anddelete_branch(defaulttrue) — the same way themergerbackend 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: holdingcreate_prdoes 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. Takesissue_numberplusaddand/orremove, 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. Unlikecomment, it can label any issue, not only the one the task was launched from -
create_repository— create a new repository indefault_organization, the organisation tasks are forked into. Takesnameplus optionaldescription,private(defaulttrue) andauto_init(defaultfalse, 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 theAdministration: read and writepermission. 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 aGH_TOKENto export, since that variable is what authenticates its work on its own fork. A token fromrefresh_tokenis minted for whichever installation the task runs under, so it reaches the new repository only when that installation coversdefault_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, likemerge_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
bodyand setreview: true; optionally includeinline_comments - reply to inline comment: provide
bodyandreply_to_comment_id - new inline comment: provide
body,path, andline
The
commenttool requires the task to carry anissue_number(set automatically by the listener when triggered from an issue or pull request). - regular comment: provide only
-
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 thetoolslist 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'sspawn_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 | |
|---|---|
name | Must match the directory name. Every surface refers to the identity by it |
description | One or two sentences, shown to the agent as part of who it is |
taxis_token | API 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-claimedis 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 pipelineexamples/concerts/loop.yaml— for-each loop with typed outputsexamples/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 inexamples/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": "..."}pairssource.trigger— only events whose body contains this string are processedsource.authorized_users— list of GitHub logins that may trigger the listener; empty means allow everyonesource.limit_unclaimed_to_open_issues—label-dispatcheronly: 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_issues—label-dispatcheronly: 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 workedaction.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. Ashellsource supplies neither, so a listener on one that names neither queues a repository-independent taskaction.workflow_path— path to a.yamlworkflow file; when set the listener starts a concert instead of enqueueing a single taskaction.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 limitsinterval_seconds— how often the listener looks. What it may do once it has looked israte_limits, belowrate_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
...Atfield. 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
...Secondsfield. - 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 takelimit,offset, and, where they are ordered by time,since.totalcounts matches before the window, so "50 of 812" needs no second request. A parameter that is malformed, or that a collection cannot honour, is a400rather 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-
GETroute requires the credential.POST /api/loginis the one exception, since it is how the credential is obtained. - Every write must be
Content-Type: application/json, or it is a415. That is the second of two locks against cross-site forgery: the session cookie isSameSite=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 noOrigincheck — is written out in the module docs ofOrchestra/Dashboard.lean. POSTto a collection creates and refuses to overwrite (409), naming the record from the body.PUTto a member creates or replaces, naming it from the path; a body naming a different one is a400rather than a silent rename.DELETEis204, or404when there was nothing there. Listeners have noPOST: a listener config carries no name of its own, soPUT /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 theconfigfield of a detail response, change it,PUTit 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:
- distrobuilder: https://linuxcontainers.org/distrobuilder/docs/latest/
- incus: https://linuxcontainers.org/incus/docs/main/installing/
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.