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
📚 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
| Decision | Choice Made | Alternatives | Why This Approach |
|---|---|---|---|
| Async runtime | stdlib asyncio | trio, anyio, sync-only | asyncio is the "standard" everyone interops with; no extra deps |
| JSON-RPC transport | hand-rolled _jsonrpc.py with threads + asyncio bridge | jsonrpc-async pkg; pure asyncio.subprocess_exec | asyncio subprocess on Windows has years-old bugs with stdin/stdout fragmentation. Threads with call_soon_threadsafe sidestep it |
| Type system | TypedDict payloads + dataclasses for config | pydantic, attrs, plain dicts | TypedDict is stdlib and works with mypy/pyright; pydantic adds 1MB+; dataclasses fit config with defaults |
| FS provider | abc.ABC with abstract methods | Protocol, duck-typed callables | Clear compiler/IDE errors at class-definition time; users prefer explicit interface |
| Lifecycle | async with context manager | Explicit start()/stop() | Impossible-to-forget cleanup; fits Python idioms (like open(...)) |
| Package layout | Flat copilot/ package, private modules prefixed _ | Sub-packages | Short import paths (from copilot import CopilotClient); underscore signals "private" |
| Error translation in SessionFs | Catch exception → convert to SessionFSError | Let exceptions propagate through RPC | RPC 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
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:
async with CopilotClient() as client—__aenter__callsawait self.start(), which locates the CLI binary, spawns viasubprocess.Popen(..., stdin=PIPE, stdout=PIPE, stderr=PIPE), wraps it inJsonRpcClient.- Thread launch —
JsonRpcClient.start()spawns the_read_thread(daemon) and_stderr_thread(drains stderr so it can't deadlock on a full pipe). - Protocol handshake —
await client._call("getStatus"). Reads back{protocolVersion, capabilities}; compares against minimum supported. await client.create_session(...)— sendssession.createRPC, getssessionId+ session capabilities.- Register handlers —
register_client_session_api_handlers(rpc, {...self})wires callbacks for server→client calls (tool invoke, permission, elicit, FS). - send_and_wait — pre-registers an event handler (same race fix as Node) then
await self._rpc.session_send(...). - 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). - User handler fires — the captured lambda stores the last
assistant.message, detectssession.idle, resolves the internal future. - Tool invocation (if any) — server sends
session/toolInvokerequest._dispatch_requeston the loop finds the registered handler, schedules as a task, awaits result, writes response back to stdin. - Idle resolves —
send_and_waitreturns with the captured message. __aexit__—await self.stop()sendsshutdownRPC, waits for process exit, threads see EOF and exit cleanly.
Key Components
| File | Key class / fn | Role (WHY it exists) |
|---|---|---|
copilot/client.py | CopilotClient, ExternalServerConfig, SubprocessConfig, ModelCapabilitiesOverride | Owns subprocess + JSON-RPC; async with entry point. Async context manager simplifies cleanup. |
copilot/session.py | CopilotSession, CommandContext, ElicitationContext, SessionFsConfig | One conversation; async-native event handlers; TypedDict event payloads. |
copilot/_jsonrpc.py | JsonRpcClient, JsonRpcError, ProcessExitedError | Threaded JSON-RPC bridge (see deep-dive); the only place where thread/asyncio interop lives. |
copilot/session_fs_provider.py | SessionFsProvider (ABC), SessionFsFileInfo, create_session_fs_adapter | User subclasses ABC, SDK wraps into generated SessionFsHandler; exceptions get translated to SessionFSError. |
copilot/tools.py | define_tool, convert_mcp_call_tool_result | Helpers to wrap user functions into Tool objects with JSON Schema params. |
copilot/_telemetry.py | get_trace_context, trace_context | 48-line W3C Trace Context plumbing; underscore-private because OTel is outside this SDK's scope. |
copilot/_sdk_protocol_version.py | get_sdk_protocol_version | Single getter for the embedded integer; own module for testability. |
copilot/generated/rpc.py | ServerRpc, ClientSessionApiHandlers, register_client_session_api_handlers | Codegen'd. Typed RPC wrappers around JsonRpcClient. |
copilot/generated/session_events.py | TypedDict union of session events | Codegen'd. |
copilot/__init__.py | Public re-exports + __version__ | Keeps import paths short. |
samples/chat.py | (executable) | Interactive chat demo. |
e2e/ | Test harness | Cross-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:
FileNotFoundError→{code: "not_found"}PermissionError→{code: "permission_denied"}NotImplementedError→{code: "not_supported"}- other →
{code: "internal", message: str(exc)}
⚠ What Could Go Wrong
| Failure | Detection | Recovery | Cascading Effects |
|---|---|---|---|
| CLI process crashes | Reader thread sees EOF; sets _process_exit_error | All pending futures rejected with ProcessExitedError via call_soon_threadsafe | Client must be recreated (await __aexit__ then new async with) |
| Malformed JSON from CLI | Reader thread logs to stderr, continues | Tolerant — keeps reading | Response matching may time out if the malformed message was a critical reply |
| Back-pressure: CLI stops reading stdin | Write thread blocks on OS pipe buffer (~64KB) | No timeout; relies on CLI resuming | If CLI deadlocked, await client.stop() will terminate it forcibly after the shutdown grace period |
| Windows subprocess quirks | N/A — sidestepped by using threads | This is why the threaded bridge exists | Zero flakiness even on CI Windows runners |
| Tool handler raises | Caught in _dispatch_request | Converted 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
| Kwarg | Type | Default | Purpose |
|---|---|---|---|
cli_path | str | Path | bundled | Override CLI binary |
cli_url | str | — | Connect to external server |
model | str | CLI default | Model for sessions |
enable_config_discovery | bool | False | Auto-load .mcp.json + skill dirs |
on_permission_request | async callable | deny | Permission gate |
tools | list[Tool] | [] | Custom tools |
commands | list[CommandDefinition] | [] | Slash commands |
hooks | dict | — | pre/post tool use, lifecycle |
system_message | SystemMessageConfig | — | replace / customize / transform |
session_fs | SessionFsConfig | — | Virtual FS provider |
reasoning_effort | "low"|"medium"|"high" | — | Reasoning budget |
trace_context_provider | callable | — | 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