nodejs — TypeScript / Node.js SDK

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

98
.ts files
1990
client.ts lines
1097
session.ts lines
1735
types.ts lines
88
Exports from index.ts

📚 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

DecisionChoice MadeAlternativesWhy This Approach
JSON-RPC libraryvscode-jsonrpcHand-rolled, json-rpc-2.0 npmBattle-tested in millions of VS Code extensions; LSP-style Content-Length framing out of the box; cancellation tokens native
Tool parameter specAccept JSON Schema or ZodForce JSON Schema onlyZod is the de-facto TS validation lib; the toJSONSchema probe keeps Zod a truly optional dependency
Event deliveryCustom Set<Handler> + Map<EventType, Set>Node EventEmitterEventEmitter leaks listeners silently; custom impl returns typed unsubscribe fns and enforces discriminated-union typing
Module formatESM with .js extensions in importsCJS, dual packageModern Node default; avoids "dual package hazard"; forces users to modern tooling
System-message transformsWire stub + side-map of callbacksPre-expand on client; RPC with closuresFunctions can't be serialised; pre-expanding defeats the server-driven substitution point
Bundled-CLI resolutioncreateRequire(import.meta.url).resolve('@github/copilot')Env var; hard-coded pathUses Node's own resolver; works in nested node_modules layouts; respects peerDeps
Types organisationSingle types.ts (1735 lines)Per-domain filesAvoids 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:

  1. Client instantiation — new CopilotClient(options) validates inputs but does no I/O. No process yet.
  2. Session creation (lazy start) — createSession triggers start() if not already started. Spawn path: createRequire resolves @github/copilot → spawn(cliPath, ['serve','--stdio']). Wire StreamMessageReader/Writer into createMessageConnection.
  3. Protocol handshake — getStatus RPC. Compare protocolVersion against MIN_PROTOCOL_VERSION = 2; reject if lower. Cache negotiated version for capability gates.
  4. Register server→client handlers — registerClientSessionApiHandlers installs shims for tool dispatch, permission, elicitation, and SessionFs ops. These handlers dispatch by session ID to the right Session instance.
  5. session.create RPC — sends serialisable config. System-message callbacks are stripped and stored in a side-map; wire sends {action:"transform"} stubs.
  6. Listener pre-registration (race fix) — sendAndWait attaches its session.idle listener before calling send. Comment in session.ts L231-243 explains this.
  7. session.send RPC — returns a messageId. The agent turn is now running in the CLI.
  8. Event stream — assistant.message (partial) — CLI emits streaming chunks; your on() handlers fire for each. The sendAndWait listener captures the last one.
  9. Tool invocation (server→client RPC) — CLI's LLM decided to call get_weather. session/toolInvoke RPC arrives. SDK looks up the session, then toolHandlers.get("get_weather").
  10. Hook chain — if hooks.preToolUse is set, invoke it first. If it denies, RPC replies with error. If it modifies args, the modified set goes to the next stage.
  11. Permission gate — if the tool is permission-gated (or the policy requires it), permissionHandler runs. Can return approved, denied, or (on v2+) modified.
  12. User handler — awaits the user's (args) => result. Exceptions are caught and become {isError:true, content:...} results.
  13. Post-hook + reply — hooks.postToolUse can rewrite the result. RPC responds.
  14. session.idle event — agent loop complete. sendAndWait's idlePromise resolves with the captured last assistant message. Listener unsubscribes in finally.

Key Components

FileExportRole (WHY it exists)
src/client.tsCopilotClientOwns the CLI subprocess + JSON-RPC connection. Exists separately from Session because multiple sessions can share one CLI.
src/session.tsCopilotSessionOne 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.ts80+ exportsSingle types file (1735 lines) avoids circular imports. All public types re-exported from index.ts one-by-one for stable public API.
src/sessionFsProvider.tscreateSessionFsAdapterAdapter that wraps a user SessionFsProvider into the generated SessionFsHandler shape. Translates Node errors (ENOENT, etc.) into structured SessionFSError.
src/telemetry.tsgetTraceContextPulls 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.tsgetSdkProtocolVersion19-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.tscreateServerRpc, createSessionRpc, registerClientSessionApiHandlersCodegen'd. Factory fns wrap the raw MessageConnection in typed method objects.
src/generated/session-events.tsSessionEvent union + subtypesCodegen'd. 40+ event variants discriminated by type field.
src/index.tsRe-exports 80+ public namesStable 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

FailureHow DetectedRecovery StrategyCascading Effects
CLI binary not foundcreateRequire.resolve throwsFall back to options.cliPath; error thrown on start() if still missingClient construction proceeds but any RPC fails
CLI process crashes mid-sessionstdout EOF detected by readerAll pending sessions receive session.error; client state moves to DisconnectedUser must call stop() then recreate client
Protocol mismatch (server older than SDK)getStatus returns protocolVersion < 2Client throws immediately on startFail-fast — avoids silent feature degradation
Tool handler throwsCaught by server→client RPC wrapperConverted to {isError:true, content:String(err)}; LLM sees the error and can apologise/retryAgent turn continues; session stays alive
Permission handler returns invalid kind on v1 serverVersion-gated check in session.tsThrows NO_RESULT_PERMISSION_V2_ERRORUser must check session.capabilities before using v2-only return values
Listener never unregisteredNot detectedClosure retains session state; use returned unsubscribe functionMemory 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.

Cross-module focus page

Protocol Version Negotiation Focus

How SDK and CLI agree on protocol version and capabilities. What happens on mismatch.

Cross-module focus page

Schema-Driven Codegen Focus

How scripts/codegen/ turns one JSON Schema into four language binding files.

Cross-module focus page

Configuration Reference

OptionTypeDefaultPurpose
cliPathstringbundled via npmOverride the Copilot CLI binary path
cliUrlstring—Connect to an external CLI server (skip spawn)
modelstringCLI defaultModel for sessions created by this client
enableConfigDiscoverybooleanfalseAuto-load .mcp.json and skill dirs from CWD
onPermissionRequestPermissionHandlerdeny-allGate for permission-gated tools
toolsTool[][]Custom tools available to the session
commandsCommandDefinition[][]Slash-command handlers
hooksSessionHooks—preToolUse / postToolUse / userPromptSubmitted / sessionLifecycle
systemMessageSystemMessageConfigCLI defaultreplace / customize / transform modes
sessionFsSessionFsConfig—Virtual filesystem provider
reasoningEffort"low"|"medium"|"high"model defaultReasoning budget for reasoning-capable models
traceContextProviderTraceContextProvider—W3C Trace Context for telemetry correlation
telemetryTelemetryConfigCLI defaultOpt in/out of CLI telemetry
onElicitationRequestElicitationHandler—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