dotnet — .NET SDK

GitHub.Copilot.SDK on NuGet. .NET 8.0+, nullable refs, System.Text.Json.

The .NET SDK targets .NET 8+ — modern C# with records, nullable reference types, source-generated JSON converters, and AOT-ready code. It uses System.Text.Json exclusively (no Newtonsoft.Json), implements IAsyncDisposable for deterministic cleanup, and passes CancellationToken through every async entry point.

.NET 8+ Nullable refs STJ only

76
.cs files
1936
Client.cs
1283
Session.cs
2723
Types.cs
AOT
Friendly

📚 What is this & why does it exist?

A .NET SDK for C# / F# / VB developers. CopilotClient is IAsyncDisposable; you write await using var client = new CopilotClient(); create sessions, subscribe to events, and the C# compiler's state machine guarantees cleanup. Under the hood: custom MillisecondsTimeSpanConverter bridges wire-format ms to idiomatic TimeSpan, codegen'd polymorphic converters deserialise 40+ SessionEvent variants, and internal Channel<T> queues decouple the read loop from user handlers.

💡 Real-world analogy

Think of the .NET SDK as a factory floor with typed conveyor belts. Every package (event) on the belt is labelled by type ([JsonDerivedType]), and a dispatcher sorts them into typed bins (your callbacks). When the factory closes (dispose), a PA system announces the shutdown (shutdown RPC), staff clock out (flush queues), the lights go off (cancel CTS), and the building auto-locks (Process.Kill). Even if someone trips the fire alarm (unhandled exception), the state machine ensures the building still gets locked.

🎯 Why This Module Exists

.NET is the canonical runtime for enterprise systems, ASP.NET Core services, Azure Functions, and Microsoft's own agent products (see docs/integrations/microsoft-agent-framework.md). Without a .NET SDK, integrating Copilot into a Blazor app or an Azure Function app means shelling out to copilot; the .NET SDK gives first-class async/cancellation semantics, proper disposal, and type-safe event streams. Targeting .NET 8 means all idioms (records, primary constructors, STJ source gen) are available, and AOT compilation is feasible.

🏗️ Design Decisions

DecisionChoice MadeAlternativesWhy
JSON librarySystem.Text.JsonNewtonsoft.JsonSTJ is in-box, AOT-safe, 3x faster; Newtonsoft adds a dep and breaks under trimming
Async patternTask + CancellationToken on every async methodValueTask, plain TaskTask is lowest-friction; CancellationToken is mandatory-pass, never swallowed
Resource lifetimeIAsyncDisposableFinalizer only; explicit CloseWorks with await using; coordinates subprocess termination; compiler-enforced cleanup paths
Polymorphic eventsCustom JsonConverter (codegen'd)[JsonDerivedType] attributesMore stable across STJ versions; codegen emits everything at once
Generated code locationCommitted in src/Generated/MSBuild BeforeBuild targetReviewable in PRs, debuggable, no Node.js needed at build time
Timespan wire formatMillisecondsTimeSpanConverter (custom)long ms; Duration structTimeSpan is the idiomatic .NET type; converter bridges the wire number to TimeSpan.FromMilliseconds
Event deliveryChannel<T> internallyevent EventHandler<T>Channel gives back-pressure and ordering guarantees that events lack
Nullable refsEnabled project-wideDisabledModern default; every public signature is explicit about nullability

Architecture

classDiagram
  class CopilotClient {
    <>
    +Task StartAsync(ct)
    +Task~Session~ CreateSessionAsync(cfg, ct)
    +Task StopAsync(ct)
    +ValueTask DisposeAsync()
  }
  class CopilotSession {
    <>
    +string SessionId
    +SessionCapabilities Capabilities
    +IAsyncEnumerable~SessionEvent~ Events
    +Task~string~ SendAsync(opts, ct)
    +Task~SessionEvent~ SendAndWaitAsync(opts, ct)
  }
  class SessionEvent {
    <>
  }
  class AssistantMessageEvent
  class SessionIdleEvent
  class ToolCallStartedEvent
  class PermissionRequestedEvent
  class SessionFsProvider
  class MillisecondsTimeSpanConverter {
    <>
    +Read() TimeSpan
    +Write(value)
  }
  class SessionEventConverter {
    <>
  }

  CopilotClient "1" o-- "*" CopilotSession
  SessionEvent <|-- AssistantMessageEvent
  SessionEvent <|-- SessionIdleEvent
  SessionEvent <|-- ToolCallStartedEvent
  SessionEvent <|-- PermissionRequestedEvent
  CopilotSession ..> SessionEvent
  CopilotSession ..> SessionFsProvider
  CopilotClient ..> MillisecondsTimeSpanConverter
  SessionEvent ..> SessionEventConverter

Lifecycle Diagram

stateDiagram-v2
    [*] --> Constructed : new CopilotClient()
    Constructed --> Starting : await StartAsync()
    Starting --> Connected : handshake OK
    Starting --> Faulted : spawn/handshake fail
    Connected --> Active : CreateSessionAsync()
    Active --> Connected : session disposed
    Connected --> Stopping : DisposeAsync / StopAsync
    Active --> Stopping : DisposeAsync
    Stopping --> Stopped : process.WaitForExitAsync OK
    Stopping --> Killed : timeout -> process.Kill
    Killed --> Stopped
    Stopped --> [*]
    Faulted --> [*]

🚶 Narrated Walkthrough

Follow one SendAndWaitAsync round-trip:

  1. await using var client = new CopilotClient() — zero I/O in constructor; DisposeAsync queued by compiler.
  2. await client.StartAsync(ct) — resolves CLI path, Process.Start, attaches stdin/stdout streams, starts read-loop Task.
  3. Handshake — GetStatusAsync; cache negotiated version.
  4. await client.CreateSessionAsync(new SessionConfig{...}, ct) — serialises config (STJ); sends session.create; stores new CopilotSession in ConcurrentDictionary<string, CopilotSession>.
  5. Event loop — read loop parses incoming JSON via Utf8JsonReader, polymorphic converter dispatches to concrete event record, writes to the session's Channel<SessionEvent>.
  6. User consumes — either await foreach (var e in session.Events) or a callback handler reading from the channel.
  7. SendAndWaitAsync — subscribes to the channel, sends the message, awaits until SessionIdleEvent observed, returns last AssistantMessageEvent.
  8. Tool invocation — read loop sees session/toolInvoke server→client request; dispatches to registered handler; catches exceptions, converts to {IsError=true}; writes response.
  9. Cancellation — caller passes CancellationToken. SDK registers continuation on ct, cancels the read-loop's pending RPC registration, propagates OperationCanceledException.
  10. DisposeAsync — best-effort shutdown RPC with short timeout, cancels read-loop CTS, awaits read Task, kills process if still alive, disposes sessions, closes pipes.

Key Components

FileKey symbolsRole (WHY)
src/Client.csCopilotClient, ClientOptionsSubprocess + connection + sessions; IAsyncDisposable.
src/Session.csCopilotSession, SessionConfigConversation state; Channel<SessionEvent> event stream.
src/Types.cs2723 lines of record/struct typesSingle giant file avoids circular refs; mirrors Node's types.ts.
src/SessionFsProvider.csISessionFsProvider interface + adapterUser implements interface; adapter converts .NET exceptions to SessionFsError.
src/PermissionHandlers.csStatic class with pre-built handlers (13 lines)Parity with Go/Python — namespaces the helpers.
src/Telemetry.csActivity-based W3C Trace Context (51 lines)Thin wrapper over System.Diagnostics.Activity.
src/MillisecondsTimeSpanConverter.csCustom JsonConverter<TimeSpan>Bridges wire ms to TimeSpan; attached via [JsonConverter].
src/ActionDisposable.cs19-line IDisposable that runs an action on disposeUsed for subscription unsubscribe; classic utility.
src/SdkProtocolVersion.cs20-line static classExposes the embedded integer.
src/Generated/Rpc.csCodegen'd RPC method bindings + polymorphic convertersAdding a method = schema update + regen.
src/Generated/SessionEvents.csCodegen'd event records + SessionEventConverter40+ sealed records.
src/build/MSBuild / NuGet packagingNuGet contentFiles/ ships bundled CLI.
samples/Chat.csDemoInteractive chat.

⚡ Algorithm Spotlight

MillisecondsTimeSpanConverter

public sealed class MillisecondsTimeSpanConverter : JsonConverter<TimeSpan>
{
    public override TimeSpan Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions o)
        => TimeSpan.FromMilliseconds(reader.GetInt64());

    public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions o)
        => writer.WriteNumberValue((long)value.TotalMilliseconds);
}

Attached to every TimeSpan property via [JsonConverter(typeof(MillisecondsTimeSpanConverter))]. Without this, STJ would either fail (if NRTs caught the unset converter) or emit "00:00:00.500" which the CLI cannot parse.

Polymorphic event deserialisation

Codegen emits abstract SessionEvent record + ~40 sealed concrete records, plus a SessionEventConverter that reads-ahead the type field, switches on its value, and deserialises into the matching record. Dispatch is O(1) via dictionary keyed by type string.

DisposeAsync escalation

The .NET contract requires DisposeAsync to always complete, even on failure. Order: (1) send shutdown RPC with short timeout; (2) cancel read-loop CTS; (3) await read task; (4) process.Kill(entireProcessTree: true) if still alive; (5) dispose sessions (fire SessionDisposed events); (6) close pipes / CTS. Exceptions aggregated and only rethrown if ThrowOnDispose is configured — idiomatic for .NET (cleanup mustn't mask original exception).

⚠ What Could Go Wrong

FailureDetectionRecoveryEffects
CLI not foundFileNotFoundException in StartAsyncRe-throw with guidanceClient unusable
Process crashRead task observes EOF or ProcessExitedFaults pending RPCs; SessionDisposed firesClient must be recreated
Cancellation mid-RPCCancellationToken registrationUnregister pending; propagate OperationCanceledExceptionIn-flight work continues server-side briefly
Tool handler throwstry/catch in dispatcher{IsError=true, Content=ex.ToString()}Agent sees error
Shutdown hangsTimeout on graceful shutdownForce kill via Process.Kill(tree)Lost in-flight state acceptable

📊 Performance Profile

Cold start: 80-150ms on JIT; AOT builds approach 30ms. JSON hot path: Utf8JsonReader/Writer zero-alloc for primitives; one object allocation per event record. Throughput: Channel<T> unbounded means 50k+ events/sec easily on modest hardware. Memory: ~3-5 MB per idle client; ~50 KB per session. Cancellation: near-zero overhead — uses WaitHandle under the hood.

Deep-Dive Highlights

JSON-RPC Transport Focus

STJ-based framing with Utf8JsonReader.

Protocol Negotiation Focus

Version handshake & capability gates.

Codegen Pipeline Focus

How Generated/Rpc.cs and SessionEvents.cs are produced from the schema.

Configuration Reference

PropertyTypeDefaultPurpose
ClientOptions.CliPathstring?bundledOverride CLI path
ClientOptions.CliUrlstring?nullTCP connect to external server
SessionConfig.Modelstring?nullModel override
SessionConfig.EnableConfigDiscoverybool?nullAuto-load .mcp.json
SessionConfig.OnPermissionRequestPermissionHandler?nullPermission gate
SessionConfig.ToolsIReadOnlyList<Tool>?nullCustom tools
SessionConfig.CommandsIReadOnlyList<CommandDefinition>?nullSlash commands
SessionConfig.HooksSessionHooks?nullpre/post tool use
SessionConfig.SystemMessageSystemMessageConfig?nullreplace/customize/transform
SessionConfig.SessionFsSessionFsConfig?nullVirtual FS
SessionConfig.ReasoningEffortReasoningEffort?nulllow/medium/high
SessionConfig.TraceContextProviderTraceContextProvider?nullW3C Trace Context

Code Examples

Minimal example

using GitHub.Copilot.SDK;

await using var client = new CopilotClient();
await client.StartAsync();

var session = await client.CreateSessionAsync(new SessionConfig {
    Model = "gpt-4",
    OnPermissionRequest = PermissionHandlers.ApproveAll,
});

await foreach (var evt in session.Events) {
    if (evt is AssistantMessageEvent msg) Console.WriteLine(msg.Data.Content);
    if (evt is SessionIdleEvent) break;
}

await session.SendAsync(new MessageOptions { Prompt = "Hello!" });

ActionDisposable pattern

// src/ActionDisposable.cs
internal sealed class ActionDisposable : IDisposable
{
    private readonly Action _onDispose;
    public ActionDisposable(Action onDispose) => _onDispose = onDispose;
    public void Dispose() => _onDispose();
}

MillisecondsTimeSpanConverter usage

public sealed record CommandContext {
    [JsonConverter(typeof(MillisecondsTimeSpanConverter))]
    public TimeSpan Timeout { get; init; }
}

Directory Map

dotnet/ source tree
dotnet/
├── src/
│   ├── Client.cs                       ← CopilotClient + IAsyncDisposable
│   ├── Session.cs                      ← CopilotSession + Channel stream
│   ├── Types.cs                        ← All public types (2723 lines)
│   ├── SessionFsProvider.cs            ← Interface + adapter + error translation
│   ├── PermissionHandlers.cs           ← Pre-built handlers (13 lines)
│   ├── Telemetry.cs                    ← Activity-based W3C (51 lines)
│   ├── MillisecondsTimeSpanConverter.cs← Custom JsonConverter
│   ├── ActionDisposable.cs             ← Disposable helper (19 lines)
│   ├── SdkProtocolVersion.cs           ← Embedded constant
│   ├── Generated/
│   │   ├── Rpc.cs                      ← Codegen RPC methods + converters
│   │   └── SessionEvents.cs            ← Codegen event records
│   └── build/                          ← MSBuild targets, NuGet packaging
├── samples/
│   └── Chat.cs                         ← Demo
├── test/
│   ├── Harness/                        ← Conformance adapter
│   └── (test projects)
├── .config/                            ← dotnet-tool manifest
└── Copilot.SDK.sln