smol-symphony
v0.4.4
Published
TypeScript orchestrator that runs coding agents (Claude, Codex) in per-issue Gondolin microVMs over ACP.
Maintainers
Readme
smol-symphony
Disclaimer: this project is written using AI — the orchestrator dispatches AI coding agents at itself, and the bulk of the code in this repository was authored by those agents under human review.
A small TypeScript orchestrator that reads issues off a local Markdown tracker, prepares per-issue workspaces, and runs coding agents (Claude Code, Codex) inside isolated per-issue Gondolin microVMs over the Agent Client Protocol.
You file issues; symphony dispatches one agent per issue and handles state,
retry, and concurrency. The agent signals progress through an injected MCP
server (transition, request_human_steering, propose_issue). When you point
it at a GitHub remote it opens a pull request per issue; in local-only mode the
per-issue agent/<id> branch is left in the workspace for review.
New here? Jump to Quick start — install, scaffold, run, with no repo checkout and no image to build.
┌──────────────────────────────────────────────────────────────────────────┐
│ symphony (node host) │
│ │
│ ./issues/<state>/*.md ──┐ │
│ ./WORKFLOW.yaml ├──▶ orchestrator ──▶ agent runner │
│ ./WORKFLOW.template.yaml ──┘ poll · reconcile · dispatch │
│ │ │
│ ▼ ACP/RPC │
│ ┌───────────────────────────────┐ │
│ │ Gondolin (per-issue VM) │ │
│ │ adapter (claude / codex) │ │
│ │ workspace mount │ │
│ │ mcp client ────────────────┼─┐│
│ └───────────────────────────────┘ ││
│ ││
│ symphony MCP server ◀─────────────────────────────────────┘│
│ ( transition · request_human_steering · propose_issue ) │
│ │
│ HTTP dashboard (HTMX): / │
│ attention · sessions · on disk · new issue · totals │
└──────────────────────────────────────────────────────────────────────────┘SPEC.md documents the contracts this repo's code references; for the
original architectural narrative, see
openai/symphony/SPEC.md.
Quick start
From zero to your first dispatched issue. No repo checkout, no image to build — the agent image is a prebuilt asset symphony fetches from a published release the first time it runs.
# 1. Install symphony. (Or skip this and prefix each command below with `npx`,
# e.g. `npx smol-symphony doctor WORKFLOW.yaml`.)
npm i -g smol-symphony
# 2. In your project, scaffold a starter workflow. With no WORKFLOW.yaml present,
# symphony offers to write a ~30-line starter (plus a prompts/Todo.md and an
# issue-filing skill), then exits so you can glance at it. It runs as-is.
cd /path/to/your/project
symphony WORKFLOW.yaml
# 3. Check the host is ready before the first dispatch. `doctor` runs every
# prerequisite probe — Node version, the agent image, your adapter credential,
# a writable tracker, a bindable dashboard port — and prints a PASS/FAIL line
# with a one-line fix for each failure, exiting 0 only if every check passes.
symphony doctor WORKFLOW.yaml
# 4. Run symphony. The first run downloads the prebuilt agent image (one-time,
# surfaced on the dashboard); then it serves the dashboard and starts dispatching.
symphony WORKFLOW.yaml --port 8787
# 5. Open http://127.0.0.1:8787/ and add your first issue from the "new issue"
# form. symphony picks it up, boots a per-issue microVM, and dispatches the
# agent — watch the issue move from "on disk" into a running session.That's the whole path. Two host prerequisites symphony can't install for you,
both checked by doctor in step 3:
- Node.js ≥ 23.6 (the
engines.nodefloor;doctorreports your version). - One adapter credential. For the default
claudeadapter, a~/.claude/.credentials.jsonon the host — runclaude loginonce. (Forcodex, see WORKFLOW.template.yaml.) The credential never enters the VM — see Trust posture.
The scaffolded WORKFLOW.minimal.yaml runs as-is on the
managed image; the only edits a different setup needs are that adapter credential
and — if you'd rather pin your own image than fetch the managed one —
gondolin.image. The first-run scaffold also drops an issue-filing skill at
.claude/skills/symphony-issues/SKILL.md (best-effort — it never blocks the
scaffold), so a coding agent in the freshly-onboarded repo can file well-formed
tracker issues and decompose a large task into a blocked_by DAG from day one.
When you want a power feature the starter omits — typed action DAGs, PR autopilot, per-state adapters, egress allowlists, sleep-cycle reflection — graduate to the annotated WORKFLOW.template.yaml. Full detail behind each step is in Prerequisites in detail — you should not need it to get here.
Prerequisites in detail
- Node.js ≥ 23.6. The
engines.nodefloor;symphony doctorreports your running version against it. - The agent image — fetched, not built. Gondolin is the in-process microVM
substrate (
@earendil-works/gondolin), so there is no separate VM daemon to run. The scaffolded workflow setsgondolin.image: managed, which fetches a prebuilt agent rootfs from a published smol-symphony release on first run — one asset per host arch (x86_64,aarch64), keyed to the symphony version. No docker, nonpm run build:image, no local OCI convert. The image ships only themise(jdx/mise) binary; node + every ACP-capable coding agent CLI (claude-agent-acp,codex-acp) come from a mise SYSTEM config symphony stages into the guest at dispatch (assets/symphony-mise.system.toml), and a consuming repo's ownmise.tomladds project toolchains (rust, go, kotlin+gradle, …) on top. So bumping an agent CLI is a one-line edit to the staged mise config + a restart — no image rebuild. On an unsupported arch (no published asset), or when you'd rather build/pin your own, setgondolin.imageto a local build id /name:tag(npm run build:imagefrom a checkout prints one — see images/agents/README.md) orgondolin.oci_imageto an OCI ref symphony auto-converts on first reconcile. Tune the managed fetch and the mise data dir via thegondolinkeys in WORKFLOW.template.yaml. - An adapter credential. For the default
acp.adapter: claude: a credentials file at~/.claude/.credentials.jsonon the host (runclaude loginonce). Symphony reads it only on the host side: the guest holds only a token-shaped placeholder, and the host substitutes the real OAuth access token into the outbound request at Gondolin egress (TLS-MITM). The credential file itself is never staged into the VM. (Foracp.adapter: codex, see WORKFLOW.template.yaml.) - Where state lives. The tracker, run logs, and per-issue workspaces default
to
~/.symphony/{trackers,logs,workspaces}/<project>— outside your repo, so a delete / re-clone doesn't wipe them.<project>is derived fromworkspace.github_repo(or the workflow-file directory name). The startup banner prints the resolved tracker root, workspace root, dashboard URL, and log file; override any with the matching*.rootkey (e.g.tracker.root: ./issuesto keep issues inside the repo). - Console output. With the default log file configured, symphony prints a
one-line-per-field startup banner (workflow, tracker root, dashboard URL,
log-file path) and routes the structured
key=valuestream to the log file only (~/.symphony/logs/<project>/symphony.logby default).tail -fthat file to follow the detail. Pass--verbose(alias--foreground) to mirror the structured stream back to the console for interactive debugging. With no log file configured (theSYMPHONY_LOG_FILE=""override), the structured stream stays on stderr.
From a checkout
If you're hacking on symphony itself:
git clone https://github.com/dizk/smol-symphony.git
cd smol-symphony
npm install
npm run build
npx symphony WORKFLOW.yaml # the local binnpm run dev (via tsx watch) reruns on source edits.
Local Markdown tracker
Issues live as .md files under tracker.root. The parent directory is the
issue state; the set of valid state directories comes from the states: block
in WORKFLOW.yaml (see below) and is auto-mkdir'd on startup.
issues/
├── Todo/
│ ├── 1.md
│ └── 2.md
├── Review/
│ └── 3.md
├── Done/
│ └── 4.md
└── Triage/
└── 5.mdThe basename is the issue identifier. When a caller (dashboard form, MCP
propose_issue) omits an explicit identifier, the tracker picks the next free
positive integer by scanning every state directory under tracker.root.
Operator-supplied identifiers (e.g. CACHE-7.md) pass through unchanged and
coexist with the numeric ones.
Each file has YAML front matter and an optional body:
---
title: "Fix the login bug"
priority: 2
labels: [bug, auth]
blocked_by: [5]
---
Long-form description in the body.State comparison is case-insensitive. Moving the file between state
directories is the canonical state transition; the orchestrator does this
itself in response to symphony.transition. The agent inside the VM does
not have filesystem access to the tracker root: it signals progress
through the MCP server and the orchestrator does the file move.
WORKFLOW.yaml
WORKFLOW.yaml is a pure YAML config file (the orchestrator config). Each
active state's prompt lives in its own prompt_file — a
Liquid-templated file under prompts/, wrapped in the
shared prompt.preamble_file/footer_file. The shipped file in this repo is
the canonical project workflow; see
WORKFLOW.template.yaml for the annotated reference
covering every supported option, its type, default, and example.
The workflow is a state machine. A required top-level states: block
declares every state an issue can occupy, its role (active — dispatched;
terminal — triggers cleanup and handoff; holding — sits outside the
dispatch loop, e.g. Triage), and optional per-state adapter, model,
max_turns, and allowed_transitions overrides. A single issue can travel
through any number of states with distinct adapters and instructions; each
active state names its own prompt_file, so the agent in each state sees only
that state's instructions plus the shared preamble/footer. The shipped workflow
uses a two-stage Todo → Review → Done flow (Claude implements, Codex reviews).
Symphony watches the file and its prompt files, and re-applies poll interval, concurrency, typed actions, prompts, gondolin settings, etc. on change without restart. In-flight runs keep the settings they started with.
Dashboard
When server.port is set (or --port <n> is passed), a single-page HTMX
dashboard is served at /. Five live regions poll their own partials every
2s; idiomorph keeps unchanged DOM stable so polling doesn't twitch text.
- header strip — workflow file + tracker root + status badge
(
working/attention/idle). - attention — only present when something needs you. Steering requests
(question, original task, agent's context, reply textarea) and retry
queue ease open with a CSS
max-heighttransition so the page doesn't jump. - sessions — running issues. Two-line rows: identifier + pill + turn + tokens, then a dim last-message line.
- on disk — active-state issues not currently dispatched.
- new issue — collapsed
<details>form. Posts JSON toPOST /api/v1/issues. - totals — dim footer with token + runtime aggregate.
The steering reply form posts form-encoded with HX-Request: true and a
same-origin check. The endpoint also accepts application/json for direct
API clients. CSRF-relevant content types (text/plain,
multipart/form-data) are rejected with 415.
MCP — how the agent talks back
Symphony injects an MCP server into each ACP session at
http://<host>:<bound-port>/api/v1/issues/<id>/mcp, gated by a per-dispatch
bearer token. Three tools:
symphony.transition({ to_state, notes? })— canonical (and only) exit verb. Moves the issue into another declared state, optionally appending markdownnotesto the issue body before the move so the next agent (into_state) reads them as part ofissue.description. A transition into arole: terminalstate ends the run and triggers workspace cleanup; transitions between active/holding states preserve the workspace so the sameagent/<id>git branch survives the handoff. Rejected transitions (unknown target, disallowed byallowed_transitions) return MCP tool-result errors the agent can read and retry.symphony.request_human_steering({ question, context? })— call when blocked on something only a human can answer. The turn ends immediately; the human's reply arrives as the prompt for the next turn. Steering-reply turns don't count againstagent.max_turns.symphony.propose_issue({ title, description?, labels?, priority? })— call when the agent notices work that is out of scope for its current task. The proposal lands in theTriage/state directory, which the orchestrator does not dispatch; the operator approves (→ first active state) or discards (→ first terminal state, prefersCancelled) from the dashboard. The calling issue's identifier and a timestamp are stamped into the proposal's front-matter asproposed_by/proposed_atso provenance is visible.
In Gondolin, the VM's 127.0.0.1 transparently reaches the host's
127.0.0.1 (verified empirically), so the agent reaches the orchestrator
without any mount or special host alias.
ACP — adapter registry
One ACP client (symphony's agent/acp.ts), two shipped adapter profiles.
Each profile encodes the binary symphony launches and the credential path
the adapter reaches for inside the VM:
| Adapter | Binary | Credential surface |
| --------- | ------------------ | --------------------------------- |
| claude | claude-agent-acp | placeholder + host egress swap |
| codex | codex-acp | placeholder + host egress swap |
WORKFLOW.yaml:
acp:
adapter: claude
shell: bash
prompt_timeout_ms: 1800000
read_timeout_ms: 30000
stall_timeout_ms: 300000Selecting an adapter is enough — symphony auto-derives the launch command.
For claude, a per-VM identity file (organization + account UUIDs only,
no tokens) is staged into the workspace runtime dir and copied to
~/.claude.json inside the VM; the guest holds only a token-shaped
placeholder, and the host substitutes the real access token into the
outbound request at Gondolin egress. For codex, no identity file is
staged (OpenAI ships no third-party fingerprint check); the VM is launched
with OPENAI_API_KEY=<placeholder>, and the host substitutes the real
OpenAI credential at egress. Set command: only to override (testing a
forked adapter, a non-standard binary path).
Credentials never enter the VM for either adapter. For claude, the
host's ~/.claude/.credentials.json is read only on the host side; the VM
sees ~/.claude.json (identity-only) plus the placeholder value in its
Authorization header, which the host swaps for the real token at egress.
For codex, the host's ~/.codex/auth.json (access token /
OPENAI_API_KEY, never the refresh token) is read only host-side; the
real OPENAI_API_KEY is stripped from the forwarded VM boot env, so the VM
holds only the placeholder.
After-run handoff: pull request
On transition into the Done state the orchestrator runs that state's typed
actions: block — two records, push_branch then create_pr_if_missing —
which push the per-issue branch and open a PR when a GitHub repo is configured:
set workspace.github_repo: <owner>/<repo> in WORKFLOW.yaml (or export
SYMPHONY_REPO=<owner>/<repo>, which overrides the file). Each action's
if: $repo predicate short-circuits to a no-op in local-only mode (neither
set), so the branch is simply left in the workspace until cleanup; pick the
commits up with git log agent/<id> against your local clone. The PR is opened with gh pr create --base
$base_branch ..., which requires gh auth status to be clean on the host; the
token never enters the VM.
The orchestrator stages the action context — $branch, $base_branch,
$pr_title (already id-prefixed), and $pr_body_file (a temp file holding the
current issue body) — before the block fires, so the PR description carries
every symphony.transition notes block accumulated across the run, the full
handoff thread from implementer through reviewer to approval. Per-action retry
and snapshot plumbing replaces the old opaque shell-exit surface: on a
rate-limit the create_pr_if_missing action surfaces "retrying in 60s" on the
dashboard rather than failing silently.
See states.Done.actions in WORKFLOW.yaml for the canonical
record pair and WORKFLOW.template.yaml for the
typed-action reference.
Trust posture
Sandbox isolation comes from running each agent inside a Gondolin microVM.
The VM has no OAuth refresh tokens or long-lived access tokens: for both
claude and codex, the guest holds only a token-shaped placeholder and
the host substitutes the real credential into the outbound request at
Gondolin egress, so no real Anthropic or OpenAI credential is present in
the VM. The VM has no tracker filesystem access (the tracker is reached
only through the MCP server) and stripped git remotes (set by the
orchestrator's setupWorkspaceDir).
Within the ACP session, the orchestrator follows SPEC §6.1's "high-trust" posture:
- Command execution and file change approvals: auto-approve.
- User-input-required turns: end the turn (the orchestrator retries on backoff).
- Unsupported dynamic tool calls: return failure; the session keeps running.
Tests
npm run typecheck # tsc --noEmit
npm test # ~1400 tests across the core deciders + shell adapters:
# workflow, tracker, prompt, workspace, adapters, http, mcp,
# acp-bridge, orchestrator, run log, and runner state resolution
npm run build # tsc emit to dist/npm run verify runs the full gate set CI enforces: typecheck, the eslint
purity/budget rules, the dependency-cruiser layering walls, the ratcheted
architecture gates, and the tests.
An end-to-end smoke run needs the built agent image (see images/agents/).
See CHANGELOG.md for operator-visible changes between releases.
License
MIT. See LICENSE.
