python — Async Python SDK

github-copilot-sdk on PyPI. asyncio-native, 3.11+.

The Python SDK brings Copilot's agent runtime into the world of notebooks, LangChain agents, MLOps pipelines, and data-science scripting. It's stdlib-only (no pydantic, no aiohttp — just asyncio, subprocess, threading, json), uses async with for lifecycle, and carefully side-steps asyncio-on-Windows subprocess bugs with a threaded-read-loop JSON-RPC bridge.

asyncio Python 3.11+ Stdlib only

79
.py files
2730
client.py lines
2047
session.py lines
385
_jsonrpc.py lines
2
OS threads per Client

📚 What is this & why does it exist?

A Python SDK that mirrors the Node.js reference implementation nearly 1:1. CopilotClient, CopilotSession, SessionFsProvider — same concepts, different syntax. You can open a client with async with CopilotClient() as client: and every resource is deterministically cleaned up on exit. Under the hood, two daemon threads read the CLI's stdout/stderr into a queue, and asyncio.call_soon_threadsafe bridges the results back to the event loop.

💡 Real-world analogy

Imagine an async receptionist at a busy clinic who needs to talk to an old phone system that only works with a real human picking up the handset. The Python SDK uses a dedicated human (an OS thread) to hold the phone, listen for incoming messages, and relay them to the async receptionist via a tiny physical pigeonhole (call_soon_threadsafe). The receptionist never touches the phone herself — she only reads slips from the pigeonhole. This keeps her async queue flowing even if a phone call takes a long time, and sidesteps all the weirdness of the Windows-era phone system.

🎯 Why This Module Exists

A massive fraction of AI/agent development happens in Python — LangChain, DSPy, LlamaIndex, every ML paper's reference code. Without a Python SDK, these developers would be forced to shell out to copilot and scrape stdout, losing the event stream, tool callback flow, and permission handling. This SDK exists to make the Copilot agent loop a first-class Python citizen: an awaitable, typed, context-managed object that behaves like aiohttp.ClientSession or httpx.AsyncClient.

The design also intentionally avoids heavy dependencies. Pydantic would add 1 MB+ of transitive dependencies that Python data-science environments often already have in a conflicting version; aiohttp pulls in SSL and DNS libraries that are overkill for stdio communication. By sticking to stdlib only, pip install github-copilot-sdk finishes in seconds and doesn't conflict with anything.

🏗️ Design Decisions

DecisionChoice MadeAlternativesWhy This Approach
Async runtimestdlib asynciotrio, anyio, sync-onlyasyncio is the "standard" everyone interops with; no extra deps
JSON-RPC transporthand-rolled _jsonrpc.py with threads + asyncio bridgejsonrpc-async pkg; pure asyncio.subprocess_execasyncio subprocess on Windows has years-old bugs with stdin/stdout fragmentation. Threads with call_soon_threadsafe sidestep it
Type systemTypedDict payloads + dataclasses for configpydantic, attrs, plain dictsTypedDict is stdlib and works with mypy/pyright; pydantic adds 1MB+; dataclasses fit config with defaults
FS providerabc.ABC with abstract methodsProtocol, duck-typed callablesClear compiler/IDE errors at class-definition time; users prefer explicit interface
Lifecycleasync with context managerExplicit start()/stop()Impossible-to-forget cleanup; fits Python idioms (like open(...))
Package layoutFlat copilot/ package, private modules prefixed _Sub-packagesShort import paths (from copilot import CopilotClient); underscore signals "private"
Error translation in SessionFsCatch exception → convert to SessionFSErrorLet exceptions propagate through RPCRPC counterpart expects structured error payload; exceptions can't cross JSON

Architecture

flowchart LR
  subgraph app["Your Python app"]
    USER[async def main]
  end
  subgraph pub["copilot (public API)"]
    CLIENT[CopilotClient
client.py] SESSION[CopilotSession
session.py] FS[SessionFsProvider
session_fs_provider.py] TOOLS[define_tool
tools.py] end subgraph priv["copilot._internals"] JRPC[JsonRpcClient
_jsonrpc.py
2 threads + bridge] VER[_sdk_protocol_version.py] TEL[_telemetry.py] end subgraph gen["copilot.generated"] RPC[rpc.py
ServerRpc, ClientSessionApiHandlers] EVT[session_events.py
TypedDict event payloads] end CLIPROC[copilot CLI subprocess] USER --> CLIENT CLIENT --> SESSION CLIENT --> FS SESSION --> TOOLS CLIENT --> JRPC SESSION --> JRPC JRPC -->|stdio pipes| CLIPROC CLIENT --> RPC SESSION --> RPC SESSION --> EVT CLIENT --> VER CLIENT --> TEL

The Threaded JSON-RPC Bridge

The single most interesting file in this SDK is _jsonrpc.py (385 lines). Everything else ports behaviour from Node; the bridge solves a Python-specific problem.

sequenceDiagram
    participant Loop as asyncio event loop
    participant User as user coroutine
    participant Client as JsonRpcClient
    participant RT as read_thread (daemon)
    participant Proc as CLI subprocess (stdio)

    User->>Client: await call("session.send", params)
    Client->>Client: allocate request_id (UUID)
    Client->>Client: future = Loop.create_future()
    Client->>Client: pending_requests[id] = future
    Client->>Proc: write Content-Length header + JSON (behind write_lock)
    Client-->>User: return future

    loop forever
      RT->>Proc: blocking read of headers
      RT->>Proc: blocking read of body N bytes
      RT->>RT: parse JSON
      alt response
        RT->>Loop: call_soon_threadsafe(future.set_result, result)
      else notification
        RT->>Loop: call_soon_threadsafe(notif_handler, method, params)
      else incoming request
        RT->>Loop: call_soon_threadsafe(dispatch_request)
      end
    end

    Loop-->>User: future resolves with result
Why threads. The cleanest pure-asyncio alternative (asyncio.subprocess_exec + StreamReader) has long-standing reliability issues on Windows: the ProactorEventLoop can drop bytes under load, return partial reads as empty strings, or deadlock during child process shutdown. Blocking stdio reads on a dedicated thread avoid this entire category of bugs.

🚶 Step-by-Step Narrated Walkthrough

Follow await session.send_and_wait({"prompt": "What is 2+2?"}) end-to-end:

  1. async with CopilotClient() as client — __aenter__ calls await self.start(), which locates the CLI binary, spawns via subprocess.Popen(..., stdin=PIPE, stdout=PIPE, stderr=PIPE), wraps it in JsonRpcClient.
  2. Thread launch — JsonRpcClient.start() spawns the _read_thread (daemon) and _stderr_thread (drains stderr so it can't deadlock on a full pipe).
  3. Protocol handshake — await client._call("getStatus"). Reads back {protocolVersion, capabilities}; compares against minimum supported.
  4. await client.create_session(...) — sends session.create RPC, gets sessionId + session capabilities.
  5. Register handlers — register_client_session_api_handlers(rpc, {...self}) wires callbacks for server→client calls (tool invoke, permission, elicit, FS).
  6. send_and_wait — pre-registers an event handler (same race fix as Node) then await self._rpc.session_send(...).
  7. Event delivery — as events arrive on stdout, the read thread parses JSON, recognises it as a notification, and posts to the loop: loop.call_soon_threadsafe(self._dispatch_event, event).
  8. User handler fires — the captured lambda stores the last assistant.message, detects session.idle, resolves the internal future.
  9. Tool invocation (if any) — server sends session/toolInvoke request. _dispatch_request on the loop finds the registered handler, schedules as a task, awaits result, writes response back to stdin.
  10. Idle resolves — send_and_wait returns with the captured message.
  11. __aexit__ — await self.stop() sends shutdown RPC, waits for process exit, threads see EOF and exit cleanly.

Key Components

FileKey class / fnRole (WHY it exists)
copilot/client.pyCopilotClient, ExternalServerConfig, SubprocessConfig, ModelCapabilitiesOverrideOwns subprocess + JSON-RPC; async with entry point. Async context manager simplifies cleanup.
copilot/session.pyCopilotSession, CommandContext, ElicitationContext, SessionFsConfigOne conversation; async-native event handlers; TypedDict event payloads.
copilot/_jsonrpc.pyJsonRpcClient, JsonRpcError, ProcessExitedErrorThreaded JSON-RPC bridge (see deep-dive); the only place where thread/asyncio interop lives.
copilot/session_fs_provider.pySessionFsProvider (ABC), SessionFsFileInfo, create_session_fs_adapterUser subclasses ABC, SDK wraps into generated SessionFsHandler; exceptions get translated to SessionFSError.
copilot/tools.pydefine_tool, convert_mcp_call_tool_resultHelpers to wrap user functions into Tool objects with JSON Schema params.
copilot/_telemetry.pyget_trace_context, trace_context48-line W3C Trace Context plumbing; underscore-private because OTel is outside this SDK's scope.
copilot/_sdk_protocol_version.pyget_sdk_protocol_versionSingle getter for the embedded integer; own module for testability.
copilot/generated/rpc.pyServerRpc, ClientSessionApiHandlers, register_client_session_api_handlersCodegen'd. Typed RPC wrappers around JsonRpcClient.
copilot/generated/session_events.pyTypedDict union of session eventsCodegen'd.
copilot/__init__.pyPublic re-exports + __version__Keeps import paths short.
samples/chat.py(executable)Interactive chat demo.
e2e/Test harnessCross-SDK conformance suite adapter.

⚡ Algorithm Spotlight

Threaded JSON-RPC bridge

See the sequence diagram above. Key insight: call_soon_threadsafe is the only cross-thread handoff — everything else stays on either (a) the asyncio loop or (b) the dedicated reader thread. This localises the thread-safety reasoning to one function.

class JsonRpcClient:
    def __init__(self, process):
        self.process = process
        self.pending_requests: dict[str, asyncio.Future] = {}
        self.notification_handler = None
        self.request_handlers: dict[str, RequestHandler] = {}
        self._write_lock = threading.Lock()
        self._pending_lock = threading.Lock()
        # ...

async with lifecycle

Users write:

async with CopilotClient() as client:
    session = await client.create_session()
    await session.send_and_wait({"prompt": "hi"})
# On exit: client.stop() runs no matter what exception killed the block

Even if the user's code raises KeyboardInterrupt, Python guarantees __aexit__ runs. The shutdown escalation: graceful RPC → SIGTERM → SIGKILL with configurable timeout.

SessionFs error translation

_to_session_fs_error maps Python exceptions to the wire-format SessionFSError shape:

⚠ What Could Go Wrong

FailureDetectionRecoveryCascading Effects
CLI process crashesReader thread sees EOF; sets _process_exit_errorAll pending futures rejected with ProcessExitedError via call_soon_threadsafeClient must be recreated (await __aexit__ then new async with)
Malformed JSON from CLIReader thread logs to stderr, continuesTolerant — keeps readingResponse matching may time out if the malformed message was a critical reply
Back-pressure: CLI stops reading stdinWrite thread blocks on OS pipe buffer (~64KB)No timeout; relies on CLI resumingIf CLI deadlocked, await client.stop() will terminate it forcibly after the shutdown grace period
Windows subprocess quirksN/A — sidestepped by using threadsThis is why the threaded bridge existsZero flakiness even on CI Windows runners
Tool handler raisesCaught in _dispatch_requestConverted to {isError: True, content: str(e)}Agent sees the error and can apologise or retry

📊 Performance Profile

Fast path: event dispatch is O(1) — one dict lookup, one future resolve. Slow path: JSON serialisation for large tool results holds the GIL, stalling the asyncio loop briefly. Thread overhead: 2 OS threads per client regardless of session count — cheap on Linux/Mac (~100 KB each) but accumulates across many clients. Memory: ~3 MB per idle client, ~500 KB per active session.

Optimised for: reliability across platforms. Sacrificed: lowest possible latency (the cross-thread hop adds ~10-50 µs per message). Acceptable because LLM latency dominates.

Deep-Dive Highlights

JSON-RPC Transport Focus

Deep-dive on Content-Length framing and bidirectional dispatch (covers this SDK's threaded bridge).

Protocol Negotiation Focus

How the Python SDK reconciles MIN_PROTOCOL_VERSION with the server's version.

Codegen Pipeline Focus

How generated/rpc.py is produced from the shared JSON Schemas.

Configuration Reference

KwargTypeDefaultPurpose
cli_pathstr | PathbundledOverride CLI binary
cli_urlstr—Connect to external server
modelstrCLI defaultModel for sessions
enable_config_discoveryboolFalseAuto-load .mcp.json + skill dirs
on_permission_requestasync callabledenyPermission gate
toolslist[Tool][]Custom tools
commandslist[CommandDefinition][]Slash commands
hooksdict—pre/post tool use, lifecycle
system_messageSystemMessageConfig—replace / customize / transform
session_fsSessionFsConfig—Virtual FS provider
reasoning_effort"low"|"medium"|"high"—Reasoning budget
trace_context_providercallable—W3C Trace Context

Code Examples

Minimal usage

import asyncio
from copilot import CopilotClient

async def main():
    async with CopilotClient() as client:
        session = await client.create_session(model="gpt-4")
        response = await session.send_and_wait({"prompt": "Hello!"})
        print(response["data"]["content"])

asyncio.run(main())

Custom tool (Python)

from copilot import define_tool

get_weather = define_tool(
    name="get_weather",
    description="Get current weather for a city",
    parameters={
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
    handler=lambda args, _inv: f"Cloudy, 62F in {args['city']}",
)

Custom SessionFsProvider

from copilot import SessionFsProvider, SessionFsFileInfo

class InMemoryFs(SessionFsProvider):
    def __init__(self):
        self._store: dict[str, bytes] = {}

    async def read(self, path: str) -> bytes:
        try:
            return self._store[path]
        except KeyError:
            raise FileNotFoundError(path)  # becomes {code:"not_found"}

    async def write(self, path: str, content: bytes) -> None:
        self._store[path] = content

Directory Map

python/ source tree
python/
├── copilot/
│   ├── __init__.py                 ← Public exports + __version__
│   ├── client.py                   ← CopilotClient — async with lifecycle
│   ├── session.py                  ← CopilotSession — async event pump
│   ├── tools.py                    ← define_tool, convert_mcp_call_tool_result
│   ├── session_fs_provider.py      ← ABC + adapter + error translation
│   ├── _jsonrpc.py                 ← Threaded JSON-RPC bridge (385 lines)
│   ├── _telemetry.py               ← W3C Trace Context
│   ├── _sdk_protocol_version.py    ← Embedded constant
│   └── generated/
│       ├── rpc.py                  ← Codegen: ServerRpc + handlers
│       └── session_events.py       ← Codegen: TypedDict event union
├── samples/
│   └── chat.py                     ← Interactive chat demo
├── e2e/
│   ├── __init__.py
│   └── testharness/                ← Cross-SDK conformance adapter
├── scripts/                        ← Build + release helpers
├── pyproject.toml                  ← hatch-based build
└── README.md