Protocol Negotiation Focus Deep-Dive

The 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:

🔧 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?

ApproachProsConsWhy Not Chosen
Version hard-pin (exact match)SimpleBreaks on any minor update; upgrade lockstep across a Go fleet is impracticalToo brittle for multi-service fleets
Semver range on the wireFamiliarRequires parsing semver strings in Go/C# at startupOverkill; a single integer plus capability strings is more explicit
Skip handshake; fail on first bad payloadNo extra round-tripErrors deep in event processing; impossible to surface a clear "upgrade your CLI" hintTerrible DX
Only check capabilities (no integer version)Simplest feature-level controlNeed a capability for every minor wire change; list grows unboundedInteger 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 targetMINSDK_PROTOCOL_VERSIONTalks to CLI reporting
Node 0.3.0-preview23any CLI reporting 2 or 3
Python 0.3.023same
Go 0.3.023same
.NET 0.3.023same
Hypothetical 0.1 SDK11only 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

ScenarioBehaviourNotes
CLI reports version higher than SDK knowsSDK pins to its own versionForward-compatible; new CLI features are just unused
SDK needs a capability the CLI lacksThrow IncompatibleCLI errorHelpful error, not a mysterious runtime failure
CLI hangs during getStatusTimeout on handshake (5s default)Prevents the SDK from wedging if CLI is broken
Handshake response missing capabilities fieldTreat as empty listResilient to older CLIs that predate capabilities
User calls non-existent capability-gated methodMethod-level error: "requires capability X"Second layer of defense after handshake

Configuration

KnobDefaultPurpose
MIN_PROTOCOL_VERSION2Lowest CLI version the SDK accepts
SDK_PROTOCOL_VERSION3 (codegen'd)The schema's own version
handshake timeout5sAbort if CLI doesn't answer
NO_RESULT_PERMISSION_V2_ERRORstring constantError thrown on missing capability
accept-unknown-capstrueForward-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