npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ghostmind-dev/ensemble

v0.24.0

Published

Multi-model agent ensembles. Describe a scene — nodes wired by edges, each node any model with scoped state — in one TypeScript file, and run it: conditions, loops, parallel groups, live visualization.

Readme

ensemble

Multi-model agent ensembles. You describe a scene — nodes wired by edges, each node a model with scoped state access — in one TypeScript file, and ensemble runs it: conditions, loops, parallel groups, live visualization.

Every node can use a different model from a different vendor, and a node is whichever kind of worker the job needs:

  • runtime: "model" (default) — one direct OpenRouter call. Streams tokens live. Pure think.
  • runtime: "agent" — our own tool-calling loop: built-in tools (read and write) plus any MCP servers the node allowlists, looping until the model stops asking for tools. Pure do.
  • runtime: "fn" — a plain function over state. Free, instant, deterministic, and held to the same schema contract as model output. Use it for arithmetic, tallies and formatting, and never pay a model to count.
  • runtime: "refine" — the keep-or-revert step for a value: compares the candidate's score with the incumbent's, keeps a winner, writes the incumbent back over a regression, and says when the score has stopped rising. Free. The score gate, done right. See Refine mode.
  • runtime: "compare" — a pairwise judge: asks a model which of the candidate and the incumbent is better, in both orders, and reports a winner only when the orderings agree. The judge a refine loop can trust when the judge is a model.
  • runtime: "ask" — no model call at all: the run pauses until a human (or another agent) supplies the node's outputs.
  • runtime: "opencode" — rents a real coding-agent CLI for one node, when a node must genuinely build something. See Agent backends.

The default path needs no subprocess and nothing installed but this package, and the only credential is OPENROUTER_API_KEY — which is also all the opencode backend needs, if you reach for it.

scenes/*.ts ──import──> Scene ──validate──> engine ──events──> terminal / browser
                                              │
                            ┌─────────────────┴────────────────┐
                    runtime: "model"                    runtime: "agent"
                    one OpenRouter call                 tool-calling loop
                    SSE streaming · usage.cost          built-ins + MCP servers

A scene is one TypeScript file

import { scene } from "@ghostmind-dev/ensemble";

export default scene({
  name: "research-and-critique",
  defaults: { model: "openrouter/anthropic/claude-sonnet-5" },

  nodes: {
    researcher: {
      model: "openrouter/google/gemini-2.5-flash",
      prompt: "Research the goal. Be concrete.",
      outputs: ["findings"],
    },
    critic: {
      model: "openrouter/anthropic/claude-haiku-4.5",
      inputs: ["findings"],                      // sees ONLY this state
      outputs: ["verdict", "notes"],
    },
    inspector: {
      runtime: "agent",                          // gets tools, loops until done
      mcp: ["fs"],                               // MCP servers it may use
      skills: ["graphify"],                      // skills inlined into its prompt
      inputs: ["findings"],
      outputs: ["report"],
    },
    writer: {
      inputs: ["findings", "notes"],
      outputs: ["result"],
    },
  },

  groups: { review: ["critic", "inspector"] },   // run concurrently, merge on completion

  edges: [
    { from: "researcher", to: "review" },
    { from: "review", to: "writer",     when: (s) => s["verdict"] === "accept" },
    { from: "review", to: "researcher", when: (s) => s["verdict"] === "reject", maxLoops: 2 },
  ],

  entry: "researcher",
  exit: "writer",
});

Two graphs, and the one that matters is proved

Edges route the cursor — that is control flow. Data lives on the shared blackboard, and inputs/outputs describe how it moves — that is the data graph, and it is the one that decides whether a workflow is a recipe or an accident.

State keys have exactly three origins: goal, a node's declared outputs, and the scene's declared inputs (keys that arrive from outside — seeded at launch, or injected mid-run through answers). So validate proves that every key a node reads, and every key a when reads, has one:

node "judge" reads "house_rules" but nothing in the scene produces it — the node
would run with that context silently missing. Keys produced in this scene: "answers"
(by collect), "round" (by open_board, judge), … If it is meant to come from outside
the workflow, declare it: inputs: ["…"] at the scene level.

Before this check existed, that node ran anyway — the model was simply not told — and the scene it was found in had been doing so for weeks. A workflow that works with an input silently missing did not work; it got lucky. The fix is one line:

inputs: ["house_rules"],

ensemble serve draws both graphs: control edges solid, data edges dotted (toggle data). A when box shows which keys it reads, and the scene's inputs appear as boxes in the left gutter — the outside world, as an object.

Fan-out over data: each

A group runs its members concurrently — but until now the members were a fixed list, so "review every file" meant hand-writing reviewer_1, reviewer_2, reviewer_3 and hoping there were three files. A group is an object, and each is the property that makes it run once per item of a state key:

nodes: {
  list:     { runtime: "agent", prompt: "List the changed files.", outputs: ["files"] },
  reviewer: { prompt: "Review this one file.", inputs: ["file"], outputs: ["review"] },
  tally:    { runtime: "fn", fn: (s) => ({ bad: s.review.filter((r) => !r.ok) }), inputs: ["review"], outputs: ["bad"] },
},
groups: {
  reviews: { members: ["reviewer"], each: "files", as: "file" },   // once per file
},
edges: [{ from: "list", to: "reviews" }, { from: "reviews", to: "tally" }],

Every instance sees its item under as (default item) on top of the shared snapshot, and nothing a sibling wrote. When the group finishes, each member's outputs land as arrays in item orderreview is [{…}, {…}, {…}] — so the next node reads the whole set at once. An empty list runs nothing and still writes empty arrays. concurrency (default 8) caps how many instances are in flight.

Because state holds the array, a schema on a collected key is the array: review: z.array(z.object({ file: z.string(), ok: z.boolean() })). Each instance is shown and held to the element shape — validate refuses a non-array schema on a collected key, an ask node as a member (a pause cannot be instanced), and an alias that collides with a key some node produces. The data graph draws the group as the producer of file and the consumer of files, and every event an instance emits says which item it was (reviewer [2/3] in the terminal).

Typed state: pin the shape of the blackboard

outputs says which keys a node owes. state says what shape they must be — and because the values arrive as JSON from a model, that check has to exist at run time, which a TypeScript type alone cannot do:

import { scene, z } from "@ghostmind-dev/ensemble";   // z is re-exported for you

export default scene({
  name: "review",
  state: {
    findings: z.array(z.object({ file: z.string(), severity: z.enum(["low", "high"]) })),
    score:    z.number().min(0).max(10),
    verdict:  z.enum(["accept", "reject"]),
  },
  nodes: { scanner: { outputs: ["findings"] }, judge: { inputs: ["findings"], outputs: ["score", "verdict"] } },
  edges: [{ from: "judge", to: "writer", when: (s) => s.score >= 8 }],   // s is typed
  entry: "scanner", exit: "writer",
});

One declaration does three jobs:

  • The model is shown the shape. The auto-generated output contract renders "score": number (0-10), not "score": ... — compliance improves from that alone.
  • Wrong shapes self-correct. A mismatch becomes the retry reason naming the exact path (findings.0.severity: …), so the existing free retry fixes it instead of a bad value poisoning state. What lands in state is zod's parsed value.
  • when predicates are typed. s.score >= 8 type-checks; no Number() guard, and a typo'd key is a compile error rather than a silent undefined.

Entirely additive: keys with no schema behave exactly as before, so existing scenes are unaffected. Schema the keys gates depend on; leave prose keys as plain strings.

Research mode: three things, and nothing else

Sometimes you do not want a workflow that answers — you want one that improves something, measurably, over and over. That loop is Karpathy's autoresearch, and it works because of what the researcher is not allowed to touch: one file changes, one command scores it, and the directive is written once and read identically every iteration. Anything else you could turn into a knob is a confound.

So research mode is sealed. research() takes exactly three things and refuses every other key by name:

import { research } from "@ghostmind-dev/ensemble";

export default research({
  modify:      "train.py",                       // 1 · the ONE thing that may change
  evaluate:    { command: "python train.py",     // 2 · how it is scored — code, not a judge
                 metric: "val_bpb", minimize: true, budget: "5m" },
  instruction: "Lower validation bits-per-byte. Do not touch the data or the eval.",
});                                              // 3 · the directive, constant forever
ensemble validate program.mts                    # free
ensemble research program.mts --iterations 50    # no goal argument — see below

No nodes, no edges, no entry, no model, no prompts. Pass one and you get a refusal that says why ("edges" — the loop is generated: propose → evaluate → keep or revert is the whole topology). The loop is identical for every program in the world, which is the point: two people's results are comparable because their scaffolding is not a variable. Which model proposes, how many iterations, and the noise threshold are flags (--model, --iterations, --threshold) — the file is the experiment, the flags are the session.

            ┌───────────────────────────────────────────────┐
            ▼                                               │
      ┌──────────┐   hypothesis   ┌────────────┐            │
      │ propose  │ ─────────────► │  evaluate  │ ── keep or revert ──┘
      │ edits    │                │ 🔬 scores  │
      │ ONE file │ ◄───────────── │ under the  │
      └──────────┘  best, verdict │  budget    │
                    reason, output└─────┬──────┘
                                        ▼
                                  results.tsv

ensemble research deliberately takes no goal argument: the directive lives in instruction, so there is nowhere for it to drift between iterations. Entry is the evaluator, not the proposer — the first pass measures whatever is on disk, and that baseline is what every later candidate is compared against.

What the mode enforces:

  • The proposer gains write_file and edit_file — scoped to modify and nothing else. This is the only way an agent node ever gets a write tool. It physically cannot edit the evaluator, so it cannot optimise the scorer instead of the artefact.
  • The evaluator runs under a hard time budget — the whole process group is killed at the limit, so an overrun is a crash, never a longer experiment. Results stay comparable.
  • The metric is parsed from the command's output. No model judges anything: a judge adds its own variance, and an optimiser cannot tell "it improved" from "the judge felt different today".
  • Keep or revert, written down. The incumbent is snapshotted, a candidate is kept only if it clears --threshold, and the incumbent is restored otherwise. Every try appends iteration · score · best · verdict · ms · note to results.tsv. Verdicts: baseline, keep, revert, crash. A revert is a result — the proposer sees verdict, reason, and the evaluator's output on its next turn, so a rejected idea informs the next one.
  • validate checks the artefact exists and the budget parses — before anything runs. Resume works too: the incumbent snapshot lives in the run directory.

examples/05-autoresearch is a complete, cheap one.

If three things are genuinely not enough — a jury of proposers, a human ask gate before each experiment, two metrics — drop to an ordinary scene() with a scene-level research: block and runtime: "experiment". That is the same machinery with the guardrails off, and it is the escape hatch, not the default:

export default scene({
  name: "reviewed-research",
  research: { edit: "train.py", measure: "python train.py", metric: "val_bpb", minimize: true, budget: "5m" },
  nodes: {
    propose:    { runtime: "agent", inputs: ["best", "verdict", "reason"], outputs: ["hypothesis"] },
    approve:    { runtime: "ask", question: "Run this experiment?", inputs: ["hypothesis"], outputs: ["ok"], always: true },
    experiment: { runtime: "experiment", note: "hypothesis", outputs: ["iteration", "best", "verdict", "reason", "output"] },
  },
  edges: [
    { from: "experiment", to: "propose", maxLoops: 50 },
    { from: "propose", to: "approve" },
    { from: "approve", to: "experiment", when: (s) => s["ok"] === "yes" },
  ],
  entry: "experiment", exit: "experiment",
});

Three things carry the design:

  • inputs / outputs are the whole data-flow contract — and the access-control model. A node sees exactly the state keys it declares, nothing else. State lives on a shared blackboard, checkpointed after every node.
  • Conditions are real code. when: (s) => s["verdict"] === "reject" — typed, autocompleted, no expression mini-language to learn.
  • scene() is an identity function carrying types. A model authoring a scene gets its mistakes flagged by the type checker before a single token is spent — which is the point: this format is designed to be generated.

Refine mode: keep-or-revert on the blackboard

The score-gate pattern — writer → judge → loop while score < 8, maxLoops: 3 — has a flaw its own example admits. When the budget runs out, the run ends with the last attempt, not the best one. And every revision builds on the previous attempt even when that attempt was a regression, so a loop can drift away from its best work while looking busy.

Research mode already has the fix — snapshot the incumbent, keep a candidate only if it beats it, restore otherwise — but only for a file on disk scored by a command. runtime: "refine" is the same discipline for a state key scored by a node:

import { scene, z } from "@ghostmind-dev/ensemble";

export default scene({
  name: "refine-tagline",
  defaults: { model: "openrouter/anthropic/claude-haiku-4.5" },
  state: { score: z.number(), converged: z.boolean() },

  nodes: {
    writer: {
      prompt: "Write a one-sentence tagline. If `tagline` is present it is the best so far — improve on it. " +
              "`feedback` is the judge's critique of the most recent attempt; on a revert that attempt was discarded.",
      inputs: ["tagline", "feedback", "best", "verdict", "reason"],
      outputs: ["tagline"],
    },
    judge: {
      model: "openrouter/anthropic/claude-sonnet-5",
      prompt: "Score the tagline 0-10 as a NUMBER in `score`; put actionable critique in `feedback`.",
      inputs: ["tagline"],
      outputs: ["score", "feedback"],
    },
    keep: {
      runtime: "refine",          // ⬆ free: no model call
      candidate: "tagline",       // the state key under refinement
      patience: 2,                // two straight non-improvements → converged
      target: 9,                  // or stop as soon as best reaches 9
      outputs: ["tagline", "best", "verdict", "reason", "converged"],
    },
  },

  edges: [
    { from: "writer", to: "judge" },
    { from: "judge", to: "keep" },
    { from: "keep", to: "writer", when: (s) => !s.converged, maxLoops: 8 },
  ],
  entry: "writer",
  exit: "keep",
});
   ┌──────────┐  tagline   ┌─────────┐  score, feedback  ┌────────────┐
   │  writer  │ ─────────► │  judge  │ ────────────────► │  keep  ⬆   │ ──► exit
   └──────────┘            └─────────┘                   │ keep/revert│
        ▲                                                └─────┬──────┘
        │        tagline (the incumbent), best, verdict, reason │  !converged
        └──────────────────────────────────────────────────────┘  (⟲ max 8)

Each round the refine node compares the candidate's score with the incumbent's (best). A winner — by more than threshold — is kept and becomes the incumbent. Anything else is reverted: the incumbent is written back over the candidate key, so the writer's next revision starts from the best version, never from the regression it just produced. converged turns true after patience consecutive non-improvements, or as soon as best reaches target.

Two guarantees follow, and neither holds for a plain gate:

  • Whatever ends the loop — converged, target, or maxLoops — the candidate key holds the best version seen. The refine node just put it there.
  • Every attempt is scored against the incumbent it was asked to improve, so a score that moves is attributable to the change that moved it.

When the judge is a model, compare — do not score

A model asked to score one draft 0-10 answers with a spread of a point or more between samples of the same draft. Keep-or-revert on that number keeps whichever candidate drew the lucky sample, and the incumbent is never re-scored. A real tape from this repo's own refine loop: scores 6.5 · 7 · 6 · 7.5 · 7 · 5, with a 7 kept over a 6.5. That half-point is inside the judge's noise, not an improvement.

Models are far more reliable at which of these two is better than at absolute scores — but only when asked in both orders, because they also favour whichever version they read first. runtime: "compare" owns that discipline the way research mode owns its rules:

judge: {
  runtime: "compare",           // ⚖ two calls: candidate-first, then incumbent-first
  model: "openrouter/anthropic/claude-sonnet-5",
  prompt: "Prefer the tagline a stranger would repeat.",   // your criteria, optional
  candidate: "tagline",
  outputs: ["winner", "agreement", "rationale"],
},
keep: {
  runtime: "refine",
  candidate: "tagline",
  winner: "winner",             // pairwise mode: no score, no target, no threshold
  patience: 2,
  outputs: ["tagline", "incumbent", "wins", "verdict", "reason", "converged"],
},

winner is "candidate", "incumbent", or "tie" — a tie means the judge changed its mind when the order was swapped, and a tie reverts. The compare node reads incumbent (what the refine node banks) by its own rules, so the data graph proves the loop closes; on the first round there is nothing to compare and it passes through for free. In pairwise mode the refine node writes wins instead of best: how many challengers the incumbent has beaten — its weight.

The rule: code scores → score; a model judges → compare. An fn that counts something has no variance, and scalar refine is exactly right for it.

What it writes, every round: the candidate key, incumbent, best (or wins), round, verdict (baseline / keep / revert), kept, reason, converged, stalled, history (every round's score and verdict), and a one-line summary. Declare the ones your graph reads. minimize: true flips the direction for a loss; threshold is the noise floor a candidate must clear; score: "grade" names a different key.

Everything the node remembers lives on the blackboard, so a refine loop survives a stop and resume, and a run can start from an earlier run's winner. To improve an input rather than generate one, declare the candidate as a scene input, seed it, and make the judge the entry — the seed is scored as the baseline:

inputs: ["draft"],                     // arrives from outside — the thing to improve
entry: "judge",                        // score it first: that is the baseline
ensemble run improve.mts "tighten this" --answer draft="$(cat draft.md)"

validate proves the wiring before anything spends: the candidate must be produced by some other node (or arrive as an input), the score key must be produced by a node, and both appear on the data graph as the refine node's reads. A judge that writes "7/10" instead of a number fails the round with the one-line fix in the message (state: { score: z.number() }).

examples/07-refine-loop is the scene above, ready to run.

A scene is a lineage: population, memory, and the preference tape

Refine keeps one incumbent, and the sandbox's magic-squares run shows the cost: after a revert the writer is handed the same incumbent and the same critique, produces a near-identical attempt, and the loop spends its budget on three reverts in a row. One incumbent is a hill climb with no diversity. And whatever it finds dies in a run directory.

Three objects change that. None of them touches the engine.

runtime: "population" — several survivors

keep: {
  runtime: "population",   // ⬆⬆ free
  candidate: "tagline",
  winner: "winner",        // pairwise (a compare node) — or `score` for a code judge
  size: 3,                 // members that survive (min 2)
  patience: 3,             // rounds with no replacement → converged
  outputs: ["tagline", "incumbent", "population", "parent", "champion", "wins", "converged"],
},

The first size rounds fill the population; the writer starts cold each time (the candidate key is null), and nothing is compared. After that, each round the writer is handed one member, round-robin — so consecutive attempts descend from different parents — and the challenger is judged against that parent only: did the edit improve on what it was told to improve? A win replaces the parent; a loss adds a win to the parent's tally. The member with the most wins is the champion (ties go to the newest, because it beat an older one to get in), and the loop has converged when no member has been replaced in patience rounds. What survives is what keeps beating challengers: that is the weight. On convergence the candidate key holds the champion; a loop cut off by maxLoops still has champion in state.

memory — the blackboard outlives the run

memory: { keys: ["population", "champion", "parent", "round"] },

Before the entry node runs, those keys are seeded from what the last completed run of this scene banked; when a run reaches its exit, they are banked again, in .ensemble/memory/<scene>.json. Run N+1 starts where run N finished — the population carries over, the champion keeps its wins. A run that stops early banks nothing (resume it to the exit and it will); an explicit --answer beats memory; --fresh ignores it for one run without erasing it. Each key must already be written by a node or declared in inputs: memory carries a value forward, it never invents one, so the data graph's proof still holds. The terminal shows ⟲ memory: seeded … from run <id> and ⤓ memory: banked ….

The preference tape

Every comparison a compare node makes is appended to .ensemble/preferences.jsonl: the goal, the candidate, the incumbent, which won, and each ordering's raw answer. A (goal, chosen, rejected) row is what preference-training methods consume, so doing the work produces the dataset as a side effect. Ties are recorded too: a pair the judge could not separate is information about the judge.

dev/.ensemble/scenes/lineage.mts is all three in one cheap scene; run it twice and watch the second start from the first.

Getting started from scratch

Two things: the CLI and an API key. No package.json, no npm install, no project scaffolding.

npm i -g @ghostmind-dev/ensemble
export OPENROUTER_API_KEY=sk-or-...          # https://openrouter.ai/keys

Now write one .mts file anywhere:

// ask.mts
import { scene } from "@ghostmind-dev/ensemble";

export default scene({
  name: "ask",
  nodes: {
    a: {
      model: "openrouter/anthropic/claude-haiku-4.5",
      prompt: "Answer briefly.",
      outputs: ["answer"],
    },
  },
  entry: "a",
  exit: "a",
});
ensemble validate ask.mts        # free — catches every wiring mistake
ensemble run ask.mts "your goal" # costs money
ensemble serve .                 # watch it live in a browser

That directory can contain nothing but ask.mts and it works — verified. The .mts extension marks the file as an ES module without a package.json, and the import resolves against the global install.

Prefer .ts? That works too, but then the nearest package.json needs "type": "module". .mts avoids the question entirely, which is why every example here uses it.

Before spending anything, see what you already have — both are free and instant:

ensemble skills        # skills found (yours + Claude Code's)
ensemble mcp           # MCP servers, CONNECTED, with their tools
ensemble models gpt    # models you can reach

ensemble init — so your editor understands scenes

Scenes need no scaffolding to run, but an editor cannot know that: with no node_modules it reports Import "@ghostmind-dev/ensemble" not a dependency, and because the import fails your state schemas never reach when: (s) => s.score < 8, leaving s as any — typed state's whole benefit invisible exactly where it pays off.

ensemble init            # + tsconfig.json, deno.json, .gitignore, package symlink
ensemble init --starter  # …and an example scene to edit

It writes a tsconfig.json (TypeScript/VS Code), a deno.json import map (Deno-backed editors), and symlinks the installed package into .ensemble/node_modules/ so ordinary resolution works with nothing to install. Idempotent — existing files are kept unless --force. Verified with the real compiler: after init, s.score === "high" and a misspelled key are compile errors, which is the point.

Driving it: you at a terminal, or an agent with a shell

There is one front door — the CLI — and it is the whole interface. ensemble run blocks until the run finishes, which is what you want when you are watching; an agent that needs to do other work meanwhile runs it in the background and reads the run directory, which is checkpointed after every node and is the same thing a live viewer reads.

ensemble validate review.mts                          # free — always first
ensemble run review.mts "<goal>" --budget 0.50        # the run
cat .ensemble/runs/<id>/state.json                    # the blackboard, after every node
cat .ensemble/runs/<id>/costs.json                    # who spent what
ensemble resume .ensemble/runs/<id> --budget 1.00     # continue; never restart

Everything an operator needs is a file: journal.json says where the run is and what a paused one is waiting for, state.json is the blackboard, costs.json is the receipt. Nothing to register, no daemon, no second protocol to keep in step with the engine.

Per-folder, nothing to set up

Runs are cwd-relative, and everything a project owns lives under one folder — no per-project install and no init step:

my-project/
└── .ensemble/
    ├── scenes/review.mts    ← your workflows
    ├── ensemble.json        ← OPTIONAL: MCP servers for agent nodes
    ├── .env                 ← OPTIONAL: secrets for that config (gitignore it)
    ├── memory/              ← what a scene's `memory` block banks between runs
    └── runs/                ← run artifacts, created on first run

So "a workflow per project" is simply: put a .mts file in .ensemble/scenes/. ensemble serve then finds it with no arguments, and ensemble run <path> accepts any path. A legacy ./ensemble.json or ./scenes/ still loads, so older projects keep working — but new work goes under .ensemble/.

Getting the plugin — the lowest-friction path

The plugin bundles three skills:

| skill | what it teaches | |---|---| | autoresearch | the concept — Karpathy's loop, why its constraints exist, and whether a goal qualifies | | autoresearch-build | the implementation — the three things, writing an evaluator, launching, reading the ledger | | ensemble | general scene authoring — the format, the patterns, casting, budget and resume discipline |

The split is deliberate: an agent asked why the loop refuses something loads the first, one asked to build one loads the second. Two lines, nothing else to configure:

/plugin marketplace add ghostmind-labo/ensemble
/plugin install ensemble@ghostmind-ensemble

The skills teach the CLI, so an agent that can run a shell can operate ensemble with no other setup. You still need the binary and the key:

npm i -g @ghostmind-dev/ensemble
export OPENROUTER_API_KEY=sk-or-...

Then you skip the syntax entirely and ask for what you want:

"Build me a scene where three models answer independently and a fourth picks the best, then run it on this question with a $0.50 cap."

Where skills and MCP servers live

You probably don't need to configure anything. Both registries are inherited from Claude Code if you already use it.

Skills

Same SKILL.md format and the same six directories Claude Code reads, project first:

.claude/skills/<name>/SKILL.md          ← project   (Claude Code's own location)
.opencode/skills/<name>/SKILL.md        ← project
.agents/skills/<name>/SKILL.md          ← project
~/.claude/skills/<name>/SKILL.md        ← global    (Claude Code's own location)
~/.config/opencode/skills/<name>/       ← global
~/.agents/skills/<name>/SKILL.md        ← global

Every skill you already wrote for Claude Code works here unchanged. A node opts in with skills: ["name"]; the file's body is inlined into that node's system prompt.

MCP servers

Four sources, first definition wins:

| Order | File | Format | |---|---|---| | 1 | ./ensemble.jsonmcp | ours | | 2 | ./.mcp.jsonmcpServers | Claude Code's | | 3 | ~/.config/ensemble/ensemble.jsonmcp | ours | | 4 | ~/.claude.jsonmcpServers | Claude Code's |

So your existing Claude Code MCP servers just work. Verified on a real machine:

$ ensemble mcp
  github  connected  44 tool(s)  global:claude
  tmux    connected  13 tool(s)  global:claude

The formats differ slightly — Claude splits command/args and calls remote servers "http" — and ensemble normalises both. Declare your own only when you want something Claude Code doesn't have, or want to override a name:

{
  "mcp": {
    "fs": { "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."] }
  }
}

ensemble mcp shows the source of every server, so you always know which file a definition came from.

Transports — all of them

| Kind | Support | |---|---| | stdio (local process) | ✅ | | Streamable HTTP (current spec) | ✅ including stateless servers | | SSE (earlier spec) | ✅ automatic fallback |

Remote servers try Streamable HTTP first and fall back to SSE, so a server built against either spec connects without you declaring which.

Authentication — including OAuth

| Method | How | |---|---| | No auth | nothing to do | | Token in a header | "headers": { "Authorization": "Bearer ..." } | | OAuth (browser redirect) | ensemble login <server> |

Many hosted servers issue no static token at all — the only way in is an authorization-code flow. ensemble login opens your browser, catches the redirect on a loopback port, and stores the tokens in ~/.config/ensemble/auth.json (mode 0600). Once per server, not once per run; refresh is automatic.

ensemble mcp                   # status — OAuth servers show `needs auth`
ensemble mcp login notion      # authorize (opens a browser)
ensemble mcp logout notion     # forget its tokens

The command authorizes that MCP server, not ensemble — there is no ensemble account. Set ENSEMBLE_NO_BROWSER=1 on a headless box and it prints the URL instead of opening one. ENSEMBLE_OAUTH_PORT moves the loopback port if 8976 is taken.

Dynamic client registration is handled for you: against Linear's server this registers a client, generates a PKCE S256 challenge, and negotiates read write scopes with no configuration at all.

A server needing auth shows as needs_auth in ensemble mcp, with the exact command to fix it. Nothing forces a bearer token.

Keeping tokens out of the config

ensemble.json is meant to be committed, so never put a secret in it. Reference the environment instead — ${VAR} and ${VAR:-fallback} are expanded anywhere in the config:

{
  "mcp": {
    "gh": {
      "type": "remote",
      "url": "https://api.githubcopilot.com/mcp/",
      "headers": { "Authorization": "Bearer ${GH_MCP_TOKEN}" }
    }
  }
}

The value can come from a real environment variable, or from a .env beside the config — loaded automatically, so the usual pattern is:

echo "GH_MCP_TOKEN=ghp_..." >> .env
echo ".env" >> .gitignore          # commit ensemble.json, never the token

Exported variables win over .env, so CI can override without editing files.

A variable that is referenced but unset is reported by name before connecting:

! config references ${GH_MCP_TOKEN} but GH_MCP_TOKEN is not set — export it or add it to .env

That is deliberate: expanding to the literal string ${GH_MCP_TOKEN} would send a nonsense Authorization header and produce a baffling 401 instead of a fixable error.

For servers that use OAuth, no token belongs in the config at all — ensemble mcp login <server> stores credentials outside the project entirely.

Turning inheritance off

Inheriting is the default because it is usually what you want — but a repo that must not depend on whatever is on the machine can say so, in ensemble.json:

{
  "sources": {
    "claudeSkills": false,
    "opencodeSkills": false,
    "agentsSkills": false,
    "claudeMcp": false,
    "skillDirs": ["./team-skills"]
  }
}

Every flag defaults to true. skillDirs adds your own locations and is scanned first, so an explicit skill always beats an inherited one of the same name. With the config above, ensemble skills reports exactly one skill — yours — sourced custom:./team-skills.

Install

npm i -g @ghostmind-dev/ensemble      # CLI everywhere
# or, per project:
npm i @ghostmind-dev/ensemble

Requirements:

  • Node ≥ 22.18 — scenes are TypeScript, loaded via Node's native type stripping
  • OPENROUTER_API_KEY in the environment — that's the only credential

Name scenes .mts and nothing else is needed. (.ts also works when the nearest package.json has "type": "module"ensemble validate says so if it doesn't.)

Commands

ensemble run <scene.mts> "<goal>"     # execute a scene (--budget caps the spend)
ensemble resume <run-dir>            # continue a stopped run from its checkpoint
ensemble serve [scenes-dir]          # live viewer + editor in the browser
ensemble view <scene.mts>             # draw it (--mermaid, --html[=file])
ensemble validate <scene.mts>         # check it without spending tokens
ensemble skills                      # list the skill + MCP registry (from config)
ensemble mcp                         # connect MCP servers and list their tools
ensemble models [filter]             # list models available through OpenRouter
ensemble version                     # installed version (also --version / -V)

ensemble --help prints a First time walkthrough — the tool explains itself, so this README is not the only place the setup lives.

validate catches unknown skills, edges to missing nodes, unreachable exits, parallel output collisions, and skills declared on model nodes — in milliseconds, before any spend.

Retry and fallback: a run must not die on a 429

A run driven unattended — in the background, overnight — used to end on the first rate limit at node twelve of fifteen. Resumable, yes; but resumable means someone has to notice. Every calling node now recovers on its own:

defaults: {
  model: "openrouter/anthropic/claude-sonnet-5",
  retry: 2,                                              // extra attempts on a transient failure (default 2)
  fallback: ["openrouter/google/gemini-2.5-pro"],        // then try these, in order
},
nodes: {
  judge: { retry: 4, fallback: ["openrouter/openai/gpt-5"] },   // per node overrides
},
  • Transient failures are retried on the same model with exponential backoff (1s, 2s, 4s… ENSEMBLE_BACKOFF_MS sets the base). Transient means 408, 429, 5xx, an overload message, or a dropped connection. retry: 0 turns it off.
  • A failure that will not clear — 404, 400 — goes straight to fallback, and so does a model that is out of attempts. Each model in the list gets its own retry budget, and the run fails only when the list is exhausted, with the last error as the reason.
  • The receipt tells the truth. node:end and costs.json name the model that actually answered, and every retry or hand-over is a node:retry event with its kind — transport, fallback, or the reprompt an unparseable reply gets.
  • An abort is never retried, and it cuts a backoff sleep short.

Free and waiting runtimes (fn, ask, refine, experiment) cannot fail in transit, so they do not accept these fields — validate says so.

ensemble serve — see it, run it, modify it

ensemble serve            # http://127.0.0.1:7777
  • Canvas — the graph drawn in layers; parallel groups boxed; edges labeled with their actual predicates (s["verdict"] === "accept"); ⚡ model / ⛭ agent badge on every node.
  • Live run — nodes pulse while running and stream their tokens in real time (model nodes); nodes whose inputs aren't ready show ⏳ waiting on: …; each lands with tokens · cost · elapsed.
  • State tab — the blackboard, updated after every node.
  • Source tab — edit the scene and Save. The edit is validated before the file is written: a broken scene is rejected with the exact problems and the file on disk is never touched.

Scene files stay the source of truth; the server is a window onto them. It binds 127.0.0.1 only, one viewer per port (a second is EADDRINUSE — give each project its own with --port), and runs one scene at a time: the canvas shows one run, so a second start gets 409 a run is already in progress. Fan several out from the shell instead.

Agent nodes: tools, MCP, skills

An agent node loops — call tools, read results, call more, until it can answer. maxTurns (default 12) bounds it. Tool calls the model requests together run concurrently.

Built-in tools come in two halves. Read: read_file, list_files, glob, grep, fetch_url. Write: write_file, edit_file, bash.

The write half was held back for a long time on the argument that a shell is the largest attack surface an agent can have. That is still true; what changed is the conclusion. Without it an agent node could read and report but never build, so the only way to do real work was to rent someone else's coding agent — which costs money and control of the prompt. A small, confined, auditable write surface we own beats a large one we do not.

Every path is confined to the project root, and bash runs from the root under a timeout with a process-group kill. But bash does not sandbox the command — a command that reaches outside the root (curl, ssh, a global install) will do so. Disarm it where it has no business:

defaults: { tools: { bash: false } },   // the whole scene
tools: { bash: false },                 // one node

Research mode does this for you: it withdraws bash outright and replaces the write tools with versions scoped to the artefact under study, because a proposer that can shell out can rewrite its own evaluator.

MCP servers live in ensemble.json (project) or ~/.config/ensemble/ensemble.json (global):

{
  "mcp": {
    "fs": {
      "type": "local",
      "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."]
    }
  }
}

A node opts in by name: mcp: ["fs"]. Servers connect lazily — a scene naming none never starts one. ensemble mcp connects them all and lists every tool they expose.

Skills use the same SKILL.md format and locations as Claude Code (~/.claude/skills/, .claude/skills/, …), so skills you already have work unchanged. A node's skills: [...] are inlined into its system prompt.

Scoping is by construction, not by policy. We assemble each node's tool array ourselves, so a tool a node did not ask for isn't denied — it is absent. There is no deny-list to trust and nothing to misconfigure.

Providers: where a model call goes, as an object

Every model reference is <provider>/<model>. OpenRouter is the provider that ships — openrouter/anthropic/claude-sonnet-5 — and it is the only one you need, because it fronts every vendor with one key. But it is a mounted object, not a special case: the model runtime, the agent loop and the compare judge all call provider.complete, and none of them knows an endpoint.

import { registerProvider } from "@ghostmind-dev/ensemble";

registerProvider({
  name: "local",                                  // the prefix: "local/llama-3"
  summary: "a llama.cpp server on this machine",
  check: () => [],                                // or: ["LOCAL_URL is not set"]
  complete: async ({ model, messages, tools, temperature, onDelta, signal }) => {
    /* one turn: return { text, message, toolCalls, cost, tokensIn, tokensOut } */
  },
  models: async () => ["llama-3", "qwen-2.5"],    // for `ensemble models`
});

Mount it and a scene reaches it by prefix, validate routes every model and fallback to it and asks check whether it can be reached (that is how a missing key is reported before anything spends), and ensemble models lists what it offers. A test double is the same three lines, which is how the provider suite drives model, agent and compare nodes with no network at all.

What the OpenRouter object does that two hand-rolled fetches did not: it caches the system prompt. The system message goes on the wire as a content part carrying a cache_control breakpoint. Anthropic and Gemini bill the cached prefix at a fraction of the price, OpenAI caches long prefixes on its own, and every other vendor ignores the mark. For an agent node whose system prompt carries skills and is resent on every turn of a tool loop, that is the largest free cost lever there is.

Agent backends

runtime: "agent" is our own loop, and it is the right default: no subprocess, ~250 tokens of scaffolding, and the node's prompt dominates. But a node that has to genuinely build something wants what other people have spent years on — a real editing loop, a permission model, LSP, verification. Rather than reimplement that, mount it:

nodes: {
  plan:  { runtime: "agent",    prompt: "Read the code and plan the change.", outputs: ["plan"] },
  build: { runtime: "opencode", prompt: "Make the change.", inputs: ["plan"], outputs: ["summary"] },
  check: { runtime: "fn",       fn: (s) => ({ ok: String(s.summary).includes("PASS") }),
           inputs: ["summary"], outputs: ["ok"] },
},

opencode ships in the box because it is the only agent CLI that needs nothing but the credential ensemble already requires: it reads OPENROUTER_API_KEY straight from the environment, and its model namespace is openrouter/<vendor>/<model> — byte-identical to a scene's model ref, so it passes through with no translation. Install it with brew install sst/tap/opencode; ensemble validate tells you if it is missing, before anything spends.

Your scene's grants reach it too. The same defaults.skills and defaults.mcp our own loop honours are injected into the CLI per call via OPENCODE_CONFIG_CONTENT — config as a string in the environment, so nothing is written to disk and no state survives the process. One grant, both agents.

Mounting another one

A backend is an object: build an invocation, read the output back.

import { registerAgentBackend } from "@ghostmind-dev/ensemble";

registerAgentBackend({
  name: "codex",
  summary: "OpenAI Codex CLI, sandboxed",
  bin: "codex",
  install: "npm i -g @openai/codex",
  command: ({ model, prompt, cwd }) => ({
    argv: ["codex", "exec", "--cd", cwd, "--sandbox", "workspace-write", "--json", prompt],
    env: { CODEX_MODEL: model },
  }),
  parse: (res) => ({ text: res.stdout }),
});
// nodes may now declare { runtime: "codex", ... }

The backend's name becomes the runtime name, and the generated runtime is a calling one — so it inherits the output contract, the two-attempt retry, per-node cost accounting, the run budget, journalled resume and cancellation for free. You write an argv and a parser; the orchestration is already there.

Backends verified to fit this shape, all headless with your own OpenRouter key: Codex (codex exec, real OS sandbox), Qwen Code (qwen -p, distinct exit codes for turn and budget limits), Cline (cline --yolo, but pass -P openrouter or it bills their backend), and Continue (cn -p, per-tool --allow).

Two things worth knowing before you reach for one. An external CLI carries thousands of tokens of its own scaffolding per call — that is what made renting one expensive the first time, and it is why this is a per-node choice rather than a default. And where a CLI reports no usage, its spend is invisible to --budget; only what a backend can parse gets counted.

Not every agent CLI belongs here. Anything that meters a first-party consumer subscription — Claude Pro/Max via a wrapper, an ad-supported free tier, a proxy pointed at someone's ChatGPT plan — violates the upstream terms when driven by an automated process, regardless of which agent points at it. Backends must be tools you can point at your own provider.

Safety rails

| Rail | Default | Override | |---|---|---| | Total node executions | 50 | --max-runs | | Wall clock | 20 min | --timeout (minutes) | | Run cost (USD) | unlimited | --budget 0.50, or ENSEMBLE_BUDGET machine-wide (resumable) | | Per-edge loops | unlimited | maxLoops: on the edge | | Agent tool-calling turns | 12 | maxTurns: on the node | | Filesystem writes | impossible — no write tool exists | use an MCP server |

The budget is a hard stop, not a warning. Between nodes, a run that has spent its cap ends immediately with state checkpointed. Inside an agent node, the loop checks the cap between turns: once crossed (or on the final maxTurns turn), tools are withheld and the model is told to answer from what it already learned — a best-effort answer instead of a hard failure. ENSEMBLE_BUDGET=1 in your shell profile puts a $1 ceiling under every run on the machine, including ones started from the serve UI, which also takes a per-run cap in its toolbar.

Two more things keep agent loops cheap by construction: every tool result is clamped to 8 KB before it enters the conversation, and once a result is more than six tool calls old it is cleared down to a 200-char stub (the model can re-run the tool if it truly needs it again). Without that second rule the loop pays for its early exploration on every subsequent turn — cost quadratic in turns.

Every run writes costs.json next to state.json — an itemised per-node receipt — and the terminal prints a cost by node breakdown at the end, failed runs included, because "which node burned the budget" matters most exactly when a run died on it.

A when predicate that throws fails the run naming the edge. Two JSON-contract failures in a row fail the node loudly. An extraction that keeps <25% of a long reply raises a node:lossy warning — the model probably summarised its real answer away.

Human — or agent — in the loop

A node can stop the run and wait for an answer. It makes no model call:

approval: {
  runtime: "ask",
  question: "Ship this draft? Reply approve or reject, and say why.",
  inputs: ["draft"],              // context for whoever answers
  outputs: ["verdict", "why"],    // the keys their answer must fill
},

Then gate on the answer like any other state: { from: "approval", to: "publish", when: (s) => s["verdict"] === "approve" }.

The pause is durable, not a held-open process: the question goes into the journal, so the run can wait minutes or days, survive a reboot, and be answered by whoever is around —

ensemble resume .ensemble/runs/<id> --answer verdict=approve --answer why="reads well"

Whether a human or an agent answers is not the engine's concern; both just supply the missing state keys. The question, and the exact keys expected, live in the run's journal.json under pending — which is also what ensemble serve reads to show a waiting run.

A gate cannot be bypassed by retrying. Resuming without the answers parks again on the same question rather than falling through, and the ask node itself costs nothing — it is pure wait.

Resumable runs

A run that stops early — budget spent, node failed, ctrl-C, timeout — is not a dead end. Alongside the blackboard, every checkpoint records where in the graph the run was, so it can be picked back up:

ensemble run council.mts "the goal" --budget 0.25   # stops mid-graph, cheap
cat .ensemble/runs/<id>/state.json                  # look at what you bought
ensemble resume .ensemble/runs/<id> --budget 1.00   # continue, don't restart

This is what makes --budget a pause button rather than a kill switch. Spend a little, read the partial state, then decide whether it is worth more.

The continuation skips everything already paid for and lands in the same run directory, so costs.json keeps one cumulative receipt. journal.json carries the position: the target still owed, the maxLoops counters already consumed (so a resumed run cannot quietly award itself a fresh loop budget), cumulative spend, and why it stopped. A budget applies to the running total — resuming without raising it says so immediately instead of burning a node first.

Editing the scene between attempts is allowed, and often the point: a resume warns when the file's hash changed, because loop counters are keyed by edge order.

Seeing every run: one viewer, all projects

Runs land in the project they belong to, but each one also appends a pointer line to ~/.ensemble/index.jsonl. ensemble serve's Runs tab reads that, so a viewer started in any project lists every run on the machine — grouped project → scene → run, with status, spend, and whatever a paused run is waiting for. Click one to read its journal and state.

That closes the gap where a run started from another terminal was invisible to the browser: the viewer no longer shows only its own runs, it reads the journals, which are written after every node. Append-only JSONL because several runners write at once; the reader assembles the tree and drops entries whose directory is gone. The index is pure discovery — delete it and it refills.

Run artifacts

.ensemble/runs/<timestamp>-<scene>/state.json (checkpointed blackboard), costs.json (per-node receipt), journal.json (graph position, for resume), and result.md (every key rendered, on completion).

Everything is an object

The design rule, applied inward as well as outward: nodes, edges, schemas, and — since 0.16 — runtimes are objects. Each runtime object declares its own node properties (with zod shapes), its own validation, and its own execution (park for waiting runtimes, call for model-calling ones). The engine holds no runtime-specific branches; the validator composes each node's legal surface from the object it names.

Since 0.19, nodes have a deterministic form too: runtime: "fn" makes the node a plain function over state — free, instant, schema-checked like model output. Nodes are the neurons (model stochastic, fn deterministic, ask external input, experiment measurement, refine selection — keep the fitter candidate, discard the other); edges are the synapses, gated by their when property.

Since 0.20 the rule reaches the scene level too: a top-level block like research: is a mounted capability object. A capability declares its block's schema, its semantic checks, the tools it hands to agent nodes while active, and any engine guard defaults it retunes — and research is simply the first one in the registry, not a special case. The validator composes the scene's legal top level from what is mounted, so an unregistered block is still a typo:

import { registerCapability, z } from "@ghostmind-dev/ensemble";

registerCapability({
  name: "notify", summary: "posts run milestones to a webhook",
  schema: z.object({ url: z.string().url() }).strict(),
  tools: (value) => [/* tool objects every agent node receives while active */],
  tune: () => ({ timeoutMs: 60 * 60_000 }),
});
// scenes may now declare  notify: { url: "…" }  — validated, tools delivered,
// with zero engine or validator edits.

Since 0.21, edge selection is an object too. sequential — declaration order, first match wins, per-index maxLoops — is the default and was lifted verbatim out of the engine, which now holds no edge branches at all:

registerEdgeKind({
  name: "fanout",
  summary: "take every matching edge",
  fields: {},
  select: ({ edges, cursor, state, emit }) => { /* ... */ },
});
// scenes may now declare { edgeKind: "fanout" }

So the registries are: runtimes (what a node can be), agent backends (whose coding loop a node rents), tools (what an agent can do), edge kinds (how the next node is chosen), capabilities (what a scene can declare), stores (where artifacts go), sinks (who watches). The engine is a walk over a blackboard; everything else arrives as a block.

Since 0.18 the same is true of tools (registerTool({...}) — offered to every agent node) and the run store (runScene(..., { store }) — every artifact write goes through a store object; wrap fileRunStore to mirror runs elsewhere while keeping them resumable). The engine contains zero runtime-name branches, and since 0.21 zero edge branches either.

The payoff is that adding a capability means adding an object:

import { registerRuntime, z } from "@ghostmind-dev/ensemble";

registerRuntime({
  name: "webhook", summary: "POSTs the node's inputs and waits", badge: "🌐",
  needsModel: false,
  fields: { url: z.string().url() },
  park: ({ node, spec, state }) => /* wait, or pass values through */ …,
});
// nodes may now declare { runtime: "webhook", url: "…" } — validated and drawn
// like any built-in, with zero engine edits.

Using it as a library

import { loadScene, loadRegistry, runScene } from "@ghostmind-dev/ensemble";

const scene = await loadScene("scenes/example.mts", loadRegistry());
const result = await runScene(scene, "compare Bun and Deno", {
  onEvent: (e) => {
    if (e.type === "node:delta") process.stdout.write(e.delta);   // live tokens
    if (e.type === "node:end") console.log(`\n${e.node}: $${e.cost}`);
  },
});

Everything the terminal and browser show comes from this one typed RunEvent stream — your consumer sees exactly what they see.

Examples

examples/ — one folder per example, each README documenting a real run with actual output, timings, and cost. Start with 01 — Model Jury.

Tuning the agent scaffolding

Every runtime: "agent" node receives a short block of operating instructions — batch independent calls, don't repeat failing ones, quote evidence, stop when done. That block is measurable and swappable.

ENSEMBLE_AGENT_PROMPT=my-prompt.md ensemble run scene.mts "goal"   # try one
npm run bench                                                      # score it
npm run bench:optimize -- --iterations=5 --repeat=3                # improve it

bench/ is a Karpathy autoresearch loop — propose, measure, keep or revert, repeat, with an audit trail in bench/log.jsonl. Twelve tasks, all graded by code (never a model judge), against a fixture project with known ground truth. Objective: passes × 100 − turns, so correctness dominates and efficiency breaks ties.

Two things keep it honest, both learned the hard way:

  • The metric must not punish correct answers. An early checker failed a right answer because the model wrote "does not actually mention". There are now unit tests over the exact strings that were misgraded.
  • Nothing is believed without clearing the noise floor. The same prompt scored 9/12 and 12/12 on consecutive sweeps, so every measurement averages N sweeps and a candidate must win by more than ~1 point. Ties revert.

optimize.mts never edits source — a winner lands in bench/prompts/best.md and promotion is a deliberate step.

Status

v0.12 — fully self-contained; the opencode dependency is gone. Verified: per-node cross-vendor routing, the agent loop calling built-in and MCP tools until done, skills inlined from SKILL.md, function conditions, loop caps, parallel groups, token streaming, validate-before-save editing, hard cost budgets, resumable runs, typed state enforced by zod, ask nodes that pause for a human or an agent, a machine-level run index behind one viewer, and ensemble driving itself over MCP. npm test runs 10 offline suites; CI runs them on every push.

Not built yet: the orchestrator node (dynamic routing), drag-and-drop editing, and any hosted/remote execution — runs are local by design.

Working on ensemble itself

npm test          # 10 suites, all offline (OpenRouter is mocked) — no key, no spend
npm test resume   # just the suites whose name matches
npm run typecheck && npm run build

To work on the viewer, run it from source — never the installed package, or you are testing the last release:

node src/cli.ts serve dev/.ensemble/scenes    # or: run dev

dev/ is a sandbox project with a scene that exercises a parallel group, a scored gate that loops, and an ask node. The page is read from disk on every request and the server watches ui/, so editing ui/index.html reloads the open browser — no rebuild, no restart, no reinstall. Engine edits under src/ still need a restart of that one command.

Each suite runs as its own process: they chdir into temp projects and replace global fetch, so sharing one process would let them corrupt each other.