A modern rewrite of openai/symphony, using Claude Code instead of Codex.
Cymphony turns Linear tickets into autonomous coding sessions. Drop a ticket into "Todo" and Cymphony picks it up, spins up a sandboxed workspace, and lets Claude Code work on it until the issue closes. You manage work, not agents.
Live web dashboard — per-project sections, kill/retry/pause/set-provider per session, real-time activity:
Terminal status — same data, no browser, grouped by project:
If you've used openai/symphony, the core idea is the same. Cymphony adds the bits that turn it from a single-developer toy into something a small team can lean on:
| openai/symphony | Cymphony | |
|---|---|---|
| Coding agent | Codex | Claude Code |
| How you run it | clone the repo, run a Node server, keep it alive in a terminal tab | One command from anywhere on your machine — cymphony launches the orchestrator + dashboard; cymphony start runs it as a background daemon |
| Concurrency | one project, fixed | Multi-project orchestration, with a per-project cap on how many sessions may run at once (default 10, change live from the dashboard or CLI) |
| Claude command | one binary | Custom Claude command per project — point one project at the official claude CLI, another at a wrapper that swaps in z.ai / Kimi / OpenRouter credentials, etc. |
| Providers | one API endpoint | Rotate across multiple Claude-compatible backends — list two or more providers and Cymphony spreads new sessions across them randomly. Avoids hitting any single backend's rate limit. |
| Live UI | terminal-only | Phoenix LiveView dashboard with kill / retry / pause / set-provider per running session |
| HTTP API | — | /api/v1/* for state, pause, concurrency, providers, queue order/pin, refresh, Linear connect, add-project |
| Workspace lifecycle | clone-on-create | after_create / before_run / after_run / before_remove hooks, optional retention sweep |
| Setup | edit a YAML file | cymphony setup wizard, all config in ~/.cymphony/config.json |
| Hot reload | restart | edit WORKFLOW.md, picked up next tick |
| Distribution | source build | One-liner install — brew install zaalipro/cymphony/cymphony (macOS) or grab the .deb on Ubuntu/Debian; Erlang is bundled, no system deps |
| Auth | — | optional CYMPHONY_API_TOKEN bearer auth on dashboard + API |
If you're already running symphony, switching is mostly: install cymphony, run cymphony setup, paste your Linear API key.
brew tap zaalipro/cymphony
# Recommended: bundles Erlang/Elixir, zero system deps
brew install cymphony
# Or: smaller binary, uses your existing Homebrew Elixir/Erlang
brew install cymphony-liteThe two formulas conflict — pick one. To switch: brew uninstall <one> && brew install <other>. Your config and workspaces are untouched.
Cymphony needs the Anthropic claude CLI in your $PATH:
npm install -g @anthropic-ai/claude-code
# or follow https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overviewThen download the latest .deb from GitHub Releases:
wget https://github.com/zaalipro/cymphony/releases/latest/download/cymphony_amd64.deb
sudo dpkg -i cymphony_amd64.debThe .deb bundles the Erlang VM — no separate Elixir install required. Upgrade by re-running dpkg -i with a newer file. Uninstall with sudo dpkg -r cymphony.
If you'd rather build it yourself, see Run from source.
cymphonyThe first time you run cymphony, it walks you through an interactive setup. Everything goes into ~/.cymphony/config.json. You can re-run it any time with cymphony setup, or add another project with cymphony add.
Below is each step the wizard asks you, and exactly where to find the answer.
Project name:
A nickname for this codebase. Just pick something memorable — MyApp, Backend, WebStore. You'll see this in the dashboard and in the CLI output. If you'll have multiple projects later, this is how you tell them apart.
GitHub repo URL (e.g. git@github.com:user/repo.git):
Cymphony clones a fresh copy of your repo into a workspace each time it picks up an issue, so it needs the clone URL.
Where to find it:
- Open your repo on GitHub
- Click the green
< > Codebutton - Pick the SSH tab (recommended — uses your existing SSH key, no token to manage) and copy the URL
It looks like git@github.com:your-org/your-repo.git.
If your machine isn't set up for SSH access yet, follow GitHub's SSH setup guide — it takes about two minutes. The HTTPS URL also works (
https://github.com/your-org/your-repo.git) but you'll then need a personal access token to clone private repos.
Linear project slug (e.g. myteam-ab12cd34ef56):
The slug is how Cymphony identifies your project inside Linear. It's part of the project's URL.
Where to find it:
-
Open Linear in your browser
-
Click into the project you want Cymphony to watch
-
Look at the URL bar — it'll look like:
https://linear.app/your-team/project/myteam-ab12cd34ef56/issues ^^^^^^^^^^^^^^^^^^^^ this is the slug -
Copy that part (between
/project/and/issues)
Alternatively, right-click the project in the sidebar and Copy URL — same slug, just trim it from the URL.
Cymphony watches one project per entry. If you want to orchestrate multiple Linear projects, run the wizard again (
cymphony add) and add a second one — they run side-by-side in the same daemon.
Linear API key:
A personal API key that lets Cymphony read issues and post comments on your behalf. Cymphony uses it to poll the project, pick up "Todo" issues, and let Claude reply with progress comments.
How to create one:
- Open Linear → click your avatar (top-right) → Settings
- In the left sidebar: Security & access
- Scroll to Personal API keys
- Click Create key, give it a label like "Cymphony", and copy the value (starts with
lin_api_...) - Paste it into the wizard
The key is stored in plain text in
~/.cymphony/config.jsonas top-levellinear_api_key(and stamped onto every project), and it is copied into each project's generatedWORKFLOW.mdastracker.api_key. Both files are written owner-only (mode0600), including every rewrite. If you'd rather not have it on disk, setLINEAR_API_KEYin your environment instead — that env var is a fallback only when the file has no key, and the wizard offers it as a default when set. You can also paste the key later in the dashboard Settings drawer (Linear → Connect) without re-running the wizard.
Workspace root [~/.cymphony/workspaces/MyApp]:
Where Cymphony clones each issue's working copy. Press Enter to accept the default — ~/.cymphony/workspaces/<your-project-name>. You'll usually only override this if your home is on a small disk and you want workspaces on an external drive.
Polling interval in seconds [5]:
How often Cymphony checks Linear for new "Todo" issues. The default of 5 seconds is fine for almost everything. Press Enter.
Coding agent (claude/codex/antigravity) [claude]:
Which coding agent runs this project's sessions: Claude Code (claude), Codex CLI (codex), or Antigravity CLI (agy). Press Enter for Claude Code. You can switch later with cymphony agent antigravity / cymphony agent codex or the per-project agent key in ~/.cymphony/config.json.
Add another project? [y/N]:
Press Enter to finish, or y to loop back to step 1 for a second project.
When the wizard exits you'll see:
Configuration saved to /Users/you/.cymphony/config.json
That's it. Run cymphony again to start the daemon.
Once configured, start the daemon and dashboard:
cymphony port 4089 # foreground, dashboard at http://localhost:4089
cymphony start # background daemon
cymphony stop # stop background daemon
cymphony restart # bounce
cymphony logs 100 # tail the last 100 lines of the logWithout port, Cymphony runs without the web UI — useful for headless servers. With port, you get a real-time dashboard showing the Up next / Queue board, every running session, token usage, retry queue, rate limits, and per-project controls.
cymphony port 4089 # most common
cymphony port 8080 # use any free port
cymphony --port 4089 # long form, identicalPick any port you have free. The dashboard is at http://localhost:<port>/ and the JSON API is under http://localhost:<port>/api/v1/. By default the dashboard is unauthenticated, so don't expose it publicly without setting CYMPHONY_API_TOKEN (see Auth below).
cymphony # run with saved config
cymphony project MyApp # run only the "MyApp" project
cymphony cr 3 # cap concurrent sessions at 3
cymphony c cv1,cz2 # rotate across providers cv1 and cz2
cymphony agent codex # run with the Codex CLI instead of Claude Code
cymphony agent antigravity # run with the Antigravity CLI (`agy`)
cymphony model opus effort high # model + reasoning effort passed to the agent CLI
cymphony port 4089 # enable dashboard
cymphony project MyApp cr 5 c cv1,cz port 4089 # combine flags
cymphony start # run as a background daemon
cymphony stop # stop the background daemon
cymphony webui # open the dashboard in your browser
cymphony setup # re-run the wizard
cymphony add # add a new project to existing config
cymphony list # list configured projects
cymphony v # version
cymphony h # helpLong-form flags also work: --project, --agent, --model, --effort, --concurrency, --provider, --port, --setup, --logs-root, --help, --version.
Add Linear labels — agent:codex, agent:antigravity, model:gpt-5.2-codex,
effort:high, provider:cz1 — or a directive line in the issue description:
cymphony: agent=codex model=gpt-5.2-codex effort=high
cymphony: agent=antigravity model=gemini-3.7-flash-high effort=high
Labels win over the directive; both win over project config. Changes apply on the next dispatch/retry (running sessions keep their spec).
Cymphony isn't a one-shot dispatcher — you stay in the loop the same way you would with a human teammate.
Here's the natural flow:
- You drop a ticket into your Linear "Todo" (or any active state). Cymphony picks it up on the next poll, spins up an isolated workspace, and the agent gets to work.
- When it's done it pushes a branch, opens a pull request, and moves the ticket to "Done" (or whatever terminal state your workflow uses).
- You review the PR. If something's off — wrong approach, missed edge case, code style nit, anything — don't open another ticket. Just comment on the same Linear issue describing what to change, then move the ticket back to an active state ("Todo", "In Progress", or "Rework").
- On the next poll, Cymphony notices the ticket is active again and resumes the same workspace and same branch. The agent reads your new comment alongside the original ticket, applies your feedback, and force-pushes to the existing PR.
- Repeat until you're happy and you merge it.
Because Cymphony reuses the workspace per-issue, the agent keeps full context across rounds — your comment lands on top of everything it already knows about the ticket, and you don't pay for a fresh re-read of the codebase every iteration.
When you run a few sessions in parallel, the upstream API hits rate limits fast. Cymphony solves this by letting you spread sessions across multiple backends. For Claude, each provider is a different ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY combo — Anthropic's official API, z.ai (GLM), Kimi (Moonshot), OpenRouter, or any vendor that exposes an Anthropic-compatible API. Codex providers export OPENAI_* / CODEX_*. Antigravity providers export ANTIGRAVITY_* / GOOGLE_* / GEMINI_* (plus API_TIMEOUT; fallback keys GOOGLE_API_KEY / GEMINI_API_KEY).
You can configure providers two ways. Pick whichever fits how you already work.
If you already manage API credentials in .zshrc or a private dotfiles repo, this is the most natural fit. Define a tiny shell function per provider — Cymphony picks them up automatically.
A new file that holds your Cymphony provider functions, kept separate from your main shell config. Cymphony sources it before .zshrc so the functions are guaranteed to be visible.
touch ~/.cldStick this at the top of ~/.cld — every provider function calls it first to make sure leftover variables from a previous switch don't bleed in:
# ~/.cld
_unset() {
unset ANTHROPIC_BASE_URL ANTHROPIC_API_KEY ANTHROPIC_MODEL ANTHROPIC_AUTH_TOKEN
unset CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX
}The function name must start with a lowercase c (Cymphony's discovery rule), e.g. cz, ck1, cv1, cm. Each one clears the env, then exports the credentials for that backend.
# z.ai (GLM)
cz() {
_unset
export ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
export ANTHROPIC_API_KEY="sk-zai-..."
export ANTHROPIC_MODEL="glm-5.1"
}
# Kimi (Moonshot)
ck() {
_unset
export ANTHROPIC_BASE_URL="https://api.moonshot.ai/anthropic"
export ANTHROPIC_API_KEY="sk-kimi-..."
export ANTHROPIC_MODEL="kimi-k2.6"
}
# A second Anthropic key for parallel slots
cv1() {
_unset
export ANTHROPIC_API_KEY="sk-ant-..."
}
# Anthropic default (the bare claude binary)
cm() {
_unset
# uses the Claude Code subscription — no env vars needed
}Add this near the top of ~/.zshrc (or ~/.bashrc):
[ -f "$HOME/.cld" ] && source "$HOME/.cld"Open a new terminal and cz, ck, cv1, etc. should all be defined as shell functions. You can run them by hand to manually switch credentials — cz && claude -p "hello" will route that single Claude call through z.ai.
Now reference them by name:
cymphony c cz # all sessions use z.ai
cymphony c cv1,cz,ck # rotate across three providers
cymphony project MyApp c cv1,cz # rotate, but only for MyAppCymphony spawns each session in a sub-shell, sources your rc files, calls your function, captures the resulting env vars for the active agent (Claude: ANTHROPIC_* / CLAUDE_CODE_*; Codex: OPENAI_* / CODEX_*; Antigravity: ANTIGRAVITY_* / GOOGLE_* / GEMINI_* plus API_TIMEOUT; fallback keys GOOGLE_API_KEY / GEMINI_API_KEY), and hands them to the agent CLI. The result is cached so it's only resolved once per provider per daemon run.
If you'd rather keep everything in one file and skip shell-function gymnastics, define providers directly in ~/.cymphony/config.json:
{
"linear_api_key": "lin_api_...",
"projects": [
{
"name": "MyApp",
"github_repo_url": "git@github.com:you/myapp.git",
"linear_project_slug": "myteam-ab12cd34ef56",
"linear_api_key": "lin_api_...",
"claude_command": "claude",
"provider": "cz"
}
],
"providers": {
"cz": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_API_KEY": "sk-zai-...",
"ANTHROPIC_MODEL": "glm-5.1"
},
"ck": {
"ANTHROPIC_BASE_URL": "https://api.moonshot.ai/anthropic",
"ANTHROPIC_API_KEY": "sk-kimi-...",
"ANTHROPIC_MODEL": "kimi-k2.6"
}
}
}Same usage from the CLI — cymphony c cz, cymphony c cv1,cz — Cymphony just reads the env vars from the JSON file instead of from a shell function.
When you list multiple providers, Cymphony picks one at random for each new session — so 6 sessions across 3 providers averages roughly 2 each. There is no central provider queue: each dispatch independently samples the rotation. (Issue order is a different thing — the per-project Up next / Queue board.) If one backend goes down, only the sessions assigned to it fail (and retry with backoff).
You can change a project's provider list at runtime without restarting:
- Dashboard: in each project's section header, when the selected agent is
claude, edit theprovidersinput (e.g.cv1,cz) and press Enter. Persisted toconfig.json, applied to the next dispatch. The field is hidden forcodex/antigravity/ empty / unknown kinds; persisted providers are not deleted. - API:
curl -X POST 'http://localhost:4089/api/v1/providers?project=MyApp' -d '{"value":"cv1,cz"}' - Per-session live switch: expand any running session row, type a provider in the per-session form, click Set — Cymphony kills that session and immediately re-dispatches it with the new provider.
Different projects can use different providers. Either edit ~/.cymphony/config.json directly:
"projects": [
{ "name": "Frontend", "providers": ["cv1", "cz"], ... },
{ "name": "Backend", "providers": ["ck", "cm"], ... }
]Or set them per-project from the dashboard's project header (the providers input is per-project — each project section has its own; the same header also carries agent/model/effort controls).
Cymphony runs as many projects as you have configured, all in one daemon. Each project gets:
- its own poll loop against Linear (independent intervals)
- its own concurrency cap (set globally with
cr N, or per-project via dashboard) - its own provider list (set globally with
c ..., or per-project) - its own pause/resume toggle
- its own dashboard section with the Up next / Queue board, running sessions, and retry queue
Add a project after the fact:
cymphony add # interactive — same wizard as setup, just for one new project
cymphony list # show what's configuredOr add from the dashboard Settings drawer (Linear must be connected): pick a Linear project, name it, click Add project. The new project is written to config.json, a temp WORKFLOW.md is generated, and the orchestrator starts immediately — no daemon restart. CLI setup / add are unchanged.
Or run a single project on demand:
cymphony project Backend # only Backend, ignore the others this run
cymphony project Backend cr 5 c cv1,cz # ...with a custom concurrency + provider listStart with cymphony port 4089, open http://localhost:4089.
The dashboard opens in Simple mode: plain-language autonomy status, the active work queue, and the safety controls people use day to day. Switch to Advanced in the top bar to reveal model/provider configuration, token detail, workspace data, logs, and restart overrides. The choice is saved only in that browser and does not change orchestration behavior.
The dashboard shows:
- Command bar (top) — autonomy state plus working / waiting / usage / runtime counters in Simple mode; throughput, polling cadence, and rate limits in Advanced mode. Advanced adds a
.metric-pill--queue.section--queueforcounts.waiting; the simple Waiting pill stayscounts.retrying. - Per-project sections — one card per project with the project name, counts (
N/M running · Q queued · R retrying), "tasks at once", and Pause/Resume. Pause is durable: it persistsdispatch_pausedfor that project in~/.cymphony/config.json, so the project comes back paused after a daemon restart, and queued retries hold (without burning retry attempts) until you resume. Advanced mode has a single.spec-switcher(icon + model + effort) whose hidden inputs keep the stable ids#agent-<project>/#model-<project>/#effort-<project>, plusproviders(visible only when the selected kind isclaude). Agent kinds are shown as icons, not the words claude/codex/antigravity. Changing the kind persists immediately (kind only) and hides/shows the providers field on the next render. Set still saves kind + model + effort together. Both paths rewrite the project's generatedWORKFLOW.mdand overlayconfig.jsonso the switcher stays on the new kind after the next refresh. - Up next / Queue board —
section.queue-board.section--boardabove In Progress. Cards are dispatch-ready Linear issues that are not running (and not in the retry list). Hidden whenwaitingis empty. Drag permutes the sticky operator order (reorder_queue); Cymphony rank is the left-to-right then wrap index (0= next slot). Card Edit preselects the project header harness/model/effort (or an existing pin) and pins those fields for the next dispatch (does not kill). Display pref{Board, board}hides the board (html[data-hidden-sections~=board]). - Compact session rows — Linear ID, title, state, runtime, and Stop. Advanced mode adds provider, host, token, workspace, log, and restart details. Expanding a row shows a Harness pane (live CLI stdout, Follow/Paused) and a restart form that can pin
agent_kind/ provider / model / effort via the same.spec-switcher. The Provider field is Claude-only. Session provider chips and the expanded Provider stat are hidden; agent chips are icons. - Retry queue — inline at the bottom of each project section (below In Progress; not on the board)
- Recent completions — global ring buffer of the last 100 finished sessions, collapsed by default in Simple mode
- Settings drawer — Experience mode, then Linear + Projects, then Automation / Display (including dashboard refresh seconds and the Board visibility checkbox). See below. Theme and drawer toggles are CSS geometry, not emoji.
Live updates are pushed via Phoenix Channels — no manual refresh.
Open Settings. After Experience and before Automation (visible in Simple and Advanced):
- Linear — paste a personal API key into the password field (
#linear-api-key) and click Connect (phx-submit="connect_linear", alsoPOST /api/v1/linear). Status becomes Connected and shows a last-4 mask (••••xxxx). The key is stored in~/.cymphony/config.jsonaslinear_api_key(file mode0600) and stamped onto every project.LINEAR_API_KEYis only a fallback when the file has no key. The raw key is never shown again in the UI, flashes, or API responses. - Projects — once connected, type-to-filter a Linear project from the
#add-project-slugCombobox (not a native select), enter a Cymphony name, optionally a GitHub repo URL, and click Add project (phx-submit="add_project", alsoPOST /api/v1/projects). Advanced add-project fields include the same.spec-switcher;#add-project-provideris visible only when the selected agent isclaude(preview_add_project). The project is appended toconfig.json, a tempWORKFLOW.mdis written, and the orchestrator starts immediately — no daemon restart. Duplicate name or Linear slug is rejected with a visible error. - Automation / Orchestrator — global Pause/Resume (persists
dispatch_pausedon every project), global concurrency, and dashboard payload refresh (#drawer-refresh-interval, min 1, default 3 seconds). Submit (set_refresh_interval, alsoPOST /api/v1/refresh-interval) persists top-leveldashboard_refresh_secondsin~/.cymphony/config.json. This is not Linear polling (polling.interval_ms/POST /api/v1/refresh). Open dashboards keep their current interval until remount or a successful set.
Drawer inputs use class settings-field. CLI cymphony setup and cymphony add still work the same way.
By default the dashboard and API are open to anyone with network access — kill_issue and set_provider work without authentication. To require a bearer token, set CYMPHONY_API_TOKEN before starting the daemon:
CYMPHONY_API_TOKEN=secret123 cymphony port 4089- API: send
Authorization: Bearer secret123on every request - Browser: open
http://localhost:4089/?token=secret123once — the token is stored in the session cookie and the URL is cleaned up via redirect
All under /api/v1/:
| Method | Path | Description |
|---|---|---|
GET |
/state |
full snapshot JSON (waiting + counts.waiting / waiting_count) |
GET |
/linear |
Linear connect status: connected, masked_key, source (never the raw key) |
POST |
/linear |
body {"api_key":"..."} — validate + persist linear_api_key; 202 status or 422 |
GET |
/linear/projects |
accessible Linear projects {id,name,slug_id} (422 if not connected) |
GET |
/projects |
one-line summary per project (running/retrying counts) |
POST |
/projects |
add + start a project (no daemon restart); 202 {name,linear_project_slug,started} |
GET |
/<issue_identifier> |
one running session's details |
GET |
/<issue_identifier>/harness |
live CLI stdout ring (HarnessStream snapshot) |
POST |
/refresh |
force a Linear poll right now |
POST |
/refresh-interval |
body {"value": N} — persist dashboard_refresh_seconds; 202 or 422 invalid_refresh_interval. Not POST /refresh. Declared before /<issue_identifier>. |
POST |
/pause ?project=Name |
stop dispatching new issues and persist dispatch_paused: true; 202 {paused,project}, 422 project_not_found, or 422 dispatch_pause_not_persisted when the orchestrators flipped but config.json could not be written (the pause would be lost on the next daemon restart). Legacy WORKFLOW.md mode has no config store and no flag to lose, so it still answers 202. |
POST |
/resume ?project=Name |
resume dispatching and persist dispatch_paused: false; same 202 / 422 shapes as /pause |
POST |
/concurrency ?project=Name |
body {"value": 5} |
POST |
/providers ?project=Name |
body {"value": "cv1,cz"} |
POST |
/agent ?project=Name |
body {"kind","model","effort"} — persist + rewrite project WORKFLOW.md so agent_kind survives refresh |
POST |
/queue ?project=Name |
body {"order":["LLM-51","LLM-12"]} — persist sticky waiting order; 202 {order,project} or 422 invalid_queue_order. Required ?project=. Declared before /<issue_identifier>. |
POST |
/queue-pin ?project=Name |
body {"issue":"LLM-51","kind","model","effort"} — pin agent/model/effort for the next waiting dispatch (empty/keep skipped; at least one field); 202 or 422 invalid_queue_pin. Does not kill. No Linear writes. Declared before /<issue_identifier>. |
GET |
/completed ?limit=N |
recent completions ring buffer |
/linear, /linear/projects, POST /projects, POST /api/v1/queue, POST /api/v1/queue-pin, and POST /refresh-interval are declared before /<issue_identifier>. Unsupported methods on those routes return 405. POST /linear never echoes api_key.
We use mise to manage Elixir/Erlang versions:
git clone https://github.com/zaalipro/cymphony
cd cymphony
mise trust && mise install
mix setup
make build
./bin/cymphony port 4089Or ask your favorite agent:
Set up Cymphony for my repo using https://github.com/zaalipro/cymphony/blob/main/README.md
make all # full CI gate: build, fmt, lint, coverage, dialyzer
make test # ExUnit onlyThe live end-to-end test creates real Linear issues and runs an actual Claude Code session — gated behind make e2e and an env var:
export LINEAR_API_KEY=...
make e2eLinear ──poll──> Cymphony ──spawn──> Workspace ──exec──> Claude Code
^ │ │ │
│ │ │ │
└──── close ───────┴── status ───────── result ───── tool ───────┘
- Poll — every 5 seconds, Cymphony fetches each project's "Todo" issues from Linear
- Dispatch — after
Queue.reconcile, the next free slot starts the leftmost waiting card (sticky operator order;Dispatch.sort_for_dispatchis initial order only). Cymphony picks a provider (random from the rotation list) and spawns anAgentRunnertask - Workspace — the runner creates a fresh per-issue directory, runs your
after_createhook (e.g.git clone), then yourbefore_runhook - Agent — the runner launches Claude Code with the workflow prompt as the user message, streams stdout, parses tool-use events, and updates the dashboard live
- Termination — when the issue moves to
Done,Closed,Cancelled, orDuplicate, Cymphony kills the agent and runsbefore_remove+after_runhooks - Retry — if the agent crashes or times out, the issue goes into the retry queue with exponential backoff
Per-issue workspaces are persistent across runs (deterministic re-runs), and there's an optional retention sweep that deletes idle workspaces after N days:
# WORKFLOW.md
workspace:
root: ~/code/workspaces
retention_days: 14Step 6 covers agents that crash. The nastier case is an agent that hangs without failing: it runs a foreground server (mix phx.server, npm run dev), waits on an interactive prompt, or blocks on a wedged network call. It emits no more events and never exits, so nothing would ever reclaim its slot. Cymphony watches each running session and, after 5 minutes of silence, kills it and puts the issue back in the retry queue with backoff.
Projects with long quiet stretches (big builds, slow test suites) can raise the timeout in ~/.cymphony/config.json — milliseconds, per project:
"projects": [
{ "name": "Monorepo", "stall_timeout_ms": 1800000, ... }
]It must be a positive integer; anything else (0, a negative number, "1800000" quoted as a string) falls back to the 300000 default, so a typo can't quietly switch the watchdog off. The value is written into that project's generated WORKFLOW.md as agent.stall_timeout_ms the next time the file is generated — a daemon restart, or any dashboard action that rewrites it (agent change, Linear connect).
CymphonyElixir.Supervisor (one_for_one)
├── Phoenix.PubSub
├── HarnessStream (ETS ring of live CLI stdout)
├── Registry (ProjectRegistry)
├── Task.Supervisor (AgentRunner tasks)
├── DynamicSupervisor (ProjectDynamicSupervisor)
│ └── ProjectSupervisor (one per project)
│ ├── WorkflowStore ──── hot-reloads WORKFLOW.md
│ └── Orchestrator ──── poll loop, dispatch, retry, snapshot
├── HttpServer (Bandit + Phoenix LiveView)
└── StatusDashboard (terminal)
Layered:
- CLI (
cli.ex) — flag parsing, multi-project entry point, background daemon controls - Config (
cymphony/config.ex) — reads~/.cymphony/config.json, generates per-projectWORKFLOW.mdintmp/, writes runtime updates back - Workflow store (
workflow_store.ex) — owns the parsed workflow per project, supports hot reload - Orchestrator (
orchestrator.ex) — heart of the dispatch loop, holdsrunning:,waiting:, andretrying:, enforces concurrency, picks providers, surfaces snapshots - Agent runner (
agent_runner.ex) — per-task process, runs lifecycle hooks, callsClaude.AppServer - Shell provider (
cymphony/shell_provider.ex) — sources~/.cld/.zshrc/.bashrcin a zsh subprocess to resolvecz,cv1, etc. into env-var maps; cached in:persistent_term - Workspace (
workspace.ex) — path-safety validation, lifecycle hooks, optional SSH worker, retention sweep - Tracker (
tracker.ex) — adapter behaviour;Linear.Adapteris the production impl,Tracker.Memoryfor tests
If you want to port the design somewhere else, SPEC.md is the source of truth.
Q. Why Elixir? BEAM/OTP is built for supervising thousands of long-running processes that occasionally crash. That's exactly the workload — every agent is its own task, isolated from the others. Hot-code reload during development is a bonus.
Q. Can I use it with my Anthropic Claude Code subscription?
Yes. Use the bare claude command (no provider override) and it goes through your subscription credentials. Use a provider only when you want to route around rate limits.
Q. Will it work with a different issue tracker?
The tracker layer is behaviour-based (tracker.ex). Linear is the only built-in adapter, but the surface is small — implement list_issues/2, update_state/3, add_comment/3, register your adapter in the workflow YAML, you're done. PRs welcome.
Q. Does it eat my Anthropic budget?
With sensible cr and agent.max_turns (default: 20) caps it's bounded. The dashboard's token counter and rate-limit panel make blow-ups easy to spot. For dry-runs, comment out the post-action hooks.
Q. Easiest setup?
Run cymphony and follow the wizard, or paste this README's link into a Claude Code session and ask it to set things up for you.
Warning
Cymphony is a low-key engineering preview. Run it in environments you trust, with credentials scoped to projects you're OK with an autonomous agent touching.

