Protocol Negotiation Focus Deep-Dive
getStatus handshake, version pinning, and capability flags that keep a four-SDK matrix compatible with a moving CLI.The SDK and CLI do not trust each other's versions. On every Start(), the SDK calls getStatus; the CLI responds with its highest protocol version and capability flags. If the overlap fails the minimum required by the SDK, Start() fails immediately with a helpful message, rather than the user discovering garbled events at message 50.
📚 What is this & why does it exist?
A version handshake, identical in shape across all four SDKs. The SDK sends getStatus; the CLI returns {protocolVersion: 3, capabilities: ["permissions_v2", "hooks_extended", "session_fs_v2"]}. The SDK compares this to its own SDK_PROTOCOL_VERSION constant and minimum MIN_PROTOCOL_VERSION constraint. Mismatches throw a typed error (NO_RESULT_PERMISSION_V2_ERROR in Node) rather than failing at some later opcode.
💡 Real-world analogy
Think of two radios tuning into each other. Before either side starts broadcasting music, they exchange a beep sequence: "I speak English, French, and German." "I speak English only." They then both switch to English. If one only speaks Klingon while the other speaks English, they stop the conversation with a clear "we can't talk" message — not garbled noise that gets blamed on the song three minutes later.
🎯 The Problem
The CLI is released on its own cadence; SDKs are released on theirs. Users install @github/copilot-sdk@0.3 today but upgrade the CLI tomorrow (npm i -g @github/copilot@latest). Or vice versa — a Python environment pinned at a specific SDK interacts with a freshly updated bundled CLI. The SDK must not assume version equality, but also must not silently degrade. Specific risks:
- A new event type ships that older SDKs don't know — causes
JsonConverterexceptions in .NET. - A method's parameter shape changes — silently sends malformed JSON and gets -32602 errors with no context.
- Permissions v2 introduces a new result-shape; a v1 SDK would misinterpret v2 events as malformed.
🔧 The Approach
A two-value handshake: version + capabilities. The version is a single integer (currently 3). Capabilities are named strings that gate individual features. Capabilities avoid version-number inflation when a feature is opt-in (SDK doesn't need permissions_v2? Don't care whether the CLI has it).
Each SDK embeds its own generated SDK_PROTOCOL_VERSION (not a hand-picked constant; the codegen step emits it from the schema). The Node SDK additionally exposes MIN_PROTOCOL_VERSION = 2 — it can talk to any CLI with version ≥ 2, even though its own schema targets 3.
🔄 Why Not Alternatives?
| Approach | Pros | Cons | Why Not Chosen |
|---|---|---|---|
| Version hard-pin (exact match) | Simple | Breaks on any minor update; upgrade lockstep across a Go fleet is impractical | Too brittle for multi-service fleets |
| Semver range on the wire | Familiar | Requires parsing semver strings in Go/C# at startup | Overkill; a single integer plus capability strings is more explicit |
| Skip handshake; fail on first bad payload | No extra round-trip | Errors deep in event processing; impossible to surface a clear "upgrade your CLI" hint | Terrible DX |
| Only check capabilities (no integer version) | Simplest feature-level control | Need a capability for every minor wire change; list grows unbounded | Integer caps the list; capabilities only exist for user-visible features |
Handshake Flow
sequenceDiagram
participant SDK
participant CLI
SDK->>CLI: getStatus (req, id=1)
CLI-->>SDK: {protocolVersion:3, capabilities:[...]} (resp, id=1)
alt SDK.MIN_PROTOCOL_VERSION <= reported <= SDK.SDK_PROTOCOL_VERSION
Note over SDK: cache negotiatedVersion & capabilities
SDK->>CLI: first real request
else reported < MIN
Note over SDK: throw IncompatibleCLIError with hint
SDK->>CLI: close pipes
else reported > SDK_PROTOCOL_VERSION
Note over SDK: pin to SDK's version; log warning
SDK->>CLI: continue with best-effort
end
Capability Flags
SDK declares required capabilities implicitly
// nodejs/src/client.ts (conceptual)
const required = ["permissions_v2"]; // because we call session.onPermissionRequest
for (const cap of required) {
if (!status.capabilities.includes(cap)) {
throw new Error(NO_RESULT_PERMISSION_V2_ERROR);
}
}
The SDK only enforces capabilities it actually uses. A user who never registers a permission handler doesn't need permissions_v2 and can talk to an older CLI.
Python mirrors the same check
# python/copilot/client.py
async def _negotiate(self):
status = await self._rpc.get_status()
if status.protocol_version < MIN_PROTOCOL_VERSION:
raise RuntimeError(f"CLI protocol v{status.protocol_version} < minimum v{MIN_PROTOCOL_VERSION}")
self._negotiated_version = min(status.protocol_version, SDK_PROTOCOL_VERSION)
self._capabilities = set(status.capabilities)
Go uses constants embedded from codegen
// go/sdk_protocol_version.go
const SDKProtocolVersion = 3
func (c *Client) negotiate(ctx context.Context) error {
status, err := c.rpc.GetStatus(ctx)
if err != nil { return err }
if status.ProtocolVersion < minProtocolVersion {
return fmt.Errorf("CLI protocol %d below minimum %d", status.ProtocolVersion, minProtocolVersion)
}
c.negotiated = status.ProtocolVersion
c.caps = make(map[string]bool, len(status.Capabilities))
for _, cap := range status.Capabilities { c.caps[cap] = true }
return nil
}
.NET throws a typed exception
if (status.ProtocolVersion < MinProtocolVersion) {
throw new IncompatibleCliException(
$"The Copilot CLI reports protocol version {status.ProtocolVersion}, " +
$"but this SDK requires at least {MinProtocolVersion}. " +
"Upgrade the CLI with: npm i -g @github/copilot@latest");
}
Compatibility Matrix
| SDK target | MIN | SDK_PROTOCOL_VERSION | Talks to CLI reporting |
|---|---|---|---|
| Node 0.3.0-preview | 2 | 3 | any CLI reporting 2 or 3 |
| Python 0.3.0 | 2 | 3 | same |
| Go 0.3.0 | 2 | 3 | same |
| .NET 0.3.0 | 2 | 3 | same |
| Hypothetical 0.1 SDK | 1 | 1 | only CLIs reporting exactly 1 (new CLIs no longer do) |
Handshake in the Client Lifecycle
stateDiagram-v2
[*] --> Spawned : subprocess OK
Spawned --> Handshaking : send getStatus
Handshaking --> Active : version & caps pass
Handshaking --> Failed : version below MIN or missing capability
Failed --> Stopped : close pipes
Active --> Stopped : Stop()
Stopped --> [*]
💥 Failure Recovery
If handshake fails, the SDK closes pipes, sends SIGTERM (CTRL_BREAK on Windows), and returns a typed error from Start(). There is no retry — version incompatibility is deterministic. The error string includes the exact version reported and the upgrade command. Pending tasks are rejected with the same error so await client.CreateSessionAsync() fails fast even if it was already queued.
Blast radius is minimal: a failed handshake leaves no user-visible state (no sessions created, no events emitted, no files written). The user either updates the CLI and retries or pins the SDK to an older version.
Edge Cases
| Scenario | Behaviour | Notes |
|---|---|---|
| CLI reports version higher than SDK knows | SDK pins to its own version | Forward-compatible; new CLI features are just unused |
| SDK needs a capability the CLI lacks | Throw IncompatibleCLI error | Helpful error, not a mysterious runtime failure |
CLI hangs during getStatus | Timeout on handshake (5s default) | Prevents the SDK from wedging if CLI is broken |
Handshake response missing capabilities field | Treat as empty list | Resilient to older CLIs that predate capabilities |
| User calls non-existent capability-gated method | Method-level error: "requires capability X" | Second layer of defense after handshake |
Configuration
| Knob | Default | Purpose |
|---|---|---|
MIN_PROTOCOL_VERSION | 2 | Lowest CLI version the SDK accepts |
SDK_PROTOCOL_VERSION | 3 (codegen'd) | The schema's own version |
| handshake timeout | 5s | Abort if CLI doesn't answer |
NO_RESULT_PERMISSION_V2_ERROR | string constant | Error thrown on missing capability |
| accept-unknown-caps | true | Forward-compat; log but proceed |
Directory Map
Files touching handshake
nodejs/
├── src/client.ts ← MIN_PROTOCOL_VERSION, negotiate()
└── src/generated/rpc.ts ← SDK_PROTOCOL_VERSION
python/copilot/
├── client.py ← _negotiate()
└── _sdk_protocol_version.py ← SDK_PROTOCOL_VERSION
go/
├── client.go ← negotiate()
└── sdk_protocol_version.go ← SDKProtocolVersion
dotnet/src/
├── Client.cs ← NegotiateAsync()
└── SdkProtocolVersion.cs ← constant