Ensemble · Tutorial

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.

Key takeaways
  • A score has one shape: build a Conductor, hire Agent handles, then call work, review, verify, judge, apply on them. The control flow is ordinary Rust.
  • The review and revise loop is a host-language for loop 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 VerdictPolicy decides, 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.

score.rs
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.

score.rs
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.

score.rs
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.

score.rs
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.

score.rs
// 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.

score.rs
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.

Two things a general framework cannot give you here. The verifier runs in a kernel-enforced sandbox, and the whole run, every attempt, review, verification, and verdict, is an append-only Git event log you can share and replay. The orchestration is the thin part. The isolation and the provenance are the substrate.

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.

resume in practice
$ 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.

the score, as a command
$ 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