# Copilot Python SDK Python SDK for programmatic control of GitHub Copilot CLI via JSON-RPC. ## Prerequisites To use the SDK, you'll need: - Python 3.11+ ## Installation ```bash pip install github-copilot-sdk ``` To include OpenTelemetry support: ```bash pip install "github-copilot-sdk[telemetry]" ``` ## Runtime Published wheels include a pinned runtime version. After installing, download the runtime: ```bash python -m copilot download-runtime ``` This downloads the platform release package, verifies it against the release's `SHA256SUMS.txt`, and directly stages `copilot-runtime`, its adjacent `runtime.node`, and the filtered hostless runtime assets locally without retaining the downloaded archive. If you skip this step, the SDK performs the same staging automatically on first managed stdio/TCP use. To pre-provision the native library required by the in-process (FFI) transport (see [In-process (FFI) transport](#in-process-ffi-transport)), pass `--in-process`: ```bash python -m copilot download-runtime --in-process ``` This also creates a `copilot` compatibility entrypoint from `copilot-runtime` inside the complete materialized bundle. Its adjacent `runtime.node` can then be used for in-process hosting. That canonical staged library is reused, so this does not download a second runtime artifact. | Platform | Cache path | |----------|-----------| | Linux | `~/.cache/github-copilot-sdk/cli//prebuilds//` | | macOS | `~/Library/Caches/github-copilot-sdk/cli//prebuilds//` | | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\prebuilds\\` | ### Environment variables | Variable | Description | |----------|-------------| | `COPILOT_CLI_PATH` | Use this specific binary instead of downloading | | `COPILOT_CLI_EXTRACT_DIR` | Override the version-specific cache directory | | `COPILOT_SKIP_CLI_DOWNLOAD` | Set to `1` to disable auto-download | | `COPILOT_CLI_DOWNLOAD_BASE_URL` | Override the GitHub Releases download URL used for the runtime package and checksums | ## Run the Sample Try the interactive chat sample (from the repo root): ```bash cd python/samples python chat.py ``` ## Quick Start ```python import asyncio from copilot import CopilotClient from copilot.session_events import AssistantMessageData, SessionIdleData from copilot.session import PermissionHandler async def main(): # Client automatically starts on enter and cleans up on exit async with CopilotClient() as client: # Create a session with automatic cleanup async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) as session: # Wait for response using session.idle event done = asyncio.Event() def on_event(event): match event.data: case AssistantMessageData() as data: print(data.content) case SessionIdleData(): done.set() session.on(on_event) # Send a message and wait for completion await session.send("What is 2+2?") await done.wait() asyncio.run(main()) ``` ### Message source Use `AgentMessageSource(agent_id)` for messages from an identified agent. Use `source="system"` for application-internal context, not as a substitute for agent provenance. Both `send` and `send_and_wait` accept the optional `MessageSource` type: ```python from copilot import AgentMessageSource, MessageSource source: MessageSource = AgentMessageSource("reviewer") await session.send("Review complete", source=source) await session.send_and_wait("Review findings attached", source=source) await session.send("Workspace context updated", source="system") ``` `AgentMessageSource` is immutable and requires a string ID. The SDK sends the ID unchanged after the `agent-` prefix, so `"reviewer"` becomes `"agent-reviewer"`. It does not trim whitespace, change case, or remove an existing prefix. Leave source unset (or `None`) for ordinary human sends so the field stays omitted. Use `"user"` when you need to set it explicitly. Source is independent of delivery mode and does not replace the session's `system_message` configuration, set billing flags, or use the notification API. `send_and_wait` can return `None` when the session goes idle without an assistant message; errors still propagate. ### Manual Resource Management If you need more control over the lifecycle, you can call `start()`, `stop()`, and `disconnect()` manually: ```python import asyncio from copilot import CopilotClient from copilot.session_events import AssistantMessageData, SessionIdleData from copilot.session import PermissionHandler async def main(): client = CopilotClient() await client.start() # approve_all is only valid when managed settings are disabled. session = await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) done = asyncio.Event() def on_event(event): match event.data: case AssistantMessageData() as data: print(data.content) case SessionIdleData(): done.set() session.on(on_event) await session.send("What is 2+2?") await done.wait() # Clean up manually await session.disconnect() await client.stop() asyncio.run(main()) ``` ## Features - ✅ Full JSON-RPC protocol support - ✅ stdio, TCP, and in-process (FFI) transports - ✅ Real-time streaming events - ✅ Session history with `get_events()` - ✅ Type hints throughout - ✅ Async/await native - ✅ Async context manager support for automatic resource cleanup ## API Reference ### CopilotClient ```python from copilot import CopilotClient from copilot.session import PermissionHandler async with CopilotClient() as client: async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) as session: def on_event(event): print(f"Event: {event.type}") session.on(on_event) await session.send("Hello!") # ... wait for events ... ``` > **Note:** For manual lifecycle management, see [Manual Resource Management](#manual-resource-management) above. ```python from copilot import CopilotClient, RuntimeConnection # Connect to an existing CLI server client = CopilotClient(connection=RuntimeConnection.for_uri("localhost:3000")) ``` **CopilotClient Constructor:** ```python CopilotClient() # spawn the bundled runtime with defaults CopilotClient(connection=..., log_level="debug", github_token=..., ...) ``` All options are kw-only parameters: - `connection` (RuntimeConnection | None): How to reach the runtime. Use `RuntimeConnection.for_stdio(...)`, `RuntimeConnection.for_tcp(...)`, `RuntimeConnection.for_uri(...)`, or `RuntimeConnection.for_inprocess(...)`. Defaults to a stdio connection with the bundled binary. - `working_directory` (str | None): Working directory for the CLI process (default: current dir). - `log_level` (str): Log level (default: "info"). - `env` (dict | None): Environment variables for the CLI process. - `github_token` (str | None): GitHub token for authentication. When provided, takes priority over other auth methods. - `base_directory` (str | None): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When `None`, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using a `UriRuntimeConnection`. - `use_logged_in_user` (bool | None): Whether to use logged-in user for authentication (default: True, but False when `github_token` is provided). - `telemetry` (dict | None): OpenTelemetry configuration for the CLI process. Providing this enables telemetry — no separate flag needed. See [Telemetry](#telemetry) below. - `session_fs` (dict | None): Connection-level session filesystem provider configuration. - `session_idle_timeout_seconds` (int | None): Server-wide session idle timeout in seconds. Set to `None` or `0` to disable. - `enable_remote_sessions` (bool): Enable remote/cloud session support (default: False). - `on_list_models` (callable | None): Custom handler for `list_models()`. When provided, the handler is called instead of querying the runtime. - `mode` (str): Client mode (default: `"copilot-cli"`). **RuntimeConnection variants:** - `RuntimeConnection.for_stdio(path=None, args=None)` — spawn a local CLI process and talk over stdio. - `RuntimeConnection.for_tcp(port=0, connection_token=None, path=None, args=None)` — spawn a local CLI in TCP mode. - `RuntimeConnection.for_uri(url, connection_token=None)` — connect to an existing CLI server (e.g. `"localhost:8080"`). - `RuntimeConnection.for_inprocess()` — host the runtime in-process via its native C ABI (FFI). See [In-process (FFI) transport](#in-process-ffi-transport). Managed stdio and TCP connections use the downloaded `copilot-runtime` executable with adjacent `runtime.node` by default. An explicit connection path or `COPILOT_CLI_PATH` overrides the downloaded runtime. Child-process connections (`for_stdio`/`for_tcp`) also expose a per-connection `env` field for the spawned process. Set it on the returned connection instead of the client-level `env` — setting both raises: ```python conn = RuntimeConnection.for_stdio() conn.env = {"MY_VAR": "value"} client = CopilotClient(connection=conn) # do NOT also pass env=... here ``` ### In-process (FFI) transport > ⚠️ **Experimental.** The in-process transport loads the runtime's native shared > library into your process and drives JSON-RPC over its C ABI (via stdlib > `ctypes`), instead of spawning a child process. ```python from copilot import CopilotClient, RuntimeConnection client = CopilotClient(connection=RuntimeConnection.for_inprocess()) await client.start() try: pong = await client.ping("hello") print(pong.message) finally: await client.stop() ``` **Requirements & behavior:** - Pre-provision the native runtime with `python -m copilot download-runtime --in-process`, or let the SDK download it lazily on first use of this transport. - Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package. In-process connections do not accept per-connection paths or raw process arguments. - Because the runtime shares this single host process, per-client options that lower to environment variables or a working directory **cannot** be honored and are rejected: `env`, `telemetry`, and `working_directory` all raise `ValueError` with `for_inprocess()`. Set the corresponding values on the host process environment / working directory before creating the client instead. - Set `COPILOT_SDK_DEFAULT_CONNECTION=inprocess` to select the in-process transport by default when no explicit `connection` is supplied. **`CopilotClient.create_session()`:** These are passed as keyword arguments to `create_session()`: - `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** - `capi` (CapiSessionOptions): Copilot API options. With `model="auto"`, set `auto_tier` to `"efficiency"`, `"balance"`, or `"intelligence"` to choose a routing preference. Requires a runtime with Auto tier support and V2 Auto routing. Omission preserves default behavior. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for resume semantics. - `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `list_models()` to check which models support this option. - `session_id` (str): Custom session ID - `tools` (list): Custom tools exposed to the CLI. Tools with `handler=None` are declaration-only and must be resolved via pending tool-call RPCs. - `system_message` (SystemMessageConfig): System message configuration - `streaming` (bool): Enable streaming delta events - `provider` (ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `infinite_sessions` (InfiniteSessionConfig): Automatic context compaction configuration - `working_directory` (str | None): Working directory for the session (default: runtime process working directory). - `enable_session_store` (bool): Enables the cross-session store for search and retrieval across sessions. When unset in `"copilot-cli"` mode, the runtime default applies (enabled). In `"empty"` mode, defaults to disabled. - `github_token_provider` (callable): Acquires rotating, session-scoped GitHub tokens. Token results require a positive `expiresIn` value in seconds remaining when the callback completes; production tokens typically last eight hours. Cannot be combined with `github_token`. - `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves requests when managed settings are disabled and raises an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `on_user_input_request` (callable): Handler for legacy question-and-answer requests from the agent. Enables the legacy `ask_user` tool. See [User Input Requests](#user-input-requests) section. - `ask_user_variant` (`"legacy"` | `"elicitation"`): Selects the model-facing shape of the `ask_user` tool. Defaults to `"legacy"`; use `"elicitation"` with `on_elicitation_request`. Re-supply this option when cold-resuming a session. - `hooks` (SessionHooks): Hook handlers for session lifecycle events. See [Session Hooks](#session-hooks) section. ```python async def provide_github_token(args): return { "kind": "token", "accessToken": await acquire_token_for_host(args["host"]), "expiresIn": 8 * 60 * 60, } session = await client.create_session(github_token_provider=provide_github_token) ``` Initial acquisition runs during session creation or resume. Cancellation, provider errors, and invalid token responses reject that operation instead of falling back to ambient authentication. Idle sessions refresh only before their next credential-consuming operation; there is no background refresh timer. - `available_tools` / `excluded_tools` / `default_agent.excluded_tools` / custom-agent `tools`: MCP tools registered from `mcp_servers` are exposed to the runtime as `-`. For `available_tools` and `excluded_tools`, prefer `ToolSet().add_mcp("-")` or the raw `mcp:-` form. For custom-agent `tools` and `default_agent.excluded_tools`, use `-` directly. **Session Lifecycle Methods:** ```python # Get the session currently displayed in TUI (TUI+server mode only) session_id = await client.get_foreground_session_id() # Request TUI to display a specific session (TUI+server mode only) await client.set_foreground_session_id("session-123") # Subscribe to all lifecycle events def on_lifecycle(event): print(f"{event.type}: {event.session_id}") unsubscribe = client.on_lifecycle(on_lifecycle) # Subscribe to specific event type unsubscribe = client.on_lifecycle( "session.foreground", lambda e: print(f"Foreground: {e.session_id}") ) # Later, to stop receiving events: unsubscribe() ``` **Lifecycle Event Types:** - `session.created` - A new session was created - `session.deleted` - A session was deleted - `session.updated` - A session was updated - `session.foreground` - A session became the foreground session in TUI - `session.background` - A session is no longer the foreground session ### Tools Define tools with automatic JSON schema generation using the `@define_tool` decorator and Pydantic models: ```python from pydantic import BaseModel, Field from copilot import CopilotClient, define_tool class LookupIssueParams(BaseModel): id: str = Field(description="Issue identifier") @define_tool(description="Fetch issue details from our tracker") async def lookup_issue(params: LookupIssueParams) -> str: issue = await fetch_issue(params.id) return issue.summary async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", tools=[lookup_issue], ) as session: ... ``` > **Note:** When using `from __future__ import annotations`, define Pydantic models at module level (not inside functions). #### Low-level API (without Pydantic) For users who prefer manual schema definition: ```python from copilot import CopilotClient from copilot.tools import Tool, ToolInvocation, ToolResult from copilot.session import PermissionHandler async def lookup_issue(invocation: ToolInvocation) -> ToolResult: issue_id = invocation.arguments["id"] issue = await fetch_issue(issue_id) return ToolResult( text_result_for_llm=issue.summary, result_type="success", session_log=f"Fetched issue {issue_id}", ) async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", tools=[ Tool( name="lookup_issue", description="Fetch issue details from our tracker", parameters={ "type": "object", "properties": { "id": {"type": "string", "description": "Issue identifier"}, }, "required": ["id"], }, handler=lookup_issue, ) ], ) as session: ... ``` The SDK automatically handles `tool.call`, executes your handler (sync or async), and responds with the final result when the tool completes. If a tool has no handler, it is exposed as a declaration only; observe `external_tool.requested` events and resolve the call with the pending tool RPC. You can also create a declaration-only tool with generated Pydantic parameters: ```python tool = define_tool( "lookup_issue", description="Fetch issue details from our tracker", params_type=LookupIssueParams, ) ``` #### Overriding Built-in Tools If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overrides_built_in_tool=True`. This flag signals that you intend to replace the built-in tool with your custom implementation. ```python class EditFileParams(BaseModel): path: str = Field(description="File path") content: str = Field(description="New file content") @define_tool(name="edit_file", description="Custom file editor with project-specific validation", overrides_built_in_tool=True) async def edit_file(params: EditFileParams) -> str: # your logic ``` #### Skipping Permission Prompts Set `skip_permission=True` on a tool definition to allow it to execute without triggering a permission prompt: ```python @define_tool(name="safe_lookup", description="A read-only lookup that needs no confirmation", skip_permission=True) async def safe_lookup(params: LookupParams) -> str: # your logic ``` #### Deferring Tools Set `defer` to control whether a tool may be loaded lazily via tool search rather than always pre-loaded. Use `"auto"` to allow the tool to be deferred and surfaced through tool search, or `"never"` to force it to always be pre-loaded. Defaults to `"auto"`. ```python @define_tool(name="lookup_issue", description="Fetch issue details", defer="auto") async def lookup_issue(params: LookupParams) -> str: # your logic ``` ## Auto routing tiers Change the Auto routing preference without changing the selected model. The runtime does not apply the preference immediately: it records the request and commits it only when a later user turn using the `auto` model successfully obtains a usable model from the provider, so a `pending` status confirms acceptance rather than effect. Only the most recent request survives. Watch for the outcome through the `session.model_change` event on success or the ephemeral `session.auto_tier_switch_failed` event on failure. Read the authoritative committed, pending, and activating preferences at any time through the session's `model.getCurrent` RPC method. ```python result = await session.set_auto_tier("intelligence") if result.status == ModelSwitchAutoTierStatus.PENDING: ... # Accepted, but not yet in effect. # Return to the provider's default Auto routing. await session.set_auto_tier(None) ``` `set_model()` accepts the same preference through its `auto_tier` argument, which stages the tier atomically with selecting `auto`. Pass `None` to return to provider-default routing, or omit the argument to leave the current preference unchanged. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the full lifecycle rules. ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path, or by passing base64-encoded data directly using a blob attachment: ```python # File attachment — runtime reads from disk await session.send( "What's in this image?", attachments=[ { "type": "file", "path": "/path/to/image.jpg", } ], ) # Blob attachment — provide base64 data directly await session.send( "What's in this image?", attachments=[ { "type": "blob", "data": base64_image_data, "mimeType": "image/png", } ], ) ``` Supported image formats include JPG, PNG, GIF, and other common image types. The agent's `view` tool can also read images directly from the filesystem, so you can also ask questions like: ```python await session.send("What does the most recent jpg in this directory portray?") ``` ## Streaming Enable streaming to receive assistant response chunks as they're generated: ```python import asyncio from copilot import CopilotClient from copilot.session_events import ( AssistantMessageData, AssistantMessageDeltaData, AssistantReasoningData, AssistantReasoningDeltaData, SessionIdleData, ) from copilot.session import PermissionHandler async def main(): async with CopilotClient() as client: async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", streaming=True, ) as session: # Use asyncio.Event to wait for completion done = asyncio.Event() def on_event(event): match event.data: case AssistantMessageDeltaData() as data: # Streaming message chunk - print incrementally delta = data.delta_content or "" print(delta, end="", flush=True) case AssistantReasoningDeltaData() as data: # Streaming reasoning chunk (if model supports reasoning) delta = data.delta_content or "" print(delta, end="", flush=True) case AssistantMessageData() as data: # Final message - complete content print("\n--- Final message ---") print(data.content) case AssistantReasoningData() as data: # Final reasoning content (if model supports reasoning) print("--- Reasoning ---") print(data.content) case SessionIdleData(): # Session finished processing done.set() session.on(on_event) await session.send("Tell me a short story") await done.wait() # Wait for streaming to complete asyncio.run(main()) ``` When `streaming=True`: - `assistant.message_delta` events are sent with `delta_content` containing incremental text - `assistant.reasoning_delta` events are sent with `delta_content` for reasoning/chain-of-thought (model-dependent) - Accumulate `delta_content` values to build the full response progressively - The final `assistant.message` and `assistant.reasoning` events contain the complete content Note: `assistant.message` and `assistant.reasoning` (final events) are always sent regardless of streaming setting. ## Infinite Sessions By default, sessions use **infinite sessions** which automatically manage context window limits through background compaction and persist state to a workspace directory. ```python # Default: infinite sessions enabled with default thresholds async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) as session: # Access the workspace path for checkpoints and files print(session.workspace_path) # => ~/.copilot/session-state/{session_id}/ # Custom thresholds async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", infinite_sessions={ "enabled": True, "background_compaction_threshold": 0.80, # Start compacting at 80% context usage "buffer_exhaustion_threshold": 0.95, # Block at 95% until compaction completes }, ) as session: ... # Disable infinite sessions async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", infinite_sessions={"enabled": False}, ) as session: ... ``` When enabled, sessions emit compaction events: - `session.compaction_start` - Background compaction started - `session.compaction_complete` - Compaction finished (includes token counts) ## Memory Sessions can opt into persistent memory, allowing the agent to read and write memory across turns. Memory is configured per session and applies to both `create_session` and `resume_session`. For more background, see [About GitHub Copilot Memory](https://docs.github.com/en/copilot/concepts/agents/copilot-memory). ```python async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", memory={"enabled": True}, ) as session: ... ``` When `memory` is omitted, no memory configuration is sent and the runtime default applies. In the default `"copilot-cli"` client mode the SDK leaves `memory` unset so the runtime applies its own default, while `"empty"` mode defaults `memory` to disabled unless you set it explicitly. ## Custom Providers The SDK supports custom OpenAI-compatible API providers (BYOK - Bring Your Own Key), including local providers like Ollama. When using a custom provider, you must specify the `model` explicitly. **ProviderConfig fields:** - `type` (str): Provider type - `"openai"`, `"azure"`, or `"anthropic"` (default: `"openai"`) - `base_url` (str): API endpoint URL (required) - `api_key` (str): API key (optional for local providers like Ollama) - `bearer_token` (str): Bearer token for authentication (takes precedence over `api_key`) - `wire_api` (str): API format for OpenAI/Azure - `"completions"` or `"responses"` (default: `"completions"`) - `azure` (dict): Azure-specific options with `api_version`; when omitted, the runtime uses the GA versionless `v1` route **Example with Ollama:** ```python async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="deepseek-coder-v2:16b", # Required when using custom provider provider={ "type": "openai", "base_url": "http://localhost:11434/v1", # Ollama endpoint # api_key not required for Ollama }, ) as session: await session.send("Hello!") ``` **Example with custom OpenAI-compatible API:** ```python import os async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-4", provider={ "type": "openai", "base_url": "https://my-api.example.com/v1", "api_key": os.environ["MY_API_KEY"], }, ) as session: ... ``` **Example with Azure OpenAI:** ```python import os async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-4", provider={ "type": "azure", # Must be "azure" for Azure endpoints, NOT "openai" "base_url": "https://my-resource.openai.azure.com", # Just the host, no path "api_key": os.environ["AZURE_OPENAI_KEY"], "azure": { "api_version": "2024-10-21", }, }, ) as session: ... ``` > **Important notes:** > > - When using a custom provider, the `model` parameter is **required**. The SDK will throw an error if no model is specified. > - For Azure OpenAI endpoints (`*.openai.azure.com`), you **must** use `type: "azure"`, not `type: "openai"`. > - The `base_url` should be just the host (e.g., `https://my-resource.openai.azure.com`). Do **not** include `/openai/v1` in the URL - the SDK handles path construction automatically. ## System Message Customization Control the system prompt using `system_message` in session config: ```python async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", system_message={ "mode": "append", "content": """ - Always check for security vulnerabilities - Suggest performance improvements when applicable """, }, ) as session: ... ``` ### Customize Mode Use `mode: "customize"` to selectively override individual sections of the prompt while preserving the rest: ```python async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", system_message={ "mode": "customize", "sections": { "tone": { "action": "replace", "content": "Respond in a warm, professional tone. Be thorough in explanations.", }, "code_change_rules": {"action": "remove"}, "guidelines": {"action": "append", "content": "\n* Always cite data sources"}, }, "content": "Focus on financial analysis and reporting.", }, ) as session: ... ``` Available section IDs: `"preamble"`, `"identity"`, `"tone"`, `"tool_efficiency"`, `"environment_context"`, `"code_change_rules"`, `"guidelines"`, `"safety"`, `"tool_instructions"`, `"custom_instructions"`, `"runtime_instructions"`, `"last_instructions"`. `"identity"` and `"tool_instructions"` are section groups that target a collection of related sub-sections as a unit; use `"preamble"` to target just the identity preamble. Each section override supports five string actions: `"replace"`, `"remove"`, `"append"`, `"prepend"`, and `"preserve"` (a no-op that opts an individually-addressable section out of a group-level `"remove"`). Unknown section IDs are handled gracefully: content from `"replace"`/`"append"`/`"prepend"` overrides is appended to additional instructions, and `"remove"` overrides are silently ignored. You can also pass a transform callback as the `action` instead of a string. The callback receives the current section content and returns the new content (sync or async): ```python def redact_paths(content: str) -> str: return content.replace("/home/user", "/***") async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", system_message={ "mode": "customize", "sections": { "environment_context": {"action": redact_paths}, }, }, ) as session: ... ``` ### Replace Mode For full control (removes all SDK guardrails including security restrictions), use `mode: "replace"`: ```python async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", system_message={ "mode": "replace", "content": "You are a helpful assistant.", }, ) as session: ... ``` ## Telemetry The SDK supports OpenTelemetry for distributed tracing. Provide a `telemetry` config to enable trace export and automatic W3C Trace Context propagation. ```python from copilot import CopilotClient client = CopilotClient( telemetry={ "otlp_endpoint": "http://localhost:4318", }, ) ``` **TelemetryConfig options:** - `otlp_endpoint` (str): OTLP HTTP endpoint URL - `otlp_protocol` (str): OTLP HTTP protocol for all signals (`"http/json"` or `"http/protobuf"`) - `file_path` (str): File path for JSON-lines trace output - `exporter_type` (str): `"otlp-http"` or `"file"` - `source_name` (str): Instrumentation scope name - `capture_content` (bool): Whether to capture message content Trace context (`traceparent`/`tracestate`) is automatically propagated between the SDK and CLI on `create_session`, `resume_session`, and `send` calls, and inbound when the CLI invokes tool handlers. Install with telemetry extras: `pip install "github-copilot-sdk[telemetry]"` (provides `opentelemetry-api`) ## Permission Handling An `on_permission_request` handler is optional when you create or resume a session. When provided, it is called before the agent executes each tool (file writes, shell commands, custom tools, etc.) and returns a decision. When omitted, permission requests are emitted as events and left pending for the consumer to resolve with the pending permission RPC. ### Approve All (simplest) Use the built-in `PermissionHandler.approve_all` helper to approve ordinary permission requests automatically: ```python from copilot import CopilotClient from copilot.session import PermissionHandler session = await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", ) ``` When `enable_managed_settings` is true for the session, `approve_all` raises an error. Use a custom handler for managed sessions; request-level `managed_approval_required` remains available for human-facing confirmation logic. ### Custom Permission Handler Provide your own function to inspect each request and apply custom logic (sync or async). Check `managed_approval_required` before any automatic approval: ```python from copilot import PermissionNoResult, PermissionRequest, PermissionRequestResult from copilot.rpc import ( PermissionDecisionApproveOnce, PermissionDecisionReject, ) from copilot.session_events import PermissionRequestShell def on_permission_request(request: PermissionRequest, invocation: dict) -> PermissionRequestResult: if getattr(request, "managed_approval_required", False) is True: return PermissionNoResult() # ``PermissionRequest`` is a discriminated union — pattern-match on # the variant class to access the per-kind fields. match request: case PermissionRequestShell(full_command_text=cmd): # Deny shell commands return PermissionDecisionReject(feedback=f"Shell denied: {cmd}") case _: return PermissionDecisionApproveOnce() session = await client.create_session( on_permission_request=on_permission_request, model="gpt-5", ) ``` Async handlers are also supported: ```python async def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: if getattr(request, "managed_approval_required", False) is True: return PermissionNoResult() # Simulate an async approval check (e.g., prompting a user over a network) await asyncio.sleep(0) return PermissionDecisionApproveOnce() ``` ### Permission Result Kinds The handler returns a ``PermissionRequestResult``, which is an alias for ``PermissionDecision | PermissionNoResult`` (the generated wire-level union of every decision variant, plus a sentinel that suppresses this SDK client's response). Approval decisions are present-tense — they describe the decision to apply, not the past-tense outcome reported back on `permission.completed` session events. | Variant | Meaning | | --------------------------------------------- | ------------------------------------------------------------------------------------------- | | `PermissionDecisionApproveOnce()` | Allow this single request | | `PermissionDecisionReject(feedback="…")` | Deny the request (optional feedback string forwarded to the LLM) | | `PermissionDecisionUserNotAvailable()` | Deny the request because no user is available to confirm it (the default) | | `PermissionNoResult()` | During event-based dispatch, suppress this SDK client's response so another connected client can answer the pending request; legacy direct callbacks cannot abstain | Several richer variants (``PermissionDecisionApproveForSession``, ``PermissionDecisionApproveForLocation``, ``PermissionDecisionApprovePermanently``, …) are available for granting longer-lived approvals; see the generated ``copilot.rpc`` module for the full list. ### Resuming Sessions You may pass `on_permission_request` when resuming a session too: ```python session = await client.resume_session( "session-id", on_permission_request=PermissionHandler.approve_all, ) ``` ### Per-Tool Skip Permission To let a specific custom tool bypass the permission prompt entirely, set `skip_permission=True` on the tool definition. See [Skipping Permission Prompts](#skipping-permission-prompts) under Tools. ## User Input Requests Enable the legacy question-and-answer `ask_user` tool by providing an `on_user_input_request` handler: ```python async def handle_user_input(request, invocation): # request["question"] - The question to ask # request.get("choices") - Optional list of choices for multiple choice # request.get("allowFreeform", True) - Whether freeform input is allowed print(f"Agent asks: {request['question']}") if request.get("choices"): print(f"Choices: {', '.join(request['choices'])}") # Return the user's response return { "answer": "User's answer here", "wasFreeform": True, # Whether the answer was freeform (not from choices) } async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", on_user_input_request=handle_user_input, ) as session: ... ``` ## Session Hooks Hook into session lifecycle events by providing handlers in the `hooks` configuration: ```python async def on_pre_tool_use(input, invocation): print(f"About to run tool: {input['toolName']}") # Return permission decision and optionally modify args return { "permissionDecision": "allow", # "allow", "deny", or "ask" "modifiedArgs": input.get("toolArgs"), # Optionally modify tool arguments "additionalContext": "Extra context for the model", } async def on_post_tool_use(input, invocation): print(f"Tool {input['toolName']} completed") return { "additionalContext": "Post-execution notes", } async def on_post_tool_use_failure(input, invocation): # Fires when a tool's result was a failure. `on_post_tool_use` only fires # on success, so register this handler to observe failed tool calls. The # CLI extracts the failure message and passes it as the `error` field. print(f"Tool {input['toolName']} failed: {input['error']}") return { "additionalContext": f"Retry guidance for {input['toolName']}", } async def on_user_prompt_submitted(input, invocation): print(f"User prompt: {input['prompt']}") return { "modifiedPrompt": input["prompt"], # Optionally modify the prompt } async def on_session_start(input, invocation): print(f"Session started from: {input['source']}") # "startup", "resume", "new" return { "additionalContext": "Session initialization context", } async def on_session_end(input, invocation): print(f"Session ended: {input['reason']}") async def on_error_occurred(input, invocation): print(f"Error in {input['errorContext']}: {input['error']}") return { "errorHandling": "retry", # "retry", "skip", or "abort" } async with await client.create_session( on_permission_request=PermissionHandler.approve_all, model="gpt-5", hooks={ "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, "on_post_tool_use_failure": on_post_tool_use_failure, "on_user_prompt_submitted": on_user_prompt_submitted, "on_session_start": on_session_start, "on_session_end": on_session_end, "on_error_occurred": on_error_occurred, }, ) as session: ... ``` **Available hooks:** - `on_pre_tool_use` - Intercept tool calls before execution. Can allow/deny or modify arguments. - `on_post_tool_use` - Process tool results after successful execution. Can modify results or add context. - `on_post_tool_use_failure` - Observe failed tool executions and inject extra context to guide the model's next step. - `on_user_prompt_submitted` - Intercept user prompts. Can modify the prompt before processing. - `on_session_start` - Run logic when a session starts or resumes. - `on_session_end` - Cleanup or logging when session ends. - `on_error_occurred` - Handle errors with retry/skip/abort strategies. ## Commands Register slash commands that users can invoke from the CLI TUI. When the user types `/commandName`, the SDK dispatches the event to your handler. ```python from copilot.session import CommandDefinition, CommandContext, PermissionHandler async def handle_deploy(ctx: CommandContext) -> None: print(f"Deploying with args: {ctx.args}") # ctx.session_id — the session where the command was invoked # ctx.command — full command text (e.g. "/deploy production") # ctx.command_name — command name without leading / (e.g. "deploy") # ctx.args — raw argument string (e.g. "production") async with await client.create_session( on_permission_request=PermissionHandler.approve_all, commands=[ CommandDefinition( name="deploy", description="Deploy the app", handler=handle_deploy, ), CommandDefinition( name="rollback", description="Rollback to previous version", handler=lambda ctx: print("Rolling back..."), ), ], ) as session: ... ``` Commands can also be provided when resuming a session via `resume_session(commands=[...])`. ## UI Elicitation The `session.ui` API provides convenience methods for asking the user questions through interactive dialogs. These methods are only available when the CLI host supports elicitation — check `session.capabilities` before calling. ### Capability Check ```python ui_caps = session.capabilities.get("ui", {}) if ui_caps.get("elicitation"): # Safe to call session.ui methods ... ``` ### Confirm Shows a yes/no confirmation dialog: ```python ok = await session.ui.confirm("Deploy to production?") if ok: print("Deploying...") ``` ### Select Shows a selection dialog with a list of options: ```python env = await session.ui.select("Choose environment:", ["staging", "production", "dev"]) if env: print(f"Selected: {env}") ``` ### Input Shows a text input dialog with optional constraints: ```python name = await session.ui.input("Enter your name:") # With options email = await session.ui.input( "Enter email:", { "title": "Email Address", "description": "We'll use this for notifications", "format": "email", }, ) ``` ### Custom Elicitation For full control, use the `elicitation()` method with a custom JSON schema: ```python result = await session.ui.elicitation( { "message": "Configure deployment", "requestedSchema": { "type": "object", "properties": { "region": {"type": "string", "enum": ["us-east-1", "eu-west-1"]}, "replicas": {"type": "number", "minimum": 1, "maximum": 10}, }, "required": ["region"], }, } ) if result["action"] == "accept": region = result["content"]["region"] replicas = result["content"].get("replicas", 1) ``` ## Elicitation Request Handler When the server (or an MCP tool) needs to ask the end-user a question, it sends an `elicitation.requested` event. Provide an `on_elicitation_request` handler to respond: ```python from copilot.session import ElicitationContext, ElicitationResult, PermissionHandler async def handle_elicitation( context: ElicitationContext, ) -> ElicitationResult: # context["session_id"] — the session ID # context["message"] — what the server is asking # context.get("requestedSchema") — optional JSON schema for form fields # context.get("mode") — "form" or "url" print(f"Server asks: {context['message']}") # Return the user's response return { "action": "accept", # or "decline" or "cancel" "content": {"answer": "yes"}, } async with await client.create_session( on_permission_request=PermissionHandler.approve_all, on_elicitation_request=handle_elicitation, ) as session: ... ``` When `on_elicitation_request` is provided, the SDK automatically: - Sends `requestElicitation: true` to the server during session creation/resumption - Reports the `elicitation` capability on the session - Dispatches `elicitation.requested` events to your handler - Auto-cancels if your handler throws an error (so the server doesn't hang) ## Development Install [uv](https://docs.astral.sh/uv/) and a supported [Node.js version](../nodejs/README.md#prerequisites), then from the repository root: ```bash cd nodejs npm ci ``` ```bash cd test/harness npm ci ``` ```bash cd python uv sync uv run pytest ```