nodejs — TypeScript / Node.js SDK
@github/copilot-sdk on npm.The Node.js SDK is the reference implementation of the Copilot SDK contract. It's the first SDK to receive new features, the source from which behaviour is ported to the other three languages, and the largest single surface in the repo. Uses vscode-jsonrpc for transport, supports both plain JSON Schema and Zod for tool parameters, and bundles the Copilot CLI as a transitive npm dependency so users never install it separately.
TypeScript ESM only Reference impl
📚 What is this & why does it exist?
A TypeScript SDK that wraps JSON-RPC calls into idiomatic async methods on a CopilotClient class. When you call await client.createSession({ model: "gpt-4" }), under the hood the SDK spawns the copilot CLI as a sub-process, wires stdin/stdout into a vscode-jsonrpc message connection, negotiates the protocol version, and hands you back a typed CopilotSession object.
💡 Real-world analogy
Think of the SDK as a remote-control headset for a powerful but complicated robot (the Copilot CLI). The headset doesn't contain the robot; it just speaks its language. You press a button in your app — "tell me about Seattle weather" — and the headset translates that to the correct robot protocol, streams the robot's visual feed (events) back to you, and when the robot asks "should I open the door?" (permission request), it hands the question to you before the robot moves. If your app quits mid-sentence, the headset sends a polite shutdown command before unplugging; if you kill your app abruptly, the OS cuts the pipe and the robot powers down cleanly.
🎯 Why This Module Exists
The Node.js SDK exists because the Copilot CLI itself is a Node.js binary. That means (a) @github/copilot is already the dependency graph of any TypeScript project using the SDK, so bundling adds zero new runtime; (b) the maintainers of the SDK and the CLI work in the same language daily, so fixes roundtrip fast; (c) VS Code extensions and the massive Node ecosystem get first-class support immediately. Without this SDK, every TypeScript agent developer would shell out to copilot and parse stdout — losing the event stream, tool callbacks, permission flow, and streaming responses.
Because it's the reference, it tends to pioneer features. Commands and UI elicitation shipped in Node first (v0.2.0), then landed in Python/Go/.NET in v0.2.1. The enableConfigDiscovery flag, system-message transforms, and traceContextProvider all debuted here. A consequence: occasionally the Node SDK has capabilities no other SDK does yet — check CHANGELOG.md before relying on parity.
🏗️ Design Decisions
| Decision | Choice Made | Alternatives | Why This Approach |
|---|---|---|---|
| JSON-RPC library | vscode-jsonrpc | Hand-rolled, json-rpc-2.0 npm | Battle-tested in millions of VS Code extensions; LSP-style Content-Length framing out of the box; cancellation tokens native |
| Tool parameter spec | Accept JSON Schema or Zod | Force JSON Schema only | Zod is the de-facto TS validation lib; the toJSONSchema probe keeps Zod a truly optional dependency |
| Event delivery | Custom Set<Handler> + Map<EventType, Set> | Node EventEmitter | EventEmitter leaks listeners silently; custom impl returns typed unsubscribe fns and enforces discriminated-union typing |
| Module format | ESM with .js extensions in imports | CJS, dual package | Modern Node default; avoids "dual package hazard"; forces users to modern tooling |
| System-message transforms | Wire stub + side-map of callbacks | Pre-expand on client; RPC with closures | Functions can't be serialised; pre-expanding defeats the server-driven substitution point |
| Bundled-CLI resolution | createRequire(import.meta.url).resolve('@github/copilot') | Env var; hard-coded path | Uses Node's own resolver; works in nested node_modules layouts; respects peerDeps |
| Types organisation | Single types.ts (1735 lines) | Per-domain files | Avoids circular imports (every file references types); idiomatic TS; re-exported from index.ts |
Architecture
classDiagram
class CopilotClient {
+options: CopilotClientOptions
-process: ChildProcess
-connection: MessageConnection
-sessions: Map~string, CopilotSession~
+start()
+stop()
+createSession(cfg)
+resumeSession(id)
+listSessions()
+listModels()
+getAuthStatus()
+getStatus()
}
class CopilotSession {
+sessionId: string
+capabilities: SessionCapabilities
-eventHandlers: Set~Handler~
-toolHandlers: Map~name, Handler~
-permissionHandler?
-elicitationHandler?
-hooks?: SessionHooks
+on(handler) Unsub
+send(opts) Promise~messageId~
+sendAndWait(opts, timeout)
+disconnect()
+ui SessionUiApi
}
class SessionFsProvider
class Tool {
+name: string
+description: string
+parameters: JSONSchema | ZodSchema
+handler: ToolHandler
}
class SessionEvent {
<>
assistant.message
session.idle
session.error
tool.call_started
permission.requested
...40+ variants
}
CopilotClient "1" o-- "*" CopilotSession : manages
CopilotSession "1" o-- "*" Tool : invokes
CopilotSession ..> SessionEvent : emits
CopilotClient ..> SessionFsProvider : wraps via adapter
Session Lifecycle & Event Pump
sequenceDiagram
participant App
participant Client as CopilotClient
participant CLI as copilot subprocess
participant Session as CopilotSession
App->>Client: new CopilotClient(opts)
App->>Client: await client.createSession(cfg)
Client->>CLI: spawn if needed
Client->>CLI: JSON-RPC: getStatus
CLI-->>Client: protocolVersion=3, capabilities
Client->>CLI: JSON-RPC: session.create
CLI-->>Client: sessionId + session capabilities
Client-->>App: Session instance
App->>Session: session.on(handler)
App->>Session: await session.sendAndWait({prompt})
Session->>Session: pre-register idle/error listener
Session->>CLI: session.send
loop agent turn
CLI-->>Session: event: assistant.message (partial)
Session-->>App: handler fires
CLI->>Session: RPC: session/toolInvoke
Session->>App: tool handler runs
App-->>Session: result
Session-->>CLI: RPC reply
end
CLI-->>Session: event: session.idle
Session-->>App: resolve sendAndWait promise
App->>Client: await client.stop()
Client->>CLI: JSON-RPC: shutdown
Client->>CLI: SIGTERM if no graceful exit
🚶 Step-by-Step Narrated Walkthrough
Follow a single session.sendAndWait({prompt: "weather?"}) call with one tool invocation:
- Client instantiation —
new CopilotClient(options)validates inputs but does no I/O. No process yet. - Session creation (lazy start) —
createSessiontriggersstart()if not already started. Spawn path:createRequireresolves@github/copilot→spawn(cliPath, ['serve','--stdio']). WireStreamMessageReader/WriterintocreateMessageConnection. - Protocol handshake —
getStatusRPC. CompareprotocolVersionagainstMIN_PROTOCOL_VERSION = 2; reject if lower. Cache negotiated version for capability gates. - Register server→client handlers —
registerClientSessionApiHandlersinstalls shims for tool dispatch, permission, elicitation, and SessionFs ops. These handlers dispatch by session ID to the right Session instance. - session.create RPC — sends serialisable config. System-message callbacks are stripped and stored in a side-map; wire sends
{action:"transform"}stubs. - Listener pre-registration (race fix) —
sendAndWaitattaches itssession.idlelistener before callingsend. Comment insession.tsL231-243 explains this. - session.send RPC — returns a
messageId. The agent turn is now running in the CLI. - Event stream — assistant.message (partial) — CLI emits streaming chunks; your
on()handlers fire for each. ThesendAndWaitlistener captures the last one. - Tool invocation (server→client RPC) — CLI's LLM decided to call
get_weather.session/toolInvokeRPC arrives. SDK looks up the session, thentoolHandlers.get("get_weather"). - Hook chain — if
hooks.preToolUseis set, invoke it first. If it denies, RPC replies with error. If it modifies args, the modified set goes to the next stage. - Permission gate — if the tool is permission-gated (or the policy requires it),
permissionHandlerruns. Can returnapproved,denied, or (on v2+)modified. - User handler — awaits the user's
(args) => result. Exceptions are caught and become{isError:true, content:...}results. - Post-hook + reply —
hooks.postToolUsecan rewrite the result. RPC responds. - session.idle event — agent loop complete.
sendAndWait's idlePromise resolves with the captured last assistant message. Listener unsubscribes infinally.
Key Components
| File | Export | Role (WHY it exists) |
|---|---|---|
src/client.ts | CopilotClient | Owns the CLI subprocess + JSON-RPC connection. Exists separately from Session because multiple sessions can share one CLI. |
src/session.ts | CopilotSession | One conversation. Holds event handlers, tool/permission/hook callbacks. Separate class so per-session state (tools) doesn't leak across sessions on the same client. |
src/types.ts | 80+ exports | Single types file (1735 lines) avoids circular imports. All public types re-exported from index.ts one-by-one for stable public API. |
src/sessionFsProvider.ts | createSessionFsAdapter | Adapter that wraps a user SessionFsProvider into the generated SessionFsHandler shape. Translates Node errors (ENOENT, etc.) into structured SessionFSError. |
src/telemetry.ts | getTraceContext | Pulls W3C Trace Context from a user-supplied provider and attaches traceparent/tracestate headers to outbound RPCs. Thin by design — OpenTelemetry is not a dependency. |
src/sdkProtocolVersion.ts | getSdkProtocolVersion | 19-line getter for the integer in sdk-protocol-version.json. Own module so other files can mock it in tests. |
src/extension.ts | (internal) | 44-line file — appears to be the seed of a VS Code extension integration. Not in the public surface. |
src/generated/rpc.ts | createServerRpc, createSessionRpc, registerClientSessionApiHandlers | Codegen'd. Factory fns wrap the raw MessageConnection in typed method objects. |
src/generated/session-events.ts | SessionEvent union + subtypes | Codegen'd. 40+ event variants discriminated by type field. |
src/index.ts | Re-exports 80+ public names | Stable public API surface. Adding a new type = new re-export here. |
samples/chat.ts | (executable) | Interactive chat loop — the "hello world". |
examples/basic-example.ts | (executable) | Minimal API surface walkthrough. |
⚡ Algorithm Spotlight
sendAndWait race-free idle detection
Problem. A fast-returning LLM can emit session.idle before the caller attaches a listener. Pre-registering the listener before sending the RPC closes the gap.
Chosen over: polling isIdle (misses transient states); synchronous subscribe inside the send RPC (vscode-jsonrpc doesn't cleanly support this). Complexity: O(1) per event.
// Register event handler BEFORE calling send to avoid race condition
// where session.idle fires before we start listening
const unsubscribe = this.on((event) => {
if (event.type === "assistant.message") {
lastAssistantMessage = event;
} else if (event.type === "session.idle") {
resolveIdle();
} else if (event.type === "session.error") {
const error = new Error(event.data.message);
error.stack = event.data.stack;
rejectWithError(error);
}
});
// ... now safe to send
await this.send(options);
System-message transform wire trick
User-supplied functions can't cross JSON-RPC. So extractTransformCallbacks replaces each function with {action:"transform"}, stores the originals in a Map, and when the server round-trips a sections.transform RPC, the stored callback runs and the result is returned.
Protocol version negotiation
On connect, getStatus returns {protocolVersion, capabilities}. Client checks protocolVersion >= MIN_PROTOCOL_VERSION (2); lower rejects with an error. Cached negotiatedProtocolVersion drives capability gates like NO_RESULT_PERMISSION_V2_ERROR (the no-result permission return value is only legal on v2+).
⚠ What Could Go Wrong
| Failure | How Detected | Recovery Strategy | Cascading Effects |
|---|---|---|---|
| CLI binary not found | createRequire.resolve throws | Fall back to options.cliPath; error thrown on start() if still missing | Client construction proceeds but any RPC fails |
| CLI process crashes mid-session | stdout EOF detected by reader | All pending sessions receive session.error; client state moves to Disconnected | User must call stop() then recreate client |
| Protocol mismatch (server older than SDK) | getStatus returns protocolVersion < 2 | Client throws immediately on start | Fail-fast — avoids silent feature degradation |
| Tool handler throws | Caught by server→client RPC wrapper | Converted to {isError:true, content:String(err)}; LLM sees the error and can apologise/retry | Agent turn continues; session stays alive |
| Permission handler returns invalid kind on v1 server | Version-gated check in session.ts | Throws NO_RESULT_PERMISSION_V2_ERROR | User must check session.capabilities before using v2-only return values |
| Listener never unregistered | Not detected | Closure retains session state; use returned unsubscribe function | Memory growth; sendAndWait does this correctly in finally |
📊 Performance Profile
The hot path is the event pump: a single Set.forEach(h => h(event)) per event, O(handlers) per dispatch. No allocation beyond the event object itself. Session creation is one RPC round-trip (~10ms localhost) plus subprocess spawn on first call (~50-100ms, amortised across later sessions on the same client). The bottleneck for large workloads is JSON serialisation of tool results — no streaming-tool-results protocol exists (would require a protocol extension). Memory per idle session is ~1 MB (event handler sets + tools map + capability cache); 10 sessions with 20 tools each = 200 handler closures but still well under 10 MB total.
What was optimised for: correctness and type safety. What was sacrificed: throughput for tool-heavy workloads (each tool invocation is a JSON-RPC round trip, so ~100 tool calls/sec is the practical ceiling per session).
Deep-Dive Highlights
Core mechanisms extracted into dedicated focus pages.
JSON-RPC Transport Focus
Content-Length framing, bidirectional dispatch, stdio vs TCP — how all four SDKs talk to the CLI.
Protocol Version Negotiation Focus
How SDK and CLI agree on protocol version and capabilities. What happens on mismatch.
Schema-Driven Codegen Focus
How scripts/codegen/ turns one JSON Schema into four language binding files.
Configuration Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
cliPath | string | bundled via npm | Override the Copilot CLI binary path |
cliUrl | string | — | Connect to an external CLI server (skip spawn) |
model | string | CLI default | Model for sessions created by this client |
enableConfigDiscovery | boolean | false | Auto-load .mcp.json and skill dirs from CWD |
onPermissionRequest | PermissionHandler | deny-all | Gate for permission-gated tools |
tools | Tool[] | [] | Custom tools available to the session |
commands | CommandDefinition[] | [] | Slash-command handlers |
hooks | SessionHooks | — | preToolUse / postToolUse / userPromptSubmitted / sessionLifecycle |
systemMessage | SystemMessageConfig | CLI default | replace / customize / transform modes |
sessionFs | SessionFsConfig | — | Virtual filesystem provider |
reasoningEffort | "low"|"medium"|"high" | model default | Reasoning budget for reasoning-capable models |
traceContextProvider | TraceContextProvider | — | W3C Trace Context for telemetry correlation |
telemetry | TelemetryConfig | CLI default | Opt in/out of CLI telemetry |
onElicitationRequest | ElicitationHandler | — | Interactive UI callbacks (confirm/select/input) |
Code Examples
Minimal client & session
import { CopilotClient, approveAll } from "@github/copilot-sdk";
const client = new CopilotClient({ model: "gpt-4" });
const session = await client.createSession({
onPermissionRequest: approveAll,
});
session.on((event) => {
if (event.type === "assistant.message") {
console.log(event.data.content);
}
});
const final = await session.sendAndWait({ prompt: "Hello, world!" });
console.log("final:", final?.data.content);
await client.stop();
Custom tool with Zod
import { defineTool } from "@github/copilot-sdk";
import { z } from "zod";
const getWeather = defineTool({
name: "get_weather",
description: "Get current weather for a city",
parameters: z.object({
city: z.string(),
unit: z.enum(["C", "F"]).optional(),
}),
handler: async ({ city, unit }) => {
return `Cloudy, 62°${unit ?? "F"} in ${city}`;
},
});
const session = await client.createSession({
tools: [getWeather],
onPermissionRequest: approveAll,
});
sendAndWait signature (from session.ts)
async sendAndWait(
options: MessageOptions,
timeout?: number
): Promise<AssistantMessageEvent | undefined> {
const effectiveTimeout = timeout ?? 60_000;
// ... pre-registers idle/error listener before send (race fix)
await this.send(options);
await Promise.race([idlePromise, timeoutPromise]);
// ...
return lastAssistantMessage;
}
Public surface (from index.ts)
export { CopilotClient } from "./client.js";
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
export {
defineTool,
approveAll,
convertMcpCallToolResult,
createSessionFsAdapter,
SYSTEM_PROMPT_SECTIONS,
} from "./types.js";
// + 80 individual type re-exports
Directory Map
nodejs/ source tree
nodejs/
├── src/
│ ├── client.ts ← CopilotClient — process & connection lifecycle
│ ├── session.ts ← CopilotSession — one conversation + event pump
│ ├── types.ts ← All public types (1735 lines)
│ ├── sessionFsProvider.ts ← SessionFs adapter + error translation
│ ├── telemetry.ts ← W3C Trace Context plumbing
│ ├── sdkProtocolVersion.ts ← Embedded protocol version constant
│ ├── extension.ts ← VS Code extension seed
│ ├── index.ts ← Public re-exports
│ └── generated/
│ ├── rpc.ts ← Codegen'd RPC client/server factories
│ └── session-events.ts ← Codegen'd SessionEvent union (40+ types)
├── samples/
│ └── chat.ts ← Interactive chat demo
├── examples/
│ └── basic-example.ts ← Minimal walkthrough
├── scripts/
│ └── update-protocol-version.ts← Sync sdk-protocol-version.json
├── test/e2e/ ← End-to-end tests
├── docs/ ← Per-SDK docs fragments
├── esbuild-copilotsdk-nodejs.ts ← Build pipeline
└── package.json