# GitHub Copilot CLI SDK for Rust A Rust SDK for programmatic access to the GitHub Copilot CLI. See [github/copilot-sdk](https://github.com/github/copilot-sdk) for the equivalent SDKs in TypeScript, Python, Go, .NET, and Java. The Rust SDK seeks parity with those SDKs; see [Differences From Other SDKs](#differences-from-other-sdks) below for the small set of intentional divergences. **Releases:** [github.com/github/copilot-sdk/releases](https://github.com/github/copilot-sdk/releases) — combined release notes for all SDK languages. ## Prerequisites To use the SDK, you'll need: - Rust 1.94.0 or later ## Quick Start ```rust,no_run use std::sync::Arc; use github_copilot_sdk::{Client, ClientOptions, SessionConfig}; use github_copilot_sdk::handler::ApproveAllHandler; # async fn example() -> Result<(), github_copilot_sdk::Error> { let client = Client::start(ClientOptions::default()).await?; let session = client.create_session( SessionConfig::default().with_permission_handler(Arc::new(ApproveAllHandler)), ).await?; let _message_id = session.send("Hello!").await?; session.disconnect().await?; client.stop().await.ok(); # Ok(()) # } ``` When targeting MCP tools configured through `mcp_servers`, remember the runtime tool name is `-`. For `available_tools` and `excluded_tools`, prefer `ToolSet::new().add_mcp("-")` or the raw `mcp:-` form. For `custom_agents[].tools` and `default_agent.excluded_tools`, use `-` directly. ## Architecture ```text Your Application ↓ github_copilot_sdk::Client (manages CLI process lifecycle) ↓ github_copilot_sdk::Session (per-session event loop + handler dispatch) ↓ JSON-RPC over stdio or TCP copilot --server --stdio ``` The SDK manages the CLI process lifecycle: spawning, health-checking, and graceful shutdown. Communication uses [JSON-RPC 2.0](https://www.jsonrpc.org/specification) over stdin/stdout with `Content-Length` framing (the same protocol used by LSP). TCP transport is also supported. ## API Reference ### Client ```rust,ignore // Start a client (spawns CLI process) let client = Client::start(options).await?; // Create a new session let session = client.create_session(config.with_permission_handler(handler)).await?; // Resume an existing session let session = client.resume_session(config.with_permission_handler(handler)).await?; // Low-level RPC let result = client.call("method.name", Some(params)).await?; let response = client.send_request("method.name", Some(params)).await?; // Health check (echoes message back, returns typed PingResponse) let pong = client.ping("hello").await?; // Shutdown client.stop().await?; ``` After `Client::start` succeeds, inspect its startup cost without parsing logs: ```rust,ignore let timings = client.startup_timings().expect("started by Client::start"); println!( "startup={}ms transport={}ms handshake={}ms", timings.total_ms, timings.transport_setup_ms, timings.handshake_ms ); ``` Transport-specific phases are optional. For example, `port_wait_ms` is present only for TCP and `process_spawn_ms` is absent for external and in-process transports. **`ClientOptions`:** | Field | Type | Description | | ------------------- | --------------------------- | ----------------------------------------------------------------- | | `program` | `CliProgram` | `Resolve` (default: auto-detect) or `Path(PathBuf)` (explicit) | | `prefix_args` | `Vec` | Args before `--server` (e.g. script path for node) | | `working_directory` | `PathBuf` | Working directory for CLI process (empty = host process's cwd) | | `env` | `Vec<(OsString, OsString)>` | Environment variables for CLI process | | `env_remove` | `Vec` | Environment variables to remove | | `extra_args` | `Vec` | Extra CLI flags | | `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` | | `extension_launch_provider` | `Option>` | Connection-global extension launch resolver | With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport loads the native runtime library adjacent to that resolved runtime bundle. There is no PATH scanning. #### Extension launch provider Hosts that own legacy extension process assets can supply a typed, asynchronous launch resolver: ```rust,ignore use std::collections::HashMap; use async_trait::async_trait; use github_copilot_sdk::extension_launch_provider::{ ExtensionLaunchProfile, ExtensionLaunchProvider, ExtensionLaunchProviderResolveRequest, ExtensionLaunchProviderResolveResult, }; use github_copilot_sdk::{Client, ClientOptions, Result}; struct AppExtensionLaunchProvider; #[async_trait] impl ExtensionLaunchProvider for AppExtensionLaunchProvider { async fn resolve( &self, request: ExtensionLaunchProviderResolveRequest, ) -> Result { Ok(ExtensionLaunchProviderResolveResult { launch: Some(ExtensionLaunchProfile { executable: "/app/copilot".to_string(), args: vec!["/app/preloads/extension_bootstrap.mjs".to_string()], env: HashMap::from([ ("COPILOT_AUTO_UPDATE".to_string(), "false".to_string()), ("EXTENSION_PATH".to_string(), request.module_path), ]), }), }) } } let client = Client::start( ClientOptions::new().with_extension_launch_provider(AppExtensionLaunchProvider), ).await?; ``` `Client::start` registers the provider before returning, and reverse requests are routed at the connection level rather than through a session. The SDK forwards the returned executable, arguments, and environment unchanged; it does not discover or bundle an executable or bootstrap. The runtime owns and overrides `COPILOT_SDK_PATH`, `SESSION_ID`, and `COPILOT_EXTENSION_PARENT_PID`. `COPILOT_CLI_DIST_DIR` is only appropriate when the host supplies a complete CLI distribution containing `index.js` and its matching preloads. When the executable is a version-matched standalone Copilot binary, omit that variable and set `COPILOT_AUTO_UPDATE=false` so its embedded distribution remains selected. ### Session Created via `Client::create_session` or `Client::resume_session`. Owns an internal event loop that dispatches CLI callbacks to the focused handler traits you install on `SessionConfig`, and broadcasts session events through `subscribe()`. `SessionConfig::working_directory` sets the session working directory. When unset, the runtime uses its process working directory. ```rust,ignore use github_copilot_sdk::MessageOptions; // Simple send — &str / String convert into MessageOptions automatically. // Returns the assigned message ID for correlation with later events. let _id = session.send("Fix the bug in auth.rs").await?; // Send with mode and attachments let _id = session .send( MessageOptions::new("What's in this image?") .with_mode("autopilot") .with_attachments(attachments), ) .await?; // Message history let messages = session.get_events().await?; // Abort the current agent turn session.abort().await?; // Model management session.set_model("claude-sonnet-4.5", None).await?; // Generated typed RPCs cover lower-level session operations. let model = session.rpc().model().get_current().await?; let mode = session.rpc().mode().get().await?; // Workspace files let files = session.rpc().workspaces().list_files().await?; let content = session .rpc() .workspaces() .read_file(github_copilot_sdk::rpc::WorkspacesReadFileRequest { path: "plan.md".to_string(), }) .await?; // Plan management let plan = session.rpc().plan().read().await?; session .rpc() .plan() .update(github_copilot_sdk::rpc::PlanUpdateRequest { content: "Updated plan content".to_string(), }) .await?; // Fleet (sub-agents) session .rpc() .fleet() .start(github_copilot_sdk::rpc::FleetStartRequest { prompt: Some("Implement the auth module".to_string()), }) .await?; // Cleanup (preserves on-disk session state for later resume) session.disconnect().await?; ``` #### Typed RPC namespace High-level helpers are convenience wrappers over a fully-typed JSON-RPC namespace generated from the GitHub Copilot CLI schema. `Client::rpc()` and `Session::rpc()` give direct access to every method on the wire, including ones with no helper today, with strongly-typed request and response structs. ```rust,ignore // Common generated RPCs. let files = session.rpc().workspaces().list_files().await?.files; let models = client.rpc().models().list().await?.models; // Methods with no helper — full schema-typed access. let agents = session.rpc().agent().list().await?.agents; let tasks = session.rpc().tasks().list().await?.tasks; let forked = client .rpc() .sessions() .fork(github_copilot_sdk::rpc::SessionsForkRequest { session_id: "session-id".into(), to_event_id: None, }) .await?; ``` New RPCs land in the namespace immediately as the schema regenerates; helpers are added on top only when an ergonomic story is worth the maintenance. ### Handler Traits The SDK exposes five focused handler traits, one per CLI callback type. Implement only the traits you need and install each with the matching `SessionConfig` setter. Each trait has a single `async fn handle(...)` method: | Trait | Setter | Purpose | | ----------------------- | --------------------------------- | --------------------------------------------- | | `PermissionHandler` | `with_permission_handler(...)` | Approve/deny tool-use permission requests | | `ElicitationHandler` | `with_elicitation_handler(...)` | Respond to structured elicitation prompts | | `UserInputHandler` | `with_user_input_handler(...)` | Answer free-form / choice user-input prompts | | `ExitPlanModeHandler` | `with_exit_plan_mode_handler(...)`| Respond when the agent exits plan mode | | `AutoModeSwitchHandler` | `with_auto_mode_switch_handler(...)`| Respond to automatic mode-switch proposals | The CLI's `requestPermission` / `requestElicitation` / `requestUserInput` / etc. wire flags are derived automatically from which traits you've installed — clients that don't install a handler are silently skipped, letting another connected client handle the request. ```rust,ignore use std::sync::Arc; use async_trait::async_trait; use github_copilot_sdk::handler::{PermissionHandler, PermissionResult}; use github_copilot_sdk::types::{PermissionRequestData, RequestId, SessionId}; struct MyPermissions; #[async_trait] impl PermissionHandler for MyPermissions { async fn handle( &self, _sid: SessionId, _rid: RequestId, data: PermissionRequestData, ) -> PermissionResult { if data.managed_approval_required == Some(true) { return PermissionResult::no_result(); } if data.extra.get("tool").and_then(|v| v.as_str()) == Some("view") { PermissionResult::approve_once() } else { PermissionResult::reject(None) } } } let config = SessionConfig::default().with_permission_handler(Arc::new(MyPermissions)); ``` A single type can implement multiple handler traits — share one `Arc` across the setters by cloning: ```rust,ignore let h = Arc::new(MyHandler); let config = SessionConfig::default() .with_permission_handler(h.clone()) .with_user_input_handler(h); ``` The built-in `ApproveAllHandler` and `DenyAllHandler` implement `PermissionHandler` for the common cases. When `enable_managed_settings` is true, `ApproveAllHandler` logs an error and returns a user-not-available decision; custom handlers can inspect `managed_approval_required` when implementing a human-facing confirmation flow. To observe streamed session events (assistant messages, tool calls, etc.), call `session.subscribe()` — see [Streaming](#streaming) below. ### SessionConfig ```rust,ignore let config = SessionConfig { model: Some("gpt-5".into()), system_message: Some(SystemMessageConfig { content: Some("Always explain your reasoning.".into()), ..Default::default() }), ..Default::default() } .with_elicitation_handler(Arc::new(my_elicitation_handler)) .with_permission_handler(handler); let session = client.create_session(config).await?; ``` Use `with_ask_user_variant(AskUserVariant::Elicitation)` together with `with_elicitation_handler(...)` to expose the structured form-based `ask_user` tool. The default remains `AskUserVariant::Legacy`. Re-supply the option and handler through `ResumeSessionConfig` on a cold resume. For rotating per-session GitHub credentials, install a `GitHubTokenProvider` instead of setting `github_token`: ```rust,ignore use github_copilot_sdk::{ GitHubToken, GitHubTokenProviderArgs, GitHubTokenProviderResult, SessionConfig, }; let provider = Arc::new(|args: GitHubTokenProviderArgs| async move { let access_token = acquire_for_host(&args.host).await?; Ok(GitHubTokenProviderResult::Token(GitHubToken::new( access_token, 8 * 60 * 60, ))) }); let config = SessionConfig::default().with_github_token_provider(provider); ``` The remaining lifetime is required and must be positive when the callback completes; production GitHub tokens typically last eight hours. Static `github_token` and a provider are mutually exclusive. The same provider API is available on `ResumeSessionConfig`. 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. ### Auto routing tiers Use `CapiSessionOptions::with_auto_tier` to select `AutoTier::Efficiency`, `AutoTier::Balance`, or `AutoTier::Intelligence`. This option is meaningful only with model `auto` (Auto mode V2). It requires a runtime version that supports `capi.autoTier`. ```rust use github_copilot_sdk::{AutoTier, CapiSessionOptions, SessionConfig}; let config = SessionConfig::default() .with_model("auto") .with_capi(CapiSessionOptions::new().with_auto_tier(AutoTier::Balance)); ``` The same options work with `ResumeSessionConfig::with_capi` and can be combined with `with_enable_web_socket_responses(false)`. The SDK omits an unset tier: the runtime chooses its default on create and preserves the persisted/current tier on resume. An explicit tier overrides the persisted tier on cold resume. On resident resume, a different tier requests a safe switch applied after the resume succeeds; it cannot change a turn that is already in flight. The SDK does not choose a default or manage tier persistence. ### Changing the Auto tier during a session 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. ```rust,ignore use github_copilot_sdk::{AutoTier, ModelSwitchAutoTierStatus}; let result = session.set_auto_tier(Some(AutoTier::Intelligence)).await?; if result.status == ModelSwitchAutoTierStatus::Pending { // Accepted, but not yet in effect. } // Return to the provider's default Auto routing. session.set_auto_tier(None).await?; ``` `set_model` accepts the same preference through `SetModelOptions::with_auto_tier`, which stages the tier atomically with selecting `auto`. Use `with_reset_auto_tier` instead to return to provider-default routing. See [Auto tier persistence](../docs/features/session-persistence.md#auto-tier-persistence) for the lifecycle rules. ### Session Hooks Hooks intercept CLI behavior at lifecycle points — tool use, prompt submission, session start/end, and errors. Install a `SessionHooks` impl with [`SessionConfig::with_hooks`] — the SDK auto-enables `hooks` in `SessionConfig` when one is set. ```rust,ignore use std::sync::Arc; use github_copilot_sdk::hooks::*; use async_trait::async_trait; struct MyHooks; #[async_trait] impl SessionHooks for MyHooks { async fn on_hook(&self, event: HookEvent) -> HookOutput { match event { HookEvent::PreToolUse { input, ctx } => { if input.tool_name == "dangerous_tool" { HookOutput::PreToolUse(PreToolUseOutput { permission_decision: Some("deny".to_string()), permission_decision_reason: Some("blocked by policy".to_string()), ..Default::default() }) } else { HookOutput::None // pass through } } HookEvent::SessionStart { input, .. } => { HookOutput::SessionStart(SessionStartOutput { additional_context: Some("Extra system context".to_string()), ..Default::default() }) } _ => HookOutput::None, } } } let session = client .create_session( config .with_permission_handler(handler) .with_hooks(Arc::new(MyHooks)), ) .await?; ``` **Hook events:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmitted`, `UserPromptTransformed`, `SessionStart`, `SessionEnd`, `ErrorOccurred`. Each carries typed input/output structs. `PostToolUse` only fires on success; override `on_post_tool_use_failure` to observe failed tool calls. Return `HookOutput::None` for events you don't handle. ### System Message Transforms Transforms customize system message sections during session creation. The SDK injects `action: "transform"` entries for each section ID your transform handles. ```rust,ignore use github_copilot_sdk::transforms::*; use async_trait::async_trait; struct MyTransform; #[async_trait] impl SystemMessageTransform for MyTransform { fn section_ids(&self) -> Vec { vec!["instructions".to_string()] } async fn transform_section( &self, _section_id: &str, content: &str, _ctx: TransformContext, ) -> Option { Some(format!("{content}\n\nAlways be concise.")) } } let session = client .create_session( config .with_permission_handler(handler) .with_system_message_transform(Arc::new(MyTransform)), ) .await?; ``` ### Tool Registration Define client-side tools as named types implementing `ToolHandler` and attach them to `Tool` declarations via `Tool::with_handler`, then install via `SessionConfig::with_tools`. Enable the `derive` feature for `schema_for::()` — it generates JSON Schema from Rust types via `schemars`. ```rust,ignore use std::sync::Arc; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::tool::{schema_for, JsonSchema, ToolHandler}; use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; use serde::Deserialize; use async_trait::async_trait; #[derive(Deserialize, JsonSchema)] struct GetWeatherParams { /// City name city: String, /// Temperature unit unit: Option, } struct GetWeatherTool; #[async_trait] impl ToolHandler for GetWeatherTool { async fn call(&self, inv: ToolInvocation) -> Result { let params: GetWeatherParams = serde_json::from_value(inv.arguments)?; Ok(ToolResult::Text(format!("Weather in {}: sunny", params.city))) } } let tool = Tool::new("get_weather") .with_description("Get weather for a city") .with_parameters(schema_for::()) .with_handler(Arc::new(GetWeatherTool)); let config = SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) .with_tools(vec![tool]); let session = client.create_session(config).await?; ``` Tools are named types (not closures) — visible in stack traces and navigable via "go to definition". The SDK registers each tool's handler under its `Tool::name` and surfaces the same `Tool` definitions to the CLI automatically. Tools without an attached handler (`Tool::with_handler` never called) are declaration-only: the SDK advertises them on the wire but doesn't dispatch invocations to anything. Useful when another connected client services the tool. For trivial tools that don't need a named type, the `define_tool` helper function (available with the `derive` feature) collapses the definition to a single expression and returns a fully-formed `Tool` with handler attached: ```rust,ignore use github_copilot_sdk::tool::{define_tool, JsonSchema}; use github_copilot_sdk::ToolResult; use serde::Deserialize; #[derive(Deserialize, JsonSchema)] struct GetWeatherParams { city: String } let tool = define_tool( "get_weather", "Get weather for a city", |_inv, params: GetWeatherParams| async move { Ok(ToolResult::Text(format!("Sunny in {}", params.city))) }, ); let config = SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) .with_tools(vec![tool]); ``` The closure receives the full [`ToolInvocation`](crate::types::ToolInvocation) alongside the deserialized parameters, so handlers that need `inv.session_id` or `inv.tool_call_id` for telemetry, streaming updates, or scoped lookups can use them directly. Use `_inv` when you don't need the metadata. Reach for the `ToolHandler` trait directly when you need shared state across multiple methods or want a named type that shows up by name in stack traces. ### Permission Policies Set a permission policy directly on `SessionConfig` with the chainable builders. They install a synthesized `PermissionHandler` so only permission requests are intercepted; every other event flows through unchanged. When `enable_managed_settings` is true, the approve-all policy logs an error and returns a user-not-available decision. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. ```rust,ignore let session = client .create_session( SessionConfig::default() .approve_all_permissions(), // or .deny_all_permissions() // or .approve_permissions_if(|data| { // data.extra.get("tool").and_then(|v| v.as_str()) != Some("shell") // }) ) .await?; ``` > The policy builders set the permission handler slot directly; they're equivalent to calling `with_permission_handler(...)` with the corresponding built-in (`ApproveAllHandler`, `DenyAllHandler`, or `permission::approve_if(...)`). The `permission` module also exposes the policy primitives as standalone helpers for the rare case where you want to construct the handler value separately and install it via `with_permission_handler`: ```rust,ignore use github_copilot_sdk::permission; let handler = permission::approve_if(|data| { data.extra.get("tool").and_then(|v| v.as_str()) != Some("shell") }); // or permission::approve_all() / permission::deny_all() let session = client .create_session(config.with_permission_handler(handler)) .await?; ``` ### Elicitation To opt your client into receiving `elicitation.requested` broadcasts, install an `ElicitationHandler` on the session config. The wire flag `requestElicitation` is derived from the presence of the handler; clients without one are silently skipped, allowing other connected clients on the same CLI to handle the request. ```rust,ignore use async_trait::async_trait; use github_copilot_sdk::handler::{ElicitationHandler, ElicitationResult}; use github_copilot_sdk::types::{ElicitationRequest, RequestId, SessionId}; struct MyElicitation; #[async_trait] impl ElicitationHandler for MyElicitation { async fn handle( &self, _sid: SessionId, _rid: RequestId, _request: ElicitationRequest, ) -> ElicitationResult { ElicitationResult::cancel() } } let config = SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) .with_ask_user_variant(AskUserVariant::Elicitation) .with_elicitation_handler(Arc::new(MyElicitation)); ``` The handler receives a message, optional JSON Schema for form fields, and an optional mode. Known modes include `Form` and `Url`, but the mode may be absent or an unknown future value. ### User Input Requests Some sessions ask the user free-form questions (or multiple-choice prompts) outside the elicitation flow. Install a `UserInputHandler` and the SDK will forward `userInput.request` callbacks: ```rust,ignore use async_trait::async_trait; use github_copilot_sdk::handler::{UserInputHandler, UserInputResponse}; use github_copilot_sdk::types::SessionId; struct MyUserInput; #[async_trait] impl UserInputHandler for MyUserInput { async fn handle( &self, _sid: SessionId, question: String, _choices: Option>, _allow_freeform: Option, ) -> Option { // Render `question` + `choices` to your UI, then: Some(UserInputResponse { answer: "Yes".to_string(), was_freeform: false, }) } } let config = SessionConfig::default() .with_user_input_handler(Arc::new(MyUserInput)); ``` Return `None` to signal "no answer available" (the CLI falls back to its own prompt). ### Slash Commands Register named commands so users can invoke them as `/name args` from the TUI: ```rust,ignore use github_copilot_sdk::types::{CommandContext, CommandDefinition, CommandHandler}; use async_trait::async_trait; struct DeployCommand; #[async_trait] impl CommandHandler for DeployCommand { async fn on_command(&self, ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> { println!("deploy {}", ctx.args); Ok(()) } } let mut config = SessionConfig::default(); config.commands = Some(vec![ CommandDefinition::new("deploy", Arc::new(DeployCommand)) .with_description("Deploy the application"), ]); ``` Only `name` and `description` are sent over the wire; the handler stays in your process. Returning `Err(_)` surfaces the message back through the TUI. ### Streaming Set `streaming: true` to receive incremental delta events alongside finalized messages: ```rust,ignore let mut config = SessionConfig::default(); config.streaming = Some(true); let mut events = session.subscribe(); while let Ok(event) = events.recv().await { match event.event_type.as_str() { "assistant.message_delta" | "assistant.reasoning_delta" => { if let Some(d) = event.data.get("delta").and_then(|v| v.as_str()) { print!("{d}"); } } "assistant.message" => println!(), // final _ => {} } } ``` When streaming is off (the default), only the final `assistant.message` and `assistant.reasoning` events fire. Delta events arrive in order; concatenating their `delta` text payloads reproduces the final message. #### Subscribing before the session starts `session.subscribe()` can only be called once the session exists, so any event the runtime emits while `session.create` / `session.resume` is still in flight is broadcast with no receiver installed and is not delivered. Ephemeral events such as `session.idle` are not written to the session log either, so `get_messages` can't recover them afterwards. `Client::prepare_session` / `Client::prepare_resume_session` close that window. They return a `PreparedSession` that owns the session's broadcast channel up front: ```rust,ignore let prepared = client.prepare_session( SessionConfig::default().with_event_buffer_capacity(2048), )?; // Installed before any wire activity happens. let mut events = prepared.subscribe(); tokio::spawn(async move { while let Ok(event) = events.recv().await { println!("{}", event.event_type); } }); let session = prepared.start().await?; ``` `prepare_*` is synchronous and inert — it validates the buffer capacity, allocates a local channel and cancellation token, and touches neither the router nor the transport until `start()` is first polled. `start(self)` consumes the handle and `PreparedSession` is deliberately not `Clone`, so a prepared session can never spawn two event loops. Dropping an unstarted handle leaves no state and closes its subscriptions; dropping the `start()` future cancels the startup, unregisters the session, and lets a same-ID retry succeed. Cleanup removes only the exact registration that startup owned, so a retry started while an abandoned attempt is still unwinding is never evicted by it. The buffer is finite — `session::DEFAULT_EVENT_BUFFER_CAPACITY` (512) unless `event_buffer_capacity` overrides it, and `Some(0)` is rejected as `ErrorKind::InvalidConfig` rather than clamped. Subscribers that fall behind observe `RecvErrorKind::Lagged` with the skipped count instead of applying backpressure, so a consumer that needs a lossless view of a large startup burst must configure enough capacity or drain concurrently with `start()`. For cloud sessions where the server assigns the session ID, notifications can't be routed until the create response arrives; the guarantee is that *routed* events are never dropped for lack of a receiver. Pin `session_id` for full pre-response coverage. `create_session` / `resume_session` are unchanged wrappers over `prepare_*(...)?.start()`, with identical RPC sequences and error kinds. ### Infinite Sessions Enable the SDK's session-store integration so conversations persist across CLI restarts and grow beyond the model's context window via automatic compaction: ```rust,ignore use github_copilot_sdk::types::InfiniteSessionConfig; let mut infinite = InfiniteSessionConfig::default(); infinite.workspace_path = Some("/path/to/workspace".into()); let mut config = SessionConfig::default(); config.infinite_sessions = Some(infinite); ``` The CLI emits `session.compaction_start` / `session.compaction_complete` events around each compaction. The session id remains stable across compactions; resume with `Client::resume_session` to pick up a prior conversation. Workspace state lives under `~/.copilot/session-state/{sessionId}` by default — override with `workspace_path` to relocate. `enable_session_store` on `SessionConfig` enables the cross-session store for search and retrieval across sessions. When unset in the default client mode, the runtime default applies (enabled). In `Empty` mode, defaults to disabled. ### Memory Configure the runtime memory feature for a session: For more background, see [About GitHub Copilot Memory](https://docs.github.com/en/copilot/concepts/agents/copilot-memory). ```rust,ignore use github_copilot_sdk::types::{MemoryConfiguration, SessionConfig}; let config = SessionConfig::default().with_memory(MemoryConfiguration::enabled()); ``` `MemoryConfiguration` is accepted on both `Client::create_session` and `Client::resume_session` (via `ResumeSessionConfig::with_memory`). `enabled` toggles the feature. The client mode affects the default: in the default `ClientMode::CopilotCli` the SDK leaves `memory` unset so the runtime applies its own default, while `ClientMode::Empty` defaults `memory` to disabled unless you set it explicitly. ### Custom Providers (BYOK) Route model traffic through your own inference endpoint instead of GitHub's hosted models: ```rust,ignore use github_copilot_sdk::types::ProviderConfig; let mut provider = ProviderConfig::default(); provider.provider_type = Some("openai".to_string()); provider.base_url = "https://my-proxy.example.com/v1".to_string(); provider.bearer_token = Some(std::env::var("OPENAI_API_KEY")?); let mut config = SessionConfig::default(); config.provider = Some(provider); ``` Provider types include `"openai"`, `"azure"`, and `"anthropic"`. Set `wire_api` to `"completions"` or `"responses"` (OpenAI/Azure only). Custom headers go in `provider.headers`. The SDK forwards the configuration to the CLI verbatim — the CLI handles the upstream call, including authentication. ### Telemetry Forward OpenTelemetry signals from the spawned CLI process to your collector: ```rust,ignore use github_copilot_sdk::{ClientOptions, OtelExporterType, OtlpHttpProtocol, TelemetryConfig}; let mut telem = TelemetryConfig::default(); telem.exporter_type = Some(OtelExporterType::OtlpHttp); telem.otlp_endpoint = Some("http://localhost:4318".to_string()); telem.otlp_protocol = Some(OtlpHttpProtocol::HttpProtobuf); telem.source_name = Some("my-app".to_string()); let mut opts = ClientOptions::default(); opts.telemetry = Some(telem); let client = Client::start(opts).await?; ``` The SDK injects the appropriate environment variables (`COPILOT_OTEL_EXPORTER_TYPE`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`, ...) into the spawned CLI process. The SDK takes no OpenTelemetry dependency; the CLI itself owns the exporter pipeline. Caller-supplied `ClientOptions::env` entries override telemetry-injected values. ### Message Source Use `MessageSource::System` for automated messages sent by your application. Ordinary human sends leave `source` unset, so the field is omitted from the request. Use `MessageSource::User` when you need to set it explicitly. For messages from another agent, use `MessageSource::Agent("sender-id".into())` with the trusted sender ID. It serializes as `"agent-sender-id"` and works with both `MessageOptions::with_source` and `rpc::SendRequest::with_source`. Unlike internal system context, an identified agent message retains agent provenance. ```rust,no_run use github_copilot_sdk::{MessageOptions, MessageSource, session::Session}; # async fn example(session: &Session) -> Result<(), github_copilot_sdk::Error> { session .send(MessageOptions::new("Context updated").with_source(MessageSource::System)) .await?; # Ok(()) # } ``` The raw RPC path supports the same builder, including requests with JSON attachments: ```rust,no_run use github_copilot_sdk::{MessageSource, rpc::SendRequest, session::Session}; # async fn example(session: &Session) -> Result<(), github_copilot_sdk::Error> { let mut request = SendRequest::default().with_source(MessageSource::System); request.prompt = "Context updated".into(); request.attachments = Some(vec![serde_json::json!({ "type": "github_url", "url": "https://github.com/github/copilot-sdk" })]); session.rpc().send(request).await?; # Ok(()) # } ``` Both paths use ordinary `session.send`. Source does not select a delivery mode or set billing flags; the runtime applies its existing source behavior. `send_and_wait` still completes on `session.idle` and may return `Ok(None)` when no assistant message was emitted. Genuine errors still propagate. ### Progress Reporting (`send_and_wait`) For fire-and-forget messaging where you need to block until the agent finishes: ```rust,ignore use std::time::Duration; use github_copilot_sdk::MessageOptions; // Sends a message and blocks until session.idle or session.error session .send_and_wait( MessageOptions::new("Fix the bug").with_wait_timeout(Duration::from_secs(120)), ) .await?; ``` Default timeout is 60 seconds. Only one `send_and_wait` can be active per session — concurrent calls return an error. ### Newtypes **`SessionId`** — a newtype wrapper around `String` that prevents accidentally passing workspace IDs or request IDs where session IDs are expected. Transparent serialization (`#[serde(transparent)]`), zero-cost `Deref`, and ergonomic comparisons with `&str` and `String`. ```rust,ignore use github_copilot_sdk::SessionId; let id = SessionId::new("sess-abc123"); assert_eq!(id, "sess-abc123"); // compare with &str let raw: String = id.into_inner(); // unwrap when needed ``` ### Error Handling The SDK uses a typed error enum: ```rust,ignore pub enum Error { Protocol(ProtocolError), // JSON-RPC framing, CLI startup, version mismatch Rpc { code: i32, message: String }, // CLI returned an error response Session(SessionError), // Session not found, agent error, timeout, conflicts Io(std::io::Error), // Transport I/O error Json(serde_json::Error), // Serialization error BinaryNotFound { name, hint }, // CLI binary not found } // Check if the transport is broken (caller should discard the client) if err.is_transport_failure() { client = Client::start(options).await?; } ``` ## Differences From Other SDKs The Rust SDK aligns closely with the Node, Python, Go, and .NET SDKs but diverges in a few places where Rust idiom or the type system gives a clearly better shape, and exposes a small additional surface where the language affords ergonomics the dynamically-typed SDKs don't. ### Shape divergence - **`SessionFsProvider` registration is direct, not factory-closure.** Where Node/Python/Go/.NET accept a closure that the runtime calls on each session-create to build a fresh provider, the Rust SDK takes `Arc` directly via [`SessionConfig::with_session_fs_provider`]. The factory pattern doesn't cleanly express in Rust at the session-config call site — there is no `Session` value to thread in, and the SDK already prefers traits over boxed closures for handler-shaped APIs (`PermissionHandler`, `ToolHandler`, `SessionHooks`, `SystemMessageTransform`). ```rust,ignore use std::sync::Arc; use github_copilot_sdk::session_fs::{SessionFsConfig, SessionFsConventions}; let mut options = ClientOptions::default(); options.session_fs = Some(SessionFsConfig::new( "/workspace", "/workspace/.copilot", SessionFsConventions::Posix, )); let client = Client::start(options).await?; let session = client .create_session( SessionConfig::default() .with_permission_handler(Arc::new(ApproveAllHandler)) .with_session_fs_provider(Arc::new(MyProvider::new())), ) .await?; ``` See [`examples/session_fs.rs`](examples/session_fs.rs) for a complete in-memory provider implementation. - **Canvas action dispatch is a single trait method, not per-action closures.** The Node SDK binds an optional `handler` closure on each entry of a canvas's `actions[]`. The Rust SDK exposes [`CanvasHandler::on_action`](crate::canvas::CanvasHandler::on_action) and expects the implementor to match on `ctx.action_name`. Same reasoning as `SessionFsProvider`: per-callback `Box` fields fight `Send + Sync + 'static` and skip exhaustiveness checks, and the SDK prefers trait + default-impl methods for handler-shaped extension points. ### Rust-only API A handful of conveniences exist only on the Rust SDK as of 0.1.0. These are surface areas where Rust idiom (newtypes, enums, trait objects) gives a clearly nicer shape than Node/Python/Go/.NET currently expose. Rust gets to be Rust here — cross-SDK parity for these is a post-release conversation, not a release blocker. None of these are deprecated and none of them are scheduled for removal. - **Typed newtypes** — `SessionId` and `RequestId` are `#[serde(transparent)]` newtypes around `String`, so the type system distinguishes a session identifier from an arbitrary `String` at compile time. Node/Python/Go use bare strings. - **Permission policy builders** — `permission::approve_all`, `permission::deny_all`, and `permission::approve_if(predicate)` in `crate::permission` provide composable, no-handler-needed `PermissionHandler` shortcuts. Other SDKs require a full handler implementation for these patterns. - **`Client::from_streams`** — connect to a CLI server over arbitrary caller-supplied `AsyncRead` / `AsyncWrite`. Useful for testing, in-process embedding, or custom transports. Other SDKs are spawn-only or fixed-stdio. - **`enum Transport { Default, Stdio, InProcess, Tcp, External }`** — explicit transport selector on `ClientOptions::transport`. Node/Python/Go rely on conditional config field combinations instead. - **Split `prefix_args` / `extra_args`** on `ClientOptions` — separate arg vectors for "prepend before subcommand" vs "append after the built-in flags", giving precise control over CLI invocation order without string-splicing. - **`Client::prepare_session` / `prepare_resume_session`** — return an inert `PreparedSession` whose `subscribe()` installs an event receiver before any protocol activity, so startup events (including ephemeral `session.idle`) aren't dropped. Other SDKs register callbacks on a config object instead, which sidesteps the problem in a way Rust's broadcast-based `subscribe()` cannot. ## Layout | File | Description | | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | | `lib.rs` | `Client`, `ClientOptions`, `CliProgram`, `Transport`, `Error` | | `extension_launch_provider.rs` | Connection-global `ExtensionLaunchProvider` trait and launch profile DTOs | | `session.rs` | `Session` struct, `PreparedSession`, event loop, `send`/`send_and_wait`, `Client::create_session`/`resume_session`/`prepare_session`/`prepare_resume_session` | | `subscription.rs` | `EventSubscription` / `LifecycleSubscription` (`Stream`-able observer handles for `subscribe()` / `subscribe_lifecycle()`) | | `handler.rs` | `PermissionHandler`, `ElicitationHandler`, `UserInputHandler`, `ExitPlanModeHandler`, `AutoModeSwitchHandler` traits; `ApproveAllHandler`, `DenyAllHandler` | | `hooks.rs` | `SessionHooks` trait, `HookEvent`/`HookOutput` enums, typed hook inputs/outputs | | `transforms.rs` | `SystemMessageTransform` trait, section-level system message customization | | `tool.rs` | `ToolHandler` trait, `define_tool`, `schema_for::()` (with `derive` feature) | | `types.rs` | CLI protocol types (`SessionId`, `SessionEvent`, `SessionConfig`, `Tool`, etc.) | | `resolve.rs` | Bundled-CLI resolution (`copilot_binary`) | | `embeddedcli.rs` | Embedded CLI extraction (gated on the default `bundled-cli` feature) | | `router.rs` | Internal connection-global request dispatch and per-session event demux | | `jsonrpc.rs` | Internal Content-Length framed JSON-RPC transport | ## Bundled runtime artifacts The SDK provisions two verified artifacts at build time. By default the `bundled-cli` feature embeds both the full Copilot CLI/Node SEA and a separate runtime bundle containing `copilot-runtime`, adjacent `runtime.node`, and its required assets. Managed transports use only the runtime bundle; the full CLI is available through `install_bundled_cli` for diagnostics and version probes. Enable `bundled-in-process` to additionally include the native runtime library in the runtime bundle and use `Transport::InProcess`: ```toml github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } ``` `CliProgram::Path` and raw `ClientOptions::extra_args` apply only to child-process transports. Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package with in-process transport. For builds that prefer a smaller artifact, disable the `bundled-cli` feature: ```toml github-copilot-sdk = { version = "1", default-features = false } ``` > **You become responsible for supplying the runtime at deployment.** With > `bundled-cli` disabled, the produced binary does not contain these artifacts > and will not search the system for them. For managed child-process transports, > supply a compatible wrapper pair via an explicit [`CliProgram::Path`]. > `COPILOT_CLI_PATH` remains a direct program override. > > **Convenience on the build machine only.** As a special case, > `build.rs` downloads and integrity-verifies the compatible CLI version and > drops it into the build machine's per-user cache; the runtime > resolver on that same machine will pick it up automatically. This > makes local development and CI ergonomic, but it does **not** carry > over when you copy the built binary to another machine — distributed > builds (release artifacts, signed installers, container images, etc.) > must either keep `bundled-cli` enabled or ship the runtime pair and set > `CliProgram::Path`. ### How it works 1. **Version pin.** `build.rs` reads the CLI version from one of two sources: - `cli-version.txt` and `cli-version-in-process.txt` at the crate root (present in published crate tarballs and vendored slots). - Otherwise, `../nodejs/package.json` (contributor build inside the github/copilot-sdk repo). The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib. 2. **Build time:** `build.rs` downloads the platform-specific full CLI archive and runtime package, then verifies both SHA-256 hashes against the release's `SHA256SUMS.txt` or the publish snapshots. Then: - **`bundled-cli` on (default):** embeds the full CLI release archive and a separately filtered runtime archive containing `copilot-runtime[.exe]`, `runtime.node`, and required assets. - **`bundled-in-process` on:** the runtime archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`). - **`bundled-cli` off:** downloads only the runtime package and extracts its managed runtime artifacts directly into the platform cache using staging files and atomic renames. 3. **Runtime:** in both modes the artifacts share one versioned directory: | OS | Path | |----|------| | macOS | `~/Library/Caches/github-copilot-sdk/cli//` | | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//` | | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\` | Old version directories accumulate in siblings; clean them up at your leisure. ### Overriding the extraction location [`ClientOptions::with_bundled_cli_extract_dir`] redirects embed-mode extraction to a custom directory (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.): ```rust,ignore use std::path::PathBuf; use github_copilot_sdk::{Client, ClientOptions}; let options = ClientOptions::new() .with_bundled_cli_extract_dir(PathBuf::from("/var/run/my-app/copilot")); let client = Client::start(options).await?; ``` With `bundled-cli` disabled the equivalent knob is the **`COPILOT_CLI_EXTRACT_DIR`** environment variable, which is honored symmetrically at build time (where `build.rs` writes the binary) and at runtime (where the resolver reads it). When set, the binary lives directly under the named directory (no per-version subdir). The most ergonomic way to pin it from a consumer crate is `.cargo/config.toml`: ```toml # .cargo/config.toml at the consumer's repo root [env] COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = true } ``` `relative = true` resolves the path against the config file's directory, so the value is stable regardless of where `cargo build` is invoked from. `force = true` makes the value visible to invocations of the produced binary under `cargo run` / `cargo test`, keeping build and runtime in sync. For runtime invocations outside cargo (e.g. a deploy script running the binary directly), either export the same env var or use [`CliProgram::Path`] / `COPILOT_CLI_PATH` at runtime. ### Skipping the bundle entirely Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the managed runtime via `ClientOptions::program = CliProgram::Path(...)`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. ### Resolution priority For managed child-process transports, `Client::start` resolves the program in this order: 1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`. 2. `COPILOT_CLI_PATH` environment variable, if it points at a real file. 3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call. 4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache. In-process transport loads the native runtime library adjacent to the runtime wrapper selected from `COPILOT_CLI_PATH`, the embedded runtime archive, or the build-time cache. There is no PATH scanning. ### Reaching the bundled binary without a `Client` Health checks, diagnostics, and version probes often need the bundled CLI's path *before* any session starts — and for callers that always override `program` with `CliProgram::Path(...)`, `Client::start`'s resolver may never run. Use [`install_bundled_cli`] for those cases: ```rust,no_run use github_copilot_sdk::{HAS_BUNDLED_CLI, install_bundled_cli}; if HAS_BUNDLED_CLI { if let Some(path) = install_bundled_cli() { // lazily extracts on first call; idempotent thereafter println!("bundled CLI at {}", path.display()); } } ``` This returns the bundled CLI artifact, preserving the public API's original meaning. Managed child-process transports resolve `copilot-runtime` instead. The function returns `None` when `bundled-cli` is off or the target is unsupported and does not fall back to the build-time extraction cache. Use [`install_bundled_runtime`] when a health check or intermediate launcher needs the managed runtime executable: ```rust,no_run use github_copilot_sdk::install_bundled_runtime; if let Some(path) = install_bundled_runtime() { println!("bundled runtime at {}", path.display()); } ``` This extracts `copilot-runtime` together with adjacent `runtime.node`, then returns the wrapper path. ### Download cache (build-time, embed mode) In embed mode `build.rs` downloads both verified archives on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache them between builds (CI keys this on `-` for near-zero-cost rebuilds on cache hits). For Copilot CLI 1.0.83-5, the two upstream archives total roughly 132-157 MB per platform before the runtime package is filtered. With `bundled-cli` disabled there is no separate archive cache: the extracted runtime bundle is the cache. ### Platforms Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `linuxmusl-x64`, `linuxmusl-arm64`, `win32-x64`, and `win32-arm64`. The target platform is auto-detected from `CARGO_CFG_TARGET_OS`, `CARGO_CFG_TARGET_ARCH`, and `CARGO_CFG_TARGET_ENV` (cross-compilation works). ## Features | Feature | Default | Description | | ------- | ------- | ----------- | | `bundled-cli` | ✓ | Embeds the managed wrapper pair and compatible CLI artifact. Disable via `default-features = false` when supplying the runtime explicitly. | | `bundled-in-process` | — | Enables `Transport::InProcess`, implies `bundled-cli`, and additionally embeds the platform-native runtime library. | | `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). | ```toml # Default — bundles the Copilot CLI in your binary. github-copilot-sdk = "1" # Enable the in-process transport and bundle its native runtime library. github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } # Opt out of bundling — supply the CLI explicitly at runtime. github-copilot-sdk = { version = "1", default-features = false } # Derive JSON Schema for tool parameters (adds to default bundled-cli). github-copilot-sdk = { version = "1", features = ["derive"] } ``` ## Development Tests require a supported [Node.js version](../nodejs/README.md#prerequisites). From the repository root: ```bash cd nodejs npm ci ``` ```bash cd test/harness npm ci ``` ```bash cd rust cargo test --features test-support ```