diff --git a/docs/compatibility.md b/docs/compatibility.md
index bfd17915..e113a22f 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -174,15 +174,85 @@ The SDK can only access features exposed through the CLI's JSON-RPC protocol. If
## Version Compatibility
-| SDK Version | CLI Version | Protocol Version |
-|-------------|-------------|------------------|
-| Check `package.json` | `copilot --version` | `getStatus().protocolVersion` |
+| SDK Version | Min Protocol | Max Protocol | Notes |
+|-------------|-------------|--------------|-------|
+| 0.1.x | 2 | 2 | Exact match required |
+| 0.2.x+ | 2 | 3 | Range-based negotiation |
-The SDK and CLI must have compatible protocol versions. The SDK will log warnings if versions are mismatched.
+The SDK and CLI negotiate a compatible protocol version at startup. The SDK advertises a supported range (`minVersion`–`version`) and the CLI reports its version via `ping`. If the CLI's version falls within the SDK's range, the connection succeeds; otherwise, the SDK throws an error.
+
+You can check versions at runtime:
+
+```typescript
+const status = await client.ping();
+console.log("Server protocol version:", status.protocolVersion);
+```
+
+## Protocol v3: Broadcast Events and Multi-Client Sessions
+
+Protocol v3 changes how the SDK handles tool calls and permission requests internally. **No user-facing API changes are required** — existing code continues to work.
+
+### What Changed
+
+| Aspect | Protocol v2 | Protocol v3 |
+|--------|-------------|-------------|
+| **Tool calls** | CLI sends RPC request directly to the SDK | CLI broadcasts `external_tool.requested` event to all connected clients |
+| **Permission requests** | CLI sends RPC request directly to the SDK | CLI broadcasts `permission.requested` event to all connected clients |
+| **Multi-client** | One SDK client per CLI server | Multiple SDK clients can share a CLI server and session |
+
+### How It Works
+
+In v3, the CLI broadcasts tool and permission events to every connected client. Each client checks whether it has a matching handler:
+
+- If the client has a handler for the requested tool, it executes the tool and sends the result back via `session.tools.handlePendingToolCall`.
+- If the client doesn't have the handler, it responds with an "unsupported" result.
+- Permission requests follow the same pattern via `session.permissions.handlePendingPermissionRequest`.
+
+The SDK handles all of this automatically — you register tools and permission handlers the same way as before:
+
+```typescript
+import { CopilotClient, defineTool } from "@github/copilot-sdk";
+
+const myTool = defineTool("my_tool", {
+ description: "A custom tool",
+ parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] },
+ handler: async (args: { input: string }) => {
+ return { result: args.input.toUpperCase() };
+ },
+});
+
+// Works identically on both v2 and v3
+const session = await client.createSession({
+ tools: [myTool],
+ onPermissionRequest: approveAll,
+});
+```
+
+### Multi-Client Sessions
+
+With v3, multiple SDK clients can connect to the same CLI server (via `cliUrl`) and share sessions. Each client can register different tools, and the broadcast model routes tool calls to the client that has the matching handler.
+
+See the [Multi-Client Session Sharing](./guides/session-persistence.md#multi-client-session-sharing) section in the Session Persistence guide for details and code samples.
+
+## Upgrading from v2 to v3
+
+Upgrading is straightforward — no code changes required:
+
+1. **Update the SDK package** to the latest version
+2. **Update the CLI** to a version that supports protocol v3
+3. **That's it** — the SDK auto-negotiates the protocol version
+
+The SDK remains backward-compatible with v2 CLI servers. If the CLI only supports v2, the SDK operates in v2 mode automatically. Multi-client session features are only available when both the SDK and CLI use v3.
+
+| Step | TypeScript | Python | Go | .NET |
+|------|-----------|--------|-----|------|
+| Update SDK | `npm install @github/copilot-sdk@latest` | `pip install --upgrade copilot-sdk` | `go get github.com/github/copilot-sdk/go@latest` | Update `PackageReference` version |
+| Update CLI | `npm install @github/copilot@latest` | Bundled with SDK | External install | Bundled with SDK |
## See Also
- [Getting Started Guide](./getting-started.md)
+- [Session Persistence & Multi-Client](./guides/session-persistence.md)
- [Hooks Documentation](./hooks/overview.md)
- [MCP Servers Guide](./mcp/overview.md)
- [Debugging Guide](./debugging.md)
diff --git a/docs/guides/session-persistence.md b/docs/guides/session-persistence.md
index e2b736c1..2660294c 100644
--- a/docs/guides/session-persistence.md
+++ b/docs/guides/session-persistence.md
@@ -502,12 +502,13 @@ const session = await client.createSession({
|------------|-------------|------------|
| **BYOK re-authentication** | API keys aren't persisted | Store keys in your secret manager; provide on resume |
| **Writable storage** | `~/.copilot/session-state/` must be writable | Mount persistent volume in containers |
-| **No session locking** | Concurrent access to same session is undefined | Implement application-level locking or queue |
| **Tool state not persisted** | In-memory tool state is lost | Design tools to be stateless or persist their own state |
### Handling Concurrent Access
-The SDK doesn't provide built-in session locking. If multiple clients might access the same session:
+With protocol v3, the SDK supports **multi-client session sharing** — multiple SDK clients can connect to the same CLI server and operate on the same session simultaneously. The CLI broadcasts tool and permission events to all connected clients, and each client handles the events for tools it has registered.
+
+If your use case requires **strict serialization** (e.g., only one client sends prompts at a time), you can still use application-level locking:
```typescript
// Option 1: Application-level locking with Redis
@@ -540,12 +541,213 @@ await withSessionLock("user-123-task-456", async () => {
});
```
+For multi-client session sharing without locking, see the next section.
+
+## Multi-Client Session Sharing
+
+Protocol v3 enables multiple SDK clients to share a session via broadcast events. This is useful for architectures where different services each provide different tools, or where a session needs to be accessible from multiple processes.
+
+```mermaid
+flowchart TB
+ subgraph clients["SDK Clients"]
+ A["Client A
(tool: search_docs)"]
+ B["Client B
(tool: run_tests)"]
+ end
+
+ subgraph server["CLI Server"]
+ CLI["Copilot CLI
cliUrl: localhost:3000"]
+ S["Session: task-123"]
+ end
+
+ A -->|cliUrl| CLI
+ B -->|cliUrl| CLI
+ CLI --> S
+ S -->|broadcast: external_tool.requested| A
+ S -->|broadcast: external_tool.requested| B
+```
+
+### How It Works
+
+1. Start a CLI server (or use an existing one accessible via `cliUrl`)
+2. Client A connects and creates a session, registering its tools
+3. Client B connects and resumes the same session, registering different tools
+4. When the model calls a tool, the CLI broadcasts the request to all clients
+5. Each client checks if it has the requested tool and responds accordingly
+
+### TypeScript
+
+```typescript
+import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk";
+
+// Client A: provides search capabilities
+const searchDocs = defineTool("search_docs", {
+ description: "Search the documentation",
+ parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
+ handler: async (args: { query: string }) => {
+ return { results: [`Result for: ${args.query}`] };
+ },
+});
+
+const clientA = new CopilotClient({ cliUrl: "localhost:3000" });
+const sessionA = await clientA.createSession({
+ sessionId: "shared-task-123",
+ tools: [searchDocs],
+ onPermissionRequest: approveAll,
+});
+
+// Client B: provides test capabilities (different process or service)
+const runTests = defineTool("run_tests", {
+ description: "Run the test suite",
+ parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
+ handler: async (args: { path: string }) => {
+ return { passed: true, path: args.path };
+ },
+});
+
+const clientB = new CopilotClient({ cliUrl: "localhost:3000" });
+const sessionB = await clientB.resumeSession("shared-task-123", {
+ tools: [runTests],
+ onPermissionRequest: approveAll,
+});
+```
+
+### Python
+
+```python
+from copilot import CopilotClient, PermissionHandler
+from copilot.tools import define_tool
+from pydantic import BaseModel, Field
+
+# Client A: provides search capabilities
+class SearchParams(BaseModel):
+ query: str = Field(description="Search query")
+
+@define_tool(description="Search the documentation")
+async def search_docs(params: SearchParams) -> dict:
+ return {"results": [f"Result for: {params.query}"]}
+
+client_a = CopilotClient(cli_url="localhost:3000")
+await client_a.start()
+session_a = await client_a.create_session({
+ "session_id": "shared-task-123",
+ "tools": [search_docs],
+ "on_permission_request": PermissionHandler.approve_all,
+})
+
+# Client B: provides test capabilities (different process)
+class TestParams(BaseModel):
+ path: str = Field(description="Test path")
+
+@define_tool(description="Run the test suite")
+async def run_tests(params: TestParams) -> dict:
+ return {"passed": True, "path": params.path}
+
+client_b = CopilotClient(cli_url="localhost:3000")
+await client_b.start()
+session_b = await client_b.resume_session("shared-task-123", {
+ "tools": [run_tests],
+ "on_permission_request": PermissionHandler.approve_all,
+})
+```
+
+### Go
+
+
+```go
+ctx := context.Background()
+
+// Client A: provides search capabilities
+searchDocs := copilot.DefineTool(
+ "search_docs",
+ "Search the documentation",
+ func(params struct {
+ Query string `json:"query" jsonschema:"Search query"`
+ }, inv copilot.ToolInvocation) (map[string]any, error) {
+ return map[string]any{"results": []string{fmt.Sprintf("Result for: %s", params.Query)}}, nil
+ },
+)
+
+clientA := copilot.NewClient(&copilot.ClientOptions{CLIUrl: "localhost:3000"})
+sessionA, _ := clientA.CreateSession(ctx, &copilot.SessionConfig{
+ SessionID: "shared-task-123",
+ Tools: []copilot.Tool{searchDocs},
+ OnPermissionRequest: copilot.ApproveAll,
+})
+
+// Client B: provides test capabilities (different process)
+runTests := copilot.DefineTool(
+ "run_tests",
+ "Run the test suite",
+ func(params struct {
+ Path string `json:"path" jsonschema:"Test path"`
+ }, inv copilot.ToolInvocation) (map[string]any, error) {
+ return map[string]any{"passed": true, "path": params.Path}, nil
+ },
+)
+
+clientB := copilot.NewClient(&copilot.ClientOptions{CLIUrl: "localhost:3000"})
+sessionB, _ := clientB.ResumeSession(ctx, "shared-task-123", &copilot.ResumeSessionConfig{
+ Tools: []copilot.Tool{runTests},
+ OnPermissionRequest: copilot.ApproveAll,
+})
+```
+
+### C# (.NET)
+
+
+```csharp
+using GitHub.Copilot.SDK;
+using Microsoft.Extensions.AI;
+using System.ComponentModel;
+
+// Client A: provides search capabilities
+var searchDocs = AIFunctionFactory.Create(
+ ([Description("Search query")] string query) =>
+ new { results = new[] { $"Result for: {query}" } },
+ "search_docs",
+ "Search the documentation"
+);
+
+var clientA = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:3000" });
+var sessionA = await clientA.CreateSessionAsync(new SessionConfig
+{
+ SessionId = "shared-task-123",
+ Tools = [searchDocs],
+ OnPermissionRequest = PermissionHandler.ApproveAll,
+});
+
+// Client B: provides test capabilities (different process)
+var runTests = AIFunctionFactory.Create(
+ ([Description("Test path")] string path) =>
+ new { passed = true, path },
+ "run_tests",
+ "Run the test suite"
+);
+
+var clientB = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:3000" });
+var sessionB = await clientB.ResumeSessionAsync("shared-task-123", new ResumeSessionConfig
+{
+ Tools = [runTests],
+ OnPermissionRequest = PermissionHandler.ApproveAll,
+});
+```
+
+### Best Practices for Multi-Client Sessions
+
+| Practice | Description |
+|----------|-------------|
+| **Distribute tools uniquely** | Each tool should be registered on exactly one client. If multiple clients register the same tool, only one response will be used. |
+| **Always provide `onPermissionRequest`** | Each client that might receive permission broadcasts should have a handler. |
+| **Use meaningful session IDs** | Shared sessions need predictable IDs so all clients can find them. |
+| **Handle disconnections gracefully** | If a client disconnects, its tools become unavailable. Design your system so remaining clients can still operate. |
+
## Summary
| Feature | How to Use |
|---------|------------|
| **Create resumable session** | Provide your own `sessionId` |
| **Resume session** | `client.resumeSession(sessionId)` |
+| **Multi-client sharing** | Multiple clients connect via `cliUrl`, each registers its own tools |
| **BYOK resume** | Re-provide `provider` config |
| **List sessions** | `client.listSessions(filter?)` |
| **Disconnect from active session** | `session.disconnect()` — releases in-memory resources; session data on disk is preserved for resumption |
@@ -554,6 +756,6 @@ await withSessionLock("user-123-task-456", async () => {
## Next Steps
+- [Compatibility Guide](../compatibility.md) - SDK vs CLI feature comparison, protocol v3 details
- [Hooks Overview](../hooks/overview.md) - Customize session behavior with hooks
-- [Compatibility Guide](../compatibility.md) - SDK vs CLI feature comparison
- [Debugging Guide](../debugging.md) - Troubleshoot session issues
diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs
index a340cd63..a0330003 100644
--- a/dotnet/src/Client.cs
+++ b/dotnet/src/Client.cs
@@ -67,6 +67,8 @@ public sealed partial class CopilotClient : IDisposable, IAsyncDisposable
private readonly Dictionary>> _typedLifecycleHandlers = [];
private readonly object _lifecycleHandlersLock = new();
private ServerRpc? _rpc;
+ private JsonRpc? _jsonRpc;
+ private int _negotiatedProtocolVersion;
///
/// Gets the typed RPC client for server-scoped methods (no session required).
@@ -318,6 +320,8 @@ private async Task CleanupConnectionAsync(List? errors)
// Clear RPC and models cache
_rpc = null;
+ _jsonRpc = null;
+ _negotiatedProtocolVersion = 0;
_modelsCache = null;
if (ctx.NetworkStream is not null)
@@ -915,27 +919,31 @@ private Task EnsureConnectedAsync(CancellationToken cancellationToke
return (Task)StartAsync(cancellationToken);
}
- private static async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
+ private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken)
{
- var expectedVersion = SdkProtocolVersion.GetVersion();
+ var maxVersion = SdkProtocolVersion.GetVersion();
+ var minVersion = SdkProtocolVersion.GetMinVersion();
var pingResponse = await InvokeRpcAsync(
connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken);
if (!pingResponse.ProtocolVersion.HasValue)
{
throw new InvalidOperationException(
- $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
+ $"SDK protocol version mismatch: SDK supports versions {minVersion}-{maxVersion}, " +
$"but server does not report a protocol version. " +
$"Please update your server to ensure compatibility.");
}
- if (pingResponse.ProtocolVersion.Value != expectedVersion)
+ var serverVersion = pingResponse.ProtocolVersion.Value;
+ if (serverVersion < minVersion || serverVersion > maxVersion)
{
throw new InvalidOperationException(
- $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " +
- $"but server reports version {pingResponse.ProtocolVersion.Value}. " +
+ $"SDK protocol version mismatch: SDK supports versions {minVersion}-{maxVersion}, " +
+ $"but server reports version {serverVersion}. " +
$"Please update your SDK or server to ensure compatibility.");
}
+
+ _negotiatedProtocolVersion = serverVersion;
}
private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
@@ -1135,6 +1143,7 @@ private async Task ConnectToServerAsync(Process? cliProcess, string?
rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke);
rpc.StartListening();
+ _jsonRpc = rpc;
_rpc = new ServerRpc(rpc);
return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer);
@@ -1173,6 +1182,145 @@ private static JsonSerializerOptions CreateSerializerOptions()
return _sessions.TryGetValue(sessionId, out var session) ? session : null;
}
+ private async Task HandleExternalToolRequestedAsync(string sessionId, JsonElement eventJson)
+ {
+ string? requestId = null;
+ try
+ {
+ if (!eventJson.TryGetProperty("data", out var data)) return;
+
+ requestId = data.TryGetProperty("requestId", out var rid) ? rid.GetString() : null;
+ var toolCallId = data.TryGetProperty("toolCallId", out var tcid) ? tcid.GetString() : null;
+ var toolName = data.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
+
+ if (requestId == null || toolName == null) return;
+
+ var session = GetSession(sessionId);
+ if (session == null) return;
+
+ ToolResultObject resultObj;
+ if (session.GetTool(toolName) is not { } tool)
+ {
+ resultObj = new ToolResultObject
+ {
+ TextResultForLlm = $"Tool '{toolName}' is not supported.",
+ ResultType = "failure",
+ Error = $"tool '{toolName}' not supported"
+ };
+ }
+ else
+ {
+ try
+ {
+ var arguments = data.TryGetProperty("arguments", out var args) ? (object?)args : null;
+
+ var invocation = new ToolInvocation
+ {
+ SessionId = sessionId,
+ ToolCallId = toolCallId ?? "",
+ ToolName = toolName,
+ Arguments = arguments
+ };
+
+ var aiFunctionArgs = new AIFunctionArguments
+ {
+ Context = new Dictionary