Write Your First Score: an h5i-orchestra Tutorial
A score is a plain async Rust program. In this tutorial you build one end to end: hire two sandboxed agents, fan out the work, run a review round, verify each candidate in a neutral sandbox, gate the apply on a human, and land the winner. Then you kill the process halfway through and watch it resume for free, because every step was journaled on a Git ref.
- A score has one shape: build a
Conductor, hireAgenthandles, then callwork,review,verify,judge,applyon them. The control flow is ordinary Rust. - The review and revise loop is a host-language
forloop with an early exit on full approval. There is no special routing API. - Verify, judge, and apply are neutral and mediated: a fresh sandbox re-runs each candidate, a pluggable
VerdictPolicydecides, and apply is gated on an auto-applicable verdict plus a human. - Resume is free. Every effectful call is journaled by a stable step key on
refs/h5i/team/<run>, so re-running the binary replays completed turns instead of re-paying them.
The overview made the case for a
define-by-run orchestrator. This post is the hands-on half. We will write a complete score that
runs the classic ensemble: two agents attempt the same task independently, review each other,
a neutral verifier picks a winner, and a human approves the merge. You will see every method
you need, and by the end you will understand why the CLI command h5i team run is
just this same score with the Rust taken off the top.
One assumption before we start: the agents already run inside sandboxed h5i env
workspaces, exactly as in an h5i team.
The score does not spawn models. It coordinates warm resident sessions over a message inbox.
More on that in the execution-model section; for now, treat each agent as a handle you dispatch
turns to.
1. Build the Conductor
Everything hangs off a Conductor. It is a handle over one team run, which lives as
an append-only event log at refs/h5i/team/<run-id>. Launching the builder
creates the run if it does not exist and resumes it, replaying the journal, if it does.
use h5i_orchestra::{Conductor, LaunchResident, policy}; use std::sync::Arc; // A score is just an async main. The Conductor is the whole API. let c = Conductor::builder(".", "fix-auth") .launcher(Arc::new(LaunchResident)) // bring up sessions in tmux .max_rounds(2) .launch()?; // create-or-resume refs/h5i/team/fix-auth
The first argument is the repo, the second is the run id. From here on, the run id is the only thing that identifies this orchestration. Kill the program, pass the same id again, and you are resuming, not starting over.
2. Hire the roster
An agent is a persona bound to a sandboxed env. hire() creates that env, or binds
an existing one, and enrolls the agent on the run's roster. It is a journaled step, which means
something subtle and important: on a resume it rebinds to the env it already made rather than
creating a fresh one. You cannot accidentally double-provision by re-running.
let claude = c.agent("claude") .runtime("claude") .persona("personas/implementer.md") // baked into the env's PERSONA.md .hire().await?; let codex = c.agent("codex") .runtime("codex") .persona("personas/skeptic.md") .hire().await?;
The runtime picks the adapter (claude, codex) and steers the sandbox profile so a
claude box gets claude credentials and a codex box gets codex credentials, never each other's.
The persona is a working-style brief the env bakes into PERSONA.md, so
you can run an architect, an implementer, and a skeptic off the same model.
3. Fan out the work
Now the first real turns. work(task) dispatches a work turn to the agent's
resident session and resolves to the frozen candidate, a TeamArtifact pinned as a
commit and tree with a diffstat. Fan-out is not a framework feature. It is
tokio::try_join!, the concurrency your language already has.
let task = "implement `h5i pull` mirroring `h5i push`"; // both agents attempt the task at once, each in its own env let (mut a, mut b) = tokio::try_join!( claude.work(task), codex.work(task), )?;
Each work call is journaled under a label that embeds the agent id
(work/claude, work/codex), so the two run concurrently without their
step keys colliding. When you resume, a completed work returns its recorded
artifact instantly. The agent is not asked to redo a turn it already finished.
4. Freeze, then review and revise
Independence is the load-bearing property of an ensemble: a second attempt only adds
information if its errors are decorrelated from the first. So before any cross-talk, you seal
the round. freeze() moves the run to sealed_submit, after which the
review channel opens. h5i refuses cross-agent influence before the freeze by construction.
The review and revise cycle is a plain loop. Each agent reviews the other's frozen candidate;
if a review does not approve, the author revises. approves(&review) reads the
documented convention (a verdict line that leads with APPROVE, LGTM, or similar), so you exit
early the moment everyone is satisfied.
use h5i_orchestra::approves; c.freeze().await?; // seal the independent attempts for round in 1..=2 { let (ra, rb) = tokio::try_join!(codex.review(&a), claude.review(&b))?; if approves(&ra) && approves(&rb) { break; } // address the feedback, produce a fresh candidate (a, b) = tokio::try_join!( claude.revise(&a, &ra), codex.revise(&b, &rb), )?; c.note(format!("revision round {round} complete")).await?; }
Every review is delivered to the reviewed agent's inbox and recorded on the event log, so the next revision is correctly stamped as non-independent. The provenance of who influenced whom is not lost. It is in the ref.
5. Verify in a neutral sandbox, then judge
The winner is never chosen from an agent's own say-so. verify replays each frozen
candidate into a throwaway worktree at the shared base, never the author's box, and runs your
declared command under h5i's fail-closed sandbox. You pick the isolation tier: a container tier
gives the strongest confinement for untrusted code.
// each candidate re-run in a fresh, sandboxed worktree at the base commit c.verify(&a).command(["cargo", "test", "--quiet"]).isolation("container").await?; c.verify(&b).command(["cargo", "test", "--quiet"]).isolation("container").await?; // decide through a pluggable policy; the built-in reproduces `team finalize` let verdict = c.judge(policy::tests_then_smallest_diff()).await?;
The verdict comes from a VerdictPolicy, not a hardcoded rule. The built-in
tests_then_smallest_diff keeps candidates whose verification both applies cleanly
and passes tests, refuses to compare candidates checked with different commands, then picks the
smallest diff. When you want a different rule, policy::from_fn wraps a closure that
sees the folded run and returns a verdict with reasons. An LLM judge is just a policy that calls
agent.ask inside.
6. The durable gate, then a mediated apply
Apply is where an agent's work reaches your branch, so it stays a human decision by default.
gate is durable human-in-the-loop: it asks the question over the i5h message bus,
records that it asked, and then waits for a reply. The score can exit here and resume after the
human answers, even on a different day and a different machine.
let winner = verdict.selected()?; // asks over i5h; the human answers with `h5i msg reply <n> APPROVE` if c.gate(format!("apply {}?", winner.id)).approve().await? { c.apply(&winner).await?; // gated on an auto-applicable verdict }
apply is mediated: without --force it refuses unless the selected
candidate is covered by a verifier verdict that both applies cleanly and passed. A score cannot
silently ship a loser. The gate makes the approval durable, not optional.
7. What actually runs the agents
A common first question: if the score does not call a model, who does? The answer is the
RuntimeLauncher. The score is a coordinator. It holds no model connection and no
agent state. It delivers a turn to an agent's per-env inbox as an i5h message, and a warm
resident session, the same interactive claude or codex you would run
by hand, picks it up. The Stop hook keeps that session alive between turns, so dispatch is
sub-second and the session keeps its full context and warm prompt cache.
The default launcher, Attach, assumes a resident session is already running (you
brought it up with team-launch.sh). LaunchResident spawns that same
warm session itself in tmux. A headless per-turn claude -p is deliberately not the
default: a cold process boot per turn, with no cross-turn state, defeats the resident-session
model. Fast and stateful beats fresh and stateless when a turn costs minutes.
8. Kill it, resume it, or just use the CLI
Now the payoff. Run the score, kill the process anywhere, and run the same binary again. Every completed step, both attempts, the reviews, the verifications, replays from the journal by its stable key. The agents are not asked to redo a single turn. The run picks up at the exact step where it stopped.
$ cargo run --bin score # runs, then you Ctrl-C during verify $ cargo run --bin score # same run id: replays hire, work, review ➜ resuming: journaled steps replay without re-execution # inspect the recorded DAG any time: $ h5i team trace fix-auth --dot | dot -Tsvg > run.svg
And here is the part that makes most of this optional: the whole score you just wrote is
already a command. h5i team run drives this exact cycle over the built-in
patterns::ensemble, journal-backed and resumable, with --rounds,
--verify-cmd, and a durable --gate. You write a hand-authored score
only when you want a topology the patterns do not cover, an arena, a designer-to-implementer
pipeline, a many-to-one integrator. For the classic ensemble, the CLI is the score.
$ h5i team run fix-auth --task-file task.md \ --rounds 2 --verify-cmd "cargo test" --gate ✓ cycle complete: 2 artifacts, 2 reviews, verdict recorded
FAQ
Do I write a full binary, or is there a shorter form? For repo-resident orchestrations you write a small bin crate that depends on h5i-orchestra. For the classic cycle you write no Rust at all: h5i team run is the ensemble pattern with a CLI on top. Reach for a hand-authored score when the topology is your own.
What is the difference between work and ask? work produces code: it resolves to a frozen TeamArtifact. ask::<T> produces data: the reply must deserialize as T, with a bounded re-ask if it does not parse. Use ask for judges, routers, and summaries; use work for candidates.
How do I merge the work of several agents instead of picking one? Use work(task).with_materials(&parts). It grants an integrator agent scoped sight of the other candidates' diffs and stamps the merged artifact as non-independent with influence edges. The patterns::integrate helper packages the full fan-out then merge then verify flow.
Can I run the review loop for more than two agents? Yes. The loop is ordinary Rust, so you review every ordered pair, or use patterns::ensemble which does exactly that for N agents with early exit on full approval.
What if I change the score between runs? A resume records a digest of the running binary and warns loudly if it changed, since step keys may have shifted. To upgrade a live run on purpose, a patched("change-id") marker keeps the old and new branches selectable and consistent across resumes.
Run your first score
Drive the classic ensemble with one command, then open the Rust when your topology needs it. Every turn journaled, every run resumable.
Read the manual Star on GitHub