Run the GitHub Copilot CLI headless inside a sandbox VM and drive it from the 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: readpermission. Classicghp_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: 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 — join the VPC from your laptop, then connect to the private IP.
- Another VM on the same VPC — 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:
pnpm add freestylebun add freestylenpm install freestyleyarn add freestyle Set your API key:
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:
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, grant it Copilot requests: read, and export it:
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.
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.
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):
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 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:
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. Create an ephemeral session and write its config:
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:
brew install wireguard-toolssudo 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:
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:
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:
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:
sudo wg-quick down ./freestyle-vpc.conf
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:
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_TOKENon the server,connectionTokenon the client) authenticates the transport. A client without it is rejected withAUTHENTICATION_FAILEDbefore any session is created. onPermissionRequeston the client authorizes each tool call.approveAlllets the agent act; a handler returning{ kind: "reject" }keeps it to text only. Because the client decides, you never need--allow-allon the server.