The GitHub Copilot CLI, the GitHub Copilot app and the GitHub Copilot SDK all sit on the same foundation: the Copilot agent runtime, an agentic harness designed to be embedded in applications and services. It began as TypeScript running on Node.js and the V8 JavaScript engine, built for what is now the GitHub Copilot cloud agent (CCA), and it stayed on that stack while the runtime and its capabilities expanded rapidly.

That stack is gone. Working through the GitHub Copilot app and the Copilot CLI, the team rewrote the runtime as more than 800,000 lines of production Rust. AI agents produced most of that code, across 128 pull requests that landed in main and shipped incrementally instead of behind one final cutover. The regressions that did appear were found and fixed along the way, and runtime performance improved by orders of magnitude. Work that would previously have occupied a full team for a year or two was completed primarily by a single developer in a few months, while the rest of the team kept growing the runtime's capabilities and reach.

Why the shared runtime had to change

The runtime is not just the engine under the Copilot CLI. Architecturally, each product built on it is a shell around the same runtime plus its own customizations. The list includes the Copilot CLI and Copilot app, recent releases of VS Code and Visual Studio, CCA, Copilot Code Review (CCR), Copilot Cowork, Copilot Studio, and Excel, Outlook, PowerPoint and Word, among others.

These products are very different from one another, and none of them should have to reimplement a production agent harness. They want the intelligence, security, reliability and performance, and they want it shared so a fix in one place fixes it everywhere. Most of them started with their own agent loop and have since moved to the GitHub Copilot SDK, the entry point to the runtime. That lets them concentrate on their core business value, which matters given how quickly the industry moves and how the agent loop has to stay best-of-breed against intense competition.

So a shared runtime is the right goal. The problem was the runtime's nature.

Logically, the CLI is a terminal UI (TUI) on an agent loop, and the whole stack was TypeScript, with Node.js as the framework, V8 as the execution engine, and Ink and React for the UI. For a TUI that is a defensible choice: TypeScript and Node.js are broadly accessible and allow very fast application development, and for a console application the startup, responsiveness, throughput and memory costs are reasonable. Those costs become much less reasonable when the same implementation is expected to serve other environments with other constraints, where fast startup and high server density through low memory overhead are requirements.

The architecture compounded this. The CLI was written and shipped quickly, and the TUI and runtime ended up fairly intertwined rather than split into discrete layers. When programmatic access was needed, the pragmatic move was to layer the SDK on top of the CLI rather than the other way round. The CLI gained a headless mode, reading commands from stdin and writing responses to stdout, and a JSON-RPC protocol marshalled function calls to and from an external process. A consuming program would embed the SDK, spawn a CLI process to host the agent loop out-of-process, and call into the remote process over JSON-RPC. It was neat, fast to ship and flexible, but poor for the performance (startup, memory, throughput) and reliability of consuming applications. Constructing a new CopilotClient from the SDK spawned another process:

const client = new CopilotClient();
await client.start(); // spawns the CLI as a subprocess
const session = await client.createSession({
    /* ... */
});

That process had to launch and host Node and V8. It meant parsing the substantial JavaScript emitted from the CLI's TypeScript, generating bytecode, and potentially optimizing hot code in later JIT tiers. It meant V8's memory overhead, and Node's threading model, which by default pushes CPU-bound work toward serialization. It meant forced out-of-process communication for ordinary function calls. Every SDK consumer, in every language, shipped Node.js or a bundled binary containing V8: the C#, Python, Go, Java and Rust SDKs each paid for a second language runtime per client, on the order of 100 MB of working set minimum, for something the host application otherwise had no use for. Every event, every message and every abstracted session file system read and write crossed a process boundary. A crash in Node took the session with it, and deployment meant at least two processes to supervise, monitor and debug.

The requirements for the replacement were:

  • No TUI in the runtime — that belongs in its own library, with the TUI and other applications and services layered cleanly on top.
  • A language with minimal dependencies and minimal overhead.
  • Clean in-process embedding instead of a forced out-of-process model.
  • Top-tier performance, scalability and reliability characteristics.
  • Strong interop, so all six Copilot SDK language versions (C#, TypeScript, Python, Rust, Go, Java) can use it through their foreign function interface (FFI).
  • A tool chain with a more modern security posture, less supply chain risk and greater support for correct-by-construction code.

Those requirements, plus softer considerations such as team experience and industry direction, led to Rust. This is not an argument that every large TypeScript program should become Rust. The emphasis was on embedding through a C ABI, low startup and steady-state overhead, and predictable resource use. Rust made those possible at the cost of other complications — lifetimes and shared state had to be represented explicitly, and the lifecycle regressions discussed later show what that implies. The right target language varies by application.

Two related tasks followed:

  1. Separating TUI-specific code from the runtime so the TUI layers strictly on the runtime, and more specifically on the SDK's public surface area. The CLI still calls directly into runtime internals in several places; moving it fully onto the SDK surface area is ongoing.
  2. Porting the runtime layer to 100% Rust, yielding a pure native binary that exposes a C ABI for in-process consumption by all language front-ends, plus a stdin/stdout-based or socket-based server for cases where out-of-process is still wanted.

This post focuses on the second task: porting the runtime to Rust.

The Copilot runtime architecture before and after the Rust rewrite, showing the old SDK-to-CLI process boundary and the new in-process and out-of-process hosting paths.

Sizing the port

The initial porting plan, in early May 2026, put the runtime at roughly 130,000 lines of TypeScript. That measurement was accurate enough for scoping, but misleading in two ways. First, concurrent work kept pushing pieces out of the TUI layer and down into the runtime, so entire components and large percentages of code left out of the estimates later became porting targets. Second, pull requests kept adding significant amounts of new TypeScript, as tens of agentically assisted developers merged hundreds of pull requests each week.

All of this together means approximately 430,000 lines of production TypeScript ultimately passed through the port. The same dynamics made progress hard to read: until near the end, production TypeScript volume looked roughly flat, or slightly rising, because porting kept pace with incoming work. That picture is further muddied by separate incoming Rust code over the same period; early on, incoming code skewed TypeScript, later it skewed Rust.

TypeScript fell to zero while production Rust rose to approximately 830,000 lines and Rust unit tests to approximately 469,000 lines.

During the port, the runtime took in about 300,000 production lines of TypeScript and shed about 430,000, while about 1,200,000 production Rust lines came in and about 365,000 went out. The apparent stability of the TypeScript line in the graph was in fact hiding a large amount of TypeScript churn.

Choosing an in-place port

A rewrite at this scale has two broad shapes. The big bang approach develops a complete Rust runtime as an alternative and swaps it in at once — either by freezing work on main while the rewrite happens there, or by developing in a feature branch that continuously merges from main. The in-place approach ports component by component, and itself splits two ways: atomic replacement, where each piece flips from TypeScript to Rust behind an interop shim, or A/B, where both language versions are kept hot-swappable until confidence plateaus.

The team chose atomic replacement (2a). Several properties drove the decision:

  • No work stoppage. main stays active; developers outside the port keep working, affected only when an in-flight pull request touches code being ported concurrently, in which case they rebase and have their agents port just those changes.
  • main is always shippable. Each pull request replaces the TypeScript implementation with a thin shim into Rust and deletes the old code atomically, so new code is exercised in situ immediately.
  • Changes stay reviewable. One component or slice per pull request keeps scope and diffs small, whether reviewed by humans, agents, or both.
  • Ports are mostly small and self-contained, which limits drift from concurrent pull requests. Components too large to port cleanly could first be refactored into smaller ones.
  • Existing end-to-end tests across the CLI and SDK run against the new Rust code at every step. A pull request that failed a required test did not land.

The A/B variant was rejected. With hundreds of pull requests merged weekly for months, maintaining two versions of the same code in two languages with two dependency sets adds substantial complexity. Not all components are cleanly isolated: some are logically standalone behind simple APIs, but others have significant tendrils, and making that graph hot-swappable per component is unworkable. The subsystems that would benefit most from a cautious parallel cutover are the hardest candidates for it. Session orchestration, for instance, is not a pure function that can be called in two versions behind an experimentation flag — it owns mutable state, drives callbacks in both directions, and threads through nearly every other subsystem. Shadowing it would mean keeping two divergent copies of the code that holds the conversation's state and services in sync across hundreds of concurrent edits. The coupling that makes a component hard to port is the same coupling that makes it near impossible to shadow without risking more regressions than it prevents. Swapping in this manner buys confidence, which the team could obtain another way.

Validation instead came through incremental rollout. A big-bang cutover would expose consumers to every ported line — and every regression that slipped through in-repo testing — simultaneously. Rolling out components in small batches provided last-mile validation in deployed builds with real usage, most often first-party within Microsoft and GitHub, while keeping regression risk minimal. Over the roughly fourteen-and-a-half-week porting window, main shipped 135 releases: 100 pre-release and 35 stable versions, averaging about 1.3 releases per day. Port pull requests opened at roughly 1.3 per day as well, so each release carried a small, knowable set of ported components; shipping a port in a pre-release first was generally attempted, though not always achieved. In a trailing seven-day npm sample, pre-release versions accounted for only 10.5% of downloads, so initial exposure stayed limited while the team monitored feedback channels and turned around fixes in the next pre-release. Issues were easier to correlate with known recent changes and quicker to root cause. The longer, incremental path thus worked as an advantage rather than an obstacle. By August 21, the runtime was 100% production Rust: 832,378 lines of production Rust and 468,689 lines of Rust unit tests, alongside 174,675 lines of E2E TypeScript tests. The separate GitHub Copilot SDK repository added roughly 130,000 more lines of E2E test code across Node.js, Python, Go, C#, Rust, and Java.

Timeline from May 12 through August 21 showing 128 port pull request merges sized by changed lines and 135 public CLI releases; the largest port changes cluster near completion of the port.

Bootstrapping the initial ports

Two opening pull requests established the Rust workspace, toolchain, lint rules, CI, build pipeline, and coding instructions, then introduced the runtime crate plus code generation and interop patterns while porting pure-logic primitives selected for having no I/O, no shared state, and strong existing tests. Only after those landed did the first primary port pull request take three side-effect-free helpers through the full process. These shipping pilots converted assumptions about repository layout, FFI, packaging, testing, and review into conventions that later, larger ports reused — the machinery was tested end to end.

Work was then ordered from the leaves inward: pure helpers, content exclusion, shell utilities, and session filesystem operations established the translation and testing pattern. Stateful subsystems followed, then tools, hooks, model clients, and MCP. Session orchestration — by far the most coupled and least naturally parallel part of the runtime — came near the end.

Timeline of 128 landed port pull requests from May through August, progressing from small foundational components to larger orchestration and session work.
PeriodPull requestsMedian changed lines
May 1–1583,250
May 16–3129,421
Jun 1–15405,073
Jun 16–30318,253
Jul 1–15109,514
Jul 16–311428,159
Aug 1–151913,861
Aug 16–30499,445

Small leaf components moved quickly, but larger subsystems rarely landed in one atomic step. MCP support progressed through seven dedicated pull requests; tools came through a six-part series and then needed further work to move orchestration and retire the remaining TypeScript. Hooks, auth, telemetry, plugins, settings, and persistence followed similar paths. In practice, the useful unit of porting was not always a component but a wave through related behavior: move the pure logic, then state ownership, then orchestration, then remove fallbacks, and finally simplify the Rust once the temporary interop was gone.

The seam between languages

Two distinct interop layers exist in this port, with very different lifetimes.

  1. Temporary internal interop. Every ported function had to remain invocable from the TypeScript that called it, and Rust functions needed to invoke TypeScript callbacks. This is an implementation detail and highly fluid: shims are 1:1 with the Rust methods needed from TypeScript, so the layer grows with the Rust surface and is deleted as callers themselves get ported. Eventually the public entrypoints are reached and the shims evaporate.
  2. The SDK surface. The permanent layer. SDK libraries sit on top of the runtime over a bidirectional JSON-RPC contract: the SDK sends method call payloads, the runtime parses and invokes, and returns results over the same transport. The reverse direction serves callbacks such as hook notifications and permission demands, exposed idiomatically per language (delegates in C#, for example).

napi-rs in both directions

Layer one is handled by the napi crate from napi-rs, which builds Node native addons in Rust. Annotating a function with #[napi] makes a macro generate the N-API registration glue plus a TypeScript declaration in a generated index.d.ts. A synchronous Rust function appears as an ordinary JavaScript function, an async fn becomes a promise-returning function, and structs marked #[napi(object)] become plain objects.

Traffic flows both ways. Ported components often depended on something not yet ported, so Rust called back into TypeScript — a Rust tool implementation asking the still-TypeScript model layer for inference, raising a hook, or requesting a permission decision. napi-rs handles this with threadsafe functions, letting Rust on a Tokio worker thread invoke a JavaScript callback on Node's main thread. Node installs the callback once; Rust holds it and calls it as needed. Each one exists only because the far side is still TypeScript, and disappears when that side is ported.

Temporary Rust N-API exports and their TypeScript call sites grew during the incremental port, then declined as callers moved to Rust and the temporary interop surface disappeared.

The temporary seam peaked on August 3 at 2,019 internal N-API exports and 3,356 TypeScript call sites. At completion, with an entirely Rust runtime, both counts reached zero. The CLI still retains some internal access to the runtime that is being removed; those exports are not counted here.

Why JSON-RPC survives in-process

The SDK surface is permanent. The Copilot SDK ships for six languages — TypeScript, Python, Go, C#, Java, and Rust — all speaking the same bidirectional JSON-RPC contract, originally reached by spawning the Copilot CLI in headless mode and talking over a pipe or socket. That stayed the default through the port, but it forces consumers to ship or locate a full Node implementation, pay a process hop per event and message, and supervise two processes.

The Rust runtime makes an alternative viable. The shipped runtime.node is an ordinary platform shared library — the .node extension is Node's native-addon convention over a .dll, .so, or .dylib — and it offers two front doors onto the same engine: a napi door loaded by a Node process (the CLI's path today, with the intent of moving fully to the SDK path), and a C ABI door any language can load and call through FFI via its native interop mechanism.

SDKNative bridgeIn-process client selection
C#P/Invokenew CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForInProcess() })
Gopuregocopilot.NewClient(&copilot.ClientOptions{Connection: copilot.InProcessConnection{}})
JavaJNAnew CopilotClient(new CopilotClientOptions().setConnection(RuntimeConnection.forInProcess()))
PythoncffiCopilotClient(connection=RuntimeConnection.for_inprocess())
RustlibloadingClient::start(ClientOptions::new().with_transport(Transport::InProcess)).await?
TypeScriptkoffinew CopilotClient({ connection: RuntimeConnection.forInProcess() })

Rust and host topology are separate axes: the completed runtime supports running inside the SDK consumer's process or behind the existing JSON-RPC server boundary. In-process entry points are opt-in while confidence grows about sharing a failure boundary with the consuming application. Everything above the transport is unchanged: sessions, events, tools, permissions, and callbacks are indifferent to whether bytes crossed a pipe or a function call.

The C ABI door is small — 19 exported functions, split into four for server lifecycle, four for session registration and configuration, eight for connections, and three for the embedded host. Behind them the shared contract has 364 dispatch routes: 340 callable by SDK consumers and 24 running in the other direction as runtime-to-SDK callbacks. The napi door needs a function per route; the C ABI door is dispatch-based. API methods get no exports at all. They travel as JSON-RPC bytes written into a connection, with results, events, and server-to-client requests returned on host-supplied callbacks. Adding, changing, or removing a method touches the dispatch table, never the ABI, so an SDK binds the 19 entry points once and reaches the growing API dynamically.

Keeping JSON-RPC in a call that no longer crosses a process boundary made in-process hosting a drop-in rather than a rewrite. Every SDK already had a working client with framing, request/response correlation, and handlers for the server-to-client direction. Mounting FFI as one more transport beneath that client moves the byte path to a function call and leaves everything above untouched: six SDKs got in-process hosting as an additive, opt-in transport. A typed C function per API method would have required a second binding layer per SDK, six more bindings per new method, and versioning the ABI as a binary compatibility surface.

JSON-RPC is also still required for genuinely remote runtimes, whether across a subprocess or TCP. Sharing the protocol in-process means one bidirectional API and dispatch system instead of JSON-RPC for remote connections plus a separate per-method FFI surface for local ones. The tradeoff is real: the process hop goes away, but JSON-RPC overhead remains on every call. Serialization is generally small compared to the model round trip in inference-dominated workloads, though it is measurable in high-throughput local ones — not enough to justify duplicating hundreds of methods across six SDK bindings, and easy to revisit. Payload encoding is private to the two ends, so swapping JSON for something denser like MessagePack would change no declared export. Typed per-method exports can be added later for hot paths against the same engine and handlers without replacing the byte channel, which would remain the substrate for streaming, server-to-client requests, and rarely called methods.

The analysis in this section draws on two data sources. The first is the GitHub history of the private github/copilot-agent-runtime repository: pull requests and their diffs, review comments, CI runs. The second is the agent session logs. The runtime writes a structured event log for every session it runs — one JSON object per line, appended as the session happens. Because those logs can contain prompts, commands, command output, file paths, and potentially secrets surfaced by tools, they are sensitive data and must be handled as such. Each log is local to the machine where the session ran; remote-session features can upload it when enabled, subject to product settings and organizational policy.

Aggregated across all constituent porting pull requests:

MetricCount
Events12,760,995
User messages31,247
Assistant messages1,385,214
Hook start and end events6,438,562
Tool starts1,857,409
Compilation commands23,096
Test commands19,485
Rebase commands2,496
Commit commands7,410
Push commands5,554
Completed compactions5,116

The 31,247 user-role messages are not 31,247 personally typed prompts. They include skill instructions, automated merge ticks, cross-session messages, and child-agent traffic. The roughly 2,600 messages the author typed or spoke amount to about one in 12. Likewise, the 1,385,214 assistant messages cover subagents and tool-oriented messages, not only conversational text. The corpus holds 68 distinct event types and 67 distinct tool names, and 1,130,921 tool calls — 61% — came from subagents rather than the main session thread.

To determine what drove the ~2,600 human insertions, Copilot assigned one primary intent to each human-authored message in the corpus.

Of 2,639 human-authored messages, 31.0% focused on review, testing, and CI; 17.4% challenged technical or design decisions; and 15.0% pushed for completeness.

The first three buckets account for 63% of interactions. Only about 40 were recognizable session kickoffs, since the pattern was usually to open a chat to explore the next horizon and then ask that chat to create porting sessions for each desired slice. The human role was less "assign a task and wait" than "operate the control loop": inspect the result, challenge technical decisions, enforce quality gates, and push back when an agent treated an intermediate stopping point as the finish line. Judgment stayed heavily involved; it just moved upward, from writing syntax to framing problems, defining boundaries, choosing strategies, and adjudicating exceptions.

Cache behavior and context recycling

LLM providers generally bill input tokens and output tokens at different rates, an approximation of the computational work inference requires: each input token must be read, incorporated into the model's internal representation, and used in determining the next token. Providers often cache the results of those computations, so an identical prompt prefix that has already been processed can reuse intermediate state instead of being recomputed. That lowers the cost of processing those tokens, and the savings can be passed to the consumer. Input tokens are therefore advertised with multiple rates, including one for tokens read from cache.

The discounts are steep — commonly a 90% reduction, meaning a provider might charge $2.00 per million input tokens but only $0.20 per million cached input read tokens. Maintaining good prompt caching is what keeps a bill an order of magnitude smaller.

The porting data shows a 96.22% prompt-cache hit rate, defined as cache reads divided by all input-side token volume (cache reads plus cache writes plus fresh input). Cache writes were 3.07% and fresh input 0.71%. This follows from how GitHub Copilot shapes the agent loop to preserve a long, stable prefix — system prompt, then tool definitions, then accumulated conversation — so each turn appends to context the model has already processed. The expensive portion of the context is paid for once and re-read at roughly a tenth of the monetary cost on every later call, which is what makes the economics of long autonomous sessions work. A three-hundred-hour port that re-read its entire growing context from scratch on each of tens of thousands of calls would cost a different order of magnitude. Harness developers expend considerable effort avoiding prompt-cache breaks, and model vendors ship features to help.

Prompt-cache composition across the porting sessions: 96.22% cache reads, 3.07% cache writes, and 0.71% fresh input.

Compaction is the complementary story. Across the port sessions, Copilot compacted context automatically 5,116 times — moments when a session had filled its context window and summarized itself to continue. The single sessions-infrastructure port pull request compacted 647 times over its many-day lifespan; one small port never compacted once. Sustained multi-hundred-hour autonomous work depends on the agent recycling its working memory without losing the thread. Each of those thousands of summarizations was a chance for a lossy handoff to derail the port, and mostly didn't. Subagents also reduce compaction pressure: each gets its own context, so a parent session can ask a question, let a subagent spend a large amount of context computing an answer, and receive only the answer back.

The failures that did occur are visible in the logs. Pairing each successful compaction with the work surrounding it, where at least 20 tool calls existed on both sides, produced ~4,000 comparable windows. The mix of activity in the 20 tool calls before and after compacting is similar in scale: exploration 46.5% before and 48.1% after, mutation 8.4% before and 6.0% after, validation 4.7% before and 4.0% after, failures 1.0% before and 1.5% after. If compaction were regularly dropping the thread, the after side would be re-orientation-heavy, with a spike in reading and a collapse in editing as the agent rediscovered its position. Instead the shift is mild.

What the compiler caught

A popular claim holds that Rust is an unusually good target for AI-generated code because its strict compiler catches model mistakes. The session logs allow a test of that theory, at least for tasks resembling this porting effort.

Direct validation-command results captured 8,678 occurrences of rustc error codes. Four diagnostic families cover 84%:

  • 37%: name and import resolution, dominated by E0425 ("cannot find value in this scope")
  • 22%: missing methods or fields
  • 14%: type mismatches
  • 11%: unsatisfied trait bounds

All four are ordinary wiring problems: a name output slightly wrong, a signature that didn't line up, a renamed field, an abstraction left unimplemented. Bulk translation produces these easily and accidentally, and compilers catch them quickly. Notably absent is anything specific to Rust. Each category is bread-and-butter static typing that a C# or Java or Go compiler would catch equally well, some with friendlier diagnostics and all considerably faster. The argument for pointing agents at Rust is really an argument for any statically typed language; a strongly typed compiler or strong static analysis and linting suits this work because agents can use it as a fast feedback loop. Of the 4,478 direct cargo check runs for which the stricter result matcher captured an outcome, 87.1% came back clean — the expected result of editing in small increments and recompiling constantly.

Ownership, borrowing, and lifetime errors combined were only 1.7% of coded diagnostics. The borrow checker, the customary focus of any discussion of Rust's difficulty, remained a quiet background presence; the compiler spent nearly all its erroring energy on mechanical mistakes.

How agents spent their time

The session event corpus also yields tool call data:

ToolCallsMedianMeasured hours
powershell630,4233 s2,833.9
view590,9880 s621.7
rg281,7831 s408.4
grep126,4831 s115.3
apply_patch53,7150 s17.0
edit40,5911 s24.1
read_powershell36,72890 s1,203.9
task13,080274 s2,329.0

The headline finding is that agents spent far more time gathering evidence than changing code. Across the file-reading and search tools versus the editing tools, exploration outnumbered mutation 10x. Reading files, searching the repository, and running diagnostic commands dominated; edits were comparatively small. The popular image of AI spewing code is nearly backwards at this scale — the work resembles iterative investigation: inspect the current state, form a hypothesis, make a targeted change, repeat.

Delegation amplified the pattern. Subagents primarily fanned out exploration across independent questions, while the main agent was more likely to own the edits and integrate the answers. Many contexts can investigate in parallel, but keeping mutation close to the coordinating agent limits conflicting changes and preserves a coherent implementation strategy.

Shell traffic shows how much of autonomous software work is state management. Read-only Git inspection was the most common command pattern, because agents were constantly asking "where am I?" — what had changed, what a rebase had done, what another session had landed, how far a branch had drifted from a rapidly evolving main. That orientation work let many long-running efforts share a moving codebase without blindly overwriting one another. The most common command families, within shell-tool traffic, show the orientation/validation balance:

Command familyCallsMedianMeasured hours
git inspect300,5302 s608.1
git other89,8653 s243.1
search85,4822 s147.6
pnpm test13,85222 s219.1
pnpm lint9,75729 s177.0
cargo test8,437120 s364.2
git commit7,41011 s39.7
cargo fmt5,22318 s77.2
cargo check4,492120 s176.9
pnpm build3,630180 s215.6
cargo clippy2,115135 s107.4
git rebase2,4967 s9.9
cargo build566104 s20.3

Choosing models

GitHub Copilot allows a session to change models mid-conversation and different sessions to run different models, so model choice became a per-slice decision. Two kinds of model decision appear in the logs. On the main thread driving each port, the model and reasoning effort were chosen deliberately. Within a session, when the agent spun up subagents or subsessions to explore or grind through a bounded task, the orchestrating model chose their models.

Weekly model mix across primary porting sessions, concentrated in a small number of models and shifting over the course of the port.

The subagent mix looks different, because an agent optimizing for throughput and cost rather than for the hardest judgment calls is doing the choosing. The most frequently spawned subagents ran on Claude Opus 4.8, GPT-5.6 Sol, Claude Haiku 4.5, and GPT-5.5, followed by Gemini 3.1 Pro and Claude Opus 5. At the time of the ports, though, three frequently used agent definitions pinned their model choice — explore and task to Claude Haiku, research to Claude Sonnet — so a significant share of that volume was determined by subagent choice rather than by separately selecting a model.

Weekly model mix across subagents spawned during the port, with volume concentrated in Claude Opus 4.8, GPT-5.6 Sol, Claude Haiku 4.5, and GPT-5.5.

Agent fleets: sessions, subagents, and what each is for

The Copilot app keeps every active pull request session visible, shows its status, and lets you switch between them — useful when a port implies dozens of concurrent workstreams. The property that mattered most was that sessions can talk to one another.

A session can spawn other sessions and send them messages while they run. Each session, whether parent or child, gets its own worktree, its own branch, and its own agent loop; it is a separate entity rather than something executing inside the session that created it. A subagent is the opposite construct: it runs inside the parent's workspace and returns its answer into the parent's context. Both have their place, for different kinds of work.

Choosing between them came down to whether a task produced a diff that needed isolation. Child sessions took on the actual porting; subagents were used to explore questions and feed conclusions back into the parent's context, so the parent could get a considered answer without spending its own window on deriving it.

Porting session.ts: fifty-six minutes of reading first

The hardest target was session.ts, which had grown organically to roughly 30,000 lines of TypeScript. It is the backbone of a session and cuts horizontally across the whole runtime, touching and being touched by essentially every component — the center of state, events, tools, models, hooks, persistence, and entrypoint access. Because everything else dead-ends there, it was left for near the end and handled bottom-up.

The session that took it on began with fifty-six minutes of reading and 122 tool calls before producing anything, mapping what the file owned and where the seams ran. Only then did it split the file logically and delegate slices to subsessions. Over a 25-hour run it made 222 shell calls, 205 file views, and 197 ripgrep searches of its own, on top of whatever its children did.

Fifteen child sessions nested under the session that spawned them.

That is 15 child sessions — separate branches, separate worktrees, separate agents — all created implicitly by the parent. They arrived in seven waves over about three hours: five first, two roughly twenty minutes later, another pair twenty minutes after that, then singles and pairs across the following two hours.

Models were picked per slice: 10 of the 15 ran on GPT-5.6 Sol, five on Claude Opus 4.8. Every one was launched in the app's autopilot mode, which pursues an objective without stopping for approval at each step. The median kickoff prompt ran about 1,100 characters — enough to carry the ownership boundary and its constraints, short enough that the child had to work out the approach. The prompts were written by the parent agent, not by a human; the human prompted only the parent.

Alongside the subsessions, the same parent ran five subagents: three explore agents in parallel with the first wave, one code-review, and one rubber-duck.

The children's work touched 140 distinct files in the repo, 120 of them touched by exactly one session. The remaining 20 were all hubs like session.ts itself, but since each session worked in its own worktree, siblings did not disturb one another. Coordination was the parent's bill to pay: it polled child state 60 times and sent 89 coordination messages, and when children announced completion it cherry-picked their commits into its own branch and resolved the conflicts. Those merges were not clean, and reconciling them took real time.

Timeline of the session.ts port showing one 25-hour parent session using five subagents and spawning 15 child sessions in seven waves.

The large gaps in that timeline are travel: the laptop had to be closed at points during the port. (The workflow later moved to cloud-based virtual machines reachable remotely.)

An agentic mutex for the build queue

Fifteen concurrent agents on one machine eventually all tried to build and test at once, and the laptop ground to a halt. Prompting the parent to relay a stop-build-and-test instruction to its children worked — they killed their builds and continued with minimal CPU activity. Standing instructions were then updated: subagents and subsessions should avoid large builds and test runs while porting and defer them to the parent.

That went a step further by turning an ordinary chat session into a build scheduler for eight independent porting sessions. The policy sent to every open session was to avoid CPU-intensive building and testing where possible, and to request permission from the scheduler session when a build was necessary. The scheduler acted as a gate handing out build rights one session at a time — an agentic mutex, with an explicit owner and queue, granting a single lease through the same cross-session messaging already used to coordinate code. Sessions that were denied often waited by working through other todo items.

One chat session acting as the build resource gate for eight porting sessions.

When two sessions found each other

The port ran mostly bottom-up, which is why session.ts came late. Above it sit only the runtime entrypoints — the public SDK functions in the dispatch table, hundreds of them. Expecting some throwaway work and a rebase, the entrypoints port was launched in parallel with a standing instruction to stop at the session.ts boundary, and the kickoff prompt named the concurrent session and six component ports so the agent would know what to leave alone.

Just over four minutes in, after inventorying the ingress paths and forming a view of the overlap, it invoked the app's built-in orchestrate skill, whose purpose is coordinating work across sessions. From there:

  1. The entrypoints session enumerated every active session and messaged the ones with perceived overlap.
  2. The session.ts session replied with a 2,001-character inventory titled "Concrete overlap on stephentoub-port-session-to-rust".
  3. The entrypoints session read the other session's worktree to confirm what it had been told.
  4. It asked whether the session.ts session was ready to reconcile its 760-file diff.
  5. The session.ts session answered, in effect, "Not ready to commit/integrate."
  6. The entrypoints session asked three more times and got the same answer each time.
  7. It then ignored the refusal, reached into the other worktree, took all of its changes, and merged them into its own.
  8. Both sessions carried on.

Five conclusions followed:

  1. State intent explicitly. Naming the other sessions was meant to fence them off, but the "leave alone" instruction was never made explicit, so the naming encouraged the behavior instead of blocking it.
  2. Whatever you expose is something an agent may decide applies. The orchestrate skill ships in the app and describes itself as being for running independent workstreams in parallel. The prompt never mentioned it. The model matched its own situation to that description and loaded it. The set of capabilities you expose is the set of behaviors you might get.
  3. Peers need a tiebreaker. Neither session could compel the other; a refusal carried no weight, so the session willing to act unilaterally won by default. Adjacent parallel work needs a designated coordinator, or a human, and these had neither.
  4. "Run autonomously" needs an exception for decisions that reach beyond your own branch. The intent was "don't wake me for design details"; what was heard was that annexing a peer was in scope.
  5. The root cause was the human. The work was partitioned top-down and bottom-up simultaneously, and the two directions met at the most connected file in the codebase — too greedy for forward progress. Everything above follows from that.

The episode was an outlier. Most leaf component ports were straightforward single-session tasks; larger subsystems often used multiple subsessions and subagents, and how those participated varied widely.

Two fleet shapes, and the approval wait

Model orchestration — the layer that talks to the providers — illustrates one pattern. Its main session ran 42 wall-clock hours and started 126 subagents, 22 of them at once at the busiest point, but most of the time only the main agent ran, with occasional windows of heavy spawning.

The 42-hour model orchestration port generated most code in its first 12 hours, followed by a distinct validation and review phase involving 126 subagents.

Three things stand out there. Nearly all code generation happened in the first 12 hours, leaving the following day entirely for validation. The bottom row shifts left to right from reading and building to reading and reviewing. And the phases separate cleanly.

The extension-runtime port is the counter-example at 88 hours instead of 42:

The 88-hour extension runtime port interleaved reading, writing, building, and reviewing throughout most of the session rather than separating them into phases.
  • Writing and reviewing overlap heavily. Instead of waterfall code generation followed by review, reviewing starts well before writing ends and the two continue side by side for most of the run.
  • The bottom row's colors are scattered. The same mixture of reading, building, and reviewing runs start to finish: the middle half of the reading calls spans 49 hours, writing 33, reviewing 27, within an 88-hour session.
  • Idling is pushed to the end. The fleet works nearly continuously for the first 56 hours.
  • The proportions still match. Writing Rust is 2% of tool calls here versus 1% there, reading 44% versus 57%, reviewing 23% versus 27%. The two sessions agree on the work, just not on its timing.

The blank slices at the end reflect a problem that is becoming common in agentic coding: waiting for approval. A teammate or agent reviews, leaves feedback, there is a short burst of activity as the agent addresses it and drives CI green again, then more waiting, until approval finally lands.

Roughly a quarter of sessions resemble model orchestration; three quarters look like extensions runtime. Clean phase progression was the exception — planning, writing, and reviewing throughout was the common case.

Keeping review honest

Because changes arrived fast and often conflicted, rebasing was constant. A prompt-as-skill named rust-rebase-review, alongside a merged general Rust coding skill, was invoked by the harness through custom instructions and manually at times. It evolved over the port, roughly along these lines:

Squash into a single commit, then rebase on the latest in origin/main, resolving all conflicts, and force push. As part of rebasing, pay extra special attention to anything that has changed, been added, been removed, and ensure that logic is all ported over to the corresponding Rust code correctly. Always do the rebasing yourself / in the main agent; do not spawn a subagent for it.

Then enter a review/fix loop where you launch a subagent per opus 5, gpt-5.6-sol, and grok 4.6.

- That subagent should do a line-by-line comparison of the old TypeScript and the new Rust, confirming behavioral equality.
- Look for anything introducing any kind of incompatibility; our goal is to move this code into Rust with as close as is possible to 100% the same semantics. If you hit anything questionable, ask me about it.
- We want to ensure we're writing as efficient and idiomatic Rust code as we can; look for opportunities to simplify, to use routines like from the memchr crate to optimize searches instead of open-coded loops, avoid unnecessary allocation, use traits for reuse and loose coupling, etc.
- Ensure that all defunct TypeScript code (e.g. code that has been fully ported, tests that are now no longer necessary because they're duplicative, unnecessary napi shims, etc.) has been deleted.
- Ensure that we've ported as much code as possible, e.g. if there's any TypeScript remaining in touched files and that TypeScript is more than just a shim, that's a red flag. If new TypeScript that's not just a super thin shim is being added, that's a red flag. Look for any callers of TypeScript shims to see whether those callers can instead be ported to Rust, pushing the boundary as far as reasonably possible. Our goal is to soon get to 100% Rust in the runtime layer.
- Validate that no E2E tests have been deleted or changed. Such changes are an indication of a porting bug.

If a review surfaces issues, validate them, and then if there are any to fix, fix them, and iterate to do another full review. Continue iterating with reviewing/fixing until all reviews come back clean. After every set of changes in response to review feedback, commit and push so that CI validation runs concurrently with subsequent reviews.

Don't bother running full test suites; that'll be handled in CI. Try to minimize CPU-consuming efforts to the bare minimum, as we'll likely have many operations happening concurrently.

The in-place atomic swap produced an unexpected benefit here: because the TypeScript was deleted at the same moment the corresponding Rust was added, any incoming rebase change to that TypeScript conflicted with the deletion. Changes touching already-ported code therefore surfaced automatically, instead of requiring a line-by-line judgment about whether incoming work overlapped a prior port.

My reviews were one layer among several. CCR runs on every commit, and multiple dedicated review bots with their own prompts and approaches comment on every commit in detail. Their feedback lands on the pull requests and must be handled — which can itself be handled agentically. My attention went to architecture, design, conventions and approach; agents did the exhaustive old-versus-new comparisons; tests and static analysis enforced what could be checked mechanically; human reviewers covered architecture, API contracts, risk and anything suspicious the other layers surfaced.

The division of labor was explicit: I picked the destination architecture, decided which behavior mattered, partitioned the work, resolved ambiguous trade-offs, judged evidence, manually reviewed high-risk areas, reviewed agentic responses to feedback, and made the merge calls. Agents changed how much code one engineer could supervise; they did not remove the need for an engineer accountable for the direction, guardrails and release.

The agent merge loop

Concurrent session management in the GitHub Copilot app — switching between active sessions while carrying paired terminal, browser and canvas context — mattered throughout, but agent merge mattered most.

The GitHub Copilot app's Agent Merge panel tracking review feedback, CI, conflicts, and merge readiness.

Agent merge is built into the app (the CLI exposes it as /pr auto). On a timer or in response to external stimuli such as CI completion notifications or review comments, it inspects what changed, then invokes the agent: reject or address a review comment and reply noting the response is automated, download failing test logs and fix the bug, or merge/rebase on conflict. It automates the loop a human developer performs to drive a pull request to green and merge.

Every porting pull request went through it, though usually stopping short of the final merge. The agent cleared CI, handled comments and resolved conflicts; before merging I spot-checked its work — especially how it answered reviewers and whether the applied direction was sound — and typically left that last checkbox unchecked.

That checkbox earned its keep. In one merge loop pass a port deleted a function exposed to the SDK. The schema compatibility CI leg failed as designed, and the agent's response was to apply the repo's schema-break-ok label — the escape hatch that makes the check pass. Reviewing before merge, I asked the obvious question: what is the schema break, and why is this label ok? It wasn't. The method existed on main; the port had lost it. I called it an unacceptable regression and told the agent to restore it fully in Rust. Twenty-one seconds later the waiver was gone and the method was back as a native implementation.

Failures were fixed locally and systematically: individual cases first, then the system so they were less likely to recur. Instructions for coding and review agents evolved to reduce repeats, session logs became evals, and some lessons fed back into the runtime itself through prompt, tool-description or autopilot changes.

The dependency rewrite underneath

Every library the runtime depended on had to be replaced as well, and those replacements weren't ours to make faithful. Some were the same idea under another name, some required several crates to cover one npm package, and a few had no acceptable off-the-shelf answer and were written by hand (by agent).

CLI and runtime share a repository and a package.json. Roughly 60 npm dependencies were removed over the port because only ported runtime code used them — a lower bound, since some packages were replaced for the runtime yet still needed by the CLI. zod, the TypeScript validation library used by both, stays in the manifest for the CLI's sake while the runtime uses serde, schemars and jsonschema instead.

Straight one-to-one swaps included js-tiktoken to tiktoken-rs with the same o200k_base encoding, ignore to the crate of the same name with identical gitignore semantics, minimatch to globset, fast-myers-diff to similar, dompurify to ammonia, and github/keytar to keyring. Most of the work, however, went into regroupings: eight opentelemetry/* packages became four crates plus a hand-written tracker state machine and file exporter; mozilla/readability, linkedom and turndown collapsed into readability and htmd; sharp, image-size and file-type became image and imagesize. Five packages were replaced by fully custom implementations.

Where `unsafe` actually lives

An agent that cannot satisfy a borrow has an obvious escape hatch, so the unsafe count is a fair question. The runtime crate contains 158 unsafe blocks across 36 files, plus 26 unsafe fn declarations, 26 unsafe extern blocks and nine unsafe impl trait implementations. All of them concern interop with external components.

Why the unsafe block existsBlocksShare
C ABI boundary5132.3%
Windows API4931.0%
POSIX / libc4629.1%
SQLite C API74.4%
Dynamic library loading42.5%
Process environment10.6%
Donut chart of 158 unsafe blocks, all at external boundaries: C ABI, Windows API, POSIX and libc, SQLite, dynamic library loading, and process environment mutation.

The C ABI blocks are the front door for SDK hosts, receiving raw pointers and lengths from callers the compiler does not control. The Windows and POSIX blocks cover syscalls: registry reads, credential handshakes, process trees, sysconf. SQLite is a C library. Dynamic library loading is dlopen, which cannot be safe by construction since the resolved symbol may not be the function you expected. The process environment block exists because Rust 2024 treats mutation of process-global environment state as unsafe in a multi-threaded process. Each of these marks where Rust's guarantees genuinely end: a C function, a syscall, a pointer from a foreign runtime, or process-global host state sits on the other side.

The value is auditability — every such crossing in the Rust we own is marked. The TypeScript runtime crossed the same boundaries through Node's C++ internals and native npm packages with nothing in source indicating where the checked world stopped. That is not a complete inventory of safety boundaries in the delivered system: dependencies, build tools, C libraries, safe wrappers and incorrectly specified FFI contracts can still contain or expose unsafety.

Where unsafe is absent is more interesting: not in the model clients, the MCP layer, the agent layer or the prompt layer. No known regression from the port involved an unsafe block.

Regressions: volume, causes and what actually slipped through

Porting is straightforward; proving the port correct is not. In a runtime as large and entangled as the Copilot agent, mistakes are the default assumption.

By September 14, 2026, dozens of known port regressions had been traced and fixed — most of them correctness bugs, the rest performance losses — against roughly 832,000 lines of production Rust written from scratch. Not all of them shipped: some surfaced while developing in the repo, others appeared in a pre-release and were corrected before a stable release, and a subset reached stable after escaping one or more pre-release cycles unnoticed.

Known correctness regressions grouped into five recurring failure modes: incomplete migration, state and lifetime, behavioral contract mismatches, host boundaries, and incorrect test oracles.

The list is not exhaustive. It covers regressions that were noticed or reported, and a migration at this scale has almost certainly delivered more that remain quiet because nobody has exercised the code path hard enough. More corner cases are expected as the outer edges of the stack get hammered in the wild.

The count matters less than the causes. Nearly every correctness regression falls into three broad families: the new code implemented a different behavioral contract; state, ownership or lifetime semantics shifted; or part of the migration was skipped, partially applied, or lost during a rebase. A smaller set came from host and interop requirements, and some from tests that validated the wrong behavior confidently. In practice those families reduce to a set of recurring patterns.

Patterns behind the regressions

Semantics TypeScript leaves implicit. TypeScript has a single number; Rust forces a choice, including whether a fractional part is possible — and agents choose wrongly. Conceptually integer fields became f64, so values like 42.0 were serialized instead of 42, and strongly typed SDKs such as Go and C# failed to unmarshal a repo ID into an int64 or rejected a hook timestamp and task duration. The mirror image: timeToFirstTokenMs was declared i64 while the streaming path emitted values like 5446.712845, leaving written sessions unreadable and unresumable. Types weren't the only trap. event.error || "Unknown error" turned into .unwrap_or("Unknown error"); JavaScript's || substitutes for an empty string, whereas Rust's unwrap_or keeps it, so an empty subagent error stayed empty.

Behavior the host supplied invisibly. Quota code called toLocaleDateString, which picks up the host time zone; Rust required that zone to be passed in. And although Intl.DateTimeFormat().resolvedOptions().timeZone is typed as string, it can return undefined, which napi cannot convert into a Rust String — that broke model-list loading. In another case, an environment-variable read moved from before an await to after it, so a host change during the wait changed the result. A native-addon loader only wanted to identify the platform, yet called process.report.getReport(), which on Windows honored _NT_SYMBOL_PATH and could spend minutes downloading PDBs before the CLI rendered. Working directory, repository identity, PATH and session authentication are the same class of ambience: moving ownership into Rust means deciding when each is captured, how it is carried, and when it is refreshed.

Half of a paired operation. Paired work drifted apart. A turn-cap check updated the abort state in the native registry but left the in-process model loop running, so one more request escaped. Elsewhere, task completion was persisted and emitted but never projected into active session state, so Autopilot kept going after the task was done.

Work on Node's event loop. The CLI still drives the Rust runtime from Node's single-threaded loop, so any synchronous work across the napi boundary freezes the UI. /chronicle reindex parsed hundreds of session files synchronously and blocked rendering and input for nearly a minute; an async export moving the work to a blocking thread-pool thread resolved it. An audit turned up further blocking entry points, producing a standing rule: napi exports that do real work must be asynchronous and, where needed, use spawn_blocking.

Console windows on Windows. Spawning a child process without CREATE_NO_WINDOW briefly flashes a console window. The Node runtime had monkey patched process spawning to add the flag, hiding the need from the agent; the Rust replacement dropped it. An audit fixed two more spawn sites (pre-existing omissions, not port regressions), and the rule was added to the copilot instructions.

Lifecycle, disposal and ordering. The biggest cluster involved ownership and races. With state in Rust, TypeScript typically holds an opaque handle to an instance in a native table — and unlike an object reference, that handle can outlive the instance. One hook disposed mid-request orphaned a tool_use block, wedging the conversation because the API required a matching result. A shell canceled between “announced” and “started” leaked an orphan that kept the session alive. A sandbox toggle incremented one generation counter but not its native twin, leaving the shell stuck “reconfiguring.”

Missed features. Some features were simply never ported. One port dropped SDK callbacks and deleted their end-to-end test, which produced a rule that agents may not change E2E tests without explicit consent. A session abort kept its native half but lost the in-process cancellation that interrupts a turn waiting on a tool. The SDK's ability to replace built-in tool search depended on three things — enablement, the model-facing description and schema, and routing execution to the SDK callback — and all three were dropped, silently swapping one consumer's natural-language search for regular-expression search.

Stricter drop-in libraries. Rust equivalents are not always behavioral equivalents. At the time, the Rust MCP SDK (rmcp) replied to malformed JSON-RPC input where the TypeScript SDK didn't; against a server that answered errors with more malformed output, that politeness became an infinite loop hanging startup.

Branch drift. In a repo taking hundreds of pull requests a week, a session open for days accumulates conflicts; the port required thousands of rebases, and even a high success rate leaves failures behind.

Correct but slower. The final group passed functionally and regressed on speed. Some lost existing efficiencies — memoization, fully asynchronous waiting, bounded log streaming. Others added migration-specific overhead at the Rust–TypeScript boundary: redundant serialization, locking, polling, unbounded native concurrency, native-to-host crossings. These were degradations introduced by the port, not future optimization targets. One read-only scan deep-copied a 260 MB event log rather than borrowing it. Under sustained event traffic, another implementation completed only a small fraction of requested channel flushes while holding an async handle per event until V8 ran out of heap.

Dozens of regressions sounds like a lot, but for a port that produced more than 800,000 lines, the surprising part is not encountering an order of magnitude more. To gauge whether users felt them, issues opened from January through August in the public github/copilot-cli and github/copilot-sdk repos were classified as quality related when they carried a bug label or used failure terms such as “bug,” “regression,” “crash,” “hang,” “timeout,” “broken” or “incorrect” in the title. Levels were essentially unchanged before the porting work versus during and after it:

RepositoryJan–Apr, before the rewriteMay–Aug, during / after the rewrite
github/copilot-cli22.9% (454 / 1,982)23.7% (354 / 1,496)
github/copilot-sdk36.2% (190 / 525)32.3% (135 / 418)

This is not an availability metric or an exact count of escaped defects — an issue can stand for very different things in scope and severity. It does provide a check: despite the extraordinary volume of product change, the product-facing channels showed no meaningful spike in quality concerns during the migration.

Why a clean compile proves little

Rust's strict compiler is often cited as an advantage for AI-generated code, along with a related meme: if it compiles, it's correct. The regression corpus answers that directly — every entry was merged to main, so every one of them compiled. The compiler accepted the buggy versions because, to the compiler, they were valid Rust.

An f64 used consistently is something the compiler can prove; that a repository ID must serialize as an integer, or that a timestamp with a trailing .0 will be rejected by every strongly typed SDK on the other end of the wire, is not. The compiler prevents unsynchronized data races in code it can see, but it cannot stop a perfectly synchronized state machine from encoding the wrong states. A lock-protected queue can still let two senders each assume the other will drain it. Events can move safely between threads and still arrive in the wrong order. A synchronous napi function can be memory-safe and still freeze Node's main thread for a minute. Compilation also cannot, by construction, detect what is absent: a rebase that quietly removes a guard and its test, a process spawner missing the Windows flag that hides the console window, or a 250 MB event log cloned on every read. A compiler checks whether the program you wrote is internally coherent — not whether you wrote the whole program, preserved the old contract, called things in the right order, met the host's unwritten requirements, or did the work at acceptable cost.

None of this argues against the compiler. As with any statically typed language, it eliminated a large class of mechanical errors and gave the agents an unusually tight inner loop. “If it compiles, it's correct” is only useful as a joke.

What the port bought

The rewrite was deliberately behavior-preserving: the goal was not to redesign algorithms or fix bugs, and agents were repeatedly steered away from opportunistic optimization because changing language and behavior simultaneously makes it hard to tell which change caused a break. Performance and scalability were still explicit goals, though. As the author put it when asked why the runtime was rewritten in Rust: "I didn't set out to move to Rust, I set out to move away from Node.js and V8."

Benchmarks were run through the C# SDK against a pre-port build of the SDK and CLI, with the TypeScript runtime hosted by Node and reached over stdio. The August 21 result uses the Rust runtime both as an out-of-process server and loaded in-process through FFI. All six SDKs (TypeScript, Python, Go, C#, Java, Rust) reach the same engine through the same transport architecture. Because other changes landed during the same period, the comparison is end-to-end between delivered systems rather than an isolation of the language change alone.

Each timed turn hit a deterministic chat completion server on localhost returning a fixed, small response, so model inference and network latency are removed from the numbers. What remains is the part that changed: client startup, process launch, session creation, event handling, persistence, and teardown.

ScenarioMay 12Aug 21 out-of-processAug 21 in-process
Client, session, one turn5.25 s1.33 s (4.0x)292 ms (18.0x)
Resume 32-turn session5.64 s1.52 s (3.7x)264 ms (21.4x)
Ten concurrent client lifecycles12.34 s4.18 s (3.0x)742 ms (16.6x)
1,000 one-turn session lifecycles132.52 s22.53 s (5.9x)20.93 s (6.3x)
From May 12 to August 21, creating a client and session, completing one turn, and tearing them down improved from 5.25 seconds to 55.3 milliseconds in-process, throughput rose from 7.55 to 120.0 sessions per second, and ten-client memory fell from 1,383 MB to 126 MB.

The "client, session, one-turn" case creates a client, creates a session, performs one turn, and tears everything down. Much of the out-of-process overhead came from launching Node, initializing V8, and loading, parsing, and generating bytecode for the JavaScript produced from the TypeScript sources before the first turn could start. The Rust runtime eliminates those costs.

The 1,000-session pressure test points at a different class of workload: a single shared client running 100 concurrent pipelines, each creating a session, completing a full model turn, disposing the session, and repeating ten times. The pre-port TypeScript CLI managed 7.55 of these lifecycles per second; Rust out-of-process reached 57.45, and Rust in-process 120.0. That x-factor is workload-specific — the runtime is not universally "15.9x faster" — but it is the shape server hosts care about, where many independent sessions share one runtime. It is also not wall-clock time hidden on another core: in a separate resource-sampling pass over the same 100-by-10 workload, the pre-port process tree burned 312 seconds of aggregate CPU against roughly 110 seconds for the Rust configurations.

Resident private memory added during the ten-client batch tells the same story. The pre-port process tree peaked 1,383 MB above baseline, Rust out-of-process 247 MB, and Rust in-process 126 MB — an order of magnitude less. Absolute values will vary by use and machine, but the effect on hosting is direct: more clients and sessions per machine before memory, process count, or CPU becomes the constraint.

These are baseline numbers. Much of the implementation is still TypeScript-shaped algorithms rendered faithfully in Rust, and the broader redesign that the new ownership model, concurrency model, and in-process architecture enable has not happened yet. Starting from ~55 ms for client creation, a complete one-turn session, and teardown; 120 one-turn session lifecycles per second on a shared client; and a 91% reduction in the measured ten-client memory delta leaves plenty of room.

The bill

Porting consumed ~136.3 billion tokens in total: ~130.6 billion cached input read tokens, ~4.2 billion cached input write tokens, ~900 million fresh input tokens, and ~600 million output tokens. The monetary cost came to ~$120,000.

Tokens were not the only input. A significant amount of developer time went into guiding the agents, although agentic development is largely "hurry up and wait" — submit a prompt, let the agent work, check in occasionally to steer, and do other things until it completes. That overlap means developers are no longer confined to one task at a time. During the porting window, the Rust porting PRs accounted for ~20% of the author's PRs across all contributing repos; taken as a rough proxy for time, that is about three weeks.

The effort was not one person's. @stevesandersonms designed and implemented napi-oop, the temporary out-of-process interoperability layer, plus five of the six SDK FFI implementations, with @edburns providing the sixth. @roji built the SDK packaging needed to ship and use the Rust binaries correctly. @caarlos0 helped split the Rust code into many small subcrates as build times became problematic, and @criemen improved asset caching to speed up CI and local builds. @devm33, @examon, @MRayermannMSFT, @dereklegenzoff, and others handled pull request reviews and approvals.

Lessons carried forward

  • State the goal completely. Early instructions such as "port XYZ component to Rust" were read as covering only hot paths or only logic, with the agents treating I/O and orchestration as out of scope. Once the end state was explicit — a native binary from a 100% Rust codebase, with no execution environment left for TypeScript even if one were wanted — the agents drove far more autonomously.
  • End-to-end tests are the oracle. With one exception, every regression involving missing features, and many others, traced to insufficient E2E coverage. A port needs tests that validate correctness, and those tests cannot themselves be rewritten during the port. The original plan called for improving the E2E posture before starting; that happened, but not enough, and more coverage up front would have meant fewer regressions.
  • Protect the oracle from the agent. An agent changing an implementation must not also silently redefine correctness by weakening a test, updating a snapshot, raising a compatibility baseline, or applying an escape-hatch label — at least not without oversight. Keep the behavioral contract independent where possible, put sensitive guardrails behind separate ownership or approval, and layer checks with different failure modes so a single mistake cannot ship a large regression.
  • Translate first, redesign second. Preserving behavior and existing algorithms kept the number of simultaneous variables manageable; once the old implementation and transitional scaffolding are gone, ownership, concurrency, and performance can be redesigned against a stable baseline. The author departed from this a few times and regrets each one — every case cost more regressions, time, or tokens.
  • Convert repeated failures into safeguards. Agents will drift; a failure mode seen twice belongs in standing instructions, a reusable skill, an eval, a protected baseline, or the harness itself.
  • The inner loop matters more with agents, not less. Agents handle the thinking and writing quickly, but they still build and test, and the share of time spent building and testing grows as thinking and writing shrink. Invest up front in optimizing that inner loop — and optimize it for several things happening concurrently, as if working in multiple tasks and multiple worktrees.

Where it stands

The execution runtime that was entirely TypeScript in May was entirely Rust in August, shipping to real users continuously rather than as a single cutover. No one simply asked an agent to port the whole codebase; that is not where the industry is yet. What agents did was make a category of project feasible: a rewrite producing hundreds of thousands of lines of production Rust, in place, in main, by one engineer supported by a team. Before agents that proposal would have demanded a whole team and a year or two, would have competed against every feature that team could have shipped, and would rightly have lost.

The port is complete — the production implementation is 100% Rust and the temporary internal TypeScript/N-API seam is gone — but further work remains: improving the build system and inner loop, cleaning up translated structures, redesigning around Rust's ownership and concurrency models, and chasing further performance wins. At the micro level much of the code is idiomatic Rust; at the macro level a good deal of it is TypeScript algorithms wearing Rust syntax. Revisiting those decisions now that the underlying constraints have changed is where the interesting gains lie.

The bigger prize is what the port unlocks. The SDK can load directly into a host process in any of six languages with no Node.js or V8 in the dependency chain and no second process to supervise — the most common friction partners reported when adopting the SDK. A runtime instance costing a fraction of the old one lets a host run far more concurrent sessions before exhausting the machine. And the runtime can now go where Node.js would not follow: cloud, desktop, device, embedded systems.