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
📚 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
| Decision | Choice Made | Alternatives | Why |
|---|---|---|---|
| JSON library | System.Text.Json | Newtonsoft.Json | STJ is in-box, AOT-safe, 3x faster; Newtonsoft adds a dep and breaks under trimming |
| Async pattern | Task + CancellationToken on every async method | ValueTask, plain Task | Task is lowest-friction; CancellationToken is mandatory-pass, never swallowed |
| Resource lifetime | IAsyncDisposable | Finalizer only; explicit Close | Works with await using; coordinates subprocess termination; compiler-enforced cleanup paths |
| Polymorphic events | Custom JsonConverter (codegen'd) | [JsonDerivedType] attributes | More stable across STJ versions; codegen emits everything at once |
| Generated code location | Committed in src/Generated/ | MSBuild BeforeBuild target | Reviewable in PRs, debuggable, no Node.js needed at build time |
| Timespan wire format | MillisecondsTimeSpanConverter (custom) | long ms; Duration struct | TimeSpan is the idiomatic .NET type; converter bridges the wire number to TimeSpan.FromMilliseconds |
| Event delivery | Channel<T> internally | event EventHandler<T> | Channel gives back-pressure and ordering guarantees that events lack |
| Nullable refs | Enabled project-wide | Disabled | Modern 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:
await using var client = new CopilotClient()— zero I/O in constructor;DisposeAsyncqueued by compiler.await client.StartAsync(ct)— resolves CLI path,Process.Start, attaches stdin/stdout streams, starts read-loop Task.- Handshake —
GetStatusAsync; cache negotiated version. await client.CreateSessionAsync(new SessionConfig{...}, ct)— serialises config (STJ); sendssession.create; stores newCopilotSessioninConcurrentDictionary<string, CopilotSession>.- Event loop — read loop parses incoming JSON via
Utf8JsonReader, polymorphic converter dispatches to concrete event record, writes to the session'sChannel<SessionEvent>. - User consumes — either
await foreach (var e in session.Events)or a callback handler reading from the channel. SendAndWaitAsync— subscribes to the channel, sends the message, awaits untilSessionIdleEventobserved, returns lastAssistantMessageEvent.- Tool invocation — read loop sees
session/toolInvokeserver→client request; dispatches to registered handler; catches exceptions, converts to{IsError=true}; writes response. - Cancellation — caller passes
CancellationToken. SDK registers continuation onct, cancels the read-loop's pending RPC registration, propagatesOperationCanceledException. DisposeAsync— best-effortshutdownRPC with short timeout, cancels read-loop CTS, awaits read Task, kills process if still alive, disposes sessions, closes pipes.
Key Components
| File | Key symbols | Role (WHY) |
|---|---|---|
src/Client.cs | CopilotClient, ClientOptions | Subprocess + connection + sessions; IAsyncDisposable. |
src/Session.cs | CopilotSession, SessionConfig | Conversation state; Channel<SessionEvent> event stream. |
src/Types.cs | 2723 lines of record/struct types | Single giant file avoids circular refs; mirrors Node's types.ts. |
src/SessionFsProvider.cs | ISessionFsProvider interface + adapter | User implements interface; adapter converts .NET exceptions to SessionFsError. |
src/PermissionHandlers.cs | Static class with pre-built handlers (13 lines) | Parity with Go/Python — namespaces the helpers. |
src/Telemetry.cs | Activity-based W3C Trace Context (51 lines) | Thin wrapper over System.Diagnostics.Activity. |
src/MillisecondsTimeSpanConverter.cs | Custom JsonConverter<TimeSpan> | Bridges wire ms to TimeSpan; attached via [JsonConverter]. |
src/ActionDisposable.cs | 19-line IDisposable that runs an action on dispose | Used for subscription unsubscribe; classic utility. |
src/SdkProtocolVersion.cs | 20-line static class | Exposes the embedded integer. |
src/Generated/Rpc.cs | Codegen'd RPC method bindings + polymorphic converters | Adding a method = schema update + regen. |
src/Generated/SessionEvents.cs | Codegen'd event records + SessionEventConverter | 40+ sealed records. |
src/build/ | MSBuild / NuGet packaging | NuGet contentFiles/ ships bundled CLI. |
samples/Chat.cs | Demo | Interactive 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
| Failure | Detection | Recovery | Effects |
|---|---|---|---|
| CLI not found | FileNotFoundException in StartAsync | Re-throw with guidance | Client unusable |
| Process crash | Read task observes EOF or ProcessExited | Faults pending RPCs; SessionDisposed fires | Client must be recreated |
| Cancellation mid-RPC | CancellationToken registration | Unregister pending; propagate OperationCanceledException | In-flight work continues server-side briefly |
| Tool handler throws | try/catch in dispatcher | {IsError=true, Content=ex.ToString()} | Agent sees error |
| Shutdown hangs | Timeout on graceful shutdown | Force 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
| Property | Type | Default | Purpose |
|---|---|---|---|
ClientOptions.CliPath | string? | bundled | Override CLI path |
ClientOptions.CliUrl | string? | null | TCP connect to external server |
SessionConfig.Model | string? | null | Model override |
SessionConfig.EnableConfigDiscovery | bool? | null | Auto-load .mcp.json |
SessionConfig.OnPermissionRequest | PermissionHandler? | null | Permission gate |
SessionConfig.Tools | IReadOnlyList<Tool>? | null | Custom tools |
SessionConfig.Commands | IReadOnlyList<CommandDefinition>? | null | Slash commands |
SessionConfig.Hooks | SessionHooks? | null | pre/post tool use |
SessionConfig.SystemMessage | SystemMessageConfig? | null | replace/customize/transform |
SessionConfig.SessionFs | SessionFsConfig? | null | Virtual FS |
SessionConfig.ReasoningEffort | ReasoningEffort? | null | low/medium/high |
SessionConfig.TraceContextProvider | TraceContextProvider? | null | W3C 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