A Go implementation of the Symphony specification — a long-running automation service that reads work from issue trackers, creates isolated workspaces, and runs AI coding agents for each issue.
Symphony supports two agent backends (Gemini CLI and Claude Code) and two issue trackers (Linear and Jira Cloud). Mix and match per workflow.
Symphony supports two agent backends. Set the backend field in your WORKFLOW.md to choose:
| Gemini CLI | Claude Code | |
|---|---|---|
| Config key | backend: gemini (default) |
backend: claude |
| Protocol | ACP — JSON-RPC 2.0 over stdio (long-running process) | NDJSON stream — one CLI invocation per turn |
| Session model | Single process, session persists in-memory | --resume <session_id> across invocations, persisted to .symphony-session-id |
| Tool access | Client-side injection (ACP fs/terminal requests) | MCP servers (configured externally via .mcp.json or user config) |
| Permission handling | ACP session/request_permission auto-approve |
--permission-mode bypassPermissions flag |
| Default model | gemini-3.1-pro-preview |
claude-sonnet-4-6 |
| TTY requirement | None | Requires pseudo-TTY (script -q /dev/null wrapper, handled automatically) |
npm install -g @google/gemini-cli
gemini auth loginFor Linear integration, install the MCP extension:
gemini extensions install @google/mcp-linearnpm install -g @anthropic-ai/claude-codeFor Linear integration, add the MCP server globally:
claude mcp add -s user --transport http linear-server https://mcp.linear.app/mcpThis makes the Linear MCP server available in all workspaces. Alternatively, write a .mcp.json file in the workspace via the after_create hook for a self-contained setup.
Symphony supports Linear and Jira Cloud as issue trackers. Set tracker.kind in your WORKFLOW.md.
Symphony has two separate connections to your tracker:
-
Symphony's orchestrator (polling) — The orchestrator polls the tracker API directly every 30 seconds to discover new issues, check which state running issues are in, and decide what to dispatch. This requires an API key because the orchestrator makes direct HTTP calls to the tracker's API.
-
The agent (MCP tools) — During its work session, the agent uses MCP tools to read/write issues (transitions, comments, etc.). The agent authenticates to the MCP server separately — this is configured once via the MCP setup commands and doesn't require the API key in WORKFLOW.md.
Both are required. The API key powers the orchestrator's polling loop. The MCP tools power the agent's interactions.
Recommendation: Set your API keys as environment variables rather than hardcoding them in WORKFLOW.md. This keeps secrets out of version control and makes it easy to share workflow files across a team.
| Linear | Jira Cloud | |
|---|---|---|
| Config key | tracker.kind: linear |
tracker.kind: jira |
| API | GraphQL | REST API v3 |
| Auth | API key | API token + email |
| Project filter | tracker.project_slug (slug ID from URL) |
tracker.project_slug (Jira project key, e.g., PROJ) |
| Endpoint | Default: https://api.linear.app/graphql |
Required (e.g., https://mycompany.atlassian.net) |
| Default active states | To Do, In Progress |
To Do, In Progress |
| Default terminal states | Closed, Cancelled, Canceled, Duplicate, Done |
Done |
- Create an API key at Linear Settings > API > Personal API keys
- Find your project slug from the URL:
https://linear.app/yourteam/project/my-project-abc123→my-project-abc123 - Set the environment variable (add to your
~/.zshrcor~/.bashrcto persist):export LINEAR_API_KEY="lin_api_..."
- Add MCP so the agent can interact with Linear during work:
- Gemini:
gemini extensions install @google/mcp-linear - Claude Code:
claude mcp add -s user --transport http linear-server https://mcp.linear.app/mcp
- Gemini:
- In your WORKFLOW.md, reference the env var:
tracker: kind: linear api_key: $LINEAR_API_KEY project_slug: my-project-abc123
- Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens
- Find your project key from Jira (e.g.,
PROJfrom issue keys likePROJ-123) - Set environment variables (add to your
~/.zshrcor~/.bashrcto persist):export JIRA_API_TOKEN="your-api-token" export JIRA_EMAIL="your-email@company.com" export JIRA_ENDPOINT="https://mycompany.atlassian.net"
- Add MCP so the agent can interact with Jira during work:
- Claude Code:
claude mcp add -s user --transport http jira-server https://mcp.atlassian.com/v1/sse - Gemini: configure a Jira MCP server in
~/.gemini/settings.json
- Claude Code:
- In your WORKFLOW.md, reference the env vars:
tracker: kind: jira endpoint: $JIRA_ENDPOINT api_key: $JIRA_API_TOKEN email: $JIRA_EMAIL project_slug: PROJ
tracker:
kind: jira # required: "linear" or "jira"
endpoint: $JIRA_ENDPOINT # required for Jira; has default for Linear
api_key: $JIRA_API_TOKEN # required: API key/token (supports $VAR)
email: $JIRA_EMAIL # required for Jira only (supports $VAR)
project_slug: PROJ # required: Linear slug ID or Jira project key
active_states: # states that trigger agent work
- To Do
- In Progress
terminal_states: # states that stop agents and clean workspaces
- Done| Field | Required | Description |
|---|---|---|
kind |
Always | "linear" or "jira" |
endpoint |
Jira only | Jira Cloud base URL. Linear has a built-in default. |
api_key |
Always | API key (Linear) or API token (Jira). Use $VAR to reference env vars. Falls back to LINEAR_API_KEY or JIRA_API_TOKEN env vars. |
email |
Jira only | Atlassian account email for Basic Auth. Use $VAR. Falls back to JIRA_EMAIL. |
project_slug |
Always | Linear project slug ID or Jira project key (e.g., PROJ). |
active_states |
No | States that trigger agent work. Must match tracker exactly (case-sensitive). |
terminal_states |
No | States that stop agents and trigger workspace cleanup. |
State names must match your tracker exactly (case-sensitive). Check your Linear workflow states or Jira project board settings.
Jira state tips:
- Jira status names often include spaces:
"To Do","In Progress","In Review" - Custom workflows may have different names — check your project's board columns
- Include all "done" statuses in
terminal_statesso Symphony cleans up finished work
Symphony is driven by WORKFLOW.md files. You can create different workflow files for different strategies and backends.
| Workflow | File | Backend | Tracker | Strategy |
|---|---|---|---|---|
| Autonomous | WORKFLOW.md |
Gemini | Linear | Full automation |
| Planning First | WORKFLOW-PLAN.md |
Gemini | Linear | Human-in-the-loop |
| Planning First (Claude) | WORKFLOW-PLAN-CLAUDE.md |
Claude Code | Linear | Human-in-the-loop |
| Planning First (Jira) | WORKFLOW-PLAN-JIRA.md |
Claude Code | Jira | Human-in-the-loop |
| Feature | Autonomous | Planning First |
|---|---|---|
| Initial Action | Moves to In Progress immediately |
Analyzes code and creates a technical plan |
| Approval Gate | None — proceeds to implementation | Stops in Plan Review for human feedback |
| Execution | Continuous turn loop until PR | Only starts coding after move to Plan Approved |
| Risk Profile | High speed, less oversight | Higher quality, safe for sensitive codebases |
Both strategies work with either backend. The backend determines which AI agent runs. The workflow strategy determines how it runs (autonomous vs. human-gated).
You can tailor Symphony to any organizational need by creating a new .md file with a YAML header.
Common customization ideas:
- Security Auditor: A workflow that only runs security scans and reports findings to Linear comments.
- Documentation Agent: A workflow that focuses on updating
READMEandDOCSbased on code changes. - Issue Triage: A workflow that analyzes new issues, adds labels, and suggests a priority without writing code.
To use a custom workflow:
./bin/symphony my-custom-workflow.md-
Go 1.25+ — install
-
An agent backend — at least one of:
- Gemini CLI —
npm install -g @google/gemini-cli && gemini auth login - Claude Code —
npm install -g @anthropic-ai/claude-code(requires Anthropic API key or Claude subscription)
- Gemini CLI —
-
An issue tracker — at least one of:
- Linear — API key from Linear settings. Project slug from URL:
my-project-abc123 - Jira Cloud — API token + email. Project key (e.g.,
PROJ)
- Linear — API key from Linear settings. Project slug from URL:
-
Tracker MCP (for agent access to the tracker) — see Issue Trackers for setup
cd go/
make buildThis produces bin/symphony.
All configuration lives in a single WORKFLOW.md file. The file has two parts:
- YAML front matter (between
---delimiters) — runtime settings - Markdown body — the prompt template sent to the agent for each issue
---
tracker:
kind: linear
project_slug: my-project-slug
gemini:
command: "gemini --acp"
model: gemini-3.1-pro-preview
---
You are working on issue {{ issue.identifier }}: {{ issue.title }}.
{{ issue.description }}---
backend: claude
tracker:
kind: linear
project_slug: my-project-slug
claude:
command: claude
model: claude-sonnet-4-6
---
You are working on issue {{ issue.identifier }}: {{ issue.title }}.
{{ issue.description }}---
backend: gemini # "gemini" (default) or "claude"
tracker:
kind: linear # required: "linear" or "jira"
project_slug: my-project # required (Linear slug or Jira project key)
endpoint: https://api.linear.app/graphql # default for Linear; required for Jira
email: $JIRA_EMAIL # required for Jira (Basic Auth); ignored for Linear
active_states: # default: ["Todo", "In Progress"]
- Todo
- In Progress
terminal_states: # default: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"]
- Done
- Closed
- Cancelled
polling:
interval_ms: 30000 # default: 30000 (30s)
workspace:
root: ~/symphony_workspaces # default: <system-temp>/symphony_workspaces
# supports ~ and $VAR
hooks:
after_create: | # runs once when workspace dir is first created
git clone git@github.com:org/repo.git .
before_run: | # runs before each agent attempt
git checkout main && git pull
after_run: | # runs after each attempt (failures ignored)
echo "run complete"
before_remove: | # runs before workspace deletion (failures ignored)
echo "cleaning up"
timeout_ms: 60000 # default: 60000 (60s), applies to all hooks
agent:
max_concurrent_agents: 5 # default: 10
max_turns: 10 # default: 20, orchestrator-level turn loop
max_retry_backoff_ms: 300000 # default: 300000 (5 min)
max_concurrent_agents_by_state: # optional per-state caps
todo: 2
in progress: 5
# --- Gemini backend config (used when backend: gemini) ---
gemini:
command: "gemini --acp" # default
model: gemini-3.1-pro-preview # default
turn_timeout_ms: 3600000 # default: 3600000 (1 hour)
read_timeout_ms: 5000 # default: 5000 (5s)
stall_timeout_ms: 300000 # default: 300000 (5 min), 0 disables
# --- Claude Code backend config (used when backend: claude) ---
claude:
command: claude # default
model: claude-sonnet-4-6 # default
permission_mode: bypassPermissions # default, auto-approves all tool use
allowed_tools: # default: ["Read", "Write", "Edit", "Bash"]
- Read
- Write
- Edit
- Bash
- "Bash(git *)"
max_turns: 25 # default: 25, per-invocation Claude turns
turn_timeout_ms: 600000 # default: 600000 (10 min per invocation)
stall_timeout_ms: 300000 # default: 300000 (5 min), 0 disables
server:
port: 8080 # optional, enables HTTP dashboard
# --- cmux visibility (macOS only) ---
cmux:
enabled: true # default: false — opt-in
workspace_name: "Symphony" # default: "Symphony" (cosmetic, used for naming)
close_delay_ms: 30000 # default: 30000 (30s before closing finished tabs)
---
You are working on issue {{ issue.identifier }}: {{ issue.title }}.
{% if issue.description %}
## Description
{{ issue.description }}
{% endif %}
## Labels
{% for label in issue.labels %}- {{ label }}
{% endfor %}
{% if attempt %}
This is retry attempt {{ attempt }}. Check previous work and continue.
{% endif %}The prompt body is rendered with Liquid syntax. Available variables:
| Variable | Type | Description |
|---|---|---|
issue.id |
string | Tracker ID (Linear UUID or Jira key) |
issue.identifier |
string | Human-readable key (e.g., MT-123 or PROJ-456) |
issue.title |
string | Issue title |
issue.description |
string | Issue description (empty if none) |
issue.state |
string | Current tracker state name |
issue.priority |
int or nil | Priority (1=urgent, 4=low, nil=none) |
issue.url |
string | Issue URL (Linear or Jira) |
issue.labels |
list of strings | Lowercase label names |
issue.branch_name |
string | Suggested branch name |
issue.blocked_by |
list of objects | Blocking issues (each has .id, .identifier, .state) |
issue.created_at |
string | ISO-8601 timestamp |
issue.updated_at |
string | ISO-8601 timestamp |
attempt |
int or nil | nil on first run, integer on retry/continuation |
Symphony resolves $VAR_NAME references in config fields at startup. You can also rely on automatic fallbacks:
| Variable | Used by | Purpose |
|---|---|---|
LINEAR_API_KEY |
Linear tracker | Auto-fallback for tracker.api_key when kind is linear |
JIRA_API_TOKEN |
Jira tracker | Auto-fallback for tracker.api_key when kind is jira |
JIRA_EMAIL |
Jira tracker | Auto-fallback for tracker.email when kind is jira |
Best practice: Add these to your shell profile (~/.zshrc or ~/.bashrc) so they persist across terminal sessions:
# Linear
export LINEAR_API_KEY="lin_api_..."
# Jira
export JIRA_API_TOKEN="your-token"
export JIRA_EMAIL="you@company.com"
export JIRA_ENDPOINT="https://mycompany.atlassian.net"Then reference them in WORKFLOW.md with $VAR syntax: api_key: $LINEAR_API_KEY. Or omit the field entirely and let the auto-fallback pick up the env var.
# Default: looks for ./WORKFLOW.md
./bin/symphony
# Explicit workflow path
./bin/symphony WORKFLOW-PLAN.md
# With HTTP dashboard
./bin/symphony --port 8080
# CLI --port overrides server.port in WORKFLOW.md
./bin/symphony --port 9090 /path/to/WORKFLOW.md
# Version
./bin/symphony --versionThe service runs until stopped with Ctrl+C (SIGINT) or SIGTERM.
When a port is configured (via --port flag or server.port in config):
| Endpoint | Method | Description |
|---|---|---|
/ |
GET | HTML dashboard (auto-refreshes every 5s) |
/api/v1/state |
GET | JSON system state: running sessions, retry queue, token totals |
/api/v1/{identifier} |
GET | JSON detail for a specific issue (e.g., /api/v1/MT-123) |
/api/v1/refresh |
POST | Trigger an immediate poll + reconciliation cycle |
The server binds to 127.0.0.1 (localhost only).
When running on macOS with cmux, Symphony can show live, color-coded agent output in dedicated terminal workspaces — one per dispatched issue.
Each active issue gets its own cmux workspace (tab) named after the issue identifier (e.g., AIE-12). Inside, a live stream shows what the agent is doing:
16:42:33 ── ACP initialized — agent: gemini, protocol: 1 ──
16:42:33 ── Starting turn 1 of 15 ──
16:42:35 TOOL src/app/page.tsx in_progress
16:42:35 FAIL File not found: /Users/sascha/...
16:42:37 THINK Examining the Codebase...
16:42:37 AGENT I'll list the contents of the src/app directory...
16:42:38 TOOL list_directory: src/app completed
16:42:45 ── Turn 1 completed — end_turn ──
Events are color-coded: TOOL in yellow, AGENT in cyan, THINK in gray, FAIL in red, DONE in green. Timestamps are dim. Annotations (turn start/end, session info) appear as highlighted separator lines.
Add the cmux section to your WORKFLOW.md config:
cmux:
enabled: trueThat's it. Symphony will:
- Detect the cmux binary and verify connectivity on startup
- Create a cmux workspace per dispatched issue, running
tail -fon the agent's event log - Rename each workspace to the issue identifier (e.g.,
AIE-12) - Stream formatted events as the agent works
- Close the workspace 30 seconds after the agent finishes (configurable via
close_delay_ms)
- Agent processes still run as hidden subprocesses with pipe-based I/O — cmux is a display-only mirror, not part of the process lifecycle
- As the runner receives protocol events (ACP JSON-RPC for Gemini, NDJSON for Claude), it writes formatted lines to
<workspace>/.symphony-agent.log - Each cmux workspace runs
tail -fon that log file for real-time display - Works uniformly for both Gemini and Claude Code backends
- If cmux is not installed or not running, Symphony continues normally with zero impact
| Field | Default | Description |
|---|---|---|
cmux.enabled |
false |
Enable cmux visibility (opt-in) |
cmux.workspace_name |
"Symphony" |
Display name (cosmetic) |
cmux.close_delay_ms |
30000 |
Milliseconds to keep workspace open after agent finishes |
- macOS only — cmux is a native macOS application
- cmux must be installed and running (socket at
/tmp/cmux.sock) - cmux binary must be in PATH or at
/Applications/cmux.app/Contents/Resources/bin/cmux
- Poll — Every
polling.interval_ms, fetch candidate issues from the configured tracker (Linear or Jira) - Dispatch — Sort by priority, check eligibility (concurrency, blockers), launch workers
- Worker — Create workspace → run hooks → launch agent (Gemini or Claude) → multi-turn session
- Reconcile — Each tick, check tracker state for running issues (stop on terminal, update on active)
- Retry — Normal exit → 1s continuation retry; failure → exponential backoff (10s base, capped)
- Reload —
WORKFLOW.mdchanges are detected and applied without restart (backend change requires restart)
<workspace.root>/
MT-123/ ← one directory per issue
MT-124/
MT-125/
- Created on first dispatch, reused on retries
after_createhook runs once (e.g., git clone)before_runhook runs before each attempt (e.g., git pull)- Cleaned up when issue enters a terminal state
Gemini CLI (ACP) — Long-running JSON-RPC 2.0 process over stdio:
Symphony ──initialize──▶ Gemini CLI
◀──result──────
──session/new──▶
◀──sessionId───
──session/prompt──▶
◀──session/update── (streaming)
◀──prompt result──
Permission requests (session/request_permission) are auto-approved (high-trust mode).
Claude Code (NDJSON) — One CLI invocation per turn with streaming JSON output:
Symphony ── claude -p "<prompt>" --output-format stream-json ──▶ Claude Code
◀── {"type":"system","subtype":"init","session_id":"..."} ──
◀── {"type":"assistant","message":{...}} ── (streaming)
◀── {"type":"result","subtype":"success",...} ──
(process exits)
Next turn:
Symphony ── claude -p "<prompt>" --resume <session_id> ──▶ Claude Code
◀── ... ──
Session continuity is maintained via --resume <session_id>. The session ID is persisted to .symphony-session-id in the workspace directory.
Claude Code requires a TTY to produce stream-json output. Symphony wraps the process in script -q /dev/null to allocate a pseudo-TTY automatically.
The prompt in WORKFLOW.md is the only way you control what the agent does. Symphony is a scheduler — it picks up issues, creates workspaces, and launches the agent. Everything else is determined by your prompt.
The same prompt works with both Gemini and Claude Code. The agents have different capabilities, but both can read/write files, run shell commands, and use MCP tools.
- What it's working on — use template variables like
{{ issue.identifier }}and{{ issue.title }} - Where the code is — mention the repo so the agent understands the context
- What steps to follow — be explicit about branching, committing, pushing, PR creation
- How to interact with the tracker — tell the agent which MCP tools to use for state transitions and comments
- What to do when done — move issue to review, create a PR, etc.
The prompt template is the same regardless of tracker, but the agent instructions should reference the correct MCP tools:
Linear workflows:
Use the Linear MCP tools for ALL Linear operations:
- `mcp_linear_update_issue` to transition states
- `mcp_linear_create_comment` / `mcp_linear_update_comment` for workpad
- `mcp_linear_list_comments` to find existing commentsJira workflows:
Use the Jira MCP tools for ALL Jira operations:
- Use the Jira MCP to transition issue status (e.g., move to "In Progress")
- Use the Jira MCP to add and update comments
- Use the Jira MCP to read issue details and existing commentsThe template variables ({{ issue.identifier }}, {{ issue.title }}, etc.) work identically for both trackers — Symphony normalizes the data before rendering.
You are working on issue {{ issue.identifier }}: {{ issue.title }}.
You are working in a checkout of https://github.com/your-org/your-repo.
{% if issue.description %}
## Description
{{ issue.description }}
{% endif %}
## Instructions
1. Make the code changes needed to resolve this issue.
2. Move the issue to `In Progress` using the tracker MCP tools.
3. Create a new branch: `git checkout -b {{ issue.identifier }}`
4. Commit your changes with a clear message referencing the issue.
5. Push the branch: `git push origin {{ issue.identifier }}`
6. Create a pull request:
`gh pr create --title "{{ issue.identifier }}: {{ issue.title }}" --body "Resolves {{ issue.identifier }}"`
7. Add the PR link to the issue as a comment.
8. Move the issue to `Human Review`.
When you are done, do NOT leave the issue in an active state.
The issue will be picked up again if it stays active.
{% if attempt %}
This is retry attempt {{ attempt }}. Check previous work and continue.
{% endif %}- Be explicit. The agent does what you tell it. If you don't say "create a PR", it won't.
- Use tracker MCP tools. Both backends discover MCP tools automatically — Gemini via extensions, Claude Code via user-scoped config. Tell the agent to use them for state transitions and comments.
- Use
ghCLI for PRs. Ifghis installed and authenticated on the machine, the agent can create PRs directly. - Handle retries. Use
{% if attempt %}to give different instructions on retry. - State names matter. The states you reference in the prompt (e.g., "In Progress", "Human Review") must match your tracker's workflow exactly.
Each issue gets its own directory under workspace.root:
~/symphony_workspaces/
AIE-7/ ← cloned repo for issue AIE-7
AIE-8/ ← cloned repo for issue AIE-8
# Run tests
make test
# Build
make build
# Run directly
make run├── cmd/symphony/main.go # CLI entrypoint
├── internal/
│ ├── config/ # Typed config + defaults + validation
│ ├── workflow/ # WORKFLOW.md parser + file watcher
│ ├── tracker/ # Issue tracker clients (Linear + Jira)
│ ├── orchestrator/ # Poll loop, dispatch, reconcile, retry
│ ├── workspace/ # Directory lifecycle + hooks + safety
│ ├── agent/ # Backend runners + protocol clients
│ │ ├── runner.go # AgentLauncher interface + factory
│ │ ├── acp.go # Gemini ACP client (JSON-RPC over stdio)
│ │ ├── claude_runner.go # Claude Code runner (NDJSON streaming)
│ │ ├── ndjson.go # NDJSON line-accumulator parser
│ │ └── events.go # Event types for orchestrator
│ ├── cmux/ # cmux session visibility (macOS)
│ ├── prompt/ # Liquid template rendering
│ ├── server/ # HTTP dashboard + JSON API
│ └── logging/ # slog JSON setup
├── Makefile
├── go.mod
└── go.sum
