AI-powered ticket orchestration daemon
Project description
Symphony
Symphony turns Linear into the front end for a fleet of AI coding agents. Label a ticket, walk away, and read the agent's reply when you have a moment; when you reply, the agent picks the thread back up. It runs as a single daemon on your own machine, clones each ticket's repo into its own sandbox, hands the work to OpenCode, and posts the result back to Linear as a comment. Several tickets can be in flight at once, so you can keep planning while the agents type.
The daemon is small, self-hosted, and has no UI of its own. Linear is the UI.
Screenshots
Quickstart
You need Python 3.11+, git, bwrap (bubblewrap), and OpenCode installed and
authenticated. Then:
# 1. Install
uvx symphony-linear --help
# 2. Create a workspace directory and a minimal config inside it
mkdir ~/symphony && cd ~/symphony
cat > config.yaml <<'YAML'
linear:
api_key: ${LINEAR_API_KEY}
bot_user_email: you+symphony@example.com
YAML
# 3. Provide the bot's Linear API key and run
export LINEAR_API_KEY=lin_api_...
uvx symphony-linear
That gets you a running daemon. To actually trigger work you still need to
do the Linear setup (create a bot user, add a label, attach
a repo link to a project) and add the Agent label to a ticket.
Installation
Symphony is published on PyPI as symphony-linear, and that is also the
name of the command it installs.
With uvx (recommended)
uvx runs Symphony in a managed virtual environment without touching your
system Python:
uvx symphony-linear --help
This is convenient for casual use and for --validate-config. For a
long-running daemon you may prefer to install it once rather than have
uvx resolve the environment on every start:
uv tool install symphony-linear
symphony-linear --help
With pip
If you don't have uv, plain pip works:
pip install symphony-linear
symphony-linear --help
Use pipx or a virtualenv if you'd rather not install into your system
Python.
From source
git clone https://github.com/skorokithakis/symphony.git
cd symphony
uv sync
.venv/bin/symphony-linear --help
Runtime dependencies
These are not Python packages and won't be installed for you:
- bwrap (bubblewrap):
apt install bubblewrap,dnf install bubblewrap, orpacman -S bubblewrap. - git, configured well enough to clone the repos you want the agent to work on.
- OpenCode, installed and authenticated. The daemon invokes
opencodeinside the sandbox; whatever is on the daemon's$PATHis visible to the agent. SetSYMPHONY_SANDBOX_PATHif the daemon runs with a stripped$PATH, for example undersystemd.
Linear setup
This is the one-off plumbing that connects Symphony to your Linear workspace. You do it once per workspace, plus a small per-repo step for each project you want the agent to touch.
Create a bot user
Create a separate Linear user for the bot so its comments and state changes
are easy to spot. Gmail aliases work: yourname+symphony@gmail.com. Invite
the bot into your workspace.
Generate a Personal API key
Sign into Linear as the bot. Open Settings → API → Personal API
keys, create a key, and keep it somewhere safe; this is the value you'll
supply as LINEAR_API_KEY.
Add a "Needs Input" workflow state
In your team's workflow settings, add a state called Needs Input.
Symphony moves tickets here when it finishes a turn and is waiting for you
to reply. You can rename this state later; the name lives in config.yaml.
Create the trigger label
Add a label called Agent to the team. Any ticket carrying this label becomes eligible for the agent. The label name is configurable.
Optional: a QA workflow state
If you'd like to manually exercise the agent's work, for instance by
clicking around a running web app, add a workflow state called QA, point
linear.qa_state at it in your config, and add a .symphony/serve script
to your repo. When you drop a ticket into that state the daemon runs your
serve script inside the sandbox. Details under Manual QA.
Per-repo: attach a Repo link
For each repository you want Symphony to work on:
- Create a Linear project. Any team project will do; Symphony only uses it to find the repo URL.
- In that project, open Resources and add a link with the label
Repo(case-insensitive) and the git clone URL as the target, for examplegit@github.com:you/your-project.git.
That link is how the daemon discovers which repo belongs to which ticket.
Configuration
Symphony reads config.yaml from its workspace directory, which defaults to
the current working directory and can be overridden with --workspace. The
daemon refuses to start without a valid config; validate it any time with
symphony-linear --validate-config.
Minimal config
linear:
api_key: ${LINEAR_API_KEY}
bot_user_email: yourname+symphony@gmail.com
You can omit api_key entirely and let the daemon read the LINEAR_API_KEY
environment variable directly; that is often nicer for systemd or secret
managers.
Full annotated config
# config.yaml (placed in the workspace directory)
linear:
# REQUIRED. Linear Personal API key from the bot account.
# Use ${LINEAR_API_KEY} to read from the environment, or omit this field
# entirely and the daemon will fall back to the LINEAR_API_KEY env var.
api_key: ${LINEAR_API_KEY}
# REQUIRED. Email address of the bot user in Linear.
bot_user_email: yourname+symphony@gmail.com
# Name of the label that triggers the bot (default: Agent).
trigger_label: Agent
# Workflow state set while the AI is working (default: In Progress).
in_progress_state: In Progress
# Workflow state set while waiting for human reply (default: Needs Input).
needs_input_state: Needs Input
# Optional. Workflow state that enables manual QA. When a ticket enters
# this state the daemon runs the repo's .symphony/serve script inside the
# sandbox. Only one serve runs globally; the newest entrant wins. Omit to
# disable the feature entirely.
# qa_state: QA
sandbox:
# Paths to conceal from the agent inside the sandbox. Directories become
# an empty tmpfs; files and sockets are replaced with /dev/null. ~ and
# symlinks are expanded.
hide_paths:
- ~/.ssh
- ~/.gnupg
- ~/.aws
- ~/.config/gcloud
- ~/.netrc
- ~/.docker
- /run/docker.sock
# Optional. Extra host paths bound read-write into the sandbox.
# Missing paths cause a fatal error. Applied before hide_paths, so hiding
# still wins in case of collision.
# WARNING: these bypass the read-only host root mount.
# extra_rw_paths:
# - ~/projects/shared-tools
# Seconds between Linear poll cycles (default: 30, minimum: 1).
poll_interval_seconds: 30
# Max seconds per AI turn before the process is killed (default: 1800).
turn_timeout_seconds: 1800
A copy of this example lives at config.yaml.example in the repo root.
Running
symphony-linear
Symphony runs in the foreground and logs to stderr. For interactive use it
is fine to start it in tmux or screen; for anything more permanent a
systemd --user unit is the obvious home.
A starting point for ~/.config/systemd/user/symphony.service:
[Unit]
Description=Symphony Linear daemon
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/symphony
Environment=LINEAR_API_KEY=lin_api_...
# systemd strips PATH; tell the sandbox where to find opencode, git, bwrap.
Environment=SYMPHONY_SANDBOX_PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.local/bin/symphony-linear
Restart=on-failure
[Install]
WantedBy=default.target
Then systemctl --user daemon-reload && systemctl --user enable --now symphony.
Flags
| Flag | Effect |
|---|---|
--debug |
Enable DEBUG-level logging. |
--workspace <path> |
Override workspace directory (default: current working directory). |
--validate-config |
Load and validate the config, then exit. |
Startup behaviour
On launch the daemon recovers any orphan tickets it was working on when it
last stopped. It posts a recovery comment and parks the ticket in Needs Input so you can decide whether to retry. State is persisted at
<workspace>/state.json and rewritten atomically.
Graceful shutdown
SIGINT (Ctrl+C) or SIGTERM triggers a clean shutdown: in-flight
subprocesses are killed, state is persisted, and the daemon exits.
How it works
Every poll_interval_seconds, Symphony queries Linear for tickets that
carry the trigger label and live in one of the active workflow states. New
tickets enter the initial pipeline:
- Find the project's
Repolink to discover the git URL. - Clone or update the repo into
<workspace>/<sanitised-identifier>. - Switch to the ticket's branch, creating one if needed. If
auto_branch: falseis set, the workspace stays on whatevergit cloneproduced (typically the remote default branch). - Run
.symphony/setupinside the sandbox, if your repo has one. - Launch
opencode runinside the sandbox with the ticket's title and description as the prompt. - Post the agent's final message as a comment, plus a small metadata comment with the workspace path and OpenCode session id.
- Transition the ticket to
Needs Input.
Tickets you've already replied to enter the resume pipeline instead:
the daemon picks up the new human comments, runs opencode run --session <id>, and posts the result.
Up to five turns run in parallel across different tickets, with per-ticket serialisation so a single ticket never has two turns in flight. The agent and you only ever communicate through Linear comments; the daemon has no other channel.
Sandbox
Each OpenCode turn runs inside a bubblewrap sandbox. The ticket's workspace
is mounted read-write; the rest of the host root is read-only. Credential
directories such as ~/.ssh, ~/.gnupg, ~/.aws, the Docker socket and a
handful of others are concealed by overlaying empty tmpfs or /dev/null.
The network namespace is shared so the agent can reach the internet, but
user, PID, IPC and UTS namespaces are isolated. Environment is wiped down
to HOME plus an inherited PATH (or SYMPHONY_SANDBOX_PATH if set).
Git operations run outside the sandbox using the daemon's own credentials,
so cloning private repositories works without exposing your keys to the
agent. The flip side is that the agent itself cannot git push; you do
that yourself, after reviewing.
Manual QA
If you set linear.qa_state and add an executable .symphony/serve script
to your repo, moving a ticket into that workflow state launches the script
inside the sandbox. Use it to run a dev server, a worker, or anything else
you want to exercise by hand.
Only one serve runs across the whole daemon. Dropping a second ticket into
QA bumps the first back to Needs Input and starts the new one. Commenting
on a ticket that is currently in QA pulls it back out into In Progress:
on the next poll tick the serve is killed, the agent runs another turn on
your comment, and the ticket lands in Needs Input. Move it back to QA to
test again.
The script is given no time limit and the daemon does not interpret its
output. If it exits non-zero within ten seconds, or exits later for any
reason, the daemon posts a comment containing the exit code and a thousand
characters of stdout/stderr, and the ticket goes back to Needs Input.
Clean exits within ten seconds are treated as a parent that has daemonised
a child, and are silent.
Repo conventions
Three optional files in a repo change how Symphony treats it. All three
live under .symphony/ at the repo root.
.symphony/setup
An executable script run inside the sandbox once, right after each fresh clone, before the agent starts. Use it to install dependencies, prepare caches, or whatever else the project needs. Non-zero exit aborts the ticket with an error comment. The script has a five-minute timeout.
.symphony/serve
An executable script run inside the sandbox when the ticket enters the
configured qa_state. See Manual QA for the details.
.symphony/config.yaml
Optional per-project overrides for a small set of global settings. Currently supported keys:
| Key | Type | Default | Notes |
|---|---|---|---|
auto_branch |
bool | inherits global | Applied on first clone, not on resume. |
turn_timeout_seconds |
int (> 0) | inherits global | Re-read on every turn (initial and resume). |
# .symphony/config.yaml (committed in your project repo)
auto_branch: false
turn_timeout_seconds: 600
Unknown keys, invalid YAML or out-of-range values cause Symphony to post an error comment on the ticket and block the run until you fix the file and comment to retry. Per-project values win over the global config; missing keys fall back to the global value.
Troubleshooting
Find the workspace and session id
For every ticket it processes, Symphony posts a small metadata comment in this shape:
**Symphony**
- workspace: `<workspace>/TEAM-42`
- session: `ses_abc123`
The workspace path is where the repo was cloned; the session id is the OpenCode session you can resume manually.
Resume a session by hand
cd <workspace>/TEAM-42
opencode run --session ses_abc123 -- "Hello, what's the status?"
OpenCode session state lives under ~/.opencode/ and
~/.local/share/opencode/. These are bind-mounted into the sandbox so
session resumes work both from inside the daemon and from your shell.
Check daemon state
cat <workspace>/state.json | python -m json.tool
This shows every tracked ticket, its status, workspace path, branch, and session id.
Limitations
- No
git pushfrom inside the agent. The sandbox conceals credentials, so the agent cannot push. Pushing is a deliberate human step. - No mid-turn steering. You cannot interrupt or redirect a turn while it is running. Comments you post mid-turn are queued and delivered at the start of the next one.
- No auto-retry. A failed turn moves the ticket to
failedand stays there. Comment on the ticket to re-trigger. - Single workspace per ticket. A ticket's workspace is reused across turns; the agent works in the same clone every time.
- Label-only trigger. The trigger label is the only way to enrol a ticket. There is no manual nudge, slash command, or webhook.
- No priority. Tickets are picked in whatever order Linear returns them. There is no queue.
- One QA serve at a time. A single
.symphony/serveruns globally with no port allocation. Your script is responsible for binding to whichever port you (or your reverse proxy) expect. - Free Linear plan caps. Free workspaces are capped at 10 members and 250 issues; the bot counts against the member cap.
Development
git clone https://github.com/skorokithakis/symphony.git
cd symphony
uv sync
.venv/bin/pytest # full suite
.venv/bin/pytest -m "not integration" # unit only
Integration tests shell out to bwrap and git but never to the real
opencode binary or any LLM. See AGENTS.md for an orientation to the
codebase.
License
MIT. See LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file symphony_linear-0.2.0.tar.gz.
File metadata
- Download URL: symphony_linear-0.2.0.tar.gz
- Upload date:
- Size: 309.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
19459b0db916d48d028a7a3e3b023c61bc8c9bcb832b99783d45a4c5ead60fb6
|
|
| MD5 |
1e0dbad046f91176392c4cdf775aae7a
|
|
| BLAKE2b-256 |
c958cd580df9a1f9116ff7eb191bdd8ff1563e42952d47d85fab9c7d55a45aca
|
Provenance
The following attestation bundles were made for symphony_linear-0.2.0.tar.gz:
Publisher:
publish-pypi.yml on skorokithakis/symphony
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
symphony_linear-0.2.0.tar.gz -
Subject digest:
19459b0db916d48d028a7a3e3b023c61bc8c9bcb832b99783d45a4c5ead60fb6 - Sigstore transparency entry: 1541432235
- Sigstore integration time:
-
Permalink:
skorokithakis/symphony@05307993450cea61cf163ac72d4c0884951f8d5b -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/skorokithakis
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@05307993450cea61cf163ac72d4c0884951f8d5b -
Trigger Event:
push
-
Statement type:
File details
Details for the file symphony_linear-0.2.0-py3-none-any.whl.
File metadata
- Download URL: symphony_linear-0.2.0-py3-none-any.whl
- Upload date:
- Size: 59.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68b985bacb82f14b199afb5185a1a2f7152fb62e344c639a0be6f6de1c817ece
|
|
| MD5 |
de1b1b431514495f62df0b4d86b7b775
|
|
| BLAKE2b-256 |
90a9e9bfcdd28327634706e4039df150ffd2ac2c0c3fdf8c4747f2b68e599170
|
Provenance
The following attestation bundles were made for symphony_linear-0.2.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on skorokithakis/symphony
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
symphony_linear-0.2.0-py3-none-any.whl -
Subject digest:
68b985bacb82f14b199afb5185a1a2f7152fb62e344c639a0be6f6de1c817ece - Sigstore transparency entry: 1541432364
- Sigstore integration time:
-
Permalink:
skorokithakis/symphony@05307993450cea61cf163ac72d4c0884951f8d5b -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/skorokithakis
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@05307993450cea61cf163ac72d4c0884951f8d5b -
Trigger Event:
push
-
Statement type: