Phase 5/13 — Architecture & Patterns

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.

IPC Architecture — Host Application ↔ CLI Server
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)
Key Architectural Constraint

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

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.

Factory Pattern — Client creates Sessions
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)
Important Sequencing

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.

Observer Pattern — Event Dispatch
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.

Template Method — sendAndWait Sequence
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:

Client 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)

  1. Resolve CLI path (bundled binary, explicit path, or environment variable)
  2. Spawn child process with --sdk-protocol-version=N (or connect to external server)
  3. Establish JSON-RPC connection (stdio reader/writer or TCP socket)
  4. Protocol version negotiation via ping request
  5. 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)

  1. Disconnect all active sessions (sends session.destroy for each)
  2. Close JSON-RPC connection
  3. Terminate CLI process (if spawned)
  4. 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

Dispose Protocol per Language
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:

Session State Machine
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:

  1. RPC call session.destroy to notify CLI
  2. Clear all event handlers, tool handlers, permission handler
  3. Session object is invalidated (but disk state is preserved for resumption)

5. Event-Driven Architecture

Event Flow

Event Dispatch 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:

Event Types by Category
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

RPC Message Direction
flowchart LR
  SDK[SDK Client] -->|requests| CLI[CLI Server]
  CLI -->|notifications| SDK
  CLI -->|"requests (v2)"| SDK
          

SDK → CLI (requests)

CategoryMethods
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 request
  • session.requestPermission — synchronous permission request
  • session.requestUserInput — synchronous user input request
  • session.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

LanguageJSON-RPC LibraryTransport
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
Schema-Driven Code Generation
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

Concurrency per Language
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

Unsubscribe Mechanism per Language
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

Thread Safety per Language
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

Tool Definition per Language
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:

Error Handling 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 process
  • forceStop(): 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:

Three Callback Mechanisms
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

DecisionRationale
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