GitHub Copilot has evolved from an AI coding assistant into a platform that can run agentic workflows across the terminal, applications, cloud services, and developer tools. As those capabilities grew, the runtime behind Copilot became an increasingly important part of the architecture.

That runtime was originally built with TypeScript, Node.js, and the V8 JavaScript engine. GitHub has now completed a major rewrite of the Copilot agent runtime in Rust, with more than 800,000 lines of production Rust produced during the migration. The rewrite was performed incrementally while the existing product continued shipping.

The interesting part is not simply that GitHub chose Rust. The bigger engineering lesson is why GitHub chose Rust, how it migrated without stopping development, and what changed when a JavaScript-based runtime became a native library.

Why Was the Copilot Runtime Rewritten?

The original architecture worked, but the requirements around the runtime changed as Copilot expanded.

The runtime was no longer serving only one application. It became a shared foundation for different Copilot experiences and SDK consumers.

GitHub wanted a runtime that could be embedded directly inside applications instead of requiring every consumer to carry a separate Node.js runtime and communicate with it through another process.

The previous architecture introduced several costs:

Area

TypeScript + Node.js Runtime

Native Rust Runtime

Execution

JavaScript through V8

Native compiled code

Process model

Often required a separate process

Can run in-process

Memory overhead

Includes Node.js/V8

Lower runtime overhead

CPU-bound work

Constrained by Node's execution model

Native concurrency model

Interoperability

Node.js/N-API or process communication

C ABI and language-specific FFI

Deployment

Runtime plus host application

Runtime can be embedded

Crash isolation

Separate process can take the session with it

Can be hosted directly in the application

Resource usage

Additional runtime overhead

More predictable native resource usage

GitHub specifically identified the overhead of shipping Node.js or V8 with SDK consumers as a problem. The previous design could also require communication across process boundaries for events, messages, and filesystem operations.

The goal, therefore, was not simply "rewrite TypeScript in Rust."

The actual architectural goal was to move away from the Node.js/V8 runtime while creating a smaller, embeddable, performant, and interoperable agent engine.

Why Rust?

Rust was selected because its characteristics aligned with the requirements of the new runtime.

GitHub wanted a language that offered low runtime overhead, native execution, strong concurrency capabilities, and good interoperability with other languages.

The runtime also needed to support multiple Copilot SDK implementations. GitHub's SDK ecosystem includes TypeScript, Python, Go, C#, Java, and Rust. A native runtime could provide a common implementation underneath those language-specific interfaces.

There is an important distinction here.

The migration does not mean Rust is inherently better than TypeScript for every application. GitHub's requirements were unusually focused on embedding, resource consumption, native performance, and interoperability.

For a typical web application, TypeScript and Node.js may remain an excellent choice.

For a runtime that needs to sit underneath multiple languages and applications, the trade-offs are different.

What the Old Architecture Looked Like

The Copilot CLI was effectively a terminal user interface sitting on top of an agent runtime.

The stack included TypeScript, Node.js, V8, and a TypeScript-based terminal UI.

Conceptually, the architecture looked like this:

+----------------------+
|   Copilot CLI / App  |
+----------+-----------+
           |
           v
+----------------------+
| TypeScript Runtime   |
| Agent Loop           |
| Tool Execution       |
| Session Management   |
+----------+-----------+
           |
           v
+----------------------+
| Node.js + V8         |
+----------------------+

This approach is convenient because the application and runtime can share the same language and ecosystem.

However, it becomes more expensive when the runtime itself needs to become a reusable component for applications written in other languages.

A C#, Python, Java, Go, or Rust application should not necessarily need to start Node.js simply to use an AI agent runtime.

That additional runtime creates operational and architectural overhead.

The New Runtime Architecture

The Rust migration separated the runtime from the user interface more clearly.

The target architecture looks conceptually like this:

+-----------------------+
| Application / CLI     |
+-----------+-----------+
            |
            v
+-----------------------+
| SDK / Language Layer  |
+-----------+-----------+
            |
            v
+-----------------------+
| Native Rust Runtime   |
|                       |
| Agent Loop            |
| Tool Execution        |
| Session State         |
| Concurrency           |
| Runtime Services      |
+-----------------------+

The important architectural change is that the runtime is now a native component rather than something inherently tied to Node.js.

The terminal interface can remain a separate layer.

This separation makes the runtime easier to reuse across different products and languages.

Why In-Process Execution Matters

One of the biggest changes is the ability to embed the runtime directly into a host process.

Consider a simplified example.

A process-based architecture might look like this:

C# Application
      |
      | IPC / stdio
      v
Node.js Process
      |
      v
Copilot Runtime

An embedded architecture can instead look like:

C# Application
      |
      v
Native Runtime

The difference becomes important when an application creates many sessions or needs frequent communication with the runtime.

With an out-of-process architecture, communication can involve:

  1. Serialization

  2. IPC

  3. Context switching

  4. Deserialization

  5. Additional process supervision

With an in-process native library, function calls and memory access can occur without the same process boundary.

This does not mean IPC is always bad. Separate processes can provide useful isolation. The important point is that the runtime should not require an extra process when the host application does not need one.

How Rust Enables Cross-Language Integration

A native runtime needs a stable boundary that other languages can consume.

One common solution is a C-compatible ABI.

A simplified Rust example looks like this:

#[no_mangle]
pub extern "C" fn runtime_version() -> u32 {
    1
}

A C-compatible declaration could expose that function to another language:

uint32_t runtime_version(void);

A C# application could then access the native function through an interop layer:

using System.Runtime.InteropServices;

internal static class NativeRuntime
{
    [DllImport("copilot_runtime")]
    internal static extern uint runtime_version();
}

This example is intentionally small. A production runtime needs much more careful handling of memory ownership, strings, callbacks, errors, threading, and ABI compatibility.

The important architectural idea is that the native runtime becomes independent of the language used by the host application.

The Migration Was Not a Big-Bang Rewrite

One of the most interesting parts of GitHub's migration was the decision not to build the entire Rust runtime separately and switch to it at the end.

Instead, GitHub used an incremental, in-place migration.

The process can be summarized as:

TypeScript Component
        |
        v
Rust Implementation
        |
        v
Thin Interop Layer
        |
        v
Production Validation
        |
        v
Remove TypeScript
        |
        v
Move Next Component

Each migrated component could replace the corresponding TypeScript implementation while the rest of the system continued running.

This allowed development on the main branch to continue while the rewrite progressed. GitHub described this as an atomic replacement strategy.

Why Incremental Migration Reduced Risk

A rewrite of hundreds of thousands of lines introduces enormous regression risk.

A big-bang migration creates a difficult situation:

Old System
    |
    | months of rewrite
    v
New System
    |
    v
One huge production switch

If something fails, determining exactly where the problem originated can be difficult.

An incremental migration provides much smaller change sets:

Component A -> Rust
Component B -> Rust
Component C -> Rust
Component D -> Rust

Each change can be reviewed, tested, released, and monitored independently.

GitHub reported that the runtime continued shipping during the migration, with releases carrying relatively small sets of migrated components. The full production runtime eventually reached 100% Rust.

For large enterprise systems, this is an important lesson: reducing the size of each production change can be more valuable than trying to finish a rewrite as quickly as possible.

Temporary Interoperability Was Part of the Strategy

During the migration, Rust and TypeScript had to coexist.

For example:

TypeScript A
     |
     v
Rust B
     |
     v
TypeScript C

This required temporary interoperability layers.

As more components moved into Rust, the boundary changed:

Rust A
     |
     v
Rust B
     |
     v
TypeScript C

Eventually:

Rust A
     |
     v
Rust B
     |
     v
Rust C

At that point, the temporary interoperability layer could be removed.

GitHub reported that the temporary internal N-API surface eventually disappeared once the runtime became fully Rust.

This is an important migration pattern. Temporary compatibility code should have a clear purpose and an explicit path toward deletion.

Otherwise, temporary adapters can become permanent architecture.

The Hardest Part Was Maintaining Behavior

Converting syntax from TypeScript to Rust is not the difficult part of a large migration.

Maintaining behavior is.

A function can compile successfully and still behave differently.

For example, consider this simplified TypeScript logic:

function getTimeout(value: number | undefined): number {
    return value ?? 30000;
}

A Rust implementation might be:

fn get_timeout(value: Option<u64>) -> u64 {
    value.unwrap_or(30_000)
}

The code looks straightforward.

But real applications contain much more complicated behavior involving:

  • Async operations

  • Cancellation

  • Shared state

  • Error propagation

  • Resource ownership

  • Timeouts

  • Retries

  • Callbacks

  • Serialization

  • Concurrency

  • Process boundaries

Rust's ownership and lifetime rules also force developers to make certain relationships explicit.

That can expose assumptions that were less visible in the original JavaScript implementation.

What Went Wrong During the Migration?

GitHub reported dozens of known regressions during the migration, including correctness and performance issues. The reported problems fell into several recurring categories, including incomplete migrations, state and lifetime problems, behavioral mismatches, host-boundary issues, and incorrect test assumptions.

Some migration-related performance problems were also caused by the temporary boundary between TypeScript and Rust.

For example, unnecessary serialization or copying can turn a theoretically faster native implementation into a slower overall system.

This is an important lesson:

Native code does not automatically make an application faster.

A system can lose performance if it constantly crosses language boundaries or performs unnecessary allocations and conversions.

Testing a Large Runtime Rewrite

For a migration of this size, unit tests alone are not enough.

A useful testing strategy includes several layers.

Unit Tests

Test individual Rust components independently.

#[test]
fn uses_default_timeout() {
    assert_eq!(get_timeout(None), 30_000);
}

#[test]
fn preserves_custom_timeout() {
    assert_eq!(get_timeout(Some(10_000)), 10_000);
}

These tests verify local behavior.

Integration Tests

Integration tests should verify communication between runtime components.

For example:

Agent
  |
  +--> Tool Registry
  |
  +--> Session Manager
  |
  +--> File System
  |
  +--> Model Provider

The goal is to ensure that individual components still work correctly when connected.

End-to-End Tests

End-to-end tests are particularly important during a language migration because they validate externally visible behavior.

The migration should ideally preserve existing E2E expectations rather than modifying tests simply to make the new implementation pass.

This helps answer the right question:

"Does the new implementation behave like the old one?"

rather than:

"Can we make the tests pass with the new implementation?"

Performance Improvements Are Not Just About CPU Speed

Moving from Node.js/V8 to native Rust can change several performance characteristics at once.

Potential improvements can come from:

  • Lower runtime overhead

  • Lower memory consumption

  • Native concurrency

  • Reduced serialization

  • Fewer process boundaries

  • Fewer copies

  • More predictable resource usage

  • Faster startup

GitHub reported significant performance improvements from the migration and specifically described moving away from Node.js and V8 as a central motivation.

However, developers should avoid assuming that every Rust rewrite will automatically produce similar results.

Performance depends on the workload and architecture.

For example, this:

Application
    |
    v
Rust
    |
    v
JSON serialization
    |
    v
Node.js
    |
    v
JSON serialization

can still have considerable overhead.

The best architecture minimizes unnecessary boundaries.

Advantages of Moving a Runtime to Rust

Lower Runtime Overhead

A native runtime does not require a JavaScript engine simply to execute the runtime itself.

Better Embedding

Rust can be compiled into a native library that applications can load directly.

Cross-Language Support

A native ABI can provide a common foundation for SDKs written in different programming languages.

Concurrency

Rust provides strong language-level support for expressing safe concurrent code.

Resource Predictability

Native execution can provide more direct control over memory and system resources.

Reduced Process Complexity

When in-process execution is appropriate, applications no longer need to supervise a separate runtime process.

Disadvantages and Trade-Offs

A Rust migration also introduces costs.

Higher Development Complexity

Rust's ownership, borrowing, lifetimes, and concurrency rules require developers to reason explicitly about relationships that may have been implicit in TypeScript.

Interoperability Complexity

FFI introduces concerns around:

  • ABI stability

  • Memory ownership

  • Callback lifetimes

  • Error handling

  • Thread safety

Migration Cost

Large rewrites require substantial engineering effort, even when AI coding agents accelerate the implementation.

Temporary Complexity

During migration, two languages and interoperability layers may coexist.

Behavioral Risk

A translation can accidentally change subtle runtime behavior even when the new implementation appears logically equivalent.

Common Mistakes in Large Language Migrations

Developers planning a similar migration should avoid several common mistakes.

Rewriting Everything in One Branch

A huge parallel rewrite makes integration and debugging much harder.

Changing Architecture and Language at the Same Time

If possible, preserve behavior first.

Once the new implementation is stable, optimize or redesign it separately.

Keeping Compatibility Code Forever

Temporary adapters should have clear ownership and removal criteria.

Ignoring Allocation and Serialization

A native implementation can still perform poorly if data is repeatedly copied or serialized between components.

Treating Compilation as Validation

A successful build proves that the code compiles. It does not prove behavioral compatibility.

Removing Existing Tests

Tests are particularly valuable during a rewrite because they provide a behavioral reference for the new implementation.

A Practical Migration Strategy

If you are considering moving a large TypeScript runtime to Rust, a practical approach is:

  1. Define the reason for the migration.
    Identify concrete problems such as memory overhead, embedding requirements, startup time, concurrency, or deployment complexity.

  2. Separate the runtime from the user interface.
    Avoid carrying UI-specific dependencies into the core runtime.

  3. Define the public API.
    Decide exactly how other applications will communicate with the native runtime.

  4. Start with a small component.
    Use an isolated piece of functionality to establish the migration pattern.

  5. Build temporary interoperability.
    Allow old and new components to communicate while the migration is underway.

  6. Replace components incrementally.
    Keep production changes small enough to review and test.

  7. Preserve existing behavior.
    Treat the original implementation as the behavioral reference.

  8. Run integration and E2E tests.
    Verify the system from the perspective of the actual consumer.

  9. Monitor production behavior.
    Watch for crashes, memory changes, latency regressions, and unexpected resource usage.

  10. Remove temporary compatibility code.
    Once the migration is complete, simplify the architecture.

  11. Optimize after translation.
    Do not mix every possible optimization into the initial port. First establish correctness, then redesign around the strengths of the new language.

What Developers Can Learn From GitHub's Approach

The most important lesson is that the project was not simply a TypeScript-to-Rust translation.

It was an architectural migration.

The objective was to create a runtime that could operate independently of Node.js, run natively, integrate with multiple programming languages, and be embedded into different products.

The incremental strategy was equally important.

Instead of waiting for hundreds of thousands of lines to be rewritten before testing the result, GitHub continuously replaced pieces of the existing implementation and shipped the changes along the way.

That approach makes large rewrites more manageable because every completed component becomes a smaller unit of risk.

AI coding agents also changed the economics of the project. GitHub reported that AI agents wrote most of the production Rust during the migration, with the completed runtime reaching more than 800,000 lines of production Rust.

But the engineering process still depended on human direction, review, testing, and validation.

Summary

GitHub's move from TypeScript and Node.js to Rust was driven primarily by the architectural requirements of a shared AI agent runtime.

The important goals were native execution, lower overhead, in-process embedding, cross-language interoperability, and better control over concurrency and resources.

The migration also demonstrates how large rewrites can be approached safely: separate the runtime from the UI, migrate components incrementally, preserve behavior, keep compatibility layers temporary, test continuously, and optimize after correctness is established.

For developers, the broader lesson is simple: choose a runtime technology based on the constraints of the system you are building, not because one programming language is universally better than another.

GitHub's experience shows that Rust can be a strong fit when a high-performance runtime needs to serve multiple languages and applications, while the migration process itself provides a useful blueprint for handling large production rewrites without requiring a complete development freeze.