Skip to content

Latest commit

 

History

History
719 lines (573 loc) · 22.5 KB

backend-services.md

File metadata and controls

719 lines (573 loc) · 22.5 KB

Backend services setup

Run the SDK in backends, APIs, microservices and workers. Connect to an independent headless CLI over plaintext TCP JSON-RPC. Node.js, Python, Go, .NET and Java reject https://; http:// is only an address alias.

Best for: Web app backends, API services, internal tools, CI/CD integrations, any server-side workload.

How it works

Instead of the SDK spawning a CLI child process, you run the CLI independently in headless server mode. Your backend connects to it over TCP using the Connection option (URIConnection).

flowchart TB
    subgraph Backend["Your Backend"]
        API["API Server"]
        SDK["SDK Client"]
    end

    subgraph CLIServer["Copilot CLI (Headless)"]
        RPC["JSON-RPC Server<br/>TCP :4321"]
        Sessions["Session Manager"]
    end

    Users["👥 Users"] --> API
    API --> SDK
    SDK -- "RuntimeConnection.forUri: localhost:4321" --> RPC
    RPC --> Sessions
    RPC --> Copilot["☁️ GitHub Copilot<br/>or Model Provider"]

    style Backend fill:#0d1117,stroke:#58a6ff,color:#c9d1d9
    style CLIServer fill:#0d1117,stroke:#3fb950,color:#c9d1d9
Loading

Key characteristics:

  • CLI runs as a persistent server process (not spawned per request)
  • SDK connects over TCP—CLI and app can run in different containers
  • Multiple SDK clients can share one CLI server
  • Works with any auth method (GitHub tokens, env vars, BYOK)

For multi-user server mode, configure SDK clients with mode: "empty", pass user credentials per session, and explicitly allow tools for each session. See Multi-Tenancy & Server Deployments for the full pattern.

Architecture: auto-managed vs. external CLI

flowchart LR
    subgraph Auto["Auto-Managed (Default)"]
        A1["SDK"] -->|"spawns"| A2["CLI Process"]
        A2 -.->|"dies with app"| A1
    end

    subgraph External["External Server (Backend)"]
        B1["SDK"] -->|"forUri"| B2["CLI Server"]
        B2 -.->|"independent<br/>lifecycle"| B1
    end

    style Auto fill:#161b22,stroke:#8b949e,color:#c9d1d9
    style External fill:#0d1117,stroke:#3fb950,color:#c9d1d9
Loading

Step 1: start the CLI in headless mode

Run the CLI as a background server:

# Start with a specific port
copilot --headless --port 4321

# Or let it pick a random port (prints the port)
copilot --headless
# Output: CLI server listening on port 52431

By default the headless server only accepts connections from loopback (127.0.0.1), where it needs no authentication. To accept connections from other hosts—for example from another machine on your network—bind to a non-loopback address with --host. A non-loopback bind exposes the full runtime (sessions, shell and file tools, the configured GitHub token) to anything that can reach the port, so the server requires a connection token in that case and refuses to start without one. Set COPILOT_CONNECTION_TOKEN to a non-empty shared secret on the server, and pass the same value as connectionToken from the SDK client:

# Generate a secret once and keep it (for example in your secret store)
export COPILOT_CONNECTION_TOKEN="$(openssl rand -hex 32)"
copilot --headless --host 0.0.0.0 --port 4321

Without COPILOT_CONNECTION_TOKEN, a non-loopback bind fails with refusing to bind the runtime server to non-loopback host ... without a connection token. Keep the token out of shell history and process arguments, and prefer loopback with an authenticating reverse proxy where you can.

For production, run it as a system service or in a container.

Note

There is no official pre-built Docker image for the Copilot CLI. You can build your own from the GitHub releases:

FROM debian:bookworm-slim
ARG COPILOT_VERSION=1.0.7
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates wget \
    && ARCH=$(dpkg --print-architecture) \
    && case "${ARCH}" in amd64) COPILOT_ARCH="x64" ;; arm64) COPILOT_ARCH="arm64" ;; *) echo "Unsupported: ${ARCH}" && exit 1 ;; esac \
    && wget -q "https://github.com/github/copilot-cli/releases/download/v${COPILOT_VERSION}/copilot-linux-${COPILOT_ARCH}.tar.gz" \
    && tar -xzf "copilot-linux-${COPILOT_ARCH}.tar.gz" \
    && mv copilot /usr/local/bin/ \
    && rm "copilot-linux-${COPILOT_ARCH}.tar.gz" \
    && apt-get purge -y wget && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["copilot"]
# Build the image
docker build --build-arg COPILOT_VERSION=1.0.7 -t copilot-cli:latest .

# For remote deployments (Kubernetes, ACI, etc.), push to your registry
docker tag copilot-cli:latest your-registry/copilot-cli:latest
docker push your-registry/copilot-cli:latest
# Docker — must bind to 0.0.0.0 so the container's published port is reachable;
# a non-loopback bind requires COPILOT_CONNECTION_TOKEN, forwarded below from the
# value exported above (`-e NAME` with no value keeps it out of the command line).
docker run -d --name copilot-cli \
    -p 4321:4321 \
    -e COPILOT_GITHUB_TOKEN="$TOKEN" \
    -e COPILOT_CONNECTION_TOKEN \
    copilot-cli:latest \
    --headless --host 0.0.0.0 --port 4321

# systemd
[Service]
ExecStart=/usr/local/bin/copilot --headless --port 4321
Environment=COPILOT_GITHUB_TOKEN=your-token
Restart=always

Step 2: connect the SDK

Important

If you started the server with a non-loopback --host (for example the Docker setup above), it requires a connection token. Pass the same COPILOT_CONNECTION_TOKEN value to the client as the second argument to RuntimeConnection.forUri, for example RuntimeConnection.forUri("your-host:4321", { connectionToken: process.env.COPILOT_CONNECTION_TOKEN }) (Node), RuntimeConnection.for_uri("your-host:4321", connection_token=...) (Python), or the equivalent option in your language. A loopback server (the default copilot --headless) needs no token.

Node.js / TypeScript
import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";

// Provided by your web framework and auth layer
declare const userId: string;
declare const user: { githubToken: string };
declare const req: { body: { message: string } };
declare const res: { json(body: unknown): void };

const client = new CopilotClient({
    connection: RuntimeConnection.forUri("localhost:4321"),
    mode: "empty",
});

const session = await client.createSession({
    sessionId: `user-${userId}-${Date.now()}`,
    model: "auto",
    availableTools: ["custom:*"],
    gitHubToken: user.githubToken,
});

const response = await session.sendAndWait({ prompt: req.body.message });
res.json({ content: response?.data.content });
import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";

const client = new CopilotClient({
    connection: RuntimeConnection.forUri("localhost:4321"),
    mode: "empty",
});

const session = await client.createSession({
    sessionId: `user-${userId}-${Date.now()}`,
    model: "auto",
    availableTools: ["custom:*"],
    gitHubToken: user.githubToken,
});

const response = await session.sendAndWait({ prompt: req.body.message });
res.json({ content: response?.data.content });
Python
import time
from copilot import CopilotClient, RuntimeConnection
from copilot.session import PermissionHandler

user_id = "123"
message = "Hello!"

client = CopilotClient(
    connection=RuntimeConnection.for_uri("localhost:4321"),
)
await client.start()

session = await client.create_session(on_permission_request=PermissionHandler.approve_all, model="auto", session_id=f"user-{user_id}-{int(time.time())}")

response = await session.send_and_wait(message)
Go
package main

import (
	"context"
	"fmt"
	"time"
	copilot "github.com/github/copilot-sdk/go"
)

func main() {
	ctx := context.Background()
	userID := "user1"
	message := "Hello"

    client := copilot.NewClient(&copilot.ClientOptions{
        Connection: copilot.URIConnection{URL: "localhost:4321"},
    })
	client.Start(ctx)
	defer client.Stop()

	session, _ := client.CreateSession(ctx, &copilot.SessionConfig{
		SessionID: fmt.Sprintf("user-%s-%d", userID, time.Now().Unix()),
		Model:     "auto",
	})

	response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message})
	_ = response
}
client := copilot.NewClient(&copilot.ClientOptions{
    Connection: copilot.URIConnection{URL: "localhost:4321"},
})
client.Start(ctx)
defer client.Stop()

session, _ := client.CreateSession(ctx, &copilot.SessionConfig{
    SessionID: fmt.Sprintf("user-%s-%d", userID, time.Now().Unix()),
    Model:     "auto",
})

response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message})
.NET
using GitHub.Copilot;

var userId = "user1";
var message = "Hello";

var client = new CopilotClient(new CopilotClientOptions
{
    Connection = RuntimeConnection.ForUri("localhost:4321"),
});

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    SessionId = $"user-{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
    Model = "auto",
});

var response = await session.SendAndWaitAsync(
    new MessageOptions { Prompt = message });
var client = new CopilotClient(new CopilotClientOptions
{
    Connection = RuntimeConnection.ForUri("localhost:4321"),
});

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    SessionId = $"user-{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}",
    Model = "auto",
});

var response = await session.SendAndWaitAsync(
    new MessageOptions { Prompt = message });
Java
import com.github.copilot.CopilotClient;
import com.github.copilot.rpc.*;

var userId = "user1";
var message = "Hello!";

var client = new CopilotClient(new CopilotClientOptions()
    .setCliUrl("localhost:4321")
);

try {
    client.start().get();

    var session = client.createSession(new SessionConfig()
        .setSessionId(String.format("user-%s-%d", userId, System.currentTimeMillis() / 1000))
        .setModel("auto")
        .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
    ).get();

    var response = session.sendAndWait(new MessageOptions()
        .setPrompt(message)).get();
} finally {
    client.stop().get();
}

Authentication for backend services

Environment variable tokens

The simplest approach—set a token on the CLI server:

flowchart LR
    subgraph Server
        EnvVar["COPILOT_GITHUB_TOKEN"]
        CLI["Copilot CLI"]
    end

    EnvVar --> CLI
    CLI --> Copilot["☁️ Copilot API"]

    style Server fill:#0d1117,stroke:#58a6ff,color:#c9d1d9
Loading
# All requests use this token
export COPILOT_GITHUB_TOKEN="gho_service_account_token"
copilot --headless --port 4321

Per-user tokens (OAuth)

Pass individual user tokens when creating sessions. See GitHub OAuth for the full flow.

import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";

// Provided by your web framework and auth layer
interface AuthenticatedRequest {
    user: { id: string; githubToken: string };
    body: { message: string };
}
declare const app: {
    post(
        path: string,
        middleware: unknown,
        handler: (req: AuthenticatedRequest, res: { json(body: unknown): void }) => Promise<void>,
    ): void;
};
declare const authMiddleware: unknown;

const client = new CopilotClient({
    connection: RuntimeConnection.forUri("localhost:4321"),
    mode: "empty",
});

// Your API receives user tokens from your auth layer
app.post("/chat", authMiddleware, async (req, res) => {
    const session = await client.createSession({
        sessionId: `user-${req.user.id}-chat`,
        model: "auto",
        availableTools: ["custom:*"],
        gitHubToken: req.user.githubToken,
    });

    const response = await session.sendAndWait({
        prompt: req.body.message,
    });

    res.json({ content: response?.data.content });
});
const client = new CopilotClient({
    connection: RuntimeConnection.forUri("localhost:4321"),
    mode: "empty",
});

// Your API receives user tokens from your auth layer
app.post("/chat", authMiddleware, async (req, res) => {
    const session = await client.createSession({
        sessionId: `user-${req.user.id}-chat`,
        model: "auto",
        availableTools: ["custom:*"],
        gitHubToken: req.user.githubToken,
    });

    const response = await session.sendAndWait({
        prompt: req.body.message,
    });

    res.json({ content: response?.data.content });
});

BYOK (no GitHub auth)

Use your own API keys for the model provider. See BYOK for details.

const client = new CopilotClient({
    connection: RuntimeConnection.forUri("localhost:4321"),
});

const session = await client.createSession({
    model: "gpt-5.4",
    provider: {
        type: "openai",
        baseUrl: "https://api.openai.com/v1",
        apiKey: process.env.OPENAI_API_KEY,
    },
});

Common backend patterns

Web API with Express

flowchart TB
    Users["👥 Users"] --> LB["Load Balancer"]
    LB --> API1["API Instance 1"]
    LB --> API2["API Instance 2"]

    API1 --> CLI["Copilot CLI<br/>(headless :4321)"]
    API2 --> CLI

    CLI --> Cloud["☁️ Model Provider"]

    style API1 fill:#0d1117,stroke:#58a6ff,color:#c9d1d9
    style API2 fill:#0d1117,stroke:#58a6ff,color:#c9d1d9
    style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9
Loading

Include the helpers from Session cleanup before accepting requests. On timeout, cancel and drain before disconnecting; retain failed cancellations for explicit recovery.

Your application's authMiddleware must set req.user.id and req.user.githubToken from the authenticated identity. Clients send a per-chat sessionKey (or omit it for the default chat), not an SDK session ID. Encode the user ID and key together to keep sessions isolated without ambiguous separators. Reuse the returned sessionKey for follow-up requests.

import express from "express";
import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";

const app = express();
app.use(express.json());

// Single shared CLI connection for multi-user server mode
const client = new CopilotClient({
    connection: RuntimeConnection.forUri(process.env.CLI_URL || "localhost:4321"),
    mode: "empty",
});

app.post("/api/chat", authMiddleware, async (req, res) => {
    const { sessionKey = "default", message } = req.body;
    if (typeof sessionKey !== "string" || sessionKey.length === 0) {
        return res.status(400).json({ error: "sessionKey must be a non-empty string" });
    }
    const sessionId = Buffer.from(JSON.stringify([req.user.id, sessionKey])).toString("base64url");

    const response = await withSessionLock(sessionId, async () => {
        let session;
        try {
            session = await client.resumeSession(sessionId, {
                availableTools: ["custom:*"],
                gitHubToken: req.user.githubToken,
            });
        } catch {
            session = await client.createSession({
                sessionId,
                model: "auto",
                availableTools: ["custom:*"],
                gitHubToken: req.user.githubToken,
            });
        }
        let response;
        try {
            response = await session.sendAndWait({ prompt: message });
        } catch (error) {
            try {
                await cancelAndDisconnect(sessionId, session);
            } catch (recoveryError) {
                throw new AggregateError([error, recoveryError], "Session requires recovery");
            }
            throw error;
        }
        await session.disconnect();
        return response;
    });
    res.json({
        sessionKey,
        sessionId,
        content: response?.data.content,
    });
});

app.listen(3000);

Background worker

import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk";

// Provided by your application's job queue and result store
interface Job {
    id: string;
    prompt: string;
}
declare function saveResult(jobId: string, content: string | undefined): Promise<void>;

const client = new CopilotClient({
    connection: RuntimeConnection.forUri(process.env.CLI_URL || "localhost:4321"),
});

// Process jobs from a queue
async function processJob(job: Job) {
    const session = await client.createSession({
        sessionId: `job-${job.id}`,
        model: "auto",
    });

    const response = await session.sendAndWait({
        prompt: job.prompt,
    });

    await saveResult(job.id, response?.data.content);
    await session.disconnect();  // Clean up after job completes
}

Docker compose deployment

version: "3.8"

services:
  copilot-cli:
    image: copilot-cli:latest  # See "Step 1" above for how to build this image
    command: ["--headless", "--host", "0.0.0.0", "--port", "4321"]
    environment:
      - COPILOT_GITHUB_TOKEN=${COPILOT_GITHUB_TOKEN}
      # Required for a non-loopback bind; the server refuses to start without it.
      - COPILOT_CONNECTION_TOKEN=${COPILOT_CONNECTION_TOKEN}
    ports:
      - "4321:4321"
    restart: always
    volumes:
      - session-data:/root/.copilot/session-state

  api:
    build: .
    environment:
      - CLI_URL=copilot-cli:4321
      # Pass this as `connectionToken` to RuntimeConnection.forUri.
      - COPILOT_CONNECTION_TOKEN=${COPILOT_CONNECTION_TOKEN}
    depends_on:
      - copilot-cli
    ports:
      - "3000:3000"

volumes:
  session-data:
flowchart TB
    subgraph Docker["Docker Compose"]
        API["api:3000"]
        CLI["copilot-cli:4321"]
        Vol["📁 session-data<br/>(persistent volume)"]
    end

    Users["👥 Users"] --> API
    API --> CLI
    CLI --> Vol

    CLI --> Cloud["☁️ Copilot / Provider"]

    style Docker fill:#0d1117,stroke:#58a6ff,color:#c9d1d9
Loading

Health checks

Monitor the CLI server's health:

// Periodic health check
async function checkCLIHealth(): Promise<boolean> {
    try {
        const status = await client.getStatus();
        return status !== undefined;
    } catch {
        return false;
    }
}

Session cleanup

Lock cleanup, creation, resume, and sends until all work completes. Share locks across processes or clients sharing storage. Failed cancellation blocks reuse, not unrelated cleanup. Call recoverSession with the original resume configuration after correcting the cause to reconnect, reacquire ownership, and drain.

const sessionLocks = new Map<string, Promise<void>>();
const sessionRecoveries = new Map<string, Awaited<ReturnType<typeof client.resumeSession>>>();

async function withSessionLock<T>(sessionId: string, operation: () => Promise<T>, allowRecovery = false): Promise<T> {
    const previous = sessionLocks.get(sessionId) ?? Promise.resolve();
    let release!: () => void;
    const current = new Promise<void>((resolve) => { release = resolve; });
    const tail = previous.then(() => current);
    sessionLocks.set(sessionId, tail);
    await previous;
    try {
        if (!allowRecovery && sessionRecoveries.has(sessionId)) {
            throw new Error(`Session ${sessionId} requires recovery; call recoverSession before reuse`);
        }
        return await operation();
    } finally {
        release();
        if (sessionLocks.get(sessionId) === tail) sessionLocks.delete(sessionId);
    }
}

async function cancelAndDisconnect(sessionId: string, session: Awaited<ReturnType<typeof client.resumeSession>>) {
    sessionRecoveries.set(sessionId, session);
    const aborted = await session.rpc.abort({});
    if (!aborted.success) throw new Error(aborted.error);
    await session.disconnect();
    sessionRecoveries.delete(sessionId);
}

async function recoverSession(sessionId: string, config: Parameters<typeof client.resumeSession>[1]) {
    await withSessionLock(sessionId, async () => {
        if (!sessionRecoveries.has(sessionId)) throw new Error(`No recovery pending for session ${sessionId}`);
        await client.start();
        const session = await client.resumeSession(sessionId, config);
        await cancelAndDisconnect(sessionId, session);
    }, true);
}

async function cleanupSessions(maxIdleMs: number) {
    const sessions = await client.listSessions();

    for (const session of sessions) {
        if (Date.now() - session.modifiedTime.getTime() <= maxIdleMs) continue;
        await withSessionLock(session.sessionId, async () => {
            if (sessionRecoveries.has(session.sessionId)) return;
            // Recheck freshness while requests for this session are excluded.
            const latest = (await client.listSessions()).find((s) => s.sessionId === session.sessionId);
            if (latest && Date.now() - latest.modifiedTime.getTime() > maxIdleMs) {
                await client.deleteSession(session.sessionId);
            }
        }, true);
    }
}

// Run every hour
setInterval(() => {
    cleanupSessions(24 * 60 * 60 * 1000).catch(console.error);
}, 60 * 60 * 1000);

Limitations

Limitation Details
Single CLI server = single point of failure See Scaling guide for HA patterns
No built-in auth between SDK and CLI Secure the network path (same host, VPC, etc.)
Session state on local disk Mount persistent storage for container restarts
No idle timeout by default Pass --session-idle-timeout <seconds> to the CLI server to automatically clean up inactive sessions

When to move on

Need Next Guide
Multiple CLI servers / high availability Scaling & Multi-Tenancy
SDK isolation for concurrent users Multi-Tenancy & Server Deployments
GitHub account auth for users GitHub OAuth
Your own model keys BYOK

Next steps