---
title: "How to Run GitHub Copilot in a Sandbox"
description: "Run the GitHub Copilot CLI headless inside a sandbox and drive it from the Copilot SDK over a private VPC — or from your own machine over a WireGuard VPN. The runtime link is raw-TCP JSON-RPC, so it needs VPC reachability and a fine-grained Copilot token, not an HTTPS domain or a classic PAT."
url: "/docs/guides/run-github-copilot-in-a-sandbox"
---

Run the [GitHub Copilot CLI](https://github.com/features/copilot/cli) headless inside a sandbox VM and drive it from the [Copilot SDK](https://github.com/github/copilot-sdk) running somewhere else. The SDK talks to its runtime over JSON-RPC on a **raw TCP socket**, so the client has to reach the VM on a private network: another VM on the same **VPC**, or your own machine joined to the VPC over a **WireGuard VPN**.


## Requirements

Before you start, make sure you have:

- **A Freestyle API key** — to create the VM, VPC, and VPN.
- **A GitHub Copilot subscription**, and a **fine-grained** personal access token with the **`Copilot requests: read`** permission. **Classic `ghp_` tokens do not work** — Copilot rejects them outright (more on this below).
- **Node.js 22+** wherever the SDK client runs (the base sandbox image already ships Node).
- **WireGuard tools** (`wireguard-tools`) on your machine *only if* you want to connect from your laptop rather than from a second VM.


## Why this needs a VPC

The Copilot SDK speaks `Content-Length`-framed **JSON-RPC** to its runtime — over the runtime's stdio when it spawns it locally, or over a **raw TCP socket** when you point it at an already-running server (`copilot --headless --port`). It is **not HTTP**.

That rules out a Freestyle [domain](https://www.freestyle.sh/docs/vms/domains): domains are an HTTP reverse proxy and can't carry a raw-TCP JSON-RPC stream. The client instead has to reach the VM over its **private address**, which means one of:

- **Your own machine over a [WireGuard VPN](https://www.freestyle.sh/docs/vms/network/vpns)** — join the VPC from your laptop, then connect to the private IP.
- **Another VM on the same [VPC](https://www.freestyle.sh/docs/vms/network/vpcs)** — VMs on a VPC reach each other by private IP, no VPN needed.

This guide does both.


## Install the SDK

Install `freestyle` wherever you orchestrate VMs:

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key:

```bash
export FREESTYLE_API_KEY="your-api-key"
```

The Copilot SDK (`@github/copilot-sdk`) is installed separately, wherever the **client** runs — the second VM, or your laptop. We'll get to it below.


## Get a Copilot token

The headless runtime authenticates to Copilot with a GitHub token in `COPILOT_GITHUB_TOKEN` (or `GH_TOKEN` / `GITHUB_TOKEN`). It must be a **fine-grained personal access token** with the **`Copilot requests: read`** permission.

A **classic `ghp_` token will not work**, even one that has the `copilot` scope — the CLI refuses it:

```text
Error: Classic Personal Access Tokens (ghp_) are not supported by Copilot.
Please use a Fine-Grained Personal Access Token or another authentication method.
```

Create a fine-grained token at [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens), grant it `Copilot requests: read`, and export it:

```bash
export COPILOT_GITHUB_TOKEN="github_pat_..."
```

In `forUri` mode (below) the SDK **client** cannot supply auth — the **server** holds it. So this token lives on the VM that runs `copilot --headless`, not on the client.


## Create a VPC and the agent VM

Create a VPC, then attach a VM to it with a routed interface at a fixed private IP. Use `idleTimeoutSeconds: null` — the idle monitor only watches network traffic, and a headless Copilot server generates none, so it would otherwise be suspended out from under you.

```ts
import { freestyle } from "freestyle";

const { vpcId, vpc } = await freestyle.vpc.create({
  name: "copilot-vpc",
  cidr: "192.168.10.0/24",
});

const { vm: agent } = await freestyle.vms.create({
  idleTimeoutSeconds: null,
  nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});
```

The VPC's routed NIC still has internet egress, so the VM can `npm install` even though its default route is the VPC.


## Install and launch the headless runtime

Install the Copilot CLI and run it as a **systemd service** — systemd is PID 1 in the VM, so it supervises the process and restarts it if it dies. The server binds `0.0.0.0` so VPC peers can reach it, and reads its fine-grained token plus a **connection token** (a shared secret it requires from every client) from a root-only environment file.

`npm` installs the CLI under nvm, so resolve its absolute path for the unit's `ExecStart` — systemd doesn't search `PATH`.

```ts
await agent.exec("npm install -g @github/copilot");
const copilot = (await agent.exec("echo $(npm prefix -g)/bin/copilot")).stdout!.trim();

// A shared secret every client must present (used by both "drive it" sections).
const connectionToken = "a-long-random-shared-secret";

// Secrets go in a root-only env file, kept out of the unit so they don't show up
// in `systemctl cat`.
await agent.fs.writeTextFile(
  "/etc/copilot.env",
  `COPILOT_GITHUB_TOKEN=${process.env.COPILOT_GITHUB_TOKEN}
COPILOT_CONNECTION_TOKEN=${connectionToken}
COPILOT_AUTO_UPDATE=false`,
);
await agent.exec("chmod 600 /etc/copilot.env");

await agent.fs.writeTextFile(
  "/etc/systemd/system/copilot.service",
  `[Service]
Environment=HOME=/root
EnvironmentFile=/etc/copilot.env
ExecStart=${copilot} --headless --host 0.0.0.0 --port 4321
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target`,
);

await agent.exec("systemctl daemon-reload && systemctl enable --now copilot");
```

Binding `0.0.0.0` sounds broad, but Freestyle exposes no public raw-TCP ingress — the port is only reachable on the VPC (and over the VPN). The `COPILOT_CONNECTION_TOKEN` is the actual gate: without it the server still starts, but warns that it accepts any client.

Wait until the service is serving before connecting (the base image has Node but not `nc`/`ss`):

```ts
const check =
  `node -e "const s=require('net').connect(4321,'127.0.0.1');` +
  `s.on('connect',()=>{console.log('OPEN');process.exit(0)});` +
  `s.on('error',()=>process.exit(1))"`;

for (let i = 0; i < 20; i++) {
  const r = await agent.exec({ command: check, timeoutMs: 5_000 });
  if (r.stdout?.includes("OPEN")) break;
  await agent.exec("sleep 2");
}
```

Inspect or follow it any time with `systemctl status copilot` or `journalctl -u copilot`.


## Stream the Server's Logs

`vm.exec()` buffers a command and only returns once it finishes, so it can't show a long-running service's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit's journal. `onData` delivers the bytes as they arrive:

```ts
const session = await agent.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u copilot -f\n");

// session.detach() drops your handle — the service keeps running in the VM.
```


## Drive it from your computer

Your machine isn't on the VPC, so join it over a [WireGuard VPN](https://www.freestyle.sh/docs/vms/network/vpns). Create an ephemeral session and write its config:

```ts
import { writeFile } from "node:fs/promises";

const connection = await vpc.wireguard.createEphemeral();
await writeFile("freestyle-vpc.conf", connection.clientConfig, { mode: 0o600 });
```

Install the WireGuard CLI:

```bash macOS
brew install wireguard-tools
```

```bash Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y wireguard-tools
```

Bring the tunnel up — this needs root. Its `AllowedIPs` is just the VPC CIDR, so only VPC traffic is routed; pick a CIDR that doesn't overlap your LAN:

```bash
sudo wg-quick up ./freestyle-vpc.conf
```

Now your machine can reach the agent by its private IP. Connect with `RuntimeConnection.forUri`, passing the **connection token** — the client's only auth input, since the GitHub token stays on the server:

```ts title="client.mjs"
import { CopilotClient, RuntimeConnection, approveAll } from "@github/copilot-sdk";

const client = new CopilotClient({
  connection: RuntimeConnection.forUri("192.168.10.10:4321", {
    connectionToken: process.env.COPILOT_CONNECTION_TOKEN,
  }),
});
await client.start();

// approveAll lets the agent run tools (shell, edits) inside the agent VM.
// To forbid tools, pass a handler that returns { kind: "reject" } instead.
const session = await client.createSession({ onPermissionRequest: approveAll });

const idle = new Promise((resolve) => {
  session.on("assistant.message", (event) => console.log(event.data.content));
  session.on("session.idle", () => resolve());
});

await session.send({ prompt: "Run `hostname` with the shell tool and reply with its output." });
await idle;

await session.disconnect();
await client.stop();
```

Install the SDK and run it from your machine:

```bash
npm install @github/copilot-sdk
COPILOT_CONNECTION_TOKEN="a-long-random-shared-secret" node client.mjs
```

The shell tool runs **inside the agent VM**, so the hostname it prints is the agent's, not your laptop's — the agent really ran there, driven from your computer over the tunnel. Tear the tunnel down and close the session when you're done:

```bash
sudo wg-quick down ./freestyle-vpc.conf
```

```ts
await connection.close();
```


## Drive it from another VM on the VPC

A VM that's already on the VPC needs no VPN — it's on the private network already. Add one, install the SDK, upload the **same `client.mjs`**, and run it there:

```ts
import { readFileSync } from "node:fs";

const { vm: peer } = await freestyle.vms.create({
  idleTimeoutSeconds: null,
  nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.11" }],
});

await peer.exec("mkdir -p /root/app && cd /root/app && npm init -y && npm install @github/copilot-sdk");
await peer.fs.writeTextFile("/root/app/client.mjs", readFileSync("client.mjs", "utf8"));

const run = await peer.exec({
  command: `cd /root/app && COPILOT_CONNECTION_TOKEN='${connectionToken}' node client.mjs`,
  timeoutMs: 180_000,
});
console.log(run.stdout); // the agent VM's hostname — it ran the shell tool there
```

Same `forUri("192.168.10.10:4321")`, same connection token — only the network path differs.


## Locking it down

Two independent gates protect the runtime:

- **The connection token** (`COPILOT_CONNECTION_TOKEN` on the server, `connectionToken` on the client) authenticates the *transport*. A client without it is rejected with `AUTHENTICATION_FAILED` before any session is created.
- **`onPermissionRequest`** on the client authorizes each *tool call*. `approveAll` lets the agent act; a handler returning `{ kind: "reject" }` keeps it to text only. Because the client decides, you never need `--allow-all` on the server.
