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

Symphony Elixir screenshot

How it works

  1. Polls the configured tracker for candidate work (included adapters: Linear, GitHub Issues, Jira Cloud, Asana, and GitLab)
  2. Creates a workspace per issue
  3. Launches Codex in App Server mode inside the workspace
  4. Sends a workflow prompt to Codex
  5. 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

  1. Make sure your codebase is set up to work well with agents: see Harness engineering.
  2. Get a new personal token in Linear via Settings → Security & access → Personal API keys, and set it as the LINEAR_API_KEY environment variable.
  3. Copy this directory's WORKFLOW.md to your repo.
  4. Optionally copy the commit, push, pull, land, and linear skills to your repo.
    • The linear skill expects Symphony's linear_graphql app-server tool for raw Linear GraphQL operations such as comment editing or upload flows.
  5. Customize the copied WORKFLOW.md file 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.
  6. 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_arm64
  • macos_x86_64
  • linux_arm64
  • linux_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-root tells Symphony to write logs under a different directory (default: ./log)
  • --port also 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.kind selects an adapter. Adapter-owned endpoint, scope, and auth settings belong under tracker.provider; the current Linear adapter still accepts the older flat endpoint, api_key, project_slug, and assignee aliases for compatibility.
  • tracker.required_labels is 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_policy defaults to {"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}
    • codex.thread_sandbox defaults to workspace-write
    • codex.turn_sandbox_policy defaults to a workspaceWrite policy rooted at the current issue workspace
  • codex.turn_timeout_ms is 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_policy values depend on the targeted Codex app-server version. In the current local Codex schema, string values include untrusted, on-failure, on-request, and never, and object-form reject is also supported.
  • Supported codex.thread_sandbox values: read-only, workspace-write, danger-full-access.
  • When codex.turn_sandbox_policy is 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: true in codex.turn_sandbox_policy; otherwise DNS/network access may be denied by the Codex turn sandbox.
  • agent.max_turns caps 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_create to bootstrap a fresh workspace. For a Git-backed repo, you can run git clone ... . there, along with any other setup commands you need.
  • If a hook needs mise exec inside a freshly cloned workspace, trust the repo config and fetch the project dependencies in hooks.after_create before invoking mise later from other hooks.
  • For the Linear adapter, tracker.provider.api_key reads from LINEAR_API_KEY when unset or when value is $LINEAR_API_KEY. The legacy flat tracker.api_key alias behaves the same way.
  • Do not put a literal tracker token in a repo-owned WORKFLOW.md if 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.root resolves $VAR before path handling, while codex.command stays a shell command string and any $VAR expansion 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.md is 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.port or CLI --port enables 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: linear with tracker.provider.endpoint (default https://api.linear.app/graphql), api_key (defaults to LINEAR_API_KEY and accepts $VAR), required project_slug, and optional assignee (a Linear user ID or me, defaulting to LINEAR_ASSIGNEE). The legacy flat tracker.endpoint, api_key, project_slug, and assignee aliases remain supported. required_labels, active_states, and terminal_states stay under tracker.
  • 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.id is the Linear issue ID and issue.native_ref is currently nil. 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 become nil; RFC 3339 timestamps are parsed and unusable timestamps become nil. Labels are trimmed, lowercased, deduplicated, and blanks are dropped; blockers come from inverse blocks relations.
  • Dispatchability: the adapter marks an issue dispatchable only when optional assignee routing matches and a Todo issue 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 nonblank query and optional object variables. Symphony executes it host-side with the session-bound endpoint/token and strips declared token environment variables from the Codex child. project_slug scopes scheduler reads, not raw tool calls; the tool can access whatever the configured Linear token can access.
  • Responsibility and errors: linear_graphql adds 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" => false with {"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 to tracker_config or tracker_auth, request failures to tracker_transport, non-200 responses to tracker_response (429 is tracker_rate_limited), GraphQL/unknown payload failures to tracker_payload, and missing cursors to tracker_pagination; logs and tool responses carry the human-readable provider detail.

GitHub Issues adapter

  • Config: use tracker.kind: github with required tracker.provider.repo in owner/repo form, optional token (defaults to GITHUB_TOKEN and accepts $VAR), and optional api_url (default https://api.github.com, HTTPS only). Set explicit active_states and terminal_states; active entries may be open and terminal entries may be closed.
  • Reads and identity: polling is scoped to the configured repository; issue.id is the repository issue number, issue.identifier is GH-<number>, hidden or deleted 404 issues are omitted on refresh, and pull requests returned by the Issues API are not dispatchable.
  • Tool and auth: github_api accepts a relative REST path plus optional params and JSON body; Symphony executes it host-side with the session-bound token, strips GITHUB_TOKEN and configured $VAR token names from the Codex child, and leaves raw tool access limited by that token's GitHub permissions.

Jira Cloud adapter

  • Config: use tracker.kind: jira with provider base_url, email, api_token, and required project_key; the first three default to JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN and accept $VAR. Set explicit Jira-native active_states and terminal_states.
  • Issues and reads: candidate reads and ID refreshes stay scoped to the configured project and requested statuses; issue.id is Jira's immutable ID and issue.identifier is the issue key.
  • Blockers: inward Blocks links populate blocked_by; issues in Jira's new status category wait until blockers reach configured terminal states, while in-progress categories keep running.
  • Tool: jira_rest sends 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: asana with required tracker.provider.project_gid, optional endpoint (default https://app.asana.com/api/1.0), and api_key (defaults to ASANA_PAT and accepts $VAR); active_states and terminal_states are 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_api sends relative Asana REST requests host-side with the configured auth; Symphony strips ASANA_PAT and configured token variables from the Codex child, while raw tool calls are not limited to the configured project.

GitLab adapter

  • Configure tracker.kind: gitlab with tracker.provider.project_path, optional api_url, and api_key (default GITLAB_PAT); use opened and closed tracker states.
  • Symphony reads project issues by IID and exposes route-safe GL-<iid> identifiers.
  • gitlab_api forwards 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 http or https

Project Layout

  • lib/: application code and Mix tasks
  • test/: ExUnit coverage for runtime behavior
  • WORKFLOW.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_KEY defaults to SYME2E
  • SYMPHONY_LIVE_SSH_WORKER_HOSTS uses 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.