[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"K3MmZGhfvT":3},"# orchestra\n\norchestra is a daemon that runs agentic coding tasks. Listeners watch issues, comments, pull\nrequest reviews and an issue tracker, turn what they find into tasks, and the daemon runs each one\nin a sandbox with credentials the agent never gets to hold.\n\n## the moving parts\n\nThree pieces carry everything else.\n\n- A **task** is the unit of work and the only thing that ever runs an agent: a repository pair (or\n  none, for meta-work), a prompt, a set of tools, a budget → [task files](#task-files)\n- The **queue** is where tasks wait for the daemon that runs them, by priority and in parallel →\n  [queue mode](#queue-mode)\n- **Listeners** poll an event source and enqueue a task whenever something matches →\n  [listeners](#listeners)\n\nIt ships as two binaries. `orchestrad` is the backend — the queue daemon and the HTTP API — and\n`orchestra` is the client you type at, which reaches the backend over that API. See\n[the two binaries](#the-two-binaries).\n\n```\n task file      workflow       listener event      role dispatcher\n      \\             \\                /                  /\n       ─────────────► queue (priority, parallel slots) ◄──────\n                             │\n                             ▼\n                        task runner\n                             │\n          clone + GitHub App token + per-repo hooks\n                             │\n                             ▼\n              landrun sandbox ──── agent backend\n                             │           │\n                             └► MCP server ◄┘\n                              (GitHub + issues)\n```\n\n## what it does\n\n- **Sandboxed runs.** Every agent runs under landrun: write access to its own clone and `/tmp`,\n  read+execute on the toolchain, outbound TCP to HTTPS and the MCP port, nothing else. A task can\n  mount its clone read-only, which is what review tasks use.\n- **Repository-independent tasks.** A task can name no repository at all and run in a scratch\n  workspace instead of a checkout — for maintenance and coordination that spans every project\n  rather than belonging to one → [repository-independent tasks](#repository-independent-tasks)\n- **Credentials the agent never sees.** A GitHub App installation token is minted per task and\n  handed to `gh` for git transport only. The personal access token used for upstream pull\n  requests, reviews and comments stays in the MCP server process.\n- **Four agent backends** — `claude` (Claude Code), `vibe` (mistral-vibe), `opencode` and `pi` —\n  plus two built-in agent-less backends: `merger`, which lands an approved pull request, and\n  `triage`, which applies and removes labels deterministically.\n- **GitHub actions as MCP tools.** Creating pull requests, comments, reviews with inline\n  annotations, and reading review threads are tools, not `gh` invocations. Each task is granted a\n  subset of them.\n- **A queue daemon** with priorities, parallel execution backed by per-repository clone slots,\n  re-enqueueing of unfinished entries, and a graceful drain on `SIGTERM`.\n- **Usage-limit awareness.** Every Claude subscription limit is tracked — session, weekly, and\n  the weekly limits scoped to one model family — so work is routed to an account that can still\n  run it instead of into a wall. Several accounts can back one listener, used in order or\n  balanced across, and what each one has spent is kept window by window and graphed →\n  [usage limits](#usage-limits)\n- **Listeners** that poll GitHub issues, comments, pull request reviews, labels, or an arbitrary\n  shell command, and enqueue a task — or a whole workflow — when something matches.\n- **An autonomous project pipeline.** Projects and issues live in a\n  [taxis](https://github.com/chrisflav/taxis) tracker; *roles* are task templates a dispatcher\n  spawns as work appears, with claim locks so two agents never pick up the same issue.\n- **Persistent identities.** A task can be performed under a named identity: it gets a memory of\n  its own that carries across every run under that name, and — where the identity has a taxis\n  token — writes to the tracker as itself rather than as orchestra → [identities](#identities)\n- **Concert workflows**: YAML multi-step programs with typed step outputs, loops and conditionals.\n- **Per-repository hooks** for setup, validation and teardown, with an automatic retry loop when\n  validation fails.\n- **Recorded history.** Every run is stored, can be grouped into a named series, and resumed with\n  a follow-up prompt.\n- **A native app.** One client for macOS, Windows, Linux, iOS and Android that holds *several*\n  backends at once and switches between them — reading the same API the dashboard reads, over a\n  bearer token kept in the OS keychain. Its own repository:\n  [orchestra-app](https://github.com/chrisflav/orchestra-app) → [native app](#native-app)\n- **Chat sessions the backend holds.** A conversation with an agent, in the same sandbox a task\n  gets, reachable over the API from the CLI, the dashboard or a phone — and still running after\n  the client that started it goes away → [interactive sessions](#interactive-sessions)\n\n## prerequisites\n\nIt is recommended to run all of orchestra inside a virtual machine or container. Two ready-made\nenvironments provide the full set of tools: a NixOS incus image (see the\n[container section](#container)) and a Docker image running the queue daemon (see the\n[docker section](#docker)).\n\nBefore starting you will need to create a GitHub App with a private key, installed on the organization owning the fork. Download the private key.\n\nYou will also want a personal access token with repo scope. The two are not alternatives: every\ntask mints an installation token from the App and gives it to `gh` for cloning and pushing, so\nwithout the App no task runs at all, while the PAT covers what an installation token cannot —\npull requests against the *upstream* repository, issue comments, PR reviews, and the triage\nbackend. One PAT is enough until orchestra works across repositories no single token can see;\nthen give it several → [per-repository PATs](#per-repository-pats).\n\nInside the container (or VM), the following must be available (all installed\nautomatically when using the provided container image):\n\n- [Lean 4 / Lake](https://leanprover.github.io/lean4/doc/setup.html) to build\n  the tool\n- [landrun](https://github.com/Zouuup/landrun) for sandboxing\n- at least one agent CLI, installed and authenticated: `claude` (Claude Code), `vibe`\n  (mistral-vibe), `opencode`, or `pi`\n- `gh` (GitHub CLI) for repository operations\n- [pi-mcp-adapter](https://github.com/nicobailon/pi-mcp-adapter) — **Optional**\n  MCP OAuth support for the **Pi agent** specifically (not needed for the other\n  backends). Tested with the MCP tools defined in this project. Install in\n  the container with `pi install npm:pi-mcp-adapter`.\n\nIn addition, the private key from the GitHub App must be included as a file in the container.\n\n## building\n\n```\nlake build\n```\n\nTwo binaries land in `.lake/build/bin/`: `orchestra` (the CLI) and `orchestrad` (the backend).\n`lake build orchestra` and `lake build orchestrad` build one each.\n\n### the two binaries\n\n`orchestrad` is the half that runs continuously and holds the credentials: the queue daemon that\ndispatches agents, and the HTTP API that everything reads and writes orchestra's configuration\nthrough.\n\n```\norchestrad serve                  # the API and the queue daemon, one process\norchestrad queue                  # the queue daemon alone\norchestrad dashboard --site web/dist   # the API and the web UI alone\n```\n\n`orchestra` is the client. Its configuration commands — `orchestra config`, `orchestra listener`\n— are HTTP clients of `orchestrad`, so a running daemon picks up a change without a restart and\nthere is never a second writer racing it for the same file. `orchestra chat` is one too: the\nsession it talks to runs on the backend. Its other commands (`run`, `interactive`, `prepare`)\nexecute locally, because what they do is launch a sandbox on the machine you are sitting at.\n\n`orchestra queue start` and `orchestra dashboard` still work and still mean what they meant: they\nstart `orchestrad`, looked for next to the `orchestra` binary, then at `$ORCHESTRA_SERVER_BIN`,\nthen on `PATH`.\n\n## configuration\n\nCreate `~/.config/orchestra/config.json`:\n\n```json\n{\n  \"github_app\": {\n    \"app_id\": 12345,\n    \"private_key_path\": \"/path/to/private-key.pem\",\n    \"installation_id\": 67890\n  },\n  \"github\": {\n    \"pat\": \"github_pat_...\"\n  },\n  \"plugin_dirs\": [],\n  \"claude_token\": \"...\",\n  \"authorized_users\": [\"\u003CGitHub_username>\"],\n  \"default_organization\": \"my-org\",\n  \"queue\": {\n    \"parallel\": 4,\n    \"parallel_per_repo\": 2\n  }\n}\n```\n\n`default_organization` is the organisation under which orchestra may create repositories. It is\nused by every project/role-based task that pushes (the automatic dispatcher and `orchestra spawn`):\na task targets a repository its pull requests must land in, and the agent works on a *fork* it can\npush to. When the GitHub App already has write access to the target, the fork is the target itself\nand nothing is created; when it does not, the target is forked into `default_organization` and that\nfork is what the agent pushes to. If the App cannot push to a target and `default_organization` is\nunset, the task is skipped rather than dispatched at a repository it cannot push to — so set this\nwhenever the dispatcher works on repositories the App is not directly installed on. The App must be\ninstalled on `default_organization` with permission to create repositories.\n\nIt is also the destination of the `create_repository` MCP tool, which is the only way an agent\ncreates a repository from scratch rather than by forking — and the only owner that tool will use.\n\n**The target must be readable by the App.** A task's token is minted for the *fork's* installation,\nand that token is what fetches the upstream. Public targets are fine. A private target the App is\nnot installed on cannot be fetched — and cannot be forked in the first place — so such a task is\nskipped, with the reason on stderr under `[fork]`.\n\nTwo roles never fork. The **merger** merges with `gh pr merge` as the GitHub App, which requires\nwrite access to the pull request's own repository, and a fork cannot supply that; when the App\ncannot push to a pull request's repository — or the check stays inconclusive — no merger is queued\nand `decide_issue approve` reports that the pull request has to be merged by hand. The\n**auto-reviewer** is read-only and pushes nothing, so it works in the pull request's own repository\nand needs no writable fork at all.\n\n`installation_id` is optional; if omitted it is looked up automatically.\n`pat` is a personal access token used to create pull requests to the upstream\nrepository. The agent itself never sees this token. When one token cannot see every repository\norchestra works on, add `github.pats` beside it → [per-repository PATs](#per-repository-pats).\n\n`claude_token` is an optional long-lived Claude OAuth token. Claude login\nsessions lapse frequently; a stable token avoids repeated re-authentication.\nObtain one by running `claude setup-token` and copy the token value here. When\nset, it is passed to the agent as the `CLAUDE_CODE_OAUTH_TOKEN` environment\nvariable.\n\n`additional_sandbox_paths` grants every agent launched by this instance extra filesystem access on\ntop of what its backend already needs — `rox`/`ro`/`rw` for absolute paths, `home_rox`/`home_rw`/\n`home_rwx` for `$HOME`-relative ones. It is the usual fix when a repository's build needs a shared\ncache directory:\n\n```json\n{\n  \"additional_sandbox_paths\": {\n    \"home_rw\": [\".cache/mybuildtool\"]\n  }\n}\n```\n\nTCP ports beyond HTTPS and the MCP server are opened per backend, via `extra_ports` on that\nbackend's entry in the `agents` array — for a local model server, for instance.\n\nThe `queue` block sets how many tasks the daemon runs at once. Both keys default\nto `1`, which is the serial behaviour, and `orchestra queue start --parallel N` /\n`--parallel-per-repo N` override them for a single run.\n\n`parallel_per_repo` is capped separately because concurrent tasks on the same\nrepository each need their own clone — a *slot* — so that two agents can create\nthe same branch name without git refusing. Raising it costs one working tree per\nslot; the git history itself is hardlinked from a shared cache clone, so it is\nthe checkout and its build output that take the space, not the objects. Run\n\n```sh\norchestra prepare \u003Cupstream> \u003Cfork> --slots 2\n```\n\nwith `--slots` matching `parallel_per_repo`, so each slot's init hook is paid up\nfront rather than charged to whichever task lands there first.\n\n[Repository-independent tasks](#repository-independent-tasks) are pooled the same way and under\nthe same cap, in one pool they share with each other and with nothing else — their workspaces cost\na directory each rather than a checkout, and there is nothing to prepare up front.\n\nTwo backends always run exclusively regardless of these settings: `pi` and\n`opencode` keep per-run state at a fixed path under `$HOME`, so a second\nconcurrent run would read the first one's MCP configuration. They start only\nwhile the daemon is otherwise idle.\n\n### files and directories\n\norchestra separates configuration from state:\n\n| Path | Contents |\n| --- | --- |\n| `$XDG_CONFIG_HOME/orchestra/` (or `~/.config/orchestra/`) | `config.json`, `secrets.json`, `prompts/`, `listeners/`, `roles/`, `skills/`, `identities/` |\n| `$XDG_DATA_HOME/orchestra/` (or `~/.local/share/orchestra/`) | clones, task records, queue entries, logs, per-project role overrides, per-identity memory |\n\nInstallations predating this split keep everything in `~/.agent/`; that layout is still read, with\na deprecation warning. `orchestra migrate` moves it into place.\n\n### secrets\n\nValues that should not sit in `config.json` — or that are shared between it and the listener\nconfigs — can live in `secrets.json` next to it:\n\n```json\n{\n  \"github_pat\": \"github_pat_...\",\n  \"work_pat\": \"github_pat_...\",\n  \"taxis_token\": \"...\"\n}\n```\n\nEvery `{{key}}` occurrence in `config.json` and in listener configs is replaced with the\ncorresponding value before the file is parsed, so `\"pat\": \"{{github_pat}}\"` works in either.\nThe file is optional; when absent, nothing is substituted.\n\nA `{{key}}` that `secrets.json` does not define is left standing verbatim, which for most settings\nis a visible mistake. For a token it is not: the entry keeps a non-empty, token-shaped value that\nauthenticates as nobody. `github.pats` therefore refuses to load an entry still holding one — see\nbelow for why that particular failure is worth catching early.\n\n## per-repository PATs\n\nOne personal access token covers one account's repositories. When orchestra works across\nrepositories that no single token can see — a personal account and an organisation, two\norganisations, a client's repository beside your own — name a token per repository in\n`github.pats`:\n\n```json\n{\n  \"github\": {\n    \"pat\": \"{{github_pat}}\",\n    \"pats\": [\n      { \"label\": \"work\",     \"token\": \"{{work_pat}}\",     \"repos\": [\"acme/*\", \"acme-labs/tooling\"] },\n      { \"label\": \"consult\",  \"token\": \"{{consult_pat}}\",  \"repos\": [\"acme/client-work\"] }\n    ]\n  }\n}\n```\n\nEach entry needs a unique `label` (used in diagnostics — orchestra never logs the token itself), a\n`token`, and the `repos` it covers. A pattern is `owner/name`, `owner/*`, or `*` for everything;\nmatching is case-insensitive, as GitHub's own names are. Nothing else is a pattern — `acme-*/x`\nand `acme/widget*` are rejected at load rather than silently matching nothing.\n\n**The most specific match wins**, so a config reads top-down: the broad line for an account, then\nthe individual repositories that are exceptions to it. Exact `owner/name` beats `owner/*` beats\n`*`, whichever order they are written in; two entries claiming the same repository at the same\nspecificity resolve to the one written first. A repository no entry covers falls back to\n`github.pat`, so adding a second token cannot change where an existing repository's calls go.\n\nThe token is resolved per repository at every point a PAT is spent: the MCP server's `create_pr`,\n`merge_pr`, `label_issue`, `comment` and `get_pr_comments`, the triage backend, and — per\nrepository within a single tick — listener polling and the dispatchers' review classification. A\nlistener's `repos` list may therefore span accounts. Because GitHub's rate limits are per token,\nsplitting a busy listener across two also raises its ceiling; polling is the heaviest PAT consumer\nthere is.\n\nTwo things it deliberately does not do. It is not selected per task the way an agent\n[authentication source](#authentication-sources) is: which PAT is *correct* is a property of the\nrepository, not a choice, and keying it by repository is what reaches the dispatch paths — a role\ndispatched by a listener, a concert step — that have nowhere to name a label and would otherwise\nfall back to the wrong account. And it does not extend the GitHub App: cloning, pushing, forking\nand merging all run on installation tokens, so a repository in a new organisation still needs the\nApp installed there, or `default_organization` set so orchestra can fork into somewhere it can\npush.\n\nGet the mapping wrong and the symptom is misleading: GitHub answers a read a token is not\nentitled to with a **404**, the same answer it gives for a repository that does not exist. So a\nmisrouted token reports \"no such repository\" on a repository that plainly exists. To keep that\nfrom happening quietly, a `pats` block that is present but unreadable fails the whole config\nrather than being skipped, entries with a blank token or an unsubstituted `{{secret}}` are\nrefused, and `orchestrad` prints the coverage — labels and patterns, never tokens — at startup:\n\n```\nGitHub PAT sources (2, most specific match wins; anything uncovered falls back to github.pat):\n  work: acme/*, acme-labs/tooling\n  consult: acme/client-work\n```\n\n## authentication sources\n\nThe `agents` array in `config.json` lets you configure multiple named\nauthentication sources per backend. Each source carries either an OAuth token\nor an API key:\n\n```json\n{\n  \"github_app\": { \"...\" : \"...\" },\n  \"agents\": [\n    {\n      \"name\": \"claude\",\n      \"auth_sources\": [\n        { \"label\": \"work\", \"oauth_token\": \"sk-ant-oat-...\" },\n        { \"label\": \"personal\", \"api_key\": \"sk-ant-api-...\" }\n      ],\n      \"default_auth_source\": \"work\"\n    },\n    {\n      \"name\": \"vibe\",\n      \"auth_sources\": [\n        { \"label\": \"main\", \"api_key\": \"mistral-...\" }\n      ]\n    }\n  ]\n}\n```\n\nEach authentication source object has the following fields:\n\n- `label` — unique name within the backend, used to reference the source\n- `oauth_token` — an OAuth token (sets `CLAUDE_CODE_OAUTH_TOKEN` for the\n  claude backend)\n- `api_key` — an API key (sets `ANTHROPIC_API_KEY` for claude,\n  `MISTRAL_API_KEY` for vibe)\n- `base_url` — optional base URL used with `api_key` (sets\n  `ANTHROPIC_BASE_URL` for claude)\n\nExactly one of `oauth_token` or `api_key` must be present per source.\n\nThe `default_auth_source` field selects which source is used when a task does\nnot specify one. When omitted and only one source is configured, that source\nis selected automatically. It also accepts a *list*, which pools the accounts\nit names instead of pinning one — see\n[defaulting to several sources](#defaulting-to-several-sources).\n\nTo select a specific source in a task, set the `auth_source` field:\n\n```json\n{\n  \"upstream\": \"owner/repo\",\n  \"fork\": \"org/repo\",\n  \"mode\": \"pr\",\n  \"prompt\": \"Fix the bug.\",\n  \"auth_source\": \"personal\"\n}\n```\n\nThe same `auth_source` field is available on queue entries and listener\nactions.\n\nThe legacy flat fields (`claude_token`, `anthropic_api_key`,\n`anthropic_base_url`, `anthropic_auth_token`) still work when no `agents`\narray is present, so existing configurations remain valid.\n\n### using several sources, and failing over between them\n\nA task, queue entry or listener action can name *several* candidate sources\ninstead of one, and say how to choose between them:\n\n```json\n{\n  \"upstream\": \"owner/repo\",\n  \"fork\": \"org/repo\",\n  \"mode\": \"pr\",\n  \"prompt\": \"Fix the bug.\",\n  \"auth_sources\": [\"work\", \"personal\"],\n  \"auth_mode\": \"ordered\"\n}\n```\n\n- `ordered` (default) — use the sources in the order listed, falling through\n  to the next when one is out of quota. Burn the subscription first, then the\n  API key.\n- `distribute` — spread work across every source that is not limited,\n  preferring the least-consumed one. Two accounts with the same plan end up\n  roughly level rather than one being exhausted before the other is touched.\n  Between sources at the same utilisation it round-robins, so a burst of\n  parallel claims fans out instead of landing on one account.\n\nWhich source ran a task is recorded on its queue entry. Under `distribute` the\nchoice is made and stamped while the daemon holds its claim lock, so parallel\nworkers cannot all read the same pre-dispatch state and pick the same account.\n\n`auth_sources` takes precedence over `auth_source`; either can be omitted, in\nwhich case `default_auth_source` applies as before.\n\n### defaulting to several sources\n\nNaming candidates per task only reaches the paths that have somewhere to write\nthem. Two do not: a role dispatched by a `label-dispatcher` or\n`project-dispatcher` listener is built from the role template, which has no\nauth fields, and a concert step is built from its own YAML, which has none\neither. Both arrive at resolution naming nothing, so both take whatever\n`default_auth_source` says — and a single label there means every dispatched\nagent and every workflow step runs on one account while the others idle.\n\nGive the field a list to pool them instead:\n\n```json\n{\n  \"name\": \"claude\",\n  \"auth_sources\": [\n    { \"label\": \"work\",     \"oauth_token\": \"sk-ant-oat-...\" },\n    { \"label\": \"personal\", \"oauth_token\": \"sk-ant-oat-...\" }\n  ],\n  \"default_auth_source\": [\"work\", \"personal\"],\n  \"default_auth_mode\": \"distribute\"\n}\n```\n\n`default_auth_mode` takes the same two values as a task's `auth_mode` and means\nthe same things. It is what the pool is walked with when the task says nothing —\nwhich is the point, since a task that names no mode must not be read as asking\nfor `ordered`, and walking a pool in `ordered` picks the first account every\ntime. A task that *does* set `auth_mode` still gets it, with or without\ncandidates of its own.\n\nThe narrower forms win. A task's own `auth_sources` beats the pool, a task's\n`auth_source` pins past it, and a `default_auth_source` written as a plain\nstring keeps pinning as it always did.\n\nA pool label that is not among the backend's `auth_sources` is dropped. Under\n`distribute` an unknown label would otherwise be *preferred* — nothing has been\nrecorded against it, so it looks like the least-consumed account — and the task\nwould then fail against a source that has no credentials behind it. Both keys\nare parsed strictly when present: a misspelled `default_auth_mode` fails config\nload rather than quietly reverting the pool to `ordered`.\n\n**Which source a task runs on is decided when it starts, not when it is\nqueued.** An entry can sit in the queue for hours, and the account that was\nfree when a listener created it may be exhausted by the time a worker picks it\nup. The daemon resolves the list at claim time and records the winner on the\nentry.\n\n### usage limits\n\nOrchestra tracks how much of each subscription is left, so it can route around\na limit instead of running into it. For OAuth sources it polls the same\nendpoint Claude Code itself uses, which reports every limit at once: the\nrolling session window, the weekly total, and weekly limits scoped to a single\nmodel family.\n\n```\n$ orchestra usage --select --model claude-opus-4-8\nclaude:\n  work [oauth]: BLOCKED (weekly_scoped limit for Opus at 100%, resets in 16h 22m)\n    session: 13% [normal], resets in 1h 51m\n    weekly_all: 77% [warning], resets in 16h 22m\n    weekly_scoped (Opus): 100% [critical], resets in 16h 22m\n  personal [api-key]: available\n    no subscription limits to report (API-key sources are billed per token)\n  → would select: personal (ordered)\n```\n\nTwo details matter in practice:\n\n- **Model-scoped limits only close one model family.** An exhausted weekly-Opus\n  window leaves Sonnet work on the same account perfectly runnable, so\n  availability is a question about *(source, model)* — not about the account.\n  Pass `--model` to ask about a specific one.\n- **A limited source does not cancel anything.** Queue entries that would land\n  on it stay pending and run when the window resets; entries on any other\n  source, backend or model family keep going. Earlier versions cancelled every\n  pending entry sharing a backend, which threw away work that was about to\n  become runnable.\n\nLimits are learned two ways, and the two cover each other: the poll sees a\nlimit coming and knows the exact reset time, while a run that comes back\nrate-limited is recorded immediately, before anything else can be dispatched to\nthat source. Both write to `\u003Cdata>/usage/\u003Cbackend>/\u003Clabel>.json`, which is\nshared across processes — a limit `orchestra run` discovers in a terminal stops\nthe daemon from dispatching to that source too.\n\nEvery poll is also kept, so the account's past is readable and not only its\npresent. It is kept one record per *window* rather than one per poll: a session\nwindow and a weekly total are counters that fill and then reset, so the peak\nreading inside one is what that session or that week consumed. The records live\nin `\u003Cdata>/usage/\u003Cbackend>/\u003Clabel>.history.json`, they are what the\n[dashboard's](#dashboard) usage graphs are drawn from, and they are bounded —\n240 windows per series, nothing older than six months.\n\nA window is identified by the reset time every poll inside it reports, so a new\nreset time is a new window; where nothing reports one, utilisation that dropped\nis what marks the rollover. Only polls are recorded. An observed hit knows that\na limit was reached but not where its counter stood, so folding one in would\ninvent a reading rather than keep one — the poll that follows it reports the\nreal number.\n\nAPI-key sources have no subscription window to poll (they bill per token\nagainst an organisation), so they are always considered available until a run\nproves otherwise.\n\nPolling is an account-metadata call, not an inference call: **it costs no input\nor output tokens and consumes none of the quota it reports.** It is metered as\n*requests*, though, and the budget is small: about five of them before the\nendpoint answers `429` with a `retry-after` of five minutes, so roughly one\nrequest per minute per token — shared by everything that polls.\n\nThere is therefore one poller, and everything else reads what it wrote. The\ndaemon refreshes each source every five minutes; dispatch goes to the network\nonly if the stored numbers are older than that, which while the daemon is up\nthey never are. A poll that fails backs off before anything retries it — for as\nlong as the server's `retry-after` asks after a `429`, a minute otherwise —\nbecause a failed poll leaves the source stale, and a stale source with no\nbackoff is re-polled by every claim decision until the budget is gone.\n`orchestra usage` reuses fresh data too; pass `--refresh` to force a poll or\n`--cached` to make none. While a backoff is in effect the command says so, and\ndispatch falls back to the last known limits — an unreachable endpoint is never\ntreated as an exhausted account.\n\n`orchestra usage` flags: `--backend` to narrow to one backend, `--model` to\njudge model-scoped limits, `--cached` to skip polling, `--select` to also show\nwhich source a task queued now would be dispatched to, and `--auth_mode` to\nsimulate `distribute` instead of `ordered`.\n\nSystem prompts can be placed in `~/.config/orchestra/prompts/`. The file\n`~/.config/orchestra/prompts/default.md` is loaded automatically; named prompts can be\nreferenced via the `system_prompt` field in a task file.\n\n### the plan a setup-token cannot state\n\nClaude Code checks a model's entitlement against the subscription it is\nholding, and it learns that subscription from the account profile it fetches at\n`/login`. A long-lived `claude setup-token` — which is what an `oauth_token`\nsource carries — has inference scope and nothing else, so that profile is never\nfetched and the client ends up holding no plan at all. Checks that cannot\nconfirm the subscription covers a model then fail closed: a Max account is told\nthat Fable, a standard part of that plan, requires usage credits\n([anthropics/claude-code#79597][fable-issue]). The server grants the very same\ntoken Fable perfectly well — it is the client refusing, in the plan's name.\n\nIn that mode the client reads the plan and the rate-limit tier from\n`CLAUDE_CODE_SUBSCRIPTION_TYPE` and `CLAUDE_CODE_RATE_LIMIT_TIER` instead of\nfrom a profile, precisely because there is no profile to read. So orchestra\nsets both beside every OAuth token it passes — an `oauth_token` source or the\nlegacy flat `claude_token`: `max` and `default_claude_max_20x`, the plan its\naccounts are on. Nothing to configure, and nothing granted by it — every\nrequest is still authorised and priced by the server against the token, so the\nvalue can only make the client's local guess right or wrong. API-key sources\nget neither: they bill an organisation per token and have no subscription to\ndescribe.\n\n[fable-issue]: https://github.com/anthropics/claude-code/issues/79597\n\n## task files\n\nTasks are described in a JSON file:\n\n```json\n{\n  \"tasks\": [\n    {\n      \"upstream\": \"owner/repo\",\n      \"fork\": \"your-org/fork-repo\",\n      \"tools\": [\"create_pr\"],\n      \"prompt\": \"Implement feature X and open a pull request.\",\n      \"budget\": 8.0\n    }\n  ]\n}\n```\n\nFields:\n\n- `upstream` — upstream repository in `owner/repo` format. Omit it together with `fork` for a\n  [repository-independent task](#repository-independent-tasks)\n- `fork` — fork repository the agent has write access to. A task names both repositories or\n  neither; naming one is an error\n- `prompt` — instruction sent to the agent\n- `goal` — condition the run is held to: the agent may not stop before it holds, and a second\n  model call decides whether it does. Keep it one short, checkable sentence — it is judged on its\n  own, without the prompt around it. Only the `claude` backend can enforce one today; the others\n  say so on stderr and run without it → [goals](#goals)\n- `tools` — optional tools granted to this task on top of the always-available ones; see\n  [MCP tools](#mcp-tools)\n- `mode` — legacy shorthand for `tools`, kept for compatibility: `\"fork\"` grants nothing, `\"pr\"`\n  grants `create_pr`. Ignored when `tools` is present\n- `backend` — `\"claude\"` (default), `\"vibe\"`, `\"opencode\"`, `\"pi\"`, or one of the agent-less\n  backends `\"merger\"` / `\"triage\"`\n- `model` — optional model override passed to the agent\n- `agent` — optional sub-agent name passed to the backend\n- `auth_source` — label of the authentication source to use\n- `auth_sources` — candidate authentication sources, tried per `auth_mode`; takes precedence over\n  `auth_source` → [authentication sources](#using-several-sources-and-failing-over-between-them)\n- `auth_mode` — `\"ordered\"` (default) or `\"distribute\"`\n- `system_prompt` — optional name of a file in `~/.config/orchestra/prompts/` (without\n  `.md`); defaults to `default.md` if present\n- `budget` — maximum spend in USD (default `4.0`)\n- `read_only` — mount the clone read-only; used by review tasks\n- `memory` — which shared memory directories the agent may persist to: `\"none\"`, `\"global\"`,\n  `\"project\"`, or `\"both\"` (default)\n- `identity` — name of the [identity](#identities) this task is performed under. The task gets\n  that identity's own memory, and where the identity has a taxis token, its tracker writes are\n  recorded as coming from it. Naming one that is not configured fails the task\n- `priority` — queue priority, higher runs first (default `10`)\n- `series` — name of the series this run belongs to\n- `issue_number` — GitHub issue or PR the task was launched from; enables the `comment` tool\n- `pr_labels` — labels applied to every pull request `create_pr` opens during the task, created on\n  the target repository if missing\n- `triage_add_labels` / `triage_remove_labels` — labels the `triage` backend applies\n- `project_id` / `issue_id` / `role` — set by the project subsystem; see\n  [projects, issues and roles](#projects-issues-and-roles)\n- `spawn_policy` — what this task may itself put on the queue through the `queue_task` tool, and\n  how much of it. Absent means nothing, and the tool is not offered →\n  [queueing tasks from inside a task](docs/queue-task.md)\n\n## repository-independent tasks\n\nA task that names no `upstream` and no `fork` runs with nothing checked out. It is sandboxed like\nany other task, but instead of a clone slot it gets an empty scratch workspace, and the tools that\nact on a repository are withheld from it.\n\n```json\n{\n  \"tasks\": [\n    {\n      \"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.\",\n      \"tools\": [\"manage_issues\"],\n      \"budget\": 6.0\n    }\n  ]\n}\n```\n\nThis is the shape meta-work takes: maintenance across the whole set of projects, coordinating\nefforts on the [taxis](#projects-issues-and-roles) tracker, anything where checking out one\nrepository would mean picking an arbitrary one of the several the task is about.\n\nWhat such a task gets, and what it does not:\n\n- **A scratch workspace** at `~/.local/share/orchestra/workspaces/`, mounted read-write, and\n  `/tmp`. It is *emptied* between tasks rather than cleaned selectively — a clone slot can keep\n  gitignored build output because git says which files those are, and a workspace has nothing\n  that could draw that line. Anything meant to outlive a run belongs in a memory directory.\n- **The project, issue and task tools** — `manage_issues`, `work_issues`, `review_issues`,\n  `get_task_input`, `submit_task_output`, `health`. This is what the task is for.\n- **`create_repository`**, which creates a repository in `default_organization` rather than acting\n  on one the task named, so there is nothing about it a repository-independent task lacks. Starting\n  a project that does not exist yet is meta-work like any other.\n- **No `create_pr`, `merge_pr`, `label_issue` or `comment`.** Asking for one in `tools` is reported\n  on stderr and dropped, and the MCP server refuses the call as well. `get_pr_comments`, which no\n  task has to ask for, is simply not offered — nor is `refresh_token` when there is no\n  installation behind it.\n- **No per-project memory.** There is no upstream to name a directory after, so `memory: \"project\"`\n  resolves to nothing and `\"both\"` to the global directory alone — which is the right home for\n  what work spanning projects learns anyway.\n- **A GitHub App token only if the config says which installation.** With `installation_id` or\n  `default_organization` set, one is minted and reaches `gh` in the sandbox as `GH_TOKEN`; with\n  neither, the task runs without GitHub credentials rather than refusing to start.\n\nThe `merger` and `triage` backends need a repository by definition and refuse to run without one.\n\nWorkspaces are pooled the way clone slots are, so `--parallel-per-repo` bounds how many\nrepository-independent tasks run at once — they share one pool with each other, and with nothing\nelse. A continuation goes back to its predecessor's workspace and keeps the files there, on the\nsame terms as a continuation on a repository.\n\nListeners and concert workflows can queue these too: a listener whose action names no\n`upstream`/`fork` (and whose event source supplies neither) queues a repository-independent task,\nand so does a workflow step with no repositories at either the step or the program level.\n\n## sandboxing\n\nAgents are confined with landrun, which uses the Linux Landlock LSM — no container or VM is\ninvolved, so this works inside the Docker image as well. A task's agent gets:\n\n- read+write+execute on its own clone (read+execute only when `read_only` is set) and read+write\n  on `/tmp`\n- read+execute on the toolchain paths its backend declares, and read+write on the `$HOME`\n  subdirectories that backend needs for its own configuration and state\n- read+execute on plugin directories, read+write on the memory directories permitted by `memory`\n- outbound TCP to port 443 and to the local MCP server port, plus any `extra_ports` configured for\n  the backend\n- `GH_TOKEN` (the installation token) and the selected authentication source's key in the\n  environment — nothing else is inherited beyond `SHELL`, `PATH`, `HOME`, `USER` and `TERM`\n\n`orchestra run --debug` prints the exact landrun invocation before executing it, which is the\nquickest way to find out why an agent cannot see a path. A path that does not exist cannot be\ngranted — orchestra warns about missing `$HOME`-relative paths rather than letting the agent hang.\n\n## MCP tools\n\nThe agent has access to the following tools via the built-in MCP server. `health`, `refresh_token`,\nand `get_pr_comments` are always available; `create_pr`, `merge_pr`, `label_issue`, `comment` and\n`create_repository` must be enabled explicitly by adding them to the `tools` list in the task\nconfiguration.\n\n- `health` — check that the MCP server is running\n- `refresh_token` — refresh the GitHub App installation token\n- `get_pr_comments` — fetch review threads for a pull request\n- `create_pr` — create a pull request on the upstream repository\n- `merge_pr` — merge a pull request on the upstream repository, authenticated by the configured\n  PAT. Takes `pr_number`, plus an optional `merge_method` (`merge` | `squash` | `rebase`,\n  default `squash`) and `delete_branch` (default `true`) — the same way the `merger` backend\n  merges. A pull request that is already merged, closed, still a draft, in conflict with its base\n  branch, or held back by branch protection is refused with that reason, for the agent to report\n  back. Grant it deliberately: holding `create_pr` does not imply it, and most tasks have no\n  business merging anything — a review task that should land what it approves is the case it was\n  added for\n- `label_issue` — add and remove labels on an issue or pull request of the upstream repository:\n  the triage tool. Takes `issue_number` plus `add` and/or `remove`, each a list of label names.\n  Only labels the repository already defines can be applied — an unknown name is refused with the\n  list of labels that do exist, rather than creating one — and names are matched\n  case-insensitively. An addition the issue already carries and a removal it does not are reported\n  and skipped, so calling twice changes nothing. Unlike `comment`, it can label any issue, not\n  only the one the task was launched from\n- `create_repository` — create a new repository in `default_organization`, the organisation tasks\n  are forked into. Takes `name` plus optional `description`, `private` (default `true`) and\n  `auto_init` (default `false`, i.e. an empty repository ready for a first push). The owner is not\n  an argument: the configured organisation is the one place the operator has already agreed is\n  orchestra's to write to, and the tool refuses when none is set. Unlike forking it is not\n  idempotent — a name the organisation already uses is refused rather than handed back, so a push\n  never lands in a repository the task did not just create. Authenticated by the organisation's own\n  GitHub App installation, which needs the `Administration: read and write` permission. The result\n  carries a token to push with, scoped to the new repository alone and expiring in an hour like\n  every installation token — the agent is given it as a remote URL to use once, not as a `GH_TOKEN`\n  to export, since that variable is what authenticates its work on its own fork. A token from\n  `refresh_token` is minted for whichever installation the task runs under, so it reaches the new\n  repository only when that installation covers `default_organization` — which is the case when\n  the task works on a fork, and not when the App could push to the target directly. Grant it\n  deliberately, like `merge_pr`\n- `comment` — post a comment on the issue or pull request the task was launched from.\n  Supports four modes:\n  - **regular comment**: provide only `body`\n  - **PR review**: provide `body` and set `review: true`; optionally include `inline_comments`\n  - **reply to inline comment**: provide `body` and `reply_to_comment_id`\n  - **new inline comment**: provide `body`, `path`, and `line`\n\n  The `comment` tool requires the task to carry an `issue_number` (set automatically by the\n  listener when triggered from an issue or pull request).\n\n- `queue_task` — put a task on orchestra's queue for an agent to pick up, optionally bound to a\n  taxis issue and claiming it. This is the one tool the `tools` list cannot enable: what it may do\n  — which backends, models, tools and repositories a queued task may be given, how many may be\n  queued, whether an issue may be claimed — is written in the task's `spawn_policy`, and a task\n  without one is not offered the tool at all. Everything the agent omits is inherited from the\n  queueing task, so an empty policy still lets it queue more of itself and nothing else, and a\n  queued task never carries a policy of its own → [queueing tasks from inside a\n  task](docs/queue-task.md)\n\nThree more tool groups — `manage_issues`, `work_issues`, `review_issues` — are available when a\ntask carries them in its `tools` list, backing orchestra's project/issue/claim workflow (a taxis\nissue tracker instance, not local files — see [`examples/projects/README.md`](examples/projects/README.md)\nfor the full concept mapping, the `taxis` config section, and a CLI/tool cheat sheet).\n\n## skills\n\n`skills/` is a Claude plugin directory loaded into every agent by default. It carries two skills:\n\n- **`orchestra-pull-requests`** — opening PRs, commenting, reading review feedback.\n- **`orchestra-taxis-issues`** — claiming, splitting, attaching PRs, managing tracker issues,\n  and keeping an issue's context notes.\n\nBoth exist mainly to state one rule the agent cannot infer: **`gh` must never be used for pull\nrequests or issues.** Everything goes through the MCP tools, which select the right credential\n(PAT vs GitHub App token), record what the task did, and enforce the per-task permission groups.\n`gh` is authenticated for git transport only. Plain `git` is fine.\n\nThey also draw the distinction between **taxis** issues (the tracker; what agents claim and work)\nand **GitHub** issues (the thread a task was launched from). The ids look alike and nothing stops\nyou passing one where the other belongs.\n\nInstall by copying into the config directory, from where orchestra picks it up automatically:\n\n```\ncp -r skills $XDG_CONFIG_HOME/orchestra/skills      # or ~/.config/orchestra/skills\n```\n\nThe Docker image bundles them and seeds `/config/orchestra/skills` on first start, so nothing is\nneeded there. Local edits survive restarts; delete the directory to get the shipped copy back.\n\nAnything in `plugin_dirs` in `config.json` is loaded as well — the skills directory is prepended,\nnot a replacement.\n\nSkills are also editable through the API, which is the way to change one on a machine you are not\nsitting at — see [configuration over the API](#configuration-over-the-api). A new or edited skill\napplies to tasks launched after the write; a task already running keeps the skills it started\nwith.\n\n## identities\n\nAn identity is a persistent someone for a task to be. A task, a role or a listener names one, and\nthe run gets a memory that was there before it started — and, where the identity carries a taxis\ntoken, writes to the tracker as itself rather than as orchestra.\n\nAn identity is a directory, because it is a bundle rather than a record:\n\n```\n~/.config/orchestra/identities/maintainer/\n├── identity.json      # who it is to orchestra\n└── AGENTS.md          # how it works — optional\n```\n\n```json\n// identity.json\n{\n  \"name\": \"maintainer\",\n  \"description\": \"Keeps the tracker in order: triages what comes in and chases what has gone quiet.\",\n  \"taxis_token\": \"{{taxis_maintainer_token}}\"\n}\n```\n\n| Field | |\n| --- | --- |\n| `name` | Must match the directory name. Every surface refers to the identity by it |\n| `description` | One or two sentences, shown to the agent as part of who it is |\n| `taxis_token` | API token for the taxis actor this identity is. Optional; `{{secret}}` placeholders are substituted from `secrets.json` |\n\nA `taxis_token` that is present but unusable — blank, not a string, or a `{{placeholder}}` no\n`secrets.json` defines — is refused when the record is read, naming the identity. Two of those\nwould otherwise read as *no token*, and an identity with no token authors everything as orchestra:\nthe run succeeds and the only way to notice is to look at who signed the comments.\n\nName it from a task file, a role, or a listener's `action`:\n\n```json\n{ \"prompt\": \"Triage what came in overnight.\", \"identity\": \"maintainer\" }\n```\n\nA [concert step](docs/workflow.md) takes `identity:` the same way. An\n[interactive session](#interactive-sessions) takes one too, so a conversation you hold with an\nagent can be held as somebody in particular:\n\n```sh\norchestra chat --upstream owner/repo --fork you/repo --identity maintainer\n```\n\nThe identity is fixed for the session's life and is kept on its record, so a session that goes\ndormant and is woken later comes back as the same one. A session gets the identity's memory and\nno other: there is no `memory` field on a session to select the shared ones with.\n\nNaming an identity that is not configured fails the task, rather than running it as the instance:\nthe fallback would use the wrong tracker actor and the wrong memory, and look like it had worked.\n\n### what an identity is, and is not\n\nA **role** says what a task does — its prompt, its tools, its model. An **identity** says who does\nit, and it is the half that accumulates: two tasks dispatched for the same role a week apart share\nnothing, while two tasks run under the same identity share everything the first one wrote down.\n\nAn identity is **not a permission**. What a task may do is still its `tools`, and where it may\nwrite is still its project subtree; an identity narrows neither and widens neither. Per-identity\npermissions want an authorization service to hold them and are not here yet.\n\nAn identity is **assigned, never chosen**. It is written in configuration, and `queue_task` has no\nfield for it — a task queued by a task inherits the identity of the task that queued it, so an\nagent can pass its own on but cannot put on another one.\n\n### AGENTS.md\n\n`AGENTS.md` beside the record is the identity's standing instructions: how this one works, what\nit always checks, which conventions it holds itself to. It is the half of an identity that does\nnot change from run to run — the task's prompt is the half that does.\n\nIt reaches the agent as a section of its system prompt, under a heading naming whose instructions\nthey are, with your file inside it unedited. Deliberately *not* written into the checkout, where\nthe agent's CLI would find an `AGENTS.md` on its own: a repository may have one of its own, and\noverwriting it would be orchestra editing the project's instructions — quite apart from leaving a\nfile in the working tree that the agent then has to remember not to commit. The repository's own\n`AGENTS.md` is read by the agent as usual; the identity's is added to it, not swapped for it.\n\nA blank file contributes nothing rather than an empty heading, and the whole system prompt is\ncapped at 120 KB with a warning on stderr if it has to be cut — so an `AGENTS.md` the length of a\nmanual is a thing you will hear about.\n\n`examples/identities/` ships two written out in full.\n\n### the memory\n\nEach identity gets `$XDG_DATA_HOME/orchestra/identities/\u003Cname>/memory/`, mounted read-write into\nthe sandbox and named in the agent's system prompt as its own.\n\nIt sits outside `\u003Cdata>/memory/` on purpose. That directory *is* the global memory — a task whose\n`memory` is `global` or `both` is handed the root itself — so an identity's memory kept under it\nwould be one every ordinary task could read and rewrite. For the same reason `\"memory\": \"none\"`\ndoes not switch it off: that field chooses among the shared memories, and an identity's own is\npart of the identity.\n\nThe one-file-per-subject convention of [memory](#task-files) applies here too. Two tasks can run\nunder one identity at the same time, and neither can see the other's edits.\n\nWorth knowing: memory directories are also passed to the agent as plugin directories, so a run can\nleave behind a skill that every later run under that identity loads as instructions. That is how\nshared memory has always worked; per-identity memory makes it durable and scoped to one name.\n\n### acting on the tracker\n\nWith a `taxis_token`, everything the task's issue tools write — comments, reviews, issues created\nand updated, context notes, labels and assignees — goes out on that token, so taxis records the\nidentity as the author. A reviewer identity's request-changes review is signed by the reviewer,\nand the next agent reading the thread can see who asked for what.\n\nTwo things stay on orchestra's own token, deliberately:\n\n- **Claims.** `o-claimed` is orchestra's bookkeeping, written and read back by the daemon, and it\n  records a task id rather than an author. Keeping it on the instance token means a claim taken\n  under an identity whose token is later revoked is still one the daemon can release.\n- **GitHub.** Pull requests, comments and reviews on GitHub go out on the App installation token\n  and the configured PAT as before. A pull request an identity opens is opened by orchestra —\n  per-identity GitHub credentials are not part of this.\n\nMint the token in taxis for an actor of its own (`POST /api/me/tokens` as that actor, or\n`POST /actors/:id/tokens` as an admin — see taxis's README). It needs no admin rights: orchestra\ncreates the labels it maintains on its own token.\n\n**One known limit.** The token never enters the sandbox — nothing serializes it, no environment\nvariable carries it, and the MCP server that spends it runs in the daemon. But the taxis client\npasses its bearer as a `curl` argument rather than through a config file, and `/proc` is mounted\nread-only in the sandbox, so an agent that watched `/proc/*/cmdline` while a tracker write was in\nflight could read it. That is true of orchestra's own taxis token and of the GitHub tokens today;\nit is a fix in the taxis client (orchestra's own `Utils.Http` already uses `curl -K -` for exactly\nthis reason), not here. Do not treat an identity's token as a secret kept *from* the agent\nrunning as it.\n\n## running tasks\n\n```\norchestra run tasks.json\n```\n\nRun only one task from the file (0-based index):\n\n```\norchestra run --task 0 tasks.json\n```\n\nContinue a previous agent session:\n\n```\norchestra run --task 0 --continues \u003Ctask-id> tasks.json\n```\n\nGroup runs into a named series for later resumption:\n\n```\norchestra run --series my-series tasks.json\n```\n\nTo sit in front of the agent yourself, with the same clone, credentials and sandbox a task would\nget, use its interactive TUI:\n\n```\norchestra interactive --upstream owner/repo --fork your-org/fork\n```\n\nLeave both flags out for a session with no repository, which is what a\n[repository-independent task](#repository-independent-tasks) sees: an empty workspace, the tracker\ntools, and none of the ones that act on a repository.\n\n```\norchestra interactive\n```\n\nThat one is local: it hands *this* terminal to the agent. For the same conversation held by the\nbackend instead — reachable from the dashboard or a phone, and still there after you close the\nterminal — see [interactive sessions](#interactive-sessions).\n\n## concert workflows\n\nWorkflows are YAML files that describe a multi-step agent program. Steps run\nsequentially; later steps can receive typed outputs from earlier ones. The\nworkflow is compiled to a _Concert_ program and evaluated step by step.\n\nPass a `.yaml` file directly to `run`:\n\n```\norchestra run workflow.yaml\n```\n\nPass initial variable values with `--vars`:\n\n```\norchestra run --vars '{\"difficulty\": 5}' workflow.yaml\n```\n\nSee `docs/workflow.md` for the full workflow DSL reference and\n`examples/concerts/` for ready-made examples:\n\n- `examples/concerts/sequence.yaml` — plan / implement / review pipeline\n- `examples/concerts/loop.yaml` — for-each loop with typed outputs\n- `examples/concerts/conditionals.yaml` — conditional exit on difficulty score\n\n## task history\n\n```\norchestra tasks\norchestra task \u003Cid>\norchestra series\norchestra tag \u003Cid> \u003Cseries>    # append a finished task to a series\n```\n\n## resuming a series\n\n```\norchestra resume my-series --prompt \"Now add tests.\"\n```\n\nThis picks up the repository and settings from the latest run in the series and\nresumes the agent session from where it left off.\n\n## queue mode\n\nStart a daemon that picks up tasks from a queue:\n\n```\norchestrad queue          # or: orchestra queue start, which starts this for you\n```\n\nAdd tasks to the queue:\n\n```\norchestra queue add tasks.json\norchestra queue add --resume my-series --prompt \"Next step.\"\n```\n\nAdd a workflow (concert) to the queue:\n\n```\norchestra queue add workflow.yaml\norchestra queue add --vars '{\"key\": \"value\"}' workflow.yaml\n```\n\nInspect and control the daemon:\n\n```\norchestra queue                # show queued entries\norchestra queue status         # daemon status and running tasks\norchestra queue cancel         # cancel the running tasks, keep the daemon going\norchestra queue shutdown       # stop after the current task (--force cancels it)\n```\n\nRe-enqueue unfinished or cancelled entries:\n\n```\norchestra queue retry\norchestra queue retry --series my-series\n```\n\nUnfinished entries resume the partial agent session they died in; cancelled ones start over. On\n`SIGTERM` the daemon stops accepting work and drains what is in flight, so a `docker compose down`\ndoes not lose a running task.\n\n## per-repository configuration\n\nA repository can provide a `.orchestra/` directory with optional hooks and a config\nfile:\n\n- `.orchestra/init.sh` — run once after cloning\n- `.orchestra/before.sh` — run before each agent launch\n- `.orchestra/validation.sh` — run after each agent launch; non-zero exit triggers\n  a retry\n- `.orchestra/after.sh` — run after the validation loop completes\n- `.orchestra/config.json` — validation settings:\n\n```json\n{\n  \"validation\": {\n    \"max_retries\": 3,\n    \"retry_prompt\": \"Validation failed. Please fix the issues.\"\n  }\n}\n```\n\nThe failing script's output is available to the retry prompt as `{{validation_output}}`.\n\n## listeners\n\nListeners poll event sources and automatically enqueue tasks. Listener configs\nare JSON files placed in `~/.config/orchestra/listeners/`, and can equally be written through the\nAPI — see [configuration over the API](#configuration-over-the-api).\n\n**A listener is named by its file.** `~/.config/orchestra/listeners/issue-comments.json` is the\nlistener `issue-comments`, and that is the name its state, its API routes, `orchestra listener\nenable` and every line the daemon logs about it use. The config document carries no name of its\nown; a `name` field left over from an older version is ignored, and the daemon carries that\nlistener's state across to the file's name the first time it starts.\n\nEither way the running daemon picks them up: it re-reads each listener's config on every tick,\nand rescans the directory every fifteen seconds, so a listener added, changed, disabled or\ndeleted takes effect without a restart.\n\nExample — `issue-comments.json`, responding to issue comments containing a trigger word:\n\n```json\n{\n  \"source\": {\n    \"type\": \"github-comments\",\n    \"repos\": [\n      {\"upstream\": \"upstream-org/upstream-repo\", \"fork\": \"your-org/upstream-repo\"}\n    ],\n    \"trigger\": \"@orchestra\",\n    \"authorized_users\": [\"alice\", \"bob\"]\n  },\n  \"action\": {\n    \"upstream\": \"{{upstream}}\",\n    \"fork\": \"{{fork}}\",\n    \"mode\": \"fork\",\n    \"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.\",\n    \"series\": \"issue-{{issue_number}}\"\n  },\n  \"interval_seconds\": 120\n}\n```\n\nFields:\n\n- `source.type` — one of `\"github-issues\"`, `\"github-comments\"`, `\"github-pr-reviews\"`,\n  `\"github-labels\"`, `\"github-label-count\"`, `\"shell\"`, or the two auto-dispatchers\n  `\"project-dispatcher\"` and `\"label-dispatcher\"` (documented in\n  [`examples/projects/README.md`](examples/projects/README.md) — the first works on one project,\n  the second on every issue carrying a given label, wherever it lives)\n- `source.repos` — list of `{\"upstream\": \"...\", \"fork\": \"...\"}` pairs\n- `source.trigger` — only events whose body contains this string are processed\n- `source.authorized_users` — list of GitHub logins that may trigger the listener; empty means allow everyone\n- `source.limit_unclaimed_to_open_issues` — `label-dispatcher` only: bound the caps of roles that\n  do not pre-claim an issue by the work in scope, and skip issues an agent is already on, so one\n  open issue dispatches one agent rather than a capful of them onto the same work\n  ([`examples/projects/README.md`](examples/projects/README.md))\n- `source.exclude_root_issues` — `label-dispatcher` only: treat the labelled issues as epics —\n  neither dispatched onto nor counted as work, while still rooting their subtree — so only what\n  inherited the label is worked\n- `action.prompt_template` — template rendered with event variables (e.g. `{{upstream}}`, `{{fork}}`, `{{issue_number}}`, `{{body}}`, `{{author}}`)\n- `action.upstream` / `action.fork` — the repositories the queued task works on, as templates.\n  Left out, they fall back to the `{{upstream}}`/`{{fork}}` variables the event source supplies —\n  every GitHub source supplies both. A `shell` source supplies neither, so a listener on one that\n  names neither queues a [repository-independent task](#repository-independent-tasks)\n- `action.workflow_path` — path to a `.yaml` workflow file; when set the listener starts a concert instead of enqueueing a single task\n- `action.auth_sources` / `action.auth_mode` — candidate authentication sources for the tasks this\n  listener queues, and how to pick among them. A listener that fires repeatedly is the case\n  several accounts exist for: it keeps producing runnable work after one account's weekly window\n  closes → [usage limits](#usage-limits)\n- `interval_seconds` — how often the listener *looks*. What it may *do* once it has looked is\n  `rate_limits`, below\n- `rate_limits` — ceilings on how often this listener may dispatch (see below)\n\nTo trigger a multi-step workflow from a listener, replace `prompt_template` with `workflow_path`:\n\n```json\n{\n  \"source\": {\n    \"type\": \"github-issues\",\n    \"repos\": [{\"upstream\": \"owner/repo\", \"fork\": \"your-org/fork\"}],\n    \"trigger\": \"@orchestra\",\n    \"authorized_users\": [\"alice\"]\n  },\n  \"action\": {\n    \"upstream\": \"{{upstream}}\",\n    \"fork\": \"{{fork}}\",\n    \"mode\": \"fork\",\n    \"workflow_path\": \"/path/to/workflow.yaml\"\n  },\n  \"interval_seconds\": 120\n}\n```\n\nListeners are picked up by the running daemon; they can be inspected and switched on or off\nwithout restarting it:\n\n```\norchestra listener list\norchestra listener enable \u003Cname>\norchestra listener disable \u003Cname>\n```\n\n### how often a listener may fire\n\n`interval_seconds` says how often a listener looks; it says nothing about how much it may do\nwhen it looks. A source can hand a listener twenty events in a single tick, and a listener\nwatching a busy repository can have something to do on every tick of the day — neither is a\nreason to spend twenty tasks' worth of an account's budget in a minute.\n\n`rate_limits` is the ceiling on the doing. Each entry is a `max` and a window, and a dispatch\nhas to fit under all of them:\n\n```json\n{\n  \"source\": {\n    \"type\": \"github-issues\",\n    \"repos\": [{\"upstream\": \"owner/repo\", \"fork\": \"your-org/fork\"}],\n    \"labels\": [\"orchestra\"]\n  },\n  \"action\": {\n    \"mode\": \"pr\",\n    \"prompt_template\": \"Issue #{{issue_number}}: {{title}}\\n\\n{{body}}\"\n  },\n  \"interval_seconds\": 300,\n  \"rate_limits\": [\n    {\"max\": 5,  \"per\": \"hour\"},\n    {\"max\": 20, \"per\": \"day\"}\n  ]\n}\n```\n\nThe window is either `per` — a unit (`\"second\"`, `\"minute\"`, `\"hour\"`, `\"day\"`, `\"week\"`) or a\ncount and a unit (`\"6h\"`, `\"90 minutes\"`) — or `per_seconds` for the same thing in seconds. A\nspelling that cannot be read (`\"hourly\"`, a `max` with no window at all) fails the config rather\nthan quietly becoming no ceiling at all. `\"max\": 0` is refused on the way in too — through the\nAPI or `orchestra config set`, which is where configs written by a person arrive — because it is\nan off switch and `orchestra listener disable` is a better one, keeping the listener's configured\npace for when it comes back.\n\nThe windows are rolling, not calendar ones: \"5 per hour\" means five in the last sixty minutes,\nnot five since the top of the hour.\n\n**Nothing is dropped.** A listener that is already at a ceiling does not poll at all until the\nwindow moves — a poll is neither free nor side-effect-free, and for a source that consumes what\nit reads there would be nothing to come back to. When a tick starts with room and fills up\npart-way through, what is left over is *held*: not marked processed, so a later tick offers it\nagain. `github-comments`, the one source that pages by time rather than re-deriving what it is\nlooking at, keeps its `since` cursor where it was for that tick, so a held comment is still\nthere to be found. What a rate limit changes is the pace, not the work.\n\nWhere each ceiling stands is visible without reading the log:\n\n```\n$ orchestra listener show nightly\n...\nRate limits:\n  5 per hour (5 used, next at 2026-08-24T21:12:04Z)\n  20 per day (11 used)\n```\n\nListeners with no `rate_limits` — which is every listener written before the field existed — are\nunpaced and cost nothing: no clock is read, no timestamps are kept and no check is made. Removing\nthe field from a listener that had one drops the timestamps it had kept, too.\n\nSee `examples/listeners/` for further listener examples.\n\n## projects, issues and roles\n\nThis is the autonomous end of orchestra. A **project** is a repository-independent unit owning a\ntree of **issues**; tasks attach to issues, worker agents *claim* them, reviewer agents approve or\nreject, and the built-in `merger` backend lands the pull request. All of it is stored in a\n[taxis](https://github.com/chrisflav/taxis) instance configured under `taxis` in `config.json` —\nissues created in the taxis UI behave exactly like ones created through the tools.\n\nA **role** (`\u003Cconfig>/roles/\u003Cname>.json`) is a reusable task template: backend, prompt template,\ntool permissions, a dispatch policy, and optionally the [identity](#identities) its agents are\ndispatched under. `examples/projects/roles/` ships `implementor`,\n`reviewer`, `planner` and `maintainer`. A `project-dispatcher` or `label-dispatcher` listener\nspawns them as work appears, up to per-role caps, taking a claim lock so no two agents land on the\nsame issue.\n\n```sh\norchestra project create \"API v2\" --default-repo myorg/api --default-branch main\norchestra issue add \u003Cproject-id> --title \"Rewrite the auth handler\"\norchestra issue list \u003Cproject-id> --status open\norchestra issue show \u003Cissue-id>\norchestra roles list \u003Cproject-id>\norchestra spawn implementor \u003Cproject-id> --issue \u003Cissue-id>\norchestra project health \u003Cproject-id>          # find claims whose task is gone\n```\n\n[`examples/projects/README.md`](examples/projects/README.md) is the full reference: how orchestra\nconcepts map onto taxis, the `taxis` config block, role templates and their prompt variables, the\ndispatch triggers, and how the two dispatchers differ.\n\n### goals\n\nA taxis issue carries a **goal**: one sentence saying what must hold for it to be done. Set it in\nthe taxis UI, or through taxis's own tools — orchestra reads it and never invents one.\n\nWhen a task is launched for an issue that has one — `orchestra spawn --issue`, `orchestra issue\ncontinue`, or either dispatcher — the goal becomes the task's goal, and the agent is held to it:\nit may not stop before the condition holds, and a second model call, not the agent itself, decides\nwhether it does. Every hop preserves it, so a queued task that runs after a daemon restart, or a\ncontinuation of one that hit a usage limit, is still held to the same bar. `orchestra issue\ncontinue` re-reads it from the issue, so editing a goal changes what the next attempt is judged\nagainst.\n\nWhat the agent is *told* and what it is *judged on* are deliberately different things. The prompt\nis the role template with the issue title, body, comment thread and target rendered into it —\nthousands of words on a busy issue. The goal is the `goal` field alone. Nothing derives one from\nthe other, in either direction: a goal assembled out of a prompt would be unjudgeable, and a\nprompt cut down to a goal would starve the agent of context.\n\nOnly the `claude` backend can enforce a goal (it is what its `/goal` command does). Give a goal to\n`vibe`, `opencode` or `pi` and the run says so once on stderr and proceeds without it, rather than\nfailing or pretending.\n\nA task file can set `goal` directly, for work that has no issue behind it — see\n[task files](#task-files).\n\n## dashboard\n\nA web view of everything above: the queue and concert runs, listeners and when they last\nchecked, task history with the full structured log of each run, projects with their issue\ndependency graph, and every configured authentication source with the usage limits last reported\nfor it. Pages stream updates over Server-Sent Events, so they stay current without a reload.\n\nIt reads, with one exception: a running task's page carries a *Cancel* button beside its status,\nwhich stops that one run without a shell. It asks before it does anything, and it is per task,\nnot per batch — everything else the daemon is running carries on. The queue itself is untouched:\npending entries start as slots free up, and the cancelled entry lands as `cancelled`, which\n[`orchestra queue retry`](#queue-mode) re-enqueues.\n\nThe UI is a React/TypeScript app under [`web/`](web/), built by Vite; the backend is the Lean\nserver behind `orchestrad dashboard`, which answers the JSON API, its SSE streams, and — with\n`--site` — the built front-end, all on one port:\n\n```sh\ncd web && npm ci && npm run build && cd ..\norchestrad dashboard --site web/dist --port 8080\n```\n\n`web/dist` is a build artifact and is not tracked, so `npm run build` has to run before\n`--site` has anything to point at. During front-end work `npm run dev` is the better loop: it\nserves the app with hot reload and proxies `/api` and `/sse` through to an `orchestrad dashboard`\nrunning on 8080, which keeps the app same-origin in development exactly as it is in production.\n\nAccess is gated by a password. The login screen exchanges it for an `HttpOnly`,\n`SameSite=Strict` session cookie, so the secret is never held in `localStorage` and never rides\nin a URL — including on the SSE streams, which authenticate with the same cookie. The password\ncomes from `--password`, `$ORCHESTRA_DASHBOARD_PASSWORD`, or one generated on first run and\npersisted to `\u003Cdata>/dashboard.secret`; a generated one is printed at start-up. Scripts can skip\nthe login and send `Authorization: Bearer \u003Cpassword>` instead:\n\n```sh\ncurl -H \"Authorization: Bearer $(cat ~/.local/share/orchestra/dashboard.secret)\" \\\n     http://127.0.0.1:8080/api/v1/overview\n```\n\nThe same password is what the `orchestra` CLI authenticates with, so on a single host the two\nhalves need no configuration to find each other: both resolve `\u003Cdata>/dashboard.secret`.\n\nThe server binds loopback unless `--host` says otherwise — it is plain HTTP behind that\npassword, so anything wider wants TLS in front, plus `--secure-cookie` so the session cookie is\nonly ever sent over HTTPS. The docker image builds the front-end in a Node stage and runs the\nserver as [its own container](docker/README.md#dashboard).\n\nThe **Auth** page is the one to open when the queue has pending work but nothing is running: it\nnames the limit that is binding on each source and when it lifts — the same data as\n[`orchestra usage`](#usage-limits), read from the usage store rather than polled, so opening the\npage costs nothing.\n\nIt also draws where each source has been: a bar per session window and a bar per week, so a\nweek that ran hot, an account that is always the one at 100%, or a load that is not being\nspread the way the pool was meant to spread it are visible as a shape rather than assembled\nfrom a single percentage. The axis is the limit itself and never the data's own range — half\nheight is half a window spent, in every chart on the page — and the bar still filling is drawn\nlighter, because its number is not final. A closed window is drawn at the peak it reached,\nwhich is what it consumed; the one still filling is drawn at where the source stands *now*, so\nit is the same number as the limit track above it, with a mark where its peak was. The two are\ndifferent numbers whenever a reading inside the window came back down — why upstream's figure\nfalls is not something a poll can see, so the graph reports both rather than choosing. That history has its own endpoint, `/api/v1/usage`,\nso it is readable by anything else too.\n\n### the API\n\nThe web UI is one client of the API, not the reason it has the shape it has. Anything that\nspeaks HTTP can read the same data, and the server describes itself:\n\n```sh\ncurl http://127.0.0.1:8080/api/openapi.json     # needs no credential\n```\n\nThat document is embedded in the binary, so it describes the server answering rather than some\ncheckout, and a test fails the build if a route and the spec disagree.\n\nReads live under `/api/v1/`, and every one of them is also a Server-Sent Events stream at the\nsame path under `/sse/v1/` — identical payload, pushed when it changes and not otherwise, which\nis what makes it cheap to sit on:\n\n```sh\ncurl -N -H \"Authorization: Bearer $PASSWORD\" http://127.0.0.1:8080/sse/v1/overview\n```\n\nFour conventions hold everywhere:\n\n- **Instants** are RFC 3339 UTC in a `...At` field. Never a rendered phrase — `\"3m ago\"` cannot\n  be compared or thresholded, and it is only useful if you read English.\n- **Durations** are integer seconds, in a `...Seconds` field.\n- **Absent** is `null`. `\"\"` means present and empty, which for a name is a different fact.\n- **Collections** answer in one envelope — `items`, `total`, `limit`, `offset` — and take\n  `limit`, `offset`, and, where they are ordered by time, `since`. `total` counts matches\n  before the window, so \"50 of 812\" needs no second request. A parameter that is malformed, or\n  that a collection cannot honour, is a `400` rather than a shrug.\n\n```sh\n# the twenty most recent tasks since yesterday\ncurl -H \"Authorization: Bearer $PASSWORD\" \\\n     \"http://127.0.0.1:8080/api/v1/tasks?limit=20&since=2026-07-22T00:00:00Z\"\n```\n\n### writing\n\nThree resources are configuration, and all three are writable: **listeners**, **roles** and\n**skills**. Everything else the API serves is a record of something that already happened, or is\nowned by another system, and stays read-only. Nothing here enqueues a *task* — the queue is fed\nover the daemon's control socket, which is not on the network at all.\n\nSome routes are actions rather than documents. None of them writes a file; each forwards a single\nmessage to that same control socket, which is where the CLI sends its own. `POST\n/api/v1/queue/{id}/cancel` stops the one running task that id names, and the five\n[interactive session](#interactive-sessions) routes start, drive and end a chat. That second set\nis the one place this API starts an agent — see the note at the end of that section.\n\nTaking the cancel route as the pattern for all of them: it forwards a message naming the entry to\nthe socket, which is where [`orchestra queue cancel`](#queue-mode) sends its own. The socket\nstays off the network; these routes are the only things on the HTTP side that speak to it, and\neach takes the credential like every other non-`GET`.\n\nThe id may be a queue entry's id or the id of the run it became; both are ids this API hands out\nfor the same piece of work, and the server resolves either.\n\n```sh\n# stop one run; everything else the daemon is running carries on\ncurl -X POST -H \"Authorization: Bearer $PASSWORD\" -H 'Content-Type: application/json' \\\n     --data '{}' \\\n     http://127.0.0.1:8080/api/v1/queue/20260819-a3f1/cancel\n# → {\"id\":\"20260819-a3f1\",\"taskId\":\"t20260819T1004\"}\n```\n\n`404` means no entry and no run carries that id. `409` means it exists and is not running — the\nmessage names the status it is in — or that the daemon is not running or did not answer, which\nis a statement about the daemon rather than about the server answering. The cancelled entry\nlands as `cancelled`, which `orchestra queue retry` re-enqueues, and the pending entries behind\nit start as slots free up: cancelling stops a run, not the queue.\n\nThere is no unaddressed spelling of this route. Stopping *everything* is `orchestra queue cancel`\nover the control socket, where the person typing it is on the host already.\n\n```sh\n# create or replace a listener; the body is the config document itself\ncurl -X PUT -H \"Authorization: Bearer $PASSWORD\" -H 'Content-Type: application/json' \\\n     --data @listeners/nightly.json \\\n     http://127.0.0.1:8080/api/v1/listeners/nightly\n\n# turn one off without touching its config\ncurl -X PUT -H \"Authorization: Bearer $PASSWORD\" -H 'Content-Type: application/json' \\\n     --data '{\"enabled\": false}' \\\n     http://127.0.0.1:8080/api/v1/listeners/nightly/enabled\n```\n\nThe rules, in full:\n\n- **Every non-`GET` route requires the credential.** `POST /api/login` is the one exception,\n  since it is how the credential is obtained.\n- **Every write must be `Content-Type: application/json`**, or it is a `415`. That is the second\n  of two locks against cross-site forgery: the session cookie is `SameSite=Strict`, so a request\n  from another site carries no credential, and an HTML form cannot send JSON, so it cannot form\n  the request in the first place. The reasoning — including why there is no synchroniser token\n  and no `Origin` check — is written out in the module docs of `Orchestra/Dashboard.lean`.\n- **`POST` to a collection creates** and refuses to overwrite (`409`), naming the record from the\n  body. **`PUT` to a member creates or replaces**, naming it from the path; a body naming a\n  different one is a `400` rather than a silent rename. **`DELETE`** is `204`, or `404` when\n  there was nothing there. Listeners have no `POST`: a listener config carries no name of its\n  own, so `PUT /api/v1/listeners/{name}` is how one is created as well as replaced.\n- **A rejected body changes nothing.** Validation runs to completion before anything is opened\n  for writing, and every write is a write-and-rename, so the daemon never reads a partial or\n  half-checked file.\n- **Documents are stored verbatim**, not re-serialised. That is what keeps `{{secret}}`\n  placeholders — and any field a newer orchestra understands and your client does not — intact\n  across an edit. Fetch the `config` field of a detail response, change it, `PUT` it back.\n\nWhat a write costs: a listener change takes effect on that listener's next tick, and a listener\n*added* or *deleted* within fifteen seconds, which is how often the daemon rescans the directory.\nA role change takes effect on the dispatcher's next tick, since roles are read per dispatch. A\nskill change applies to tasks launched after it. Nothing here needs a restart.\n\n## native app\n\nThe dashboard is a view of the backend that served it: a browser page and its API are the same\norigin by construction, and the API sends no CORS headers, so a page loaded from anywhere else\ncannot reach it at all. That is fine for one orchestra and wrong for three — a machine at home, a\nbox in a datacentre, a container on the company network.\n\n**[orchestra-app](https://github.com/chrisflav/orchestra-app)** is the client for that, and it\nlives in its own repository: it shares no build with this one — orchestra is Lean and Lake, the\napp is npm and cargo — so there is no reason to check the two out together.\n\nIt holds a list of backends, talks to whichever one is selected, and switches between them\nwithout a reload and without logging in again. Everything the dashboard shows, it shows; the one\nwrite the dashboard has — cancelling a run — it has too, plus a listener's on/off switch; and the\nchat pages are the same [session routes](#interactive-sessions) `orchestra chat` uses, which is\nwhat makes a conversation started on a laptop readable from a phone. One codebase for five\nplatforms — macOS, Windows and Linux on the desktop, Android and iOS on a handset — on Tauri v2,\nwith a Rust core holding the network and the OS keychain, because a webview can do neither\nacross origins.\n\nIt authenticates with the bearer half of the scheme above: the same password, sent as\n`Authorization: Bearer`, never a cookie. Nothing in this repository has to change for it to work,\nand nothing in it is aware of the app.\n\nTwo things here are its contract, and a change to either is a change the app has to follow:\n`docs/openapi.json`, which is the document of record for every payload, and the `Json` builders\nin `Orchestra/Dashboard.lean` that emit them. The app keeps its own copy of those types, which\nthe split makes load-bearing rather than incidental — its design document says so at more\nlength.\n\n## interactive sessions\n\nA **session** is a conversation with an agent, held open by the daemon and reachable over the\nAPI. `orchestra chat` talks to one, the dashboard shows one, and a phone can too — all three are\nclients of the same five routes, and nothing in the server is specific to any of them.\n\nThis is the third shape orchestra runs an agent in. A [task](#task-files) is one prompt in and\none run out; [`orchestra interactive`](#running-tasks) is a real conversation but strictly local,\nbecause it hands your terminal to the agent's own TUI. A session is that conversation without the\nterminal: the agent runs on the backend, in the same sandbox with the same credentials and the\nsame MCP tools a task would get, and it stays up between turns rather than being relaunched for\neach one — the clone slot, the MCP server and the process are acquired once and kept, so a second\nturn costs a line on a pipe.\n\n```sh\norchestra chat --upstream owner/repo --fork your-org/repo\n```\n\nType a turn, press enter, watch it work. The other three spellings:\n\n```\norchestra chat --list              # every session, running and finished\norchestra chat --session \u003Cid>      # pick one back up, transcript and all\norchestra chat --end \u003Cid>          # end it and release what it holds\n```\n\nDetaching and ending are different, and only one of them can be undone. `/quit`, Ctrl-D and\nclosing the terminal all leave the session on the backend; `--end` is how you end it. That is\nwhat makes it worth having a session rather than a TUI: you can start one at a desk, close the\nlaptop, and pick the same conversation up from the dashboard.\n\n**A session does not expire.** Its *process* does — an agent holding a clone slot, an MCP server\nand a sandbox is expensive to keep up for a conversation nobody is having, so after\n`idle_timeout_seconds` without a turn the daemon stops it and hands the slot back. The session\ngoes `dormant`, which is not an ending: the record and the transcript are untouched, and the\nnext turn posted to it starts an agent again and resumes the agent-side history where it left\noff. Say something and it comes back, from whichever client is to hand. A daemon restart leaves\nsessions in the same state for the same reason.\n\nThe first turn to a dormant session takes as long as starting one does, because it is starting\none. If the working tree it left behind is still in the slot it gets handed back, that tree is\nkept rather than reset. What ends a session is asking: `orchestra chat --end \u003Cid>`, the\ndashboard's **End session**, or spending its budget.\n\nWhich model answers is yours to choose when the session starts: `orchestra chat --model \u003Cname>`\non the command line, and the box beside the two repositories on the dashboard's chat page.\nLeaving it empty runs whatever the backend runs by default. The name is passed through to the\nbackend's own CLI untouched, so a family alias (`opus`) and a pinned id (`claude-opus-5`) are\nboth fine, and orchestra does not keep a list of what is valid — the backend answers that.\n\nThe model is a property of the session rather than of a turn, and it is on the record, so one\nthat goes dormant wakes on the same model it went to sleep on. To carry a conversation to a\n*different* one, start a new session that resumes the old — repositories and all, since starting\na session always takes them:\n\n```sh\norchestra chat --upstream owner/repo --fork your-org/repo --resume-from \u003Cid> --model opus\n```\n\nIt inherits the old session's history and is launched with the model it was given.\n\nA session can also be held under an [identity](#identities), which is how a conversation gets a\nmemory that outlasts it:\n\n```sh\norchestra chat --upstream owner/repo --fork your-org/repo --identity maintainer\n```\n\nThe agent is told who it is, the identity's memory directory is mounted read-write, and where the\nidentity carries a taxis token the session's tracker writes are recorded as coming from it. The\nrecord is read when the session starts or wakes, so a rotated token reaches it at its next wake\nrather than mid-conversation. Like\nthe model it is a property of the session and lives on the record, so a dormant session wakes as\nthe same identity — and, like the model, changing it means starting a new session that resumes the\nold. A session gets the identity's memory and no other: there is no `memory` field on a session to\nselect the shared ones with. Naming an identity that is not configured is a `400` listing the ones\nthat are, refused before a clone slot is taken.\n\nWhat a session may spend is chosen the same way, and is the whole conversation's budget rather\nthan a turn's: `orchestra chat --budget \u003Cusd>`, or the box next to the model on the chat page.\nLeft alone it is 20 USD, and the API will not accept more than 100 — a bound on the one route\nthat spends money, not a policy about what a session should cost. Running out is not a crash:\nthe agent reports it, the session ends saying so, and the transcript is kept.\n\nOnly backends whose CLI can read turns from standard input can host a session, which today means\n`claude`. Asking for another is refused when the session is created, in a message naming it —\nnever quietly substituted, because a backend that answers the first turn and exits looks exactly\nlike a session that ended on its own.\n\nTwo limits bound them, in an `interactive` block in `config.json`:\n\n```json\n{ \"interactive\": { \"max_sessions\": 2, \"idle_timeout_seconds\": 1800 } }\n```\n\nThey are capacity, not access, and they bound processes rather than conversations. A session\nthat is awake pins a clone slot — one of the same slots the queue claims from, so a task can\nnever take it and reset the working tree mid-conversation — and an abandoned browser tab should\nnot hold one forever; `idle_timeout_seconds` is what takes it back, by putting the session to\nsleep rather than ending it. `max_sessions` bounds how many are awake at once, so waking a\ndormant session can be refused when that many already are: end one, or wait. `max_sessions` of\n`0` means a daemon that will not hold sessions at all.\n\n### over HTTP\n\n```sh\n# start one\nID=$(curl -sX POST -H \"Authorization: Bearer $PASSWORD\" -H 'Content-Type: application/json' \\\n     --data '{\"upstream\":\"owner/repo\",\"fork\":\"your-org/repo\"}' \\\n     http://127.0.0.1:8080/api/v1/interactive | jq -r .id)\n\n# say something\ncurl -X POST -H \"Authorization: Bearer $PASSWORD\" -H 'Content-Type: application/json' \\\n     --data '{\"text\":\"why does the queue stall when nothing is running?\"}' \\\n     http://127.0.0.1:8080/api/v1/interactive/$ID/messages\n\n# watch it answer\ncurl -N -H \"Authorization: Bearer $PASSWORD\" \\\n     \"http://127.0.0.1:8080/sse/v1/interactive/$ID/events?after=0\"\n```\n\n`POST /api/v1/interactive/{id}/interrupt` abandons the turn in flight without ending the session,\nand `DELETE /api/v1/interactive/{id}` ends it.\n\nThe transcript is the one stream in this API that is not a whole document re-read. A conversation\nonly grows, so re-sending all of it every time a word is added is quadratic in its length. It\ncarries a cursor instead: every frame holds only what follows the last one, and its `id` is the\nlast sequence number in it, so a browser reconnecting with `Last-Event-ID` — or anything else\npassing the same number as `?after=` — resumes exactly where it dropped, with nothing seen twice\nand nothing missed.\n\nReads come off `\u003Cdata>/interactive/\u003Cid>/`, where the daemon writes the session record and an\nappend-only transcript. Writes go the other way, forwarded to the daemon's control socket,\nbecause a session is a live process and only the daemon holds one. That split is why the reads\nanswer identically whether the API and the daemon are one process or the two containers the\n[compose deployment](docker/README.md) runs.\n\n### what this changes\n\n`POST /api/v1/interactive` is the first route in this API that **starts an agent**. Everything\nbefore it read state or edited configuration. It is gated by the same shared secret, the same\nsession cookie and the same `Content-Type: application/json` rule as every other write, and by\nnothing else — so a credential that could read the dashboard can now also start an agent with a\nrepository, credentials and tools. On a loopback bind that is the same person who could already\ntype `orchestra interactive`; on a wider one it is worth knowing before you set `--host`.\n\nThe full design — the session lifecycle, what happens on a crash or a daemon restart, and the\nschemas — is in [`docs/interactive.md`](docs/interactive.md).\n\n## configuration over the API\n\n`orchestra config` is the CLI front end to the routes above. It talks to the backend over HTTP\nrather than editing files, so a running daemon sees the change and nothing races it for the file.\n\n```\norchestra config list   \u003Ckind>                 # listeners | roles | skills\norchestra config show   \u003Ckind> \u003Cname>          # the document as stored\norchestra config set    \u003Ckind> \u003Cname> [file]   # create or replace; '-' or no file reads stdin\norchestra config remove \u003Ckind> \u003Cname>\n```\n\n```sh\norchestra config list roles\norchestra config set listeners nightly ./nightly.json\norchestra config show skills orchestra-pull-requests\necho '{\"name\":\"planner\",\"permissions\":[\"manage_issues\"],\"prompt_template\":\"plan\"}' \\\n  | orchestra config set roles planner\n```\n\n`orchestra listener list|show|enable|disable` are the same requests with a table in front of\nthem, and are unchanged in spelling from before there was an API.\n\nBoth find the backend at `$ORCHESTRA_API_URL` (default `http://127.0.0.1:8080`) and authenticate\nwith the same secret the server uses, resolved from `--api-token`,\n`$ORCHESTRA_DASHBOARD_PASSWORD`, or `\u003Cdata>/dashboard.secret`. On one host that means no\nconfiguration at all; pointing the CLI at a remote orchestra means setting both.\n\nTwo things are deliberately *not* writable through the API:\n\n- **`config.json`** — it holds the GitHub App private key path, the PAT and the agent OAuth\n  tokens. Exposing a write route for the file that holds every credential the daemon has, gated\n  on one shared secret, trades a large amount of blast radius for a small amount of convenience.\n  Edit it on the host.\n- **Project-scoped role overrides** (`\u003Cdata>/projects/\u003Cid>/roles/`) — the API writes the global\n  role catalogue. A project's own copy shadows a global role rather than extending it, and\n  belongs with the project.\n\n## other commands\n\n```\norchestra prepare \u003Cupstream> \u003Cfork>   # clone the fork and configure remotes\norchestra cleanup                     # remove all cloned repositories\norchestra cleanup list                # list clones and their task slots\norchestra chat --upstream \u003Cu> --fork \u003Cf>   # talk to an agent the backend holds open\norchestra mcp \u003Cupstream> \u003Cfork>       # start the MCP server standalone\norchestra usage                       # usage limits of every configured auth source\norchestra config list listeners       # read and change configuration through the backend API\norchestra migrate                     # move ~/.agent/ to the XDG directories\n\norchestrad serve                      # the backend: HTTP API + queue daemon in one process\norchestrad queue                      # the queue daemon alone\norchestrad dashboard --site web/dist  # the API, SSE and UI on one port\n```\n\n## container\n\nThe `container/` directory contains a NixOS image definition for incus. It\ninstalls all required tools (`claude-code`, `elan`, `gh`, `landrun`,\n`mistral-vibe`, and others) and creates an `orchestra` user.\n\nTo build the container image you need distrobuilder and incus:\n\n- distrobuilder: https://linuxcontainers.org/distrobuilder/docs/latest/\n- incus: https://linuxcontainers.org/incus/docs/main/installing/\n\nBuild the image (from the `container/` subdirectory):\n\n```\ndistrobuilder build-incus nixos.yaml\n```\n\nImport and start the container:\n\n```\nincus image import incus.tar.xz rootfs.squashfs --alias orchestra\nincus launch orchestra my-orchestra --config security.nesting=true\nincus exec my-orchestra -- nixos-rebuild switch\n```\n\nThe last command installs the software in the container. To login as the `orchestra`\nuser:\n\n```\nincus exec my-orchestra -- su orchestra\n```\n\n## docker\n\n`docker/` packages the queue daemon and the same dependency set as an image, for hosts without\nincus. See [`docker/README.md`](docker/README.md) for the details.\n\n```\ncd docker\ncp .env.example .env      # fill in at least ORCHESTRA_TAXIS_URL and a token\ndocker compose up --build\n```\n\nTwo containers come up off the one image, both running `orchestrad`: the daemon, and the\n[dashboard](#dashboard) on \u003Chttp://127.0.0.1:8080> (`docker compose logs dashboard` prints the\npassword it asks for). They are separate because the daemon drains in-flight tasks for up to half\nan hour on every stop, and the web console should not be unavailable for that long.\n\nBoth mount `/config` read-write, because the dashboard is where configuration is now written.\n\nThe daemon container runs `orchestrad queue`; override the command for one-off subcommands\nagainst the same volumes:\n\n```\ndocker compose run --rm orchestra project list\n```\n\nConfig and state live in gitignored host directories — `docker/config/`, `docker/data/` and\n`docker/secrets/` — created on first `up`, so `config/orchestra/config.json` can be edited\ndirectly and a GitHub App key just gets dropped in `secrets/`.\n\nIt expects an existing taxis instance rather than starting one — `ORCHESTRA_TAXIS_URL` must be\nreachable from inside the container, so not `localhost`. Agents are sandboxed with landrun\n(Landlock), which works under Docker's default seccomp profile; the entrypoint probes it at\nstart-up and `docker/README.md` covers what to check if that warning appears.\n",1789154844459]