Symphony Elixir
July 20, 2026 · View on GitHub
This directory contains the current Elixir/OTP implementation of Symphony, based on
SPEC.md at the repository root.
Warning
Symphony Elixir is prototype software intended for evaluation only and is presented as-is.
We recommend implementing your own hardened version based on SPEC.md.
Screenshot

How it works
- Polls the configured tracker for candidate work (included adapters: Linear, GitHub Issues, Jira Cloud, Asana, and GitLab)
- Creates a workspace per issue
- Launches Codex in App Server mode inside the workspace
- Sends a workflow prompt to Codex
- Keeps Codex working on the issue until the work is done
During app-server sessions, the selected tracker adapter may advertise provider-native tools. The
Linear serves linear_graphql, GitHub Issues serves github_api, Jira Cloud serves
jira_rest, Asana serves asana_api, and GitLab serves gitlab_api. Symphony executes those
tools with configured host-side auth and removes declared tracker-token environment variables from
the Codex child, so the agent does not need a second tracker login.
If a claimed issue moves to a terminal state (Done, Closed, Cancelled, or Duplicate),
Symphony stops the active agent for that issue and cleans up matching workspaces.
If Codex reports that operator input, approval, or MCP elicitation is required, Symphony keeps the issue claimed and exposes it as blocked in the runtime state, JSON API, and dashboard. Blocked entries are in memory only; restarting the orchestrator clears that blocked map, so any still-active tracker issue can become a dispatch candidate again after restart.
How to use it
- Make sure your codebase is set up to work well with agents: see Harness engineering.
- Get a new personal token in Linear via Settings → Security & access → Personal API keys, and
set it as the
LINEAR_API_KEYenvironment variable. - Copy this directory's
WORKFLOW.mdto your repo. - Optionally copy the
commit,push,pull,land, andlinearskills to your repo.- The
linearskill expects Symphony'slinear_graphqlapp-server tool for raw Linear GraphQL operations such as comment editing or upload flows.
- The
- Customize the copied
WORKFLOW.mdfile for your project.- To get your project's slug, right-click the project and copy its URL. The slug is part of the URL.
- When creating a workflow based on this repo, note that it depends on non-standard Linear issue statuses: "Rework", "Human Review", and "Merging". You can customize them in Team Settings → Workflow in Linear.
- Follow the instructions below to install the required runtime dependencies and start the service.
Prerequisites
We recommend using mise to manage Elixir/Erlang versions.
mise install
mise exec -- elixir --version
Run
git clone https://github.com/openai/symphony
cd symphony/elixir
mise trust
mise install
mise exec -- mix setup
mise exec -- mix build
mise exec -- ./bin/symphony ./WORKFLOW.md
Burrito releases
Symphony ships self-contained executables built with
Burrito. They embed Erlang/OTP, Elixir, and Symphony,
but still expect codex, git, and the selected tracker credentials on the target machine.
Supported release targets:
macos_arm64macos_x86_64linux_arm64linux_x86_64
v* tags publish all four targets with checksums. A manual workflow run builds the same
artifacts without creating a release.
After downloading the executable for your platform from a release:
chmod +x ./symphony-v0.0.1-macos_arm64
./symphony-v0.0.1-macos_arm64 ./WORKFLOW.md
Configuration
Pass a custom workflow file path to ./bin/symphony when starting the service:
./bin/symphony /path/to/custom/WORKFLOW.md
If no path is passed, Symphony defaults to ./WORKFLOW.md.
Optional flags:
--logs-roottells Symphony to write logs under a different directory (default:./log)--portalso starts the Phoenix observability service (default: disabled)
The WORKFLOW.md file uses YAML front matter for configuration, plus a Markdown body used as the
Codex session prompt.
Minimal example:
---
tracker:
kind: linear
provider:
project_slug: "..."
workspace:
root: ~/code/workspaces
hooks:
after_create: |
git clone git@github.com:your-org/your-repo.git .
agent:
max_concurrent_agents: 10
max_turns: 20
codex:
command: codex app-server
---
You are working on an issue from the configured tracker {{ issue.identifier }}.
Title: {{ issue.title }} Body: {{ issue.description }}
Notes:
- If a value is missing, defaults are used.
tracker.kindselects an adapter. Adapter-owned endpoint, scope, and auth settings belong undertracker.provider; the current Linear adapter still accepts the older flatendpoint,api_key,project_slug, andassigneealiases for compatibility.tracker.required_labelsis optional. When set, an issue must have every configured label to dispatch or continue running. Label matching ignores case and surrounding whitespace. A blank configured label matches no issue.- Safer Codex defaults are used when policy fields are omitted:
codex.approval_policydefaults to{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}codex.thread_sandboxdefaults toworkspace-writecodex.turn_sandbox_policydefaults to aworkspaceWritepolicy rooted at the current issue workspace
codex.turn_timeout_msis the maximum silence interval while a turn is streaming. Each app-server update resets it; it is not a total turn runtime cap.- Supported
codex.approval_policyvalues depend on the targeted Codex app-server version. In the current local Codex schema, string values includeuntrusted,on-failure,on-request, andnever, and object-formrejectis also supported. - Supported
codex.thread_sandboxvalues:read-only,workspace-write,danger-full-access. - When
codex.turn_sandbox_policyis set explicitly, Symphony passes the map through to Codex unchanged. Compatibility then depends on the targeted Codex app-server version rather than local Symphony validation. - Workflows that run package managers or other commands that resolve external hosts should set
networkAccess: trueincodex.turn_sandbox_policy; otherwise DNS/network access may be denied by the Codex turn sandbox. agent.max_turnscaps how many back-to-back Codex turns Symphony will run in a single agent invocation when a turn completes normally but the issue is still in an active state. Default:20.- If the Markdown body is blank, Symphony uses a default prompt template that includes the issue identifier, title, and body.
- Use
hooks.after_createto bootstrap a fresh workspace. For a Git-backed repo, you can rungit clone ... .there, along with any other setup commands you need. - If a hook needs
mise execinside a freshly cloned workspace, trust the repo config and fetch the project dependencies inhooks.after_createbefore invokingmiselater from other hooks. - For the Linear adapter,
tracker.provider.api_keyreads fromLINEAR_API_KEYwhen unset or when value is$LINEAR_API_KEY. The legacy flattracker.api_keyalias behaves the same way. - Do not put a literal tracker token in a repo-owned
WORKFLOW.mdif Codex can read that workspace. Use$VAR/host-side secret references so Symphony can keep the token out of the child environment. - For path values,
~is expanded to the home directory. - For env-backed path values, use
$VAR.workspace.rootresolves$VARbefore path handling, whilecodex.commandstays a shell command string and any$VARexpansion there happens in the launched shell.
tracker:
provider:
api_key: $LINEAR_API_KEY
workspace:
root: $SYMPHONY_WORKSPACE_ROOT
hooks:
after_create: |
git clone --depth 1 "$SOURCE_REPO_URL" .
codex:
command: "$CODEX_BIN --config 'model=\"gpt-5.5\"' app-server"
- If
WORKFLOW.mdis missing or has invalid YAML at startup, Symphony does not boot. - If a later reload fails, Symphony keeps running with the last known good workflow and logs the reload error until the file is fixed.
server.portor CLI--portenables the optional Phoenix LiveView dashboard and JSON API at/,/api/v1/state,/api/v1/<issue_identifier>, and/api/v1/refresh.
Linear adapter profile
- Config: use
tracker.kind: linearwithtracker.provider.endpoint(defaulthttps://api.linear.app/graphql),api_key(defaults toLINEAR_API_KEYand accepts$VAR), requiredproject_slug, and optionalassignee(a Linear user ID orme, defaulting toLINEAR_ASSIGNEE). The legacy flattracker.endpoint,api_key,project_slug, andassigneealiases remain supported.required_labels,active_states, andterminal_statesstay undertracker. - Scope and paging: candidate reads filter the configured project slug and requested state names,
following Linear pages of 50. ID refreshes are also project-scoped and batch up to 50 IDs. Empty
state/ID lists return
{:ok, []}without a Linear request. - Identity and normalization:
issue.idis the Linear issue ID andissue.native_refis currentlynil. Records missing a nonblank ID, identifier, title, or state are dropped from candidate pages and fail ID refreshes. State keeps Linear's spelling; integer priorities are preserved and other priority values becomenil; RFC 3339 timestamps are parsed and unusable timestamps becomenil. Labels are trimmed, lowercased, deduplicated, and blanks are dropped; blockers come from inverseblocksrelations. - Dispatchability: the adapter marks an issue dispatchable only when optional assignee routing
matches and a
Todoissue has no non-terminal blocker. The generic scheduler then applies active/terminal states, required labels, claims, retries, and concurrency. - Tool: the Linear adapter advertises
linear_graphql, accepting either a raw query string or an object with nonblankqueryand optional objectvariables. Symphony executes it host-side with the session-bound endpoint/token and strips declared token environment variables from the Codex child.project_slugscopes scheduler reads, not raw tool calls; the tool can access whatever the configured Linear token can access. - Responsibility and errors:
linear_graphqladds no idempotency key, retry, scope guard, or rate-limit policy, so workflows own idempotent mutations and handling provider errors. Read/config failures use{:error, :missing_linear_api_token},{:error, :missing_linear_project_slug},{:error, :invalid_linear_endpoint},{:error, :invalid_linear_assignee},{:error, :missing_linear_viewer_identity},{:error, {:linear_api_status, status}},{:error, {:linear_api_request, reason}},{:error, {:linear_graphql_errors, errors}},{:error, :linear_unknown_payload}, or{:error, :linear_missing_end_cursor}. Tool results are maps with"success", JSON-string"output", and text"contentItems"; invalid arguments, missing auth, and transport failures return"success" => falsewith{"error": {"message": ...}}, while top-level GraphQL errors preserve the response body with"success" => false. For portable reporting, map missing/invalid token, project, endpoint, assignee, or viewer errors totracker_configortracker_auth, request failures totracker_transport, non-200 responses totracker_response(429istracker_rate_limited), GraphQL/unknown payload failures totracker_payload, and missing cursors totracker_pagination; logs and tool responses carry the human-readable provider detail.
GitHub Issues adapter
- Config: use
tracker.kind: githubwith requiredtracker.provider.repoinowner/repoform, optionaltoken(defaults toGITHUB_TOKENand accepts$VAR), and optionalapi_url(defaulthttps://api.github.com, HTTPS only). Set explicitactive_statesandterminal_states; active entries may beopenand terminal entries may beclosed. - Reads and identity: polling is scoped to the configured repository;
issue.idis the repository issue number,issue.identifierisGH-<number>, hidden or deleted404issues are omitted on refresh, and pull requests returned by the Issues API are not dispatchable. - Tool and auth:
github_apiaccepts a relative RESTpathplus optionalparamsand JSONbody; Symphony executes it host-side with the session-bound token, stripsGITHUB_TOKENand configured$VARtoken names from the Codex child, and leaves raw tool access limited by that token's GitHub permissions.
Jira Cloud adapter
- Config: use
tracker.kind: jirawith providerbase_url,email,api_token, and requiredproject_key; the first three default toJIRA_BASE_URL,JIRA_EMAIL, andJIRA_API_TOKENand accept$VAR. Set explicit Jira-nativeactive_statesandterminal_states. - Issues and reads: candidate reads and ID refreshes stay scoped to the configured project and
requested statuses;
issue.idis Jira's immutable ID andissue.identifieris the issue key. - Blockers: inward
Blockslinks populateblocked_by; issues in Jira'snewstatus category wait until blockers reach configured terminal states, while in-progress categories keep running. - Tool:
jira_restsends relative/rest/api/3/requests host-side with configured Basic auth, strips token environment variables from Codex, and can reach whatever the Jira credential can.
Asana adapter
- Config: use
tracker.kind: asanawith requiredtracker.provider.project_gid, optionalendpoint(defaulthttps://app.asana.com/api/1.0), andapi_key(defaults toASANA_PATand accepts$VAR);active_statesandterminal_statesare project section names. - Scope: Symphony polls tasks in the configured project, treats their section as state, and omits deleted or out-of-project tasks during ID refreshes.
- Tool:
asana_apisends relative Asana REST requests host-side with the configured auth; Symphony stripsASANA_PATand configured token variables from the Codex child, while raw tool calls are not limited to the configured project.
GitLab adapter
- Configure
tracker.kind: gitlabwithtracker.provider.project_path, optionalapi_url, andapi_key(defaultGITLAB_PAT); useopenedandclosedtracker states. - Symphony reads project issues by IID and exposes route-safe
GL-<iid>identifiers. gitlab_apiforwards raw GitLab REST requests with host-side auth and keeps GitLab token env vars out of the Codex child.
Web dashboard
The observability UI now runs on a minimal Phoenix stack:
- LiveView for the dashboard at
/ - JSON API for operational debugging under
/api/v1/* - Bandit as the HTTP server
- Phoenix dependency static assets for the LiveView client bootstrap
- Tracker issue identifiers link to the tracker-provided URL when it uses
httporhttps
Project Layout
lib/: application code and Mix taskstest/: ExUnit coverage for runtime behaviorWORKFLOW.md: in-repo workflow contract used by local runs../.codex/: repository-local Codex skills and setup helpers
Testing
make all
Run the real external end-to-end test only when you want Symphony to create disposable Linear
resources and launch a real codex app-server session:
cd elixir
export LINEAR_API_KEY=...
make e2e
Optional environment variables:
SYMPHONY_LIVE_LINEAR_TEAM_KEYdefaults toSYME2ESYMPHONY_LIVE_SSH_WORKER_HOSTSuses those SSH hosts when set, as a comma-separated list
make e2e runs two live scenarios:
- one with a local worker
- one with SSH workers
If SYMPHONY_LIVE_SSH_WORKER_HOSTS is unset, the SSH scenario uses docker compose to start two
disposable SSH workers on localhost:<port>. The live test generates a temporary SSH keypair,
mounts the host ~/.codex/auth.json into each worker, verifies that Symphony can talk to them
over real SSH, then runs the same orchestration flow against those worker addresses. This keeps
the transport representative without depending on long-lived external machines.
Set SYMPHONY_LIVE_SSH_WORKER_HOSTS if you want make e2e to target real SSH hosts instead.
The live test creates a temporary Linear project and issue, writes a temporary WORKFLOW.md, runs
a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the
Linear issue, then marks the project completed so the run remains visible in Linear.
Run the opt-in GitHub Issues live test with a disposable/scratch repository:
cd elixir
export SYMPHONY_LIVE_GITHUB_REPO=owner/scratch-repo
export GITHUB_TOKEN=...
SYMPHONY_RUN_GITHUB_LIVE_E2E=1 mix test test/symphony_elixir/github_live_e2e_test.exs
Run the opt-in Jira Cloud live test against a disposable project whose credential can browse, create, comment on, transition, and delete issues:
cd elixir
export JIRA_BASE_URL=https://your-site.atlassian.net
export JIRA_EMAIL=...
export JIRA_API_TOKEN=...
export SYMPHONY_LIVE_JIRA_PROJECT_KEY=TEST
SYMPHONY_RUN_JIRA_LIVE_E2E=1 mix test test/symphony_elixir/jira_live_e2e_test.exs
Run the opt-in Asana live E2E against disposable Asana resources:
cd elixir
export ASANA_PAT=...
export SYMPHONY_LIVE_ASANA_WORKSPACE_GID=...
# Required only when the workspace is an organization:
# export SYMPHONY_LIVE_ASANA_TEAM_GID=...
SYMPHONY_RUN_ASANA_LIVE_E2E=1 mix test test/symphony_elixir/asana_live_e2e_test.exs
Run the opt-in GitLab live E2E against a disposable project:
cd elixir
export GITLAB_PAT=...
export SYMPHONY_LIVE_GITLAB_PROJECT_ID=...
SYMPHONY_RUN_GITLAB_LIVE_E2E=1 mix test test/symphony_elixir/gitlab_live_e2e_test.exs
FAQ
Why Elixir?
Elixir is built on Erlang/BEAM/OTP, which is great for supervising long-running processes. It has an active ecosystem of tools and libraries. It also supports hot code reloading without stopping actively running subagents, which is very useful during development.
What's the easiest way to set this up for my own codebase?
Launch codex in your repo, give it the URL to the Symphony repo, and ask it to set things up for
you.
License
This project is licensed under the Apache License 2.0.