Skip to main content
loomcycle
§ release note

Claude Code orchestrates, loomcycle executes — a real 10-agent code review through MCP fan-out.

Most of the operator-via-MCP series so far has been about using Claude Code to drive loomcycle from the outside — we wrote experiments, Claude executed them through the loomcycle MCP server, gaps in the runtime surfaced and got fixed. exp7 is something different. It asks a sharper architectural question: can Claude Code use loomcycle as its actual runtime — not as a thing to test, but as the place where its delegated agent work goes?

The setup: Claude Code is the operator, the conversation surface, the orchestrator. But it doesn't do the heavy work itself. It clones a target repo into a sandbox, synthesizes an agent + a skill from its own .claude/ artifacts, imports them into loomcycle via loomcycle import claude-code, then makes one MCP call that fans 10 reviewer agents out across the loomcycle MCP server. The reviewers do the file-reading; the agents write findings to Memory; one more MCP call spawns a consolidator that merges and returns the report. Claude Code's own context never opens a repo file.

The result: 10/10 slices reviewed across 86 files, 35 issues found (1 Critical + 34 Important), including a race condition in loomcycle's own internal/channels/scheduler.go that's already shipped as a fix in v0.33.0 today. The runtime that just ran the code review got bugs fixed in it by the code review.

This is the architectural shape the v0.23.0 thin-client topology and v0.25.0 agent ensembles have been building toward. exp7 is the first experiment where the answer is clean and the design statement is concrete: Claude Code is the operator; loomcycle is the side runtime where the multi-agent work runs.

The topology — Claude Code orchestrates at the MCP boundary; loomcycle executes

 CLAUDE CODE (orchestrator)                    loomcycle MCP thin client (`loomcycle mcp --upstream`)

   1. git clone loomcycle  →  work/loomcycle-src        (read-only review target, in the jail)

   2. synthesize .claude/{agents/code-reviewer.md, skills/code-review/SKILL.md}
      → `loomcycle import claude-code --from=work/exp7/.claude --write --skills-dest=$PWD/skills`
      → exp7 config gains the `code-reviewer` agent; the code-review skill is copied + bundled

   3. ONE `spawn_runs` (MCP, mode=join, N=10) ──────►  loomcycle runtime
        │  spawns=[ {agent: code-reviewer, prompt: slice= path=

} ×10 ] │ server-side concurrent, bounded by admission gate │ join AND-barrier: blocks until all 10 settle → index-aligned envelope ▼ 10 × code-reviewer (one per slice), each: Glob/Grep/Read its slice under loomcycle-src/<path> (READ-ONLY) → Memory.set review:<slice>:findings (confidence ≥ 80 only, file:line + fix) 4. ONE `spawn_run` (MCP) → exp7-consolidator Memory.list "review:" → merge + dedup + count → Memory.set consolidated:report → return 5. Claude Code reads the report (consolidator final_text / consolidated:report) — the delegated result

Three moving parts worth pulling apart: the import (how Claude Code's .claude/ shape becomes loomcycle's substrate); the fan-out (one MCP call that spawns 10 concurrent agent runs); and the boundary (Claude Code never reads a repo file).

The import — Claude Code's .claude/ shape maps cleanly to loomcycle

Claude Code has its own opinions about how an agent is defined: .claude/agents/.md with YAML frontmatter (model, tools, skills) plus a markdown body that becomes the system prompt; .claude/skills//SKILL.md with a methodology agents can be told to follow. Those shapes have been around long enough that any real Claude Code user has a folder full of them.

loomcycle import claude-code (RFC C2, shipped earlier in the v0.12.8 line) translates that shape into loomcycle's substrate. exp7's invocation:

loomcycle import claude-code \
  --from=work/exp7/.claude \
  --write \
  --skills-dest=$PWD/skills

The mapping:

Claude Code shapeloomcycle shape
agents/code-reviewer.md frontmatter tools:AgentDef allowed_tools
agent body (the markdown after frontmatter)AgentDef system_prompt
model: sonnettier: middle
skills/code-review/SKILL.mdcopied to $PWD/skills/code-review/SKILL.md; appended to the agent's system_prompt at config-load (skills: loaded 1)

One honest import-fidelity gap, documented in the experiment: the importer doesn't carry the agent's skills: frontmatter field through ("no loomcycle equivalent"). We re-attached skills: [code-review] by hand after the import. A small follow-up for the importer to close.

What this means in practice: a Claude Code user who already has working agents + skills can run them on loomcycle with one import command. They don't have to re-author for the new runtime. The agent's identity (tools, prompt, methodology) survives the move.

The fan-out — RFC Y's spawn_runs, shipped in v0.33.0 today

The original exp7 (a few weeks back) couldn't do this from the outside. It used an in-loomcycle dispatcher agent (an exp7-orchestrator that called Agent op=parallel_spawn) because the MCP plugin couldn't fan out N concurrent blocking spawns — that was the F17 / RFC O head-of-line-blocking problem from a few weeks ago, with the async spawn_run not yet shipped. The dispatcher worked, but the fan-out lived inside the runtime; Claude Code was just kicking off one run that happened to spawn ten more.

RFC Y (today, v0.33.0, #464) puts the fan-out at the MCP boundary. One MCP call from Claude Code spawns N concurrent loomcycle runs server-side, with the AND-barrier built into the response envelope. The in-loomcycle dispatcher is gone. The fan-out is now genuinely external.

Wire shape:

# One MCP call. 10 children, server-side-concurrent. Bounded by the admission gate.
spawn_runs({
  mode: "join",                       # mode:detach for fire-and-forget awaits RFC P
  spawns: [
    { agent: "code-reviewer", prompt: "slice=api  path=internal/api/http" },
    { agent: "code-reviewer", prompt: "slice=tools  path=internal/tools/builtin" },
    { agent: "code-reviewer", prompt: "slice=provs  path=internal/providers" },
    ...                                # 10 total, cap 32
  ]
})

# → returns when all settle, an index-aligned envelope:
[
  { index: 0, run_id: "r_…", status: "completed", final_text: "..." },
  { index: 1, run_id: "r_…", status: "completed", final_text: "..." },
  ...
]
# Per-child failures captured in-envelope; never fail the batch.

The same wire is also available as REST: POST /v1/runs:batch. Both reuse the executeParallelSpawn core that's been powering in-loomcycle parallel_spawn since v0.10 — same admission gate, same per-child failure capture, same join semantics. The external boundary just got first-class access to a primitive that already existed inside the runtime.

The exp7 reviewer agent's allowed_tools is narrow: [Read, Grep, Glob, Memory, Context]. No Bash, no Write — read-only by construction. The 10 reviewers can't write to the repo; they can only read it and record findings to Memory. The sandbox is enforced by allowed_tools, not by good intentions.

The result — 10/10 slices, 86 files, 35 issues, 1 Critical

The consolidator returned an executive report with categorized findings, file:line citations, and recommended fixes. The Critical was the headline:

Critical · channels · internal/channels/scheduler.go:81A time.AfterFunc closure can fire and run timers.Delete(id) + pendCnt.Add(-1) before the outer LoadOrStore commits the entry. In the sub-µs window between the two, the entry is then stored but its pendCnt is never decremented → permanent pendCnt leak + orphaned entry. Fix: create the timer after a successful LoadOrStore.

Real concurrency bug. Sub-microsecond race window. The kind of thing that doesn't reliably reproduce in tests but would leak over hours of production load. The fix is one statement-reorder. Already shipped in #463 today.

The Important findings — 34 of them, confidence ≥ 85 each, all with file:line citations — covered a spread of patterns the runtime had accumulated as it grew. A representative selection:

  • store/postgres/postgres.go:5976newID() panic()s on crypto/rand failure on the hot CreateSession / CreateRun / AppendEvent path → a transient entropy error crashes the process. Should return an error instead.
  • pause/manager.go:524ToolCtx (StatePausing/Paused branch) returns the bare cancel as its "cleanup" callback, never calls activeTools.Delete(id) → the finalize-pause Stage-2 barrier spins to deadline waiting for an entry that nothing is going to delete.
  • snapshot/restore.go:500 — restored paused runs are inserted with Status=RunRunning instead of Paused → the resume dispatcher (which filters by status) never picks them up; the run is stuck "running" forever. Direct regression risk for the v0.30.0 F42 cross-instance mid-run resume we just shipped yesterday.
  • tools/builtin/memory.go:884execMerge / execAppendDedupe / execBoundedList run checkQuota after MemoryAtomicUpdate commits → unbounded storage growth past the operator quota. The quota check is supposed to be a precondition, not a postcondition.
  • api/http/channels_admin.go:99 (and siblings) — admin POST handlers decode r.Body with no http.MaxBytesReader → an authenticated client can OOM the server by streaming a multi-GB request body. Bearer-authed but still an availability vector for any operator who lets non-trusted users hold a token.
  • api/http/server.go:3932 — the interactive-run background goroutine's defer release() fires when the HTTP handler returns, not when the goroutine itself finishes → concurrency-semaphore slot leak. Direct regression risk for v0.27.0's detached-interactive-run feature.
  • providers/anthropic_oauth_dev/refresh.go:74Refresher.Stop() deadlocks if called before Start() (blocks on a doneCh only Start's goroutine closes). The "always safe to defer Stop()" contract is false on bootstrap-failure paths.

Plus systematic themes the consolidator surfaced from across the 10 reviewer reports: context.Background() where a caller/timeout context is required (api-http, channels, cmd, providers); silent error discards (snapshot ExportPretty checksum, store dimensions unmarshal, scheduler shutdown hooks); dead code (ctxDone, nilEmbedding, idempotentBuiltins, forceCancelMu).

Same-day fixes. The security findings (C1/C2 tenant scope + infra-secret YAML expansion + I6/I1 input-endpoint scope) shipped as #462. The crash/correctness/hardening findings (C3/I3/I5/I7/I2/I4 scheduler error log + channel point-lookup + snapshot checksum + 3 hardening fixes) shipped as #463. Both merged before this post landed. Of the 35 findings, the highest-severity ten are already closed; the long tail is filed for triage in doc-internal/rfcs/exp7-code-review-findings.md.

The self-validating loop: Claude Code orchestrated 10 loomcycle agents to review the loomcycle runtime; the agents found real bugs; the runtime got better the same day. The thing that ran the experiment got fixed by the experiment.

Three runtime findings the experiment itself surfaced

Beyond the code-review payload, driving a real concurrent MCP fan-out (vs. the original in-loomcycle dispatcher topology) stressed the runtime in three ways worth handing back to the dev team:

1. Glob doesn't match absolute paths inside the read-root

Read-only Glob sandbox gapReviewers given an absolute glob /Users/.../work/loomcycle-src/<path>/**/*.go got zero matches, and under concurrent load concluded "repo not mounted" and bailed. The relative form loomcycle-src/<path>/**/*.go (with cwd = read-root) matched correctly.

First fan-out: 5/10 slices recorded findings; the other 5 hit the absolute-path Glob gap and gave up. After we switched the reviewer prompt to use relative paths consistently, all 10 found files. A real correctness gap in the read-only Glob sandbox semantics — should normalize absolute-into-read-root paths or fail loud rather than match-nothing.

2. Cross-provider fallback drops the thinking/reasoning_content block

Provider-fallback fidelity gapWhen 10 sonnet reviewers saturated the Claude OAuth account (HTTP 429), the runtime fell back Anthropic → DeepSeek mid-conversation. DeepSeek rejected the request with openai 400 ... "reasoning_content in the thinking mode must be passed back to the API." The fallback path doesn't carry the in-flight reasoning block that DeepSeek's thinking mode requires.

Real fallback-fidelity bug. The fallback path translates the Anthropic conversation shape to OpenAI-compatible, but the thinking/reasoning_content block is provider-specific and gets dropped. DeepSeek's thinking-mode contract requires it. Handed to the dev team as a follow-up RFC.

3. Fan-out sizing vs. OAuth subscription rate limit (operational, not a bug)

10 concurrent heavy reviewers each reading 15–100 files exceeded the Claude subscription's rate limit (HTTP 429). The fix is not in the code — it's an admission-gate sizing decision: max_concurrent_runs: 4 + queue_timeout_ms: 1_200_000 so queued children wait for a slot instead of getting rejected. The spawn_runs join still takes all 10; the rest queue and run as slots free. Result: 10/10 completed, zero 429s.

The lesson, generally: when fanning out heavy LLM work to an OAuth subscription (vs. a paid API key with higher limits), throttle the admission gate. The runtime supports it; the operator has to configure it.

What this architectural shape unlocks

Claude Code stays the operator and the conversation surface. loomcycle is the side runtime where the actual multi-agent work runs. Claude Code's context never had to carry the 86 files of the loomcycle repo. Ten loomcycle agents did. The result came back as a single consolidated report. The contract between the two systems is the MCP wire surface — narrow, structured, well-defined.

This contrasts with the "harness Claude Code from inside" design pattern (the shape ruflo / claude-flow implements, per our internal competitor analysis). There, Claude Code is the loop and the harness adds layers on top. Here, Claude Code delegates to a sidecar that owns its own loop. Both shapes exist; they have different buyers; both can be right. exp7 demonstrates that loomcycle's substrate fits cleanly into the role of "side runtime that Claude Code orchestrates" without forcing the user to give up Claude Code as their conversation surface.

The practical reading: if you already drive most of your day through Claude Code and you want a real multi-agent runtime that doesn't replace it, loomcycle is the shape that fits. The import command brings your existing .claude/agents + .claude/skills over with one invocation. The MCP fan-out (spawn_runs) lets you offload N-wide concurrent agent work in one call. The Memory primitive lets the agents converge on a shared ledger. The consolidator returns the result. Claude Code never loses the orchestrator seat.

Try it

Latest release is v0.33.0 (today). The exp7 example will ship as a self-contained directory under loomcycle/examples/exp7-code-review-delegation shortly (it's currently in the experiments repo at loomcycle-experiments/doc/exp7-code-review-delegation.md while the runtime bugs it found are being closed out). The MCP tool surface is in doc/MCP.md; the new wire shape is spawn_runs (and POST /v1/runs:batch for REST callers).

Companion reading from the operator-via-MCP series: exp1+2 — the framing of "Claude Code as operator" (the four-experiment series this finishes); exp3 side analysis — the MCP wedge (F17 / RFC O — the head-of-line-blocking gap that made the original exp7 use an in-loomcycle dispatcher; RFC Y closes the same gap from the other direction); exp5 — agent ensembles (RFC S) (the in-loomcycle equivalent shape — Channel.await fan-in); today's earlier post on context compaction (v0.32.0) (the other thing that shipped today).