Session Isolation Patterns for Multi-Tenant Copilot SDK Deployments: Three Secure Strategies

To isolate tenants in multi-tenant Copilot SDK deployments, generate unique tenant-scoped sessionId values via SessionConfig, attach per-tenant authentication handlers such as McpAuthHandler, and serialize concurrent access using an external lock to prevent the SendWhileWaiting error and data leakage between customers.

When building SaaS platforms that embed GitHub Copilot, ensuring that prompts, tool calls, and generated artifacts remain strictly partitioned between customers is critical. The session isolation patterns for multi-tenant deployments with Copilot SDK provide three architectural strategies ranging from full process isolation to shared memory models. This guide examines the implementation details found in the github/copilot-sdk repository, including the Session lifecycle in rust/src/session.rs and configuration patterns from docs/setup/scaling.md.

Understanding the Three Isolation Patterns

The Copilot SDK supports three distinct isolation patterns, each trading resource overhead against security guarantees:

Pattern Isolation Level Resource Cost Typical Use-Case
Per-user CLI instance Full OS-process isolation (dedicated Copilot-CLI binary per tenant) High Highly regulated environments requiring strict data separation
Shared CLI, isolated sessions Single CLI process serves many tenants via unique sessionId values Low Most SaaS workloads where tenant data lives in-memory or on shared storage
Shared session (collaborative) Multiple tenants share the same sessionId Very low Collaborative code-review or pair-programming rooms

The SDK does not provide internal locking mechanisms. Callers must serialize access to a given session when using the shared patterns.

Session Lifecycle and Event Loop Architecture

A session is created via Client::create_session or resumed with Client::resume_session. The method constructs a wire payload from SessionConfig and optionally registers the session before the RPC is sent when the client supplies its own sessionId.

Session registration occurs in Client::register_session, which stores a channel pair (SessionChannels) in the router at rust/src/session.rs#L891-L894. This allows the SDK’s internal event loop to route RPC replies back to the correct Session object.

When the client does not provide a sessionId, an inline callback parses the session.create response, registers the new server-generated ID, and saves the channels in an inline_stash (rust/src/session.rs#L959-L981).

After registration, spawn_event_loop creates a Tokio task that continuously reads inbound messages, dispatches them to per-event handlers, and broadcasts SessionEvents via a tokio::sync::broadcast::Sender. The loop is cancelled cooperatively via a CancellationToken (rust/src/session.rs#L1023-L1030).

Enforcing Tenant Isolation Guarantees

The SDK provides several mechanisms to enforce separation, but requires explicit developer implementation:

Unique Session IDs The SessionId type is a thin wrapper around a string. Developers must generate IDs with a tenant prefix to ensure global uniqueness:

format!("tenant-{}-{}", tenant_id, uuid::Uuid::new_v4())

Authorization Handlers Per-session handlers such as McpAuthHandler and PermissionHandler (defined in [rust/src/handler.rs](https://github.com/github/copilot-sdk/blob/main/rust/src/handler.rs)) are attached via SessionConfig. Register tenant-specific auth providers to ensure credentials remain scoped.

State Storage By default, the CLI stores session state under ~/.copilot/session-state/. For multi-tenant deployments, configure a shared storage backend (NFS, Redis, or S3) via a custom SessionFsProvider implementation ([rust/src/session_fs.rs](https://github.com/github/copilot-sdk/blob/main/rust/src/session_fs.rs)).

Concurrent Access Serialization The SDK does not serialize calls internally. The send_and_wait method uses an IdleWaiter slot protected by a parking_lot::Mutex. If two callers issue concurrent sends, the second receives ErrorKind::Session(SessionErrorKind::SendWhileWaiting). You must implement application-layer serialization, such as a per-session tokio::sync::Mutex or distributed lock.

Implementation Guide

Creating Isolated Sessions with Tenant-Scoped IDs

Generate deterministic session IDs that encode the tenant identifier to prevent collisions:

use github_copilot_sdk::{
    client::Client,
    types::{SessionConfig, SessionId},
};

/// Returns a session whose ID is namespaced by the tenant.
pub async fn session_for_tenant(client: &Client, tenant_id: u64) -> Result<Session, Error> {
    // Build a deterministic session ID: "tenant-42-<random>"
    let session_id = SessionId::new(format!("tenant-{}-{}", tenant_id, uuid::Uuid::new_v4()));

    // Attach a per-tenant token provider for MCP auth
    let config = SessionConfig::default()
        .with_session_id(session_id.clone())
        .with_mcp_oauth_handler(MyTenantMcpHandler::new(tenant_id));

    client.create_session(config).await
}

The SessionConfig::with_mcp_oauth_handler registers a tenant-specific McpAuthHandler, ensuring that authorization tokens remain isolated to the tenant's session.

Serializing Access to Prevent Session Conflicts

Wrap shared sessions in a struct that enforces one-at-a-time access to avoid the SendWhileWaiting error:

use std::sync::Arc;
use tokio::sync::Mutex;
use github_copilot_sdk::session::Session;

struct SharedSession {
    session: Arc<Session>,
    lock: Arc<Mutex<()>>,         // Guarantees one-at-a-time send_and_wait
}

impl SharedSession {
    pub async fn ask(&self, prompt: &str) -> Result<Option<SessionEvent>, Error> {
        let _guard = self.lock.lock().await;     // Serialize
        self.session.send_and_wait(prompt).await
    }
}

Any concurrent request to the same session queues behind the mutex, preventing race conditions in the SDK's internal IdleWaiter.

Configuring Persistent Shared Storage

For sessions that must survive CLI restarts or be accessible from multiple service instances, provide a custom SessionFsProvider backed by shared storage:

use github_copilot_sdk::session_fs::{SessionFsProvider, SessionFsSqliteProvider};
use std::path::PathBuf;

fn shared_fs_provider(root: PathBuf) -> SessionFsProvider {
    // Example: SQLite-backed provider stored on a network volume
    let sqlite = SessionFsSqliteProvider::new(root.join("session.sqlite"));
    SessionFsProvider::new().with_sqlite(sqlite)
}

// When constructing a session:
let config = SessionConfig::default()
    .with_session_fs_provider(shared_fs_provider(PathBuf::from("/mnt/copilot-shared")));
client.create_session(config).await?;

The provider ensures session state (history, plan files, etc.) persists across process restarts and remains accessible from any server instance mounting the shared volume.

Summary

  • Generate tenant-scoped sessionId values when calling Client::create_session to ensure logical isolation in shared CLI deployments.
  • Attach tenant-specific auth handlers via SessionConfig to keep authorization credentials partitioned per customer.
  • Implement external locking (e.g., tokio::sync::Mutex) around send_and_wait calls to prevent SendWhileWaiting errors in concurrent environments.
  • Use custom SessionFsProvider implementations for shared storage when session state must persist across CLI processes or service instances.
  • Reference docs/setup/scaling.md for architectural guidance on selecting between process isolation and shared session patterns.

Frequently Asked Questions

How does the Copilot SDK prevent session bleeding between tenants?

The SDK itself does not automatically enforce tenant isolation. According to the github/copilot-sdk source code, you must generate unique sessionId values with tenant prefixes and attach tenant-specific McpAuthHandler instances via SessionConfig. The SDK routes messages based on these IDs but relies on your application to supply properly scoped identifiers and authentication logic.

What is the difference between create_session and resume_session?

Client::create_session initializes a new session and optionally registers a client-supplied sessionId immediately (or defers registration for server-generated IDs via an inline callback at rust/src/session.rs#L959-L981). Client::resume_session reconnects to an existing session using a previously established ID, allowing you to restore state from a shared SessionFsProvider without starting a new conversation context.

How should I handle concurrent requests to the same session?

You must serialize access at the application layer. The SDK's send_and_wait implementation uses a parking_lot::Mutex protected IdleWaiter, and concurrent calls return ErrorKind::Session(SessionErrorKind::SendWhileWaiting). Wrap your Session in a struct with a tokio::sync::Mutex or use a distributed lock service to ensure only one request interacts with the session at a time.

Where should I store session state in a multi-tenant environment?

Avoid the default ~/.copilot/session-state/ location. Instead, implement a custom SessionFsProvider (as defined in [rust/src/session_fs.rs](https://github.com/github/copilot-sdk/blob/main/rust/src/session_fs.rs)) that writes to a multi-tenant storage bucket such as S3, NFS, or Redis. This allows any instance of your service to resume a tenant's session regardless of which CLI process or server node handles the request.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →