Architecture & Design Patterns
This document dissects the architecture of the copilot-sdk polyglot repository (TypeScript Python Go C#). Each SDK communicates with a Copilot CLI server process over JSON-RPC 2.0 to provide a session-based conversational AI interface with tool execution, permission management, and event streaming.
1. Overall Architecture Style
Process-Communication Model (IPC via JSON-RPC)
The fundamental architecture is a two-process client-server model where the SDK (library code in the host application) communicates with a separate Copilot CLI server process over JSON-RPC 2.0.
flowchart LR
A["SDK Client\n(CopilotClient)\nSession(s)"] <-->|"JSON-RPC 2.0\nstdio / TCP"| B["Copilot CLI\nServer Process"]
Transport options (all languages):
- stdio (default): The SDK spawns the CLI as a child process and communicates over stdin/stdout pipes using Content-Length-framed JSON-RPC messages.
- TCP: The SDK either spawns a CLI process listening on a TCP port or connects to a pre-existing external server via
cliUrl. - Child-process mode (TypeScript): The SDK itself runs as a child of the CLI, reversing the parent-child relationship (
isChildProcess: true).
// client.ts, lines 20-25
import {
createMessageConnection,
MessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node.js";
# jsonrpc.py, lines 177-191
async def _send_message(self, message: dict):
"""Send a JSON-RPC message with Content-Length header"""
def write():
content = json.dumps(message, separators=(",", ":"))
content_bytes = content.encode("utf-8")
header = f"Content-Length: {len(content_bytes)}\r\n\r\n"
with self._write_lock:
self.process.stdin.write(header.encode("utf-8"))
self.process.stdin.write(content_bytes)
self.process.stdin.flush()
await loop.run_in_executor(None, write)
The Client does not implement AI logic. All model inference, tool orchestration, and conversation state management live in the CLI server. The SDK is a thin typed wrapper for the JSON-RPC protocol.
2. Design Patterns Found
flowchart LR
A["<b>7</b><br/>Design Patterns Identified"] ~~~ B["<b>4</b><br/>Languages Implementing Each"] ~~~ C["<b>100%</b><br/>Cross-Language Consistency"]
2.1 Factory Pattern
Sessions are never constructed directly by user code. The Client acts as a Factory for Session objects.
flowchart TD
A[CopilotClient] -->|"createSession()"| B["new Session()"]
B --> C["registerTools()"]
C --> D["registerPermissions()"]
D -->|"sessions.set(id, session)"| E["RPC: session.create"]
// client.ts — createSession method
// Session is created, registered, and then the RPC call is made
const session = new CopilotSession(sessionId, this.connection);
session.registerTools(config.tools);
session.registerPermissionHandler(config.onPermissionRequest);
this.sessions.set(sessionId, session);
// Then RPC...
const response = await this.connection.sendRequest("session.create", payload);
// client.go — CreateSession, line ~539
session := newSession(sessionID, c.client, "")
session.registerTools(config.Tools)
session.registerPermissionHandler(config.OnPermissionRequest)
c.sessions[sessionID] = session
result, err := c.client.Request("session.create", req)
In all four SDKs, the session is registered in the local map before the session.create RPC is issued. This prevents a race condition where early events from the CLI (like session.start) could arrive before the session is registered.
2.2 Observer Pattern
The event system in Session is a classic Observer (publish-subscribe) pattern. Multiple handlers can subscribe; events are multicast to all.
flowchart TD
A[Session Event] -->|dispatch| B[Handler 1]
A -->|dispatch| C[Handler 2]
A -->|dispatch| D[Handler 3]
A -->|dispatch| E["Handler N..."]
B -->|returns| F[Unsubscribe Token]
C -->|returns| F
D -->|returns| F
E -->|returns| F
// session.ts, lines 60-62, 257-284
private eventHandlers: Set<SessionEventHandler> = new Set();
private typedEventHandlers: Map<SessionEventType, Set<(event: SessionEvent) => void>> = new Map();
on<K extends SessionEventType>(
eventTypeOrHandler: K | SessionEventHandler,
handler?: TypedSessionEventHandler<K>
): () => void { ... }
// session.go, lines 237-257
func (s *Session) On(handler SessionEventHandler) func() {
s.handlerMutex.Lock()
defer s.handlerMutex.Unlock()
id := s.nextHandlerID
s.nextHandlerID++
s.handlers = append(s.handlers, sessionHandler{id: id, fn: handler})
return func() { /* unsubscribe by id */ }
}
// Session.cs, lines 61, 260-264
// Uses C# multicast delegates for zero-allocation dispatch
private event SessionEventHandler? EventHandlers;
public IDisposable On(SessionEventHandler handler)
{
EventHandlers += handler;
return new ActionDisposable(() => EventHandlers -= handler);
}
# session.py, lines 212-246
def on(self, handler: Callable[[SessionEvent], None]) -> Callable[[], None]:
with self._event_handlers_lock:
self._event_handlers.add(handler)
def unsubscribe():
with self._event_handlers_lock:
self._event_handlers.discard(handler)
return unsubscribe
Unsubscribe mechanism: All four SDKs return a callable/disposable that removes the handler, following the “subscription token” variant of Observer.
2.3 Strategy Pattern
Permission handling and tool execution use the Strategy pattern. The caller provides a handler function (strategy), and the SDK invokes it at the appropriate time.
// types.ts, lines 246-251
export type PermissionHandler = (
request: PermissionRequest,
invocation: { sessionId: string }
) => Promise<PermissionRequestResult> | PermissionRequestResult;
export const approveAll: PermissionHandler = () => ({ kind: "approved" });
// permissions.go, lines 1-11
var PermissionHandler = struct {
ApproveAll PermissionHandlerFunc
}{
ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) {
return PermissionRequestResult{Kind: PermissionRequestResultKindApproved}, nil
},
}
2.4 Decorator Pattern (Python)
The Python SDK provides a define_tool decorator that wraps user functions into the standard Tool structure with automatic Pydantic schema generation.
# tools.py, lines 43-166
def define_tool(
name: str | None = None,
*,
description: str | None = None,
handler: Callable[[Any, ToolInvocation], Any] | None = None,
params_type: type[BaseModel] | None = None,
) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]:
def decorator(fn: Callable[..., Any]) -> Tool:
# ... introspects signature, generates schema, wraps handler
async def wrapped_handler(invocation: ToolInvocation) -> ToolResult:
# ... validates, calls fn, normalizes result
return Tool(name=tool_name, ...)
return decorator
2.5 Adapter Pattern (Go Generics)
Go uses generics to adapt user handler functions to the standard ToolHandler interface.
// definetool.go, lines 30-40
func DefineTool[T any, U any](name, description string, handler func(T, ToolInvocation) (U, error)) Tool {
var zero T
schema := generateSchemaForType(reflect.TypeOf(zero))
return Tool{
Name: name, Description: description,
Parameters: schema,
Handler: createTypedHandler(handler),
}
}
The createTypedHandler function (line 43) adapts func(T, ToolInvocation) (U, error) into func(ToolInvocation) (ToolResult, error) via JSON round-trip deserialization.
2.6 Proxy / Facade Pattern (Generated RPC)
The generated rpc.ts / rpc.py / rpc/ modules provide a typed facade over the raw JSON-RPC connection. They abstract connection.sendRequest("session.model.switchTo", {...}) into session.rpc.model.switchTo({modelId: "gpt-4"}).
// generated/rpc.ts, lines 540-598
export function createSessionRpc(connection: MessageConnection, sessionId: string) {
return {
model: {
getCurrent: async (): Promise<SessionModelGetCurrentResult> =>
connection.sendRequest("session.model.getCurrent", { sessionId }),
switchTo: async (params) =>
connection.sendRequest("session.model.switchTo", { sessionId, ...params }),
},
// ... plan, workspace, fleet, agent, compaction, tools, permissions, log
};
}
2.7 Template Method Pattern (sendAndWait)
sendAndWait implements a fixed algorithm: subscribe to events → send message → wait for idle/error → return result. Each SDK follows this identical template.
flowchart LR
A["Subscribe\non(event)"] --> B["Send\nsend(prompt)"]
B --> C["Wait\nidle | error"]
C --> D["Return\nresult"]
TypeScript (session.ts, lines 154-207), Python (session.py, lines 153-210), Go (session.go, lines 160-213), C# (Session.cs, lines 183-226) all follow this exact sequence.
3. Client Lifecycle Pattern
Connection State Machine
All four SDKs implement the same connection state machine:
stateDiagram-v2
[*] --> disconnected
disconnected --> connecting : start()
connecting --> connected : success
connecting --> error : failure
connected --> disconnected : stop() / crash
error --> disconnected : reset
// types.ts, line 917
export type ConnectionState = "disconnected" | "connecting" | "connected" | "error";
// Types.cs, lines 18-33
public enum ConnectionState
{
Disconnected, Connecting, Connected, Error
}
Start Sequence (all SDKs)
- Resolve CLI path (bundled binary, explicit path, or environment variable)
- Spawn child process with
--sdk-protocol-version=N(or connect to external server) - Establish JSON-RPC connection (stdio reader/writer or TCP socket)
- Protocol version negotiation via
pingrequest - Transition to
connected
Protocol Version Negotiation
# client.py — _verify_protocol_version
MIN_PROTOCOL_VERSION = 2
# ... during start():
await self._verify_protocol_version()
# Sends ping, checks server protocolVersion >= MIN_PROTOCOL_VERSION
# Stores negotiated version for feature-gating
// client.go — verifyProtocolVersion
// Sends ping, checks protocolVersion, negotiates to min(sdk, server)
Stop Sequence (all SDKs)
- Disconnect all active sessions (sends
session.destroyfor each) - Close JSON-RPC connection
- Terminate CLI process (if spawned)
- Clear caches and reset state to
disconnected
Auto-start and Auto-restart
All SDKs support autoStart (default true): the first createSession call automatically calls start() if disconnected. autoRestart handles CLI process crashes.
Dispose Protocol
flowchart LR
subgraph TS["TypeScript"]
TS1["Symbol.asyncDispose"]
TS2["await using"]
end
subgraph PY["Python"]
PY1["__aenter__ / __aexit__"]
PY2["async with"]
end
subgraph CS["C#"]
CS1["IAsyncDisposable"]
CS2["await using"]
end
subgraph GO["Go"]
GO1["Explicit call"]
GO2["defer client.Stop"]
end
4. Session State Machine
Sessions have an implicit state model driven by events from the CLI server:
stateDiagram-v2
[*] --> IDLE : create / resume
IDLE --> PROCESSING : send()
PROCESSING --> IDLE : session.idle
PROCESSING --> IDLE : session.abort
PROCESSING --> ERROR : session.error
ERROR --> IDLE : retry
Session Event Flow
When send() is called, the CLI processes the prompt, invokes tools, requests permissions, and eventually emits session.idle (or session.error). The sendAndWait convenience method bridges this into a synchronous-looking call by subscribing to events before sending:
// session.ts, lines 169-206
// Register event handler BEFORE calling send to avoid race condition
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") {
rejectWithError(new Error(event.data.message));
}
});
await this.send(options);
await Promise.race([idlePromise, timeoutPromise]);
Session Resource Cleanup
disconnect() performs:
- RPC call
session.destroyto notify CLI - Clear all event handlers, tool handlers, permission handler
- Session object is invalidated (but disk state is preserved for resumption)
5. Event-Driven Architecture
Event Flow
flowchart TD
CLI["CLI Server"] -->|"notification: session.event"| Client["SDK Client"]
Client --> dispatch["_dispatchEvent(event)"]
dispatch --> step1["handleBroadcastEvent()"]
dispatch --> step2["typedHandlers[event.type]"]
dispatch --> step3["wildcardHandlers"]
step1 -->|"tool / permission requests"| response["RPC Response back to CLI"]
Notification Routing
The Client receives JSON-RPC notifications from the CLI, identifies the target session by sessionId, and calls session._dispatchEvent(event).
Broadcast Events (Protocol v3)
Tool call requests and permission requests are broadcast as session events (not as synchronous RPC requests). The SDK intercepts these before user handlers see them:
// session.ts, lines 325-349
private _handleBroadcastEvent(event: SessionEvent): void {
if (event.type === "external_tool.requested") {
const handler = this.toolHandlers.get(toolName);
if (handler) {
void this._executeToolAndRespond(requestId, toolName, toolCallId, args, handler);
}
} else if (event.type === "permission.requested") {
if (this.permissionHandler) {
void this._executePermissionAndRespond(requestId, permissionRequest);
}
}
}
// session.go, lines 468-497
func (s *Session) handleBroadcastEvent(event SessionEvent) {
switch event.Type {
case ExternalToolRequested:
handler, ok := s.getToolHandler(*toolName)
if !ok { return }
go s.executeToolAndRespond(...) // fire-and-forget goroutine
case PermissionRequested:
handler := s.getPermissionHandler()
if handler == nil { return }
go s.executePermissionAndRespond(...)
}
}
Event Types
The event system defines a comprehensive set of event types (discriminated union on type), from generated session-events.ts:
flowchart LR
subgraph SL["Session Lifecycle"]
SL1["session.start"]
SL2["session.resume"]
SL3["session.idle"]
SL4["session.error"]
SL5["session.end"]
end
subgraph MSG["Messages"]
MSG1["user.message"]
MSG2["assistant.message"]
MSG3["assistant.message_delta"]
MSG4["assistant.reasoning_delta"]
end
subgraph TL["Tools"]
TL1["tool.executing"]
TL2["tool.completed"]
TL3["tool.aborted"]
TL4["tool.error"]
TL5["external_tool.requested"]
end
subgraph OTH["Other"]
OTH1["permission.requested"]
OTH2["compaction.start"]
OTH3["compaction.complete"]
OTH4["sdk.log"]
end
Each event carries common fields: id, timestamp, parentId, ephemeral, type, data.
6. JSON-RPC Protocol Layer
Message Format
All transports use the Language Server Protocol framing: Content-Length: N\r\n\r\n{JSON}.
Request/Response Pattern
flowchart LR
SDK[SDK Client] -->|requests| CLI[CLI Server]
CLI -->|notifications| SDK
CLI -->|"requests (v2)"| SDK
SDK → CLI (requests)
| Category | Methods |
|---|---|
| Session | session.create, session.resume, session.destroy, session.delete |
| Messaging | session.send, session.abort, session.getMessages |
| Configuration | session.model.switchTo, session.mode.set, session.plan.read |
| Server | ping, status.get, auth.getStatus, models.list |
CLI → SDK (notifications)
session.event— carries all session events (broadcast model)session.lifecycle— session lifecycle changes (create/delete/update)
CLI → SDK (requests, protocol v2 back-compat)
tool.call— synchronous tool call requestsession.requestPermission— synchronous permission requestsession.requestUserInput— synchronous user input requestsession.hooks.invoke— synchronous hook invocation
Custom JSON-RPC Implementation (Python)
Python implements a minimal JSON-RPC 2.0 client from scratch rather than using a library:
# jsonrpc.py, lines 36-43
class JsonRpcClient:
"""Minimal async JSON-RPC 2.0 client for stdio transport.
Uses threads for blocking IO but provides async interface."""
def __init__(self, process):
self.pending_requests: dict[str, asyncio.Future] = {}
self.notification_handler: Callable[[str, dict], None] | None = None
self.request_handlers: dict[str, RequestHandler] = {}
The read loop runs in a daemon thread and uses loop.call_soon_threadsafe() to resolve futures on the asyncio event loop (lines 193-211, 289-312).
Library Dependencies per Language
| Language | JSON-RPC Library | Transport |
|---|---|---|
| TypeScript | vscode-jsonrpc |
StreamReader/Writer |
| Python | Custom (jsonrpc.py) | subprocess pipes |
| Go | Custom (internal/jsonrpc2) | bufio reader/writer |
| C# | StreamJsonRpc |
Nerdbank streams |
7. Code Generation Pattern
Schema-Driven Generation
Two JSON schema files are the single source of truth:
- api.schema.json — defines all RPC methods, their parameters, and results
- session-events.schema.json — defines all session event types and their data payloads
flowchart TD
S1[api.schema.json] --> CG[codegen scripts]
S2[session-events.schema.json] --> CG
CG --> TS["TypeScript\ngenerated/rpc.ts\ngenerated/session-events.ts"]
CG --> PY["Python\ngenerated/rpc.py\ngenerated/session_events.py"]
CG --> GO["Go\nrpc/ package"]
CG --> CS["C#\nRpc/ directory"]
Generated RPC Layer
The generated RPC module provides type-safe wrappers at two scopes:
Server-scoped (no session required)
// generated/rpc.ts, lines 520-537
export function createServerRpc(connection: MessageConnection) {
return {
ping: async (params: PingParams): Promise<PingResult> =>
connection.sendRequest("ping", params),
models: { list: async () => ... },
tools: { list: async (params) => ... },
account: { getQuota: async () => ... },
};
}
Session-scoped (auto-injects sessionId)
// generated/rpc.ts, lines 540-599
export function createSessionRpc(connection: MessageConnection, sessionId: string) {
return {
model: { getCurrent, switchTo },
mode: { get, set },
plan: { read, update, delete },
workspace: { listFiles, readFile, createFile },
fleet: { start },
agent: { list, getCurrent, select, deselect },
compaction: { compact },
tools: { handlePendingToolCall },
permissions: { handlePendingPermissionRequest },
log,
};
}
Generated Event Types
The session events schema generates a discriminated union type. In TypeScript, this is a large union of object types discriminated on type. In Python, it generates dataclass-like structures with a session_event_from_dict() factory. In C#, it generates a class hierarchy with a SessionEvent.FromJson() parser.
// generated/session-events.ts, line 6
export type SessionEvent =
| { type: "session.start"; data: { sessionId: string; version: number; ... } }
| { type: "session.resume"; data: { resumeTime: string; ... } }
| { type: "assistant.message"; data: { content: string; ... } }
// ... 20+ event types
Lazy RPC Initialization
All four SDKs lazily initialize the typed RPC wrapper on first access:
// session.ts, lines 86-91
get rpc(): ReturnType<typeof createSessionRpc> {
if (!this._rpc) {
this._rpc = createSessionRpc(this.connection, this.sessionId);
}
return this._rpc;
}
// Session.cs, line 80
public SessionRpc Rpc => _sessionRpc ??= new SessionRpc(_rpc, SessionId);
8. Cross-Language Consistency
Identical API Surface
All four SDKs expose the same public API shape:
| Concept | TypeScript | Python | Go | C# |
|---|---|---|---|---|
| Client class | CopilotClient |
CopilotClient |
Client |
CopilotClient |
| Create | createSession() |
create_session() |
CreateSession() |
CreateSessionAsync() |
| Resume | resumeSession() |
resume_session() |
ResumeSession() |
ResumeSessionAsync() |
| Session class | CopilotSession |
CopilotSession |
Session |
CopilotSession |
| Send | send() |
send() |
Send() |
SendAsync() |
| Send+wait | sendAndWait() |
send_and_wait() |
SendAndWait() |
SendAndWaitAsync() |
| Subscribe | on(handler) |
on(handler) |
On(handler) |
On(handler) |
| Disconnect | disconnect() |
disconnect() |
Disconnect() |
DisposeAsync() |
| Typed RPC | session.rpc.* |
session.rpc.* |
session.RPC.* |
session.Rpc.* |
| Define tool | defineTool() |
@define_tool |
DefineTool[T,U]() |
AIFunctionFactory (MEAI) |
| Approve all | approveAll |
PermissionHandler.approve_all |
PermissionHandler.ApproveAll |
PermissionHandler.ApproveAll |
Idiomatic Adaptations
While the API surface is consistent, each SDK uses language-idiomatic patterns:
Concurrency
flowchart LR
subgraph TS["TypeScript"]
TS1["Promises"]
TS2["async/await"]
end
subgraph PY["Python"]
PY1["asyncio"]
PY2["Thread-based I/O bridge"]
PY3["run_in_executor"]
end
subgraph GO["Go"]
GO1["goroutines"]
GO2["channels"]
GO3["sync.RWMutex"]
end
subgraph CS["C#"]
CS1["Task<T>"]
CS2["async/await"]
CS3["CancellationToken"]
end
Unsubscribe Mechanism
flowchart LR
subgraph TS["TypeScript"]
TS1["Returns () => void"]
end
subgraph PY["Python"]
PY1["Returns Callable[[], None]"]
end
subgraph GO["Go"]
GO1["Returns func()"]
end
subgraph CS["C#"]
CS1["Returns IDisposable"]
end
Thread Safety
flowchart LR
subgraph TS["TypeScript"]
TS1["Single-threaded (event loop)"]
TS2["No locks needed"]
end
subgraph PY["Python"]
PY1["threading.Lock"]
PY2["For handler collections"]
end
subgraph GO["Go"]
GO1["sync.RWMutex"]
GO2["For handler slices and maps"]
end
subgraph CS["C#"]
CS1["volatile fields"]
CS2["SemaphoreSlim, lock-free CAS"]
end
Tool Definition
flowchart LR
subgraph TS["TypeScript"]
TS1["defineTool()"]
TS2["Zod schema support"]
end
subgraph PY["Python"]
PY1["@define_tool decorator"]
PY2["Pydantic introspection"]
end
subgraph GO["Go"]
GO1["DefineTool[T, U]()"]
GO2["Generics + JSON schema reflection"]
end
subgraph CS["C#"]
CS1["Microsoft.Extensions.AI"]
CS2["AIFunction abstraction"]
end
9. Error Handling Patterns
Layered Error Handling
Errors propagate through three layers:
flowchart TD
L1["Layer 1 -- Transport\nJSON-RPC transport errors\n(connection lost, process exited)"]
L2["Layer 2 -- Handlers\nTool/permission handler errors\n(fail gracefully via RPC)"]
L3["Layer 3 -- Events\nEvent handler errors\n(swallowed to protect dispatch loop)"]
L1 --> L2 --> L3
Layer 1 — JSON-RPC Transport Errors
# jsonrpc.py, lines 17-30
class JsonRpcError(Exception):
def __init__(self, code: int, message: str, data: Any = None):
self.code = code; self.message = message; self.data = data
class ProcessExitedError(Exception):
"""Error raised when the CLI process exits unexpectedly"""
All pending requests are failed when the process exits (_fail_pending_requests, line 215).
Layer 2 — Tool/Permission Handler Errors (fail gracefully)
All four SDKs catch handler exceptions and send error results back via RPC rather than crashing:
// session.ts, lines 378-389
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
try {
await this.rpc.tools.handlePendingToolCall({ requestId, error: message });
} catch (rpcError) {
if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) {
throw rpcError;
}
// Connection lost — nothing we can do
}
}
// session.go, lines 501-509 — uses defer/recover to catch panics
defer func() {
if r := recover(); r != nil {
errMsg := fmt.Sprintf("tool panic: %v", r)
s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{
RequestID: requestID, Error: &errMsg,
})
}
}()
Layer 3 — Event Handler Errors (swallowed)
All SDKs protect the event dispatch loop from handler exceptions to prevent one bad handler from blocking all others:
// session.ts, lines 298-316
for (const handler of this.eventHandlers) {
try { handler(event); } catch (_error) { /* Handler error */ }
}
// session.go, lines 452-462
func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Error in session event handler: %v\n", r)
}
}()
handler(event)
}()
Permission Denial as Default
When a permission handler is missing or fails, the default is always denial (fail-safe):
// session.ts, line 409
return { kind: "denied-no-approval-rule-and-could-not-request-from-user" };
// session.go, lines 542-548
s.RPC.Permissions.HandlePendingPermissionRequest(ctx, &rpc.SessionPermissionsHandlePendingPermissionRequestParams{
RequestID: requestID,
Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{
Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser,
},
})
Graceful vs Force Stop
All SDKs provide two shutdown paths:
stop(): Graceful — disconnects each session, then kills the processforceStop(): Immediate — kills process, clears sessions without RPC
# client.py — force_stop
async def force_stop(self) -> None:
with self._sessions_lock:
self._sessions.clear()
if self._process:
self._process.kill()
10. Callback/Hook System
Three Callback Systems
The SDK provides three distinct callback mechanisms at the session level:
flowchart LR
A["Permission\nHandlers\nrequest / response"] ~~~ B["Tool\nHandlers\nbroadcast events"] ~~~ C["Lifecycle\nHooks\nCLI invoked"]
10.1 Permission Handlers
Synchronous request/response: the CLI asks for permission, the SDK handler decides, and returns the result.
Permission result kinds (from generated/rpc.ts, lines 471-491):
result:
| { kind: "approved" }
| { kind: "denied-by-rules"; rules: unknown[] }
| { kind: "denied-no-approval-rule-and-could-not-request-from-user" }
| { kind: "denied-interactively-by-user"; feedback?: string }
| { kind: "denied-by-content-exclusion-policy"; path: string; message: string };
10.2 Tool Handlers
External tools are registered at session creation time. The CLI broadcasts external_tool.requested events; the SDK intercepts, executes the matching handler, and sends the result back via tools.handlePendingToolCall.
The tool result is normalized from various return types:
// definetool.go, lines 69-100
func normalizeResult(result any) (ToolResult, error) {
if result == nil { return ToolResult{TextResultForLLM: "", ResultType: "success"}, nil }
if tr, ok := result.(ToolResult); ok { return tr, nil }
if str, ok := result.(string); ok { return ToolResult{TextResultForLLM: str, ResultType: "success"}, nil }
jsonBytes, _ := json.Marshal(result)
return ToolResult{TextResultForLLM: string(jsonBytes), ResultType: "success"}, nil
}
10.3 Lifecycle Hooks
Hooks are invoked by the CLI via session.hooks.invoke RPC. The SDK dispatches to the appropriate handler based on hookType:
// session.ts, lines 546-578
async _handleHooksInvoke(hookType: string, input: unknown): Promise<unknown> {
const handlerMap: Record<string, GenericHandler | undefined> = {
preToolUse: this.hooks.onPreToolUse,
postToolUse: this.hooks.onPostToolUse,
userPromptSubmitted: this.hooks.onUserPromptSubmitted,
sessionStart: this.hooks.onSessionStart,
sessionEnd: this.hooks.onSessionEnd,
errorOccurred: this.hooks.onErrorOccurred,
};
const handler = handlerMap[hookType];
if (!handler) return undefined;
return await handler(input, { sessionId: this.sessionId });
}
Hook interface (types.ts, lines 470-500):
export interface SessionHooks {
onPreToolUse?: PreToolUseHandler;
onPostToolUse?: PostToolUseHandler;
onUserPromptSubmitted?: UserPromptSubmittedHandler;
onSessionStart?: SessionStartHandler;
onSessionEnd?: SessionEndHandler;
onErrorOccurred?: ErrorOccurredHandler;
}
Hook handlers can modify behavior: PreToolUseHookOutput can deny tools, modify arguments, or add context. ErrorOccurredHookOutput can dictate retry/skip/abort strategies.
// types.ts, lines 324-330
export interface PreToolUseHookOutput {
permissionDecision?: "allow" | "deny" | "ask";
permissionDecisionReason?: string;
modifiedArgs?: unknown;
additionalContext?: string;
suppressOutput?: boolean;
}
Extension System (TypeScript-specific)
The extension.ts module provides a joinSession() function for the “extension” deployment model, where the SDK runs as a child process of the CLI:
// extension.ts, lines 26-39
export async function joinSession(config: ResumeSessionConfig): Promise<CopilotSession> {
const sessionId = process.env.SESSION_ID;
if (!sessionId) {
throw new Error("joinSession() is intended for extensions running as child processes");
}
const client = new CopilotClient({ isChildProcess: true });
return client.resumeSession(sessionId, {
...config,
disableResume: config.disableResume ?? true,
});
}
Summary of Key Architectural Decisions
| Decision | Rationale |
|---|---|
| JSON-RPC over stdio (default) | No port conflicts, simpler deployment, works in sandboxed environments |
| Session registered before RPC | Prevents race condition with early events from CLI |
| Broadcast events for tool/permission calls | Enables multi-client scenarios (extensions, TUI+server) |
| Generated code from JSON schemas | Single source of truth across 4 languages |
| Handler errors swallowed in dispatch | One bad handler cannot crash the event loop |
| Permission denied by default | Fail-safe security posture |
| Lazy RPC wrapper initialization | No overhead for sessions that do not use typed RPC |
| Protocol version negotiation at startup | Forward/backward compatibility across SDK and CLI versions |