← Back

Porting a 30,000-Line Go Project to Rust with Claude Code: Agent Teams and Harness Tuning

Porting a 30,000-Line Go Project to Rust with Claude Code: Agent Teams and Harness Tuning cover image

Background

mihomo (Clash Meta) is a rule-based proxy core written in Go. It supports Shadowsocks, Trojan, VLESS and a range of other protocols, and is widely deployed on routers and VPSes. I decided to rewrite it in Rust, without any "rewrite everything in Rust" obsession behind it, because there were practical reasons: a smaller binary, lower memory use, and the safety the Rust type system brings to network protocol implementations.

The result, mihomo-rust, contains 11 workspace crates, 31,000+ lines of Rust, 40 technical specs, 2 architecture decision records (ADRs), and a full CI pipeline covering unit tests, integration tests and end-to-end TProxy tests. From the first commit to roughly finishing the M1 milestone, the whole process leaned heavily on Claude Code's Agent Team mechanism.

This post is not about how amazing AI is. It records what actually worked in engineering practice: what helped, where I hit trouble, and how to tune the harness configuration so Claude Code is genuinely usable on a large project.

mihomo-rust crate architecture: 31,178 lines spread across 11 crates

Agent Team: Four Roles

Claude Code's Agent Team lets you run several specialized agents in one session, each with its own responsibilities. On mihomo-rust I used four roles:

RoleModelResponsibility
PM (project manager)SonnetOwns the roadmap, sets priorities, writes milestone exit criteria, maintains roadmap.md
ArchitectOpusWrites gap analyses and ADRs, makes architecture decisions, reviews technical proposals
EngineerSonnetImplements code, writes tests, handles CI fixes
QAHaikuWrites test plans, reviews test coverage, maintains the CI status report

Why These Models

Opus sits on the Architect role because architecture decisions need the strongest reasoning. For example, deciding whether the gRPC transport should hand-write "gun" frames or pull in tonic (hand-writing won, because the upstream Go code has no protobuf schema of its own, and tonic would add about 30 dependencies and 2MB of binary size).

Sonnet handles PM and Engineer, because both roles do structured execution: the PM fills in roadmap tables from a fixed template, the Engineer implements code from a spec. Haiku handles QA. Test plans are highly templated work, so the fastest and cheapest model is enough.

Information Flow

The four agents do not work in isolation. They share state through the filesystem:

Agent Team: how the four roles collaborate and where information flows

TEXT
docs/vision.md          ← PM owns it, defines goals and non-goals
docs/gap-analysis.md    ← Architect produces, PM consumes
docs/roadmap.md         ← PM owns it, cites the Architect's analysis
docs/adr/*.md           ← Architect owns it, non-negotiable decisions
docs/specs/*.md         ← PM owns the format, Architect reviews content
docs/specs/*-test-plan.md ← QA produces
docs/ci-status.md       ← QA owns it

The key principle: ADRs decide the architecture (not negotiable), specs fill in the details (open to discussion), test plans verify the specs. That layering avoids decision loops between agents.

Milestone-Driven Rhythm

The project split into four milestones:

  • M0 (correctness fixes): 10 small items fixing security holes, missing wiring, CI gaps. For instance, Bearer auth on the REST API had been sitting under #[allow(dead_code)], and GEOIP rule parsing simply returned an error
  • M1 (usable): filling out protocols, transports, rules, DNS and the API
  • M2 (performance): benchmarks, allocator audits, trimming feature flags
  • M3 (operational maturity): hot reload, OpenTelemetry, config validation

M0 and M1 ran in parallel. Every M0 item was a small, contained fix, so the Engineer could slot them in while waiting for M1 spec reviews.

Development velocity: commit density rises sharply once the Agent Team is fully engaged

Example: The Transport Layer

The transport layer (M1.A) was a prerequisite for M1. The VLESS protocol needs a reusable TLS/WebSocket/gRPC transport layer, otherwise every new protocol has to copy and paste the TLS handshake code.

The process went like this:

  1. Architect wrote ADR-0001, settling on mihomo-transport as a standalone leaf crate, defining the Transport trait interface, and choosing Box<dyn Stream> trait objects over generics (because the transport chain has to be assembled dynamically at runtime from the YAML config)
  2. PM translated the ADR into four ordered roadmap tasks (A-1 through A-4) with dependencies marked. "VMess unblocks after A-2 lands"
  3. Engineer implemented them in order: first the crate skeleton and TLS layer, migrating Trojan; then the WebSocket layer, migrating v2ray-plugin; then hand-written gRPC gun frames; finally HTTP/2 and HTTPUpgrade
  4. QA verified at each step that the integration tests still passed: trojan_integration and v2ray_plugin_integration could not break during the migration

The process looks heavy: four roles to create one crate. But that structure is what guaranteed a few things: gRPC pulled in no unnecessary dependencies (Architect's decision), the build order stayed intact (PM's control), and the tests stayed green throughout the migration (QA's verification).

Spec-driven development pipeline, using the transport layer as the example

CLAUDE.md: The Main Lever

CLAUDE.md is the guidance file Claude Code loads automatically at the start of every session. It is the key to harness efficiency: write it well and the agent does not have to re-explore the project structure every time.

The CLAUDE.md in mihomo-rust is only 101 lines, but the information density is high:

Markdown
## Build Commands
cargo build --release
cargo test --lib
cargo test --test rules_test           # 78 rule matching tests
cargo test --test trojan_integration   # embedded mock server
cargo test --test shadowsocks_integration  # requires ssserver

## Architecture
Listeners → Tunnel (routing) ←→ DNS Resolver
                |
          Rule Matching
                |
          Proxy Adapters / Groups → Remote Server
          
REST API (Axum) → Runtime control

## Key Patterns
- ProxyAdapter trait — all protocols implement this
- Rule trait — all rule types implement this  
- Tunnel — Arc-shared routing engine

Writing CLAUDE.md

Only write what cannot be inferred from the code. Do not list the path of every file, the agent can find those with Glob. What you should write is: which traits form the architectural skeleton, which tests need external dependencies (ssserver), what unusual arguments the build commands take.

Spell out the extension points. "How to add a new protocol" and "how to add a new rule type", three lines each, telling the agent which three files to change. That beats a whole paragraph of architecture description, because what the agent needs is actionable instructions.

Do not write stale information. CLAUDE.md is not a changelog. Once a decision has landed in the code (fake-ip being removed, say), there is no need to explain in CLAUDE.md why it was removed.

Memory: Across Sessions

Claude Code's Memory system persists information between sessions. The mihomo-rust project accumulated 7 memories, all of type feedback, meaning corrections to or confirmations of agent behavior.

A few representative ones:

"No CatchPanic on the Router"

TEXT
prohibits adding CatchPanic or panic-absorbing middleware to axum router.
Task #26 requires panics in spawned tokio tasks to abort the process
so failures are detectable.

This memory came out of a real incident: the Engineer agent tried adding tower::catch_panic to the Axum router to "improve robustness". But the QA test plan requires a panic to terminate the process so that the soak test can detect the failure. Once this memory was saved, the Engineer stopped making the same mistake in later sessions.

"tokio::time::pause() Skips Syscalls"

TEXT
tokio::time::pause()/advance() only affects sleep/Instant futures,
not kernel syscalls like TcpStream::peek(), read(), recv().

This one is a trap the Engineer fell into while writing sniffer tests. tokio::time::pause() looks like a way to speed up timeout tests, but it only affects tokio's own timers, not real socket IO. With this saved, the same trap was avoided outright while writing the boring-tls tests later.

"Restart Teammates at Milestones"

TEXT
Mandatory shutdown and respawn all four teammates at milestone completion.
Respawn with model assignment: architect=opus, pm/engineer=sonnet, qa=haiku.
Do not clear mid-milestone or if any state isn't saved.

This is the most important operational rule of the lot. Agent Team context windows are finite. After a whole milestone of discussion, the context is stuffed with stale intermediate state. Restarting every agent at a milestone boundary, so they re-read the documents on the filesystem from a clean state, is more efficient than carrying old context forward.

Upstream Divergence: ADR-0002

One of the thorniest questions in a porting project: do you copy the upstream's bugs?

ADR-0002 defines a simple two-way classification:

  • Class A (security / privacy / routing intent): hard error, refuse to load. Reading the config file, the user would believe they got X while actually getting a less safe Y
  • Class B (performance / compatibility): warn once, keep running. Traffic reaches the right destination, just over a slower path

Upstream divergence policy: a two-class decision framework

Concrete cases:

ScenarioUpstream behaviormihomo-rustClass
VMess cipher: zeroAccepts it, transmits in plaintextErrors at parse timeA
alterId > 0Runs the deprecated MD5 key derivationWarns and forces it to 0B
Sniffer peek IO errorSkips silentlyLogs it, keeps the original metadataA
default-nameserver containing tls://Accepts it, bootstrap deadlocks at runtimeErrors at load timeA

The value of this classification is that it gives the Engineer agent a clear default rule when it hits an edge case the spec did not foresee during implementation. "When in doubt pick Class A (hard error) and flag it in the PR description." That is far more efficient than pausing every time to ask the Architect for a decision.

For QA, referencing the divergence class in a test case (Class A per ADR-0002: upstream accepts, we reject) lets a reviewer see the intent of the test at a glance.

Spec-Driven Development

The project produced 40 spec documents and matching test plans. The count looks large, but under an agent team collaboration model the spec is the key tool for coordinating four agents.

Every spec has the same fixed structure:

  1. YAML schema: the field definitions in the config file
  2. Struct shapes: the fields and types of the Rust structs
  3. Error types: an enumeration of every error case
  4. Divergences table: divergences from upstream, referencing the ADR-0002 classes
  5. Test plan: the test matrix (a separate file)

A spec beats telling the Engineer "go implement VLESS", because the spec is the interface contract between agents. The Architect defines the type signatures in the spec's struct shapes section, the Engineer implements them, QA generates test cases from the spec's error types. Without a spec, every agent has to read the upstream Go code itself to work out what to do, and you end up with three agents holding three different understandings of the same problem.

A concrete number: the transport-layer.md spec covered all four subtasks of M1.A, because ADR-0001 had already fixed the architecture. The spec only had to fill in the YAML schema, struct shapes and per-layer tests, roughly 200 lines. From those 200 lines the Engineer produced the entire mihomo-transport crate.

Efficiency: Traps and Lessons

1. Context Is the Scarcest Resource

Each agent in the team has its own context window. A long-running session ends up with its context filled by early exploration, failed attempts and intermediate state. The fixes:

  • Write the key information into CLAUDE.md so the agent does not have to re-explore every time
  • Restart every agent at milestone boundaries
  • Pass state through the filesystem (docs/, specs/) rather than through the context window

2. Docs Are For Agents

In a traditional software project, documentation is written for the next person who reads the code. Under an agent team model, documentation doubles as the agents' "system prompt". They read docs/ to understand project state and decision history.

That changes how you write it:

  • Use tables instead of prose. Agents parse tables more efficiently than paragraphs
  • Be precise in references. "See ADR-0001" beats "see the earlier architecture discussion", because the agent can locate the file directly
  • Make state explicit. Mark every work item "completed / in-progress / blocked", not "we discussed this before"

3. Keep Memory Small and Actionable

The trap with the Memory system is storing too much. mihomo-rust saved only 7 memories, all of type feedback, meaning rules of the form "do not do X" or "watch out for Z when doing Y".

What not to save:

  • Code patterns and conventions (inferable from the code itself)
  • Git history (git log is authoritative)
  • Debugging approaches (the fix is already in the code)
  • Transient task state (use the task system, not memory)

4. Tests Are the Only Real Check

Agent-generated code may look correct, but "looks correct" is not "runs correctly".

Test infrastructure: 619 test functions across 5 layers

The mihomo-rust CI pipeline includes:

  • 100+ unit tests
  • 82 API integration tests
  • 78 rule matching tests
  • 5 protocol-level integration tests (Trojan, Shadowsocks, v2ray-plugin, VLESS, boring-tls)
  • Dockerized end-to-end TProxy tests
  • MSRV validation (making sure the claimed minimum Rust version is real)

Running the full test suite after every Engineer commit is a step you cannot skip. During the ECH/uTLS work, 31 test cases (including the real BoringSSL end-to-end handshakes in C13-C15) were the only criterion for judging "this feature can be merged".

5. Let Agents Own Their Status Docs

The ECH/uTLS feature demonstrated a pattern that works: the PM agent maintained an ech-utls-status.md recording the state of 16 tasks, the owner of each, the commit hash that closed it, and the key decisions (why boring rather than rustls as the ECH backend, why the random profile resolves in TlsLayer::new instead of on every connection).

That status document is both the collaboration surface for the agent team and a quick reference for a human reviewer.

Numbers and Cost

Some objective data:

MetricValue
Total Rust code31,178 lines (117 source files)
Workspace crates11
Largest cratemihomo-proxy (9,797 lines, 27 files)
Git commits106
Commits directly by Claude10
Spec documents40 (largest 695 lines)
ADRs2
Test functions619 (408 sync + 211 async)
Integration test suites24
CI jobs5 (lint, test, tproxy, msrv, macos)
Cargo dependencies375
Development span~4 weeks (2026-02-21 to 2026-04-12)
Peak commits in one day27 (2026-04-08, M0 sweep + 6 specs)

Only 10 commits are directly authored by Claude (mostly CI fixes and the simple-obfs plugin), which does not mean Claude contributed only 10 commits worth of work. Most commits list me as the author, but the code was produced collaboratively inside Claude Code sessions: I reviewed, edited, then committed under my own name. Claude's contribution shows up more in writing specs, drafting code, carrying out refactors and maintaining documentation.

When Agent Team Pays Off

Agent Team is not a universal solution. It is worth using when:

  • The project is too big to fit in one context window. mihomo-rust has 11 crates, 31K lines of code, 40 documents. A single agent cannot hold the global architecture and the local implementation details at once
  • You need decisions at different levels. Architecture decisions (use tonic or not), project management decisions (what comes first in M1), and implementation decisions (the field type on this struct) call for different modes of thinking
  • There is a clear document-driven process. Agent team collaboration is built on the filesystem. If your team has no habit of writing specs, an agent team loses much of its efficiency
  • You need consistency across milestones. The Memory system and the documents keep knowledge from being lost between sessions

Not worth using when:

  • The project is small (< 5K lines) and a single agent is enough
  • You are doing exploratory prototyping, where a structured process is a burden
  • The project has no test infrastructure. You have no way to verify the quality of what the agents produce

What Claude Code solves is not "can AI write code" but "how do you verify and integrate the code AI writes as an engineering process". Agent Team plus CLAUDE.md plus Memory plus spec-driven development make up a complete harness, turning AI assistance from "let's see if it runs" into a repeatable, reviewable, scalable engineering process.