Autonomous work orchestration for coding agents. Symphony polls Linear for issues, creates isolated workspaces, and runs Codex sessions to get work done — without human supervision.
Warning
Symphony is a low-key engineering preview for testing in trusted environments.
- Poll — Orchestrator fetches issues in active states from Linear on a fixed cadence (default 5s)
- Dispatch — Eligible issues are sorted by priority, assigned workspace directories, and handed to Agent Runners
- Execute — Each Agent Runner launches Codex in app-server mode, renders the
WORKFLOW.mdprompt template, and runs turns until the issue moves to a terminal state - Observe — Real-time dashboards (terminal ANSI + Phoenix LiveView) and a JSON API surface token usage, rate limits, and per-issue session state
- Recover — Exponential backoff retries, stall detection, and reconciliation loops handle failures without operator intervention
symphony/
├── SPEC.md # Language-agnostic service specification (the contract)
├── README.md
├── LICENSE # Apache 2.0
├── NOTICE
│
├── .github/
│ ├── workflows/
│ │ ├── make-all.yml # CI: format + lint + test + dialyzer
│ │ └── pr-description-lint.yml # Enforces PR body format
│ ├── pull_request_template.md
│ └── media/ # Demo video + screenshots
│
├── .codex/
│ ├── worktree_init.sh # Git worktree bootstrap for Codex
│ └── skills/ # Agent skills (invoked by Codex during execution)
│ ├── commit/SKILL.md # Create logical commits
│ ├── push/SKILL.md # Push branch to remote
│ ├── pull/SKILL.md # Sync with origin/main
│ ├── land/SKILL.md # Merge PR (retry loop until landed)
│ ├── linear/SKILL.md # Raw Linear GraphQL operations
│ └── debug/SKILL.md # Troubleshooting utilities
│
└── elixir/ # Reference implementation (Elixir/OTP)
├── mix.exs # Project definition + deps
├── mix.lock
├── Makefile # `make all` = format + lint + coverage + dialyzer
├── WORKFLOW.md # Default workflow: YAML config + Jinja2 prompt template
├── AGENTS.md # Codex agent instructions for contributing
├── mise.toml # Elixir/Erlang version pinning
│
├── config/
│ └── config.exs # Phoenix + app config (Logger, JSON, endpoint)
│
├── docs/
│ ├── token_accounting.md # How token totals are tracked (critical: use absolute, not deltas)
│ └── logging.md # Structured log format and conventions
│
├── lib/
│ ├── symphony_elixir.ex # Application entry point — starts Orchestrator, WorkflowStore, Supervisor
│ │
│ ├── symphony_elixir/
│ │ ├── orchestrator.ex # Core brain: poll loop, dispatch, retry queue, reconciliation, state
│ │ ├── agent_runner.ex # Per-issue task: workspace setup → Codex turns → cleanup
│ │ ├── workflow.ex # Parses WORKFLOW.md (YAML front matter + Jinja2 prompt body)
│ │ ├── workflow_store.ex # GenServer: caches + hot-reloads WORKFLOW.md on file change
│ │ ├── config.ex # Typed config getters with NimbleOptions validation
│ │ ├── workspace.ex # Filesystem workspace lifecycle (create, hooks, remove, cleanup)
│ │ ├── prompt_builder.ex # Renders Jinja2 template with issue context variables
│ │ ├── cli.ex # CLI entry point (escript) — parses args, starts app
│ │ ├── tracker.ex # Behaviour definition for issue tracker adapters
│ │ ├── log_file.ex # Per-issue log file writer
│ │ ├── status_dashboard.ex # Terminal ANSI dashboard: sparklines, tables, token graphs
│ │ ├── http_server.ex # Starts Phoenix endpoint (optional, via config)
│ │ ├── specs_check.ex # Validates implementation against SPEC.md requirements
│ │ │
│ │ ├── tracker/
│ │ │ └── memory.ex # In-memory tracker for testing (no Linear dependency)
│ │ │
│ │ ├── linear/ # Linear issue tracker integration
│ │ │ ├── adapter.ex # Implements Tracker behaviour — fetch, comment, update state
│ │ │ ├── client.ex # GraphQL HTTP client with pagination (batch size 50)
│ │ │ └── issue.ex # Issue struct: normalize Linear API → internal model
│ │ │
│ │ └── codex/ # Codex app-server integration
│ │ ├── app_server.ex # JSON-RPC 2.0 over stdio: init → thread → turns → stop
│ │ └── dynamic_tool.ex # Runtime-injected tools (linear_graphql)
│ │
│ └── symphony_elixir_web/ # Phoenix web layer (observability dashboard)
│ ├── endpoint.ex # Cowboy/Bandit HTTP endpoint
│ ├── router.ex # Routes: / (LiveView), /api/v1/* (JSON API)
│ ├── presenter.ex # Converts orchestrator state → JSON/template data
│ ├── observability_pubsub.ex # PubSub for real-time dashboard updates
│ ├── static_assets.ex # Serves dashboard CSS
│ ├── error_json.ex
│ ├── error_html.ex
│ ├── components/
│ │ └── layouts.ex # HTML layout for LiveView
│ ├── live/
│ │ └── dashboard_live.ex # Real-time Phoenix LiveView dashboard
│ └── controllers/
│ ├── observability_api_controller.ex # JSON API: GET /state, POST /refresh, GET /:id
│ └── static_asset_controller.ex
│
├── priv/
│ └── static/
│ └── dashboard.css # Dashboard styles
│
└── test/ # Comprehensive test suite
├── test_helper.exs
├── support/
│ ├── test_support.exs # Shared test helpers
│ └── snapshot_support.exs # Snapshot testing for dashboard output
├── fixtures/ # Golden-file snapshots for status dashboard
└── symphony_elixir/ # Unit + integration tests per module
Symphony follows a single-orchestrator, multi-worker pattern built on Elixir/OTP's fault-tolerant supervision trees.
Abstraction Layers (from SPEC.md):
| Layer | Responsibility | Key Modules |
|---|---|---|
| Policy | Repo-defined workflow rules | WORKFLOW.md |
| Configuration | Typed runtime settings | Config, Workflow, WorkflowStore |
| Coordination | Polling, dispatch, retry, reconciliation | Orchestrator |
| Execution | Workspace lifecycle + agent subprocess | AgentRunner, Workspace, AppServer |
| Integration | External API normalization | Linear.Adapter, Linear.Client |
| Observability | Operator visibility | StatusDashboard, DashboardLive, API controller |
The normalized issue record used across orchestration, prompt rendering, and observability.
%Issue{
id: string # Linear internal UUID
identifier: string # Human-readable key, e.g. "PROJ-123"
title: string
description: string | nil
priority: 1..4 | nil # Lower = higher priority in dispatch sorting
state: string # "Todo", "In Progress", "Done", etc.
branch_name: string | nil # Git branch from Linear
url: string | nil # Linear issue URL
assignee_id: string | nil
labels: [string] # Normalized to lowercase
blocked_by: [%{type, issue: %{id, identifier, state}}]
created_at: DateTime | nil
updated_at: DateTime | nil
}
In-memory GenServer state — no database required.
%State{
poll_interval_ms: integer # Polling cadence (default 5000)
max_concurrent_agents: integer # Global concurrency cap (default 10)
running: %{issue_id => RunningEntry}
completed: MapSet<issue_id>
claimed: MapSet<issue_id>
retry_attempts: %{issue_id => RetryEntry}
codex_totals: %{input_tokens, output_tokens, total_tokens, seconds_running}
codex_rate_limits: %{...} # Extracted from Codex token usage events
}
%RunningEntry{
pid: pid # Task process
ref: reference # Monitor ref
identifier: string # "PROJ-123"
issue: %Issue{}
session_id: string | nil # "thread-{id}-turn-{n}"
turn_count: integer
retry_attempt: integer | nil
started_at: DateTime
codex_input_tokens: integer
codex_output_tokens: integer
codex_total_tokens: integer
last_codex_event: atom | nil
last_codex_message: string | nil
last_codex_timestamp: DateTime | nil
}
%RetryEntry{
attempt: integer
timer_ref: reference
due_at_ms: integer
identifier: string
error: string | nil
}
Defined in WORKFLOW.md YAML front matter, validated by NimbleOptions:
tracker:
kind: "linear" | "memory" # Adapter selection
endpoint: "https://api.linear.app/graphql"
api_key: "$LINEAR_API_KEY" # Env var indirection
project_slug: "my-project"
assignee: "optional-user-filter"
active_states: ["Todo", "In Progress"]
terminal_states: ["Done", "Closed", "Cancelled", "Duplicate"]
polling:
interval_ms: 5000
workspace:
root: "~/code/symphony-workspaces" # Expands ~ and $ENV
agent:
max_concurrent_agents: 10
max_turns: 20 # Turns per agent session
max_retry_backoff_ms: 300000 # 5 min cap on exponential backoff
max_concurrent_agents_by_state: # Optional per-state limits
"Todo": 5
"In Progress": 8
codex:
command: "codex app-server"
approval_policy: "never" | "on-request" | {reject: {...}}
thread_sandbox: "workspace-write"
turn_sandbox_policy:
type: "workspaceWrite"
root: "/path/to/workspace"
turn_timeout_ms: 3600000 # 1 hour
read_timeout_ms: 5000
stall_timeout_ms: 300000 # 5 min stall → kill
hooks:
after_create: "git clone ..." # Shell cmd after workspace created
before_run: "npm install" # Shell cmd before each agent run
after_run: "cleanup.sh"
before_remove: "backup.sh"
timeout_ms: 60000
server:
port: 4000 # nil = disabled
host: "127.0.0.1"
observability:
dashboard_enabled: true
refresh_ms: 1000
render_interval_ms: 16Communication with Codex uses JSON-RPC 2.0 over stdio:
SESSION LIFECYCLE:
→ initialize (capabilities exchange)
← initialized
→ thread/start (sandbox policy, cwd, dynamic tools)
← thread_id
PER-TURN:
→ turn/start (prompt, issue metadata, workspace path)
← notifications... (progress, tool calls, token usage)
← turn/completed
Tool call flow:
← request: {method: "tool/call", params: {name: "linear_graphql", arguments: {...}}}
→ response: {result: {success: true, contentItems: [...]}}
TOKEN REPORTING:
← thread/tokenUsage/updated (absolute totals per thread — NOT deltas)
SESSION END:
→ close port
Dynamic Tools Injected:
| Tool | Input | Output |
|---|---|---|
linear_graphql |
{query: string, variables?: object} |
Linear GraphQL API response |
Phoenix-based JSON API at http://localhost:4000:
| Endpoint | Method | Description |
|---|---|---|
/api/v1/state |
GET | Full orchestrator snapshot (running, retrying, tokens, rate limits) |
/api/v1/refresh |
POST | Trigger immediate poll cycle (returns 202) |
/api/v1/:issue_identifier |
GET | Detailed state for a specific issue |
Response shape for GET /api/v1/state:
{
"generated_at": "2025-03-05T12:00:00Z",
"counts": {"running": 3, "retrying": 1},
"running": [{
"issue_id": "uuid",
"issue_identifier": "PROJ-123",
"state": "In Progress",
"session_id": "thread-abc-turn-1",
"turn_count": 2,
"last_event": "message_received",
"started_at": "...",
"tokens": {"input": 5000, "output": 2000, "total": 7000}
}],
"retrying": [{
"issue_id": "uuid",
"issue_identifier": "PROJ-124",
"attempt": 3,
"due_at": "...",
"error": "agent exited: timeout"
}],
"codex_totals": {"input_tokens": 50000, "output_tokens": 20000, "total_tokens": 70000},
"rate_limits": {}
}Web Dashboard: Real-time Phoenix LiveView at / with PubSub-driven updates (same data as terminal dashboard).
Shows Symphony's place in the broader ecosystem.
graph TB
Engineer["Engineer
(manages work via Linear)"]
Symphony["Symphony Service
(Elixir/OTP)"]
Linear["Linear
(Issue Tracker)"]
Codex["Codex
(AI Coding Agent)"]
Git["Git Remote
(GitHub/GitLab)"]
Filesystem["Local Filesystem
(Workspaces)"]
Engineer -->|creates/reviews issues| Linear
Symphony -->|polls issues, reads state| Linear
Symphony -->|spawns app-server sessions| Codex
Codex -->|creates commits, PRs| Git
Codex -->|updates issues, comments| Linear
Symphony -->|creates/manages workspaces| Filesystem
Codex -->|reads/writes code in workspace| Filesystem
Engineer -->|monitors via dashboard| Symphony
Internal module relationships within the Elixir implementation.
graph TB
subgraph "Symphony Application"
CLI["CLI
(escript entry)"]
subgraph "Coordination Layer"
Orch["Orchestrator
(GenServer)
poll loop, dispatch,
retry queue, reconciliation"]
end
subgraph "Configuration Layer"
WFStore["WorkflowStore
(GenServer)
hot-reload on change"]
WF["Workflow
parse YAML + Jinja2"]
Config["Config
typed getters,
NimbleOptions validation"]
end
subgraph "Execution Layer"
TaskSup["TaskSupervisor"]
AR["AgentRunner
(per-issue Task)"]
WS["Workspace
create, hooks, cleanup"]
PB["PromptBuilder
Jinja2 rendering"]
subgraph "Codex Integration"
AS["AppServer
JSON-RPC 2.0 / stdio"]
DT["DynamicTool
linear_graphql"]
end
end
subgraph "Integration Layer"
Tracker["Tracker Behaviour"]
LA["Linear.Adapter"]
LC["Linear.Client
GraphQL + pagination"]
TM["Tracker.Memory
(test only)"]
end
subgraph "Observability Layer"
SD["StatusDashboard
terminal ANSI UI"]
HTTP["HttpServer
(Phoenix)"]
DL["DashboardLive
(LiveView)"]
API["ObservabilityAPI
JSON endpoints"]
PS["PubSub"]
Pres["Presenter
state → view data"]
end
end
CLI --> Orch
Orch --> WFStore
WFStore --> WF
WF --> Config
Orch --> TaskSup
TaskSup --> AR
AR --> WS
AR --> PB
AR --> AS
AS --> DT
Orch --> Tracker
Tracker --> LA
Tracker --> TM
LA --> LC
DT --> LC
Orch --> SD
Orch --> PS
PS --> DL
HTTP --> DL
HTTP --> API
API --> Pres
DL --> Pres
What happens every 5 seconds inside the orchestrator.
sequenceDiagram
participant Timer as Tick Timer
participant Orch as Orchestrator
participant Linear as Linear API
participant TaskSup as TaskSupervisor
participant AR as AgentRunner
Timer->>Orch: :tick
Orch->>Orch: schedule :run_poll_cycle
Note over Orch: Reconciliation Phase
Orch->>Linear: fetch_issue_states_by_ids(running_ids)
Linear-->>Orch: current states
Orch->>Orch: stop tasks for terminal/unassigned issues
Orch->>Orch: kill stalled tasks (> stall_timeout)
Note over Orch: Dispatch Phase
Orch->>Linear: fetch_candidate_issues(active_states)
Linear-->>Orch: candidate issues
Orch->>Orch: sort by priority + created_at
loop For each eligible issue
Orch->>Orch: check: not running, not claimed, slots available, not blocked
Orch->>TaskSup: start_child(AgentRunner)
TaskSup->>AR: spawn Task
AR-->>Orch: {:codex_worker_update, ...} (ongoing)
end
Note over Orch: Retry Phase
Orch->>Orch: check retry_queue for due entries
loop For each due retry
Orch->>TaskSup: start_child(AgentRunner, retry_attempt: N)
end
Note over Orch: Agent Exit Handling
AR-->>Orch: {:DOWN, ref, :process, pid, reason}
alt Normal exit
Orch->>Orch: schedule continuation check (1s)
else Error exit
Orch->>Orch: exponential backoff → retry_queue
end
The lifecycle of a single issue being worked on.
sequenceDiagram
participant Orch as Orchestrator
participant AR as AgentRunner
participant WS as Workspace
participant PB as PromptBuilder
participant Codex as Codex AppServer
participant Linear as Linear API
participant DT as DynamicTool
Orch->>AR: run(issue, opts)
AR->>WS: create_for_issue(issue)
WS->>WS: mkdir workspace dir
WS->>WS: run after_create hook (e.g. git clone)
WS-->>AR: workspace_path
AR->>WS: run before_run hook
AR->>Codex: initialize (open port)
Codex-->>AR: capabilities
AR->>Codex: thread/start (sandbox, cwd, tools)
Codex-->>AR: thread_id
loop Turn 1..max_turns
alt Turn 1
AR->>PB: build_prompt(issue, attempt)
PB-->>AR: rendered Jinja2 prompt
else Turn N > 1
AR->>AR: build continuation prompt
end
AR->>Codex: turn/start (prompt)
loop Codex execution
Codex->>DT: tool/call: linear_graphql
DT->>Linear: GraphQL request
Linear-->>DT: response
DT-->>Codex: tool result
end
Codex-->>AR: turn/completed
Codex-->>AR: thread/tokenUsage/updated
AR-->>Orch: {:codex_worker_update, tokens}
AR->>Linear: fetch issue state
alt Still in active state
AR->>AR: continue to next turn
else Terminal state
AR->>AR: break loop
end
end
AR->>Codex: close port
AR->>WS: run after_run hook
AR-->>Orch: exit (normal)
How data moves through the system from external sources to outputs.
graph LR
subgraph "External Inputs"
LI["Linear Issues
(GraphQL API)"]
WMD["WORKFLOW.md
(YAML + Jinja2)"]
ENV["Environment
($LINEAR_API_KEY, etc.)"]
end
subgraph "Processing"
WFL["Workflow Loader
parse + validate"]
CFG["Config Layer
typed getters"]
POLL["Poll Cycle
fetch + filter + sort"]
DISP["Dispatcher
eligibility + slots"]
EXEC["Agent Execution
workspace + Codex turns"]
end
subgraph "External Outputs"
FS["Filesystem
workspace dirs + logs"]
GIT["Git Commits + PRs"]
LO["Linear Updates
state changes, comments"]
end
subgraph "Observability Outputs"
TERM["Terminal Dashboard
ANSI sparklines + tables"]
WEB["Web Dashboard
Phoenix LiveView"]
JAPI["JSON API
/api/v1/state"]
end
WMD --> WFL
ENV --> CFG
WFL --> CFG
LI --> POLL
CFG --> POLL
POLL --> DISP
DISP --> EXEC
EXEC --> FS
EXEC --> GIT
EXEC --> LO
EXEC -->|token events| TERM
EXEC -->|PubSub| WEB
EXEC -->|state snapshot| JAPI
How Symphony treats Linear issue states (as defined in WORKFLOW.md).
stateDiagram-v2
[*] --> Backlog: created
Backlog --> Todo: triaged
state "Active States (Symphony picks up)" as active {
Todo --> InProgress: agent dispatched
InProgress --> HumanReview: PR ready
HumanReview --> Merging: human approves
Merging --> InProgress: merge conflict
HumanReview --> Rework: feedback
Rework --> InProgress: agent re-executes
}
state "Terminal States (Symphony stops)" as terminal {
Done
Closed
Cancelled
Duplicate
}
InProgress --> Done: work complete + merged
Merging --> Done: PR landed
Todo --> Cancelled: cancelled
InProgress --> Cancelled: cancelled
HumanReview --> Closed: rejected
Todo --> Duplicate: duplicate found
note right of active
Symphony only dispatches agents
for issues in active states.
Default: Todo, In Progress
end note
note right of terminal
Agent tasks are killed when
issue enters terminal state.
end note
Symphony works best in codebases that have adopted harness engineering.
Tell your favorite coding agent to build Symphony in any language:
Implement Symphony according to the following spec: https://github.com/openai/symphony/blob/main/SPEC.md
See elixir/README.md for setup instructions, or ask your coding agent:
Set up Symphony for my repository based on https://github.com/openai/symphony/blob/main/elixir/README.md
This project is licensed under the Apache License 2.0.
