Workflow orchestration for AI harnesses.
Orchestron turns multi-step, agentic work into repeatable, observable, and
budget-aware workflows. Define a Score (a DAG of Movements), pick a
harness (Pi, opencode, or future adapters), and let a Conductor run the
Concert while tracking spend, tokens, and state in a local SQLite store
(Loge), with a raw session stream per concert at
~/.orchestron/concerts/<concertId>/stream.jsonl and replayable native session
snapshots per attempt.
- Repeatable workflows — encode your planning/execution/review loops as YAML scores instead of one-off prompts.
- Harness-agnostic — run movements on Pi, opencode, or future adapters through the same interface.
- Observable — concerts, movements, outputs, and goal evaluations are
persisted to a local SQLite database (
Loge); every raw harness-session event (prompts, tool activity, message deltas) is streamed verbatim, in order, to a per-concertstream.jsonl, with replayable native session files per attempt under~/.orchestron/concerts/<concertId>/. - Budget-aware — set spend, movement, and duration limits at the score or section level.
- Composable — scores can spawn sub-scores as child concerts.
| Term | Meaning |
|---|---|
| Maestro | The human operator (you). |
| Score | A workflow definition: a DAG of movements with transitions. |
| Movement | A single step in a workflow. |
| Section | Logical grouping of movements (e.g. "Planning", "Execution", "Review"). |
| Concert | A running instance of a Score. |
| Conductor | Engine that executes one Concert. |
| Concert Hall | Registry that creates, finds, and manages Conductors. |
| Musician | A harness adapter (Pi, opencode, Claude). |
| Evaluator | A separate harness session that judges goal achievement. |
| Loge | The SQLite-backed observability/store layer. |
- Node.js 22+
- pnpm 9.15+
pnpm installpnpm typecheck
pnpm testOrchestron looks for score files (*.score.yaml) in two places
by default:
./.orchestron/scores/— project-local scores, checked first~/.orchestron/scores/— global scores, checked second
Local scores take priority over global scores when the same score ID is present in both.
# Copy an example to the project-local scores directory
mkdir -p ./.orchestron/scores
cp examples/opencode-demo.score.yaml ./.orchestron/scores/
# Or use the global directory
mkdir -p ~/.orchestron/scores
cp examples/opencode-demo.score.yaml ~/.orchestron/scores/
# Start a concert
pnpm orchestron start opencode-demo --context.topic='Obsidian plugins'
# Monitor it
pnpm orchestron list
pnpm orchestron status <concert-id> # reads the concert's stream.jsonl
pnpm orchestron status # overview of all concerts
pnpm orchestron status <concert-id> --watch # tail stream.jsonl (--raw for raw records)
pnpm orchestron status <concert-id> --raw # one raw JSON envelope per line
# Reopen a recorded session (per movement/attempt)
pnpm orchestron session <concert-id> <movement-id> # final session of a movement
pnpm orchestron session <concert-id> <movement-id> --attempt <n> # a specific retry
pnpm orchestron session <concert-id> <movement-id> --print # render transcript
pnpm orchestron session <concert-id> <movement-id> --open # continue in its harness
Every raw harness-session event is recorded to JSONL under
`~/.orchestron/concerts/<concertId>/stream.jsonl` (one file per concert, line
order = event order) — the SQLite `events` table keeps only concert-level
lifecycle events.
# Pause, resume, or cancel a concert
pnpm orchestron pause <concert-id>
pnpm orchestron resume <concert-id>
pnpm orchestron cancel <concert-id>
# List available scores
pnpm orchestron scores
pnpm orchestron scores --validateUse --json for scriptable output and --store <path> for a custom SQLite
path. Pass --context.key=value arguments to populate the concert's initial
context. Use --scores-dir <dir> to add a custom directory (can be passed
multiple times).
Use --harness <name> to set the default harness for movements that don't
specify one. This defaults to pi.
Settings are resolved with this priority (highest to lowest):
CLI flags →
ORCHESTRON_*environment variables →~/.orchestron/config.json→ code defaults
Create ~/.orchestron/config.json to set persistent defaults:
{
"storePath": "~/.orchestron/store.db",
"scoresDirs": ["~/.orchestron/scores"],
"defaultHarness": "pi",
"opencode": {
"provider": "opencode",
"modelId": "kimi-k2.5"
},
"pi": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514"
}
}Paths starting with ~/ are expanded to your home directory.
| Variable | Overrides | Default |
|---|---|---|
ORCHESTRON_STORE_PATH |
SQLite store location | ~/.orchestron/store.db |
ORCHESTRON_SCORES_DIRS |
Comma-separated score directories | ./.orchestron/scores, ~/.orchestron/scores |
ORCHESTRON_DEFAULT_HARNESS |
Default harness for movements | pi |
ORCHESTRON_OPENCODE_PROVIDER |
Opencode model provider | opencode |
ORCHESTRON_OPENCODE_MODEL_ID |
Opencode model ID | kimi-k2.5 |
ORCHESTRON_PI_PROVIDER |
Pi model provider | — |
ORCHESTRON_PI_MODEL_ID |
Pi model ID | — |
Environment variables take precedence over the config file but are overridden
by explicit CLI flags (--store, etc.).
Each movement picks its harness using this priority chain:
movement.harnessin the score definition- Explicit harness passed to the command (e.g.
startConcert({ harness: 'pi' })) - Default harness configuration (
--harness,ORCHESTRON_DEFAULT_HARNESS, ordefaultHarnessin config)
The same chain applies to the evaluator: score.evaluator.harness → explicit harness → default harness.
import { SqliteLoge, ScoreRegistry, ConcertHall, FakeEvaluator } from '@orchestron/core';
import { PiAdapter } from '@orchestron/adapter-pi';
import { OpencodeAdapter } from '@orchestron/adapter-opencode';
const store = new SqliteLoge('./store.db');
const registry = new ScoreRegistry();
registry.loadFrom('./examples/opencode-demo.score.yaml');
const adapters = new Map([
['pi', new PiAdapter()],
['opencode', new OpencodeAdapter()],
]);
const hall = new ConcertHall({
store,
scoreRegistry: registry,
adapters,
evaluator: new FakeEvaluator({ alwaysSucceed: true }),
});
const conductor = await hall.createConcert('opencode-demo', {
initialContext: { topic: 'Obsidian plugins' },
});
await conductor.start();
const state = await conductor.getState();
console.log(state.status, state.history);Scores are YAML files with movements, goals, transitions, and program-level constraints.
id: opencode-demo
name: "Opencode Demo"
version: "1.0.0"
program:
maxMovements: 10
persistSession: true
startMovement: analyze
movements:
- id: analyze
name: "Analyze Topic"
section: planning
harness: opencode
prompt: >
Analyze the following topic and provide a concise summary:
{{context.topic}}
output:
mode: structured
schema:
type: object
properties:
summary: { type: string }
key_points:
type: array
items: { type: string }
required: [summary, key_points]
goal:
description: "Analysis is clear and structured"
strategy: llm_judge
transitions:
- to: summarize
on: success
- to: __fail__
on: failure
- id: summarize
name: "Summarize Analysis"
section: delivery
harness: opencode
prompt: >
Based on the previous analysis, produce a one-paragraph final summary:
{{context.previousOutputs.analyze}}
goal:
description: "Final summary is concise and accurate"
strategy: llm_judge
transitions:
- to: __end__
on: successMovement prompts can reference:
{{context.<key>}}— shared context values.{{context.previousOutputs.<movementId>}}— raw output from a previous movement.
on: success— when the movement completes and the evaluator says the goal is achieved.on: failure— when the movement fails or the goal is not achieved.on: any— wildcard: matches eithersuccessorfailure.- Special targets:
__end__and__fail__.
Set limits in program:
program:
maxSpendDollars: 2 # dollars
maxMovements: 100
maxDurationMs: 600000
maxNestingDepth: 5
persistSession: trueMaestro / CLI / Plugin
│
▼
┌─────────────────┐
│ Orchestron SDK │
│ │
│ ConcertHall │── creates ──▶ Conductor
│ ScoreRegistry │
│ Loge (SQLite) │
│ Evaluator │
└─────────────────┘
│
▼
┌─────────────────┐
│ Musicians │
│ PiAdapter │
│ OpencodeAdapter│
│ ClaudeAdapter │ (future)
└─────────────────┘
packages/
core/ # Types, Conductor, ConcertHall, ScoreRegistry, Loge
adapter-pi/ # Pi harness adapter
adapter-opencode/ # Opencode harness adapter
cli/ # orchestron CLI
plugin-common/ # Shared plugin logic (tools, orchestron bootstrap)
plugin-pi/ # Pi session plugin
plugin-opencode/ # Opencode session plugin
examples/ # Example scores
import { PiAdapter } from '@orchestron/adapter-pi';
const pi = new PiAdapter({
provider: 'openai',
modelId: 'gpt-4o',
tools: ['read', 'edit'],
});import { OpencodeAdapter } from '@orchestron/adapter-opencode';
// Connect to an existing server
const opencode = new OpencodeAdapter({ baseUrl: 'http://localhost:4096' });
// Or start an embedded server
const embedded = new OpencodeAdapter({
embedded: { hostname: '127.0.0.1', port: 4096 },
});By default, each movement retains its own harness session keyed by
concertId:movementId. Re-visited movements keep their prior context, while
movement A cannot see movement B's conversation history. Set
persistSession: false in the score program to disable.
Every harness-session event is recorded exactly as the SDK emitted it — no normalization — into a unified raw envelope stream per concert:
~/.orchestron/concerts/<concertId>/
stream.jsonl # raw envelopes: `{ts, source, type, concertId, …}`
index.json # concert summary: status, stream, movement artifact refs
movements/<movementId>/
index.json # attempts[] + finalAttempt/finalStatus/finalSessionFile
final-pi-session.jsonl # cumulative mode: aggregated native snapshot
(or final-opencode-session.json)
attempt-0/
metadata.json # attempt summary written by the adapter
pi-session.jsonl # native pi session snapshot (opencode: opencode-session.json)
attempt-1/ … # one dir per retry
source: "sdk"envelopes carry rawdata;source: "concert"envelopes are conductor lifecycle events. Line order is event order; there is noseqfield.- Cumulative (
persistSession: true, default) movements keep each attempt's snapshot and a final aggregated copy (final-pi-session.jsonl/final-opencode-session.json). Fresh movements (persistSession: false) write independent per-attempt sessions only, referenced asattempt-<n>/…. - Retries increment the attempt index (
attempt-0= first attempt); each attempt gets its own snapshot +session_tracesrow in Loge.
Reopen a recorded session:
# Pi: fork the recorded session into a new one. This reads the artifact and
# never writes to it; the new session links back via `parentSession`. (Plain
# `pi --session <path>` would open the file as the live session store and
# rewrite it as you continue, which would corrupt the recording.)
pi --fork ~/.orchestron/concerts/<concertId>/movements/<id>/final-pi-session.jsonl
# or per attempt
pi --fork ~/.orchestron/concerts/<concertId>/movements/<id>/attempt-1/pi-session.jsonl
# Opencode (import is read-only; creates a new session seeded from the export)
opencode import /absolute/path/to/opencode-session.json
# Or let the CLI launch the right harness for you (--open):
pnpm orchestron session <concert-id> <movement-id> --open
# The CLI prints the exact paths + reopened commands per concert
pnpm orchestron session <concert-id> <movement-id>
pnpm orchestron session <concert-id> <movement-id> --attempt <n> --print- Core types, SQLite store, ScoreRegistry
- Conductor engine with crash recovery
- Pi harness adapter
- Opencode harness adapter
- CLI (
orchestron start,status,list, etc.) - Opencode session plugin
- Claude harness adapter
- More example scores
MIT