English · 简体中文
Copilot Fleet is a self-hosted control plane for supervising GitHub Copilot CLI agents on multiple machines. The Host combines a Fastify API, WebSocket hub, SQLite database, and React UI. Each Node makes one outbound connection and owns an isolated ACP client and Copilot process per live session.
Every agent in the fleet on one screen, grouped by the project it is working in. Cards stream their transcript live, so a wall of them is readable without opening anything.
Open one and you get the whole conversation, the node it runs on, a composer that takes slash commands and file attachments, and the agent's own Model and Mode pickers along the bottom.
Screenshots come from the deterministic
--mock-agentdemo described under Exact proof of concept, so they can be reproduced on any machine without a Copilot login. A real node streams real Copilot output in exactly the same surfaces.
- Node.js 22.5 or newer and npm 10 or newer
- GitHub Copilot CLI 1.0.69 or newer installed and authenticated on each real Node
- An absolute local path for every workspace placement
git clone <repository-url> copilot-fleet
cd copilot-fleet
npm install
cp .env.example .envStart development mode:
npm run devThis single command runs the Fastify API on http://127.0.0.1:8787, Vite on
http://127.0.0.1:5173, and the local Node service. The Node reads its
FLEET_* settings from .env.
A fresh Host has no password and no administrator. It prints a one-time claim code to its own console and admits to nothing else until somebody uses it:
Copilot Fleet is unclaimed. Claim it at http://127.0.0.1:8787 with this
one-time code:
v-0MArasdtNAfxqlM5_pnA
It expires in 30 minutes and is printed only here.
See First run: claiming a Fleet for the two proofs a claim takes and how to register the Entra app it needs.
To keep a tunnel URL stable while you edit code, start the tunnel as its own process instead:
npm run dev:tunnelThe tunnel then survives tsx watch reloads, so the public URL stops rotating
every time the Host restarts and remote nodes stay connected. The Host detects
it and leaves its lifecycle alone; the Settings toggle is disabled while it
runs. Stop everything with Ctrl+C as usual.
Open the UI → Settings:
- General — session defaults, plus export/import to move this Host.
- Security — administrators, invitations, password migration, the Host fingerprint, Node key migration, and this Host's security audit.
- Tunnel — run Dev Tunnels, Cloudflare, Tailscale Funnel, ngrok, or bore; each installed provider has its own switch and status.
- Nodes — rename/delete machines and mint a one-time connect command.
- Workspaces — map projects to per-machine paths.
A workspace is logical; a placement is the physical (workspace, node) → path
pair. The same project can sit at a different absolute path on every machine, and
a session is always started from a stored placement — never from a path typed
into a request.
A Fleet Host can start processes and read every transcript on every machine enrolled in it. Who may do that is decided by Microsoft Entra ID plus this Host's own list of administrators — and nothing else. A tunnel decides who can reach the Host; it never decides who may operate it.
Two proofs are needed to claim a fresh Host, and one alone is worth nothing:
- The console claim code. 128 random bits, printed only to the Host's own
stdout, valid for 30 minutes, and consumed by the first successful claim.
Holding it proves you have access to the machine — which is the only fact a
Host can establish about a network caller, because every supported tunnel
relays into
http://127.0.0.1:<port>and the source address,Hostheader andx-forwarded-protoall describe the relay rather than the browser. - A Microsoft account. Signing in proves who you are. Fleet records the
account's immutable
(tenant id, object id)pair and issues its own opaque session; no Microsoft access, refresh, or ID token is ever persisted.
The first account to present both becomes the one and only administrator.
Anyone else in the same tenant who signs in afterwards is refused with a named
403 and receives no session at all.
Fleet uses the Microsoft corporate tenant and the same Visual Studio public client used by KYC for local development. Click Sign in with Microsoft and choose an account; Fleet persists only the account's tenant and object IDs, not the Microsoft token.
The public client accepts a hostless http://localhost:<port>/ callback. That
is why login runs on localhost even when the Host is reached through a tunnel.
The environment variables below are advanced overrides for testing another approved registration; normal use leaves them unset:
FLEET_ENTRA_TENANT_ID=<directory (tenant) id>
FLEET_ENTRA_CLIENT_ID=<application (client) id>Neither value is a secret, but an override must name a compatible approved public client.
- Start the Host and copy the claim code from its console.
- Open
http://localhost:8787— uselocalhost, not127.0.0.1; the UI redirects if you get it wrong, because the registered reply URL is matched by name and the transaction cookie follows it. - Enter the claim code, then Sign in with Microsoft.
- You are now this Fleet's administrator. Enrol machines from Settings → Nodes.
Authorization code with PKCE and a loopback callback is the primary flow, so a remote browser has two options:
-
Forward the Host to your own machine and use
http://localhost:<port>:devtunnel connect <tunnel-id>
Any local forward works — SSH
-L, a provider's client, whatever you already use. This is the recommended path and always available. -
Device sign-in, if your tenant permits it. Microsoft recommends blocking the device code flow by default and Conditional Access commonly does, so Fleet keeps it off until it has watched one complete. An administrator turns it on from Settings → Security → Verify device sign-in: the check runs whatever the setting currently says, and only a completed flow writes it. A tenant that blocks the flow leaves it off and says so, rather than producing a login that hangs.
A device code is the one credential an attacker can ask you to enter on their behalf. Only ever enter a code the Fleet page in front of you is showing. Fleet additionally requires a fresh authorization-code sign-in — not a device sign-in — before removing an administrator, disabling the password, or exporting a portable backup.
Fleet sessions are per-origin. A session issued on localhost authorises that
forwarded UI and does not set a cookie for a public tunnel domain.
Fleet asks for no Graph permission to search your directory, so an invitation is how somebody is added:
- Settings → Security → Add administrator mints a single-use link that expires in 15 minutes.
- The recipient opens it and signs in with Microsoft.
- That records them as a candidate — it grants nothing. The exact account that turned up is shown, with its object and tenant id.
- An existing administrator approves or rejects that identity.
A leaked link is therefore not an escalation: the wrong person redeeming it appears in the pending list and is rejected.
Removing an administrator revokes every session they hold and closes their open browser connections in the same operation, mid-transcript if necessary. The last active administrator cannot be removed, and removal needs a Microsoft authorization-code sign-in from the last ten minutes.
Hosts that predate Microsoft identity keep working long enough to migrate.
FLEET_OPERATOR_PASSWORD is an explicit, warned-about escape hatch — a fresh
Host without one generates nothing.
- Sign in with the password the Host already has. Because nobody administers it yet, the console shows a migration checkpoint rather than the fleet.
- Claim with Microsoft. The account you sign in with becomes this Fleet's first administrator, the shared password is deleted automatically, its sessions are revoked, and the console appears.
Microsoft-only is the secure default after claim. An administrator who explicitly needs both methods can go to Settings → Security → Enable password sign-in and choose a new password of at least 16 characters.
The console claim code is not needed for any of that: proving the existing
password proves the same thing it stands for, so the Host trades that session
for the same short, browser-bound bootstrap grant and audits it as
bootstrap_password_granted. A Host that never had a password, and a portable
restore onto a rebuilt machine, still take the printed code.
Disabling deletes the stored verifier, records the choice so a stale
FLEET_OPERATOR_PASSWORD left in a shell profile cannot re-enable it, and
revokes every password session. If you lock yourself out, a local recovery
command on the Host console issues a temporary password and writes an audit
event; disable it again once you are back in.
| Provider | Reachability | Operator console |
|---|---|---|
Direct localhost |
This machine only | Always allowed |
| Dev Tunnels (creator-private) | Microsoft login at the tunnel | Default and recommended |
| Dev Tunnels (tenant / anonymous) | Wider, by your devtunnel access policy |
Allowed, with a warning |
| Cloudflare / ngrok / Tailscale HTTPS | Anyone with the URL | Allowed after claim, with a warning |
bore and any plain-HTTP relay |
Anyone with the URL, in clear text | Refused |
A fresh Host defaults to Dev Tunnels because its URL alone reaches nothing: the provider demands a Microsoft login before Fleet's own claim screen is even visible. A public HTTPS provider is fine after the Host is claimed — the sign-in page grants nothing on its own — but a stranger can at least see it.
bore is refused for the operator console by the Host itself, not merely greyed
out in the panel: it relays plain TCP, so the session cookie and every
transcript behind it would cross it readable. The refusal holds for any client,
including one that never renders the UI.
Not every question is about a checkout. Chats is a workspace the Host creates for itself, pinned above your projects in the sidebar, for the sessions that only need an agent and a machine: a question, a bit of reading, research on something you have not written yet. Every node that reports a home directory gets a Chats placement there automatically, so there is nothing to set up — pick Chats in New session and it runs in that machine's home directory.
Because it is derived rather than filed, it is the one workspace you cannot edit:
it has no rename, no delete, and no placements to add, move or remove. If you
already had a project called Chats, it keeps everything but its label, which moves
to Chats (2) so the reserved name is free.
An orchestrator can use it too — see Chats as a destination.
Connected nodes are told when the Host's public URL changes, so a rotated tunnel does not strand them — see Following the Host to a new URL.
For production (built Host + local Node together):
npm run build
npm startOr just the Host: npm run start:host. Open http://127.0.0.1:8787 —
Fastify serves the built UI.
Install Node.js, then run copilot update and copilot login as the same OS
user that will run the Node. Copilot CLI 1.0.69 is the minimum because older ACP
builds can report authentication success while signed out, leaving a Host
session waiting with no failure to display. From a checked-out Fleet directory
(or paste the command from the Host's Nodes → Connect card):
npm install
npm run build:node
npm run start:node -- --url="https://fleet.example.com" `
--host-id="<host-id>" `
--host-fingerprint="<sha256>" `
--enrollment-grant="<id>.<secret>"The same lines work in bash — flags avoid the $env: / VAR=value split
between shells. Generate them from Settings → Nodes → Generate a connect
command: the grant is minted on request, is good for one machine and fifteen
minutes, and is never stored by the Host in a form it could hand out again.
The node generates its own Ed25519 key pair before it contacts anything, and
pins --host-fingerprint. A relay or an impostor that answers the URL cannot
produce a signature for the matching key, so the node sends it no enrollment
completion and accepts no command from it — which is what makes a relay merely
a relay.
The node name defaults to the machine hostname, and can be changed from either
end — the Host's Nodes tab or the node's own config page. Renaming keeps the
machine's identity, so its placements and sessions come with it; the Host owns
the name, so if both ends changed while the node was offline, the Host's name
wins and is pushed back down. Pass --max-sessions 4 if you want a capacity
other than 10.
Enrollment stores the node's private key and the Host's public key at
$env:APPDATA\CopilotFleet\node.json; subsequent starts need no grant, and no
reusable shared secret is ever issued. The service uses an outbound WSS
connection, so no inbound Node port is required.
The older fleet-wide --token / ENROLLMENT_TOKEN exists only for machines that
predate Node keys. A fresh Host has none: it neither requires one to start nor
persists one, and it refuses token registration outright. It is a reusable
credential that authorises any machine and that a node sends before it can tell
the Host from a relay, so it is deprecated. Existing nodes do not upgrade
themselves: a shared secret has already reached whatever relays that node's
connection, so nothing sent back over it can prove which Host is answering.
Migrate each machine by minting a fresh Connect command and running it there —
the grant is one-time, the fingerprint comes from your screen rather than the
wire, and enrolling under the machine's existing name reclaims the same node,
keeping its id, placements and session history. Settings → Security shows how
many are left and lets an administrator switch the shared secret off for good
once none are.
Anything the node reads from the environment can be given as a flag instead, and
a flag wins over both .env and the saved settings.json — which is what makes
it usable to point one run at a different Host without editing files on that
machine. Run npm run start:node -- --help for the current list.
| Flag | Replaces |
|---|---|
--url, --host-url |
FLEET_HOST_URL |
--name, --node-name |
FLEET_NODE_NAME |
--enrollment-grant |
FLEET_ENROLLMENT_GRANT |
--host-id |
FLEET_HOST_ID |
--host-fingerprint |
FLEET_HOST_FINGERPRINT |
--token, --enrollment-token |
FLEET_ENROLLMENT_TOKEN (deprecated) |
--max-sessions |
FLEET_MAX_SESSIONS |
--copilot-command |
FLEET_COPILOT_COMMAND |
--permission-timeout-ms |
PERMISSION_TIMEOUT_MS |
--context-tier |
FLEET_CONTEXT_TIER |
--devtunnel |
FLEET_DEVTUNNEL_ID |
--config-port |
FLEET_NODE_CONFIG_PORT |
--mock-agent, --no-mock-agent |
FLEET_MOCK_AGENT |
Both --flag value and --flag=value are accepted. The -- after the npm
script name is npm's own separator; without it npm eats the flags. The same
flags work on npm run node, npm run dev and npm start, where they are
forwarded to the node process only:
npm start -- --url=https://fleet.example.comFlags apply to that run; edits made later in the config page win until the process restarts.
Note that --url takes effect by restarting the node, which ends the sessions
running on it — they settle as "Node reconnected without this session", and
anything that reached the agent can be picked up again with Resume. To
follow a rotated tunnel URL without losing live sessions, retarget from the node
config page instead: it reconnects in place.
The nodeId stored in node.json is the machine's identity. --name proposes a
new label for that identity; it does not create a second node or abandon the
existing node's placements and sessions.
A workspace or node row folds shut once nothing under it is running — every session on it stopped, finished, or offline while its machine is away — so the tree stays as short as the work in front of you rather than growing with every transcript kept for Resume.
It opens again the moment work turns up there: a session started on that machine, or one coming back to life as its node reconnects. Only those changes move a row, never the standing state, so a dormant branch opened by hand to read an old transcript stays open until something under it actually happens.
The sidebar tree can be rearranged by hand at every level: workspace rows, node rows, and the sessions under them. Drag a row above or below a sibling — the pointer's half of the target row decides which, and a line appears at that edge — and the order is stored, so it survives a reload and is the same in every browser watching the Host.
Dropping onto a row would only ever mean "take its place", which leaves no way to say "put it last": there is no row after the last one to aim at. The above/below distinction is what makes the end of a list reachable.
New workspaces, placements and sessions are added at the end rather than sorted in by name or date, so an arrangement made by hand is not undone by the next machine or run added. A fleet nobody has rearranged keeps the order it always had.
Dropping a node row onto a different workspace files that checkout under it instead of reordering, taking its sessions along: they carry their own workspace id so the sidebar can group history without a join, and leaving that behind would file every past run under the project the checkout no longer belongs to. That move is refused if the target workspace already has a placement on the same machine, since a workspace can only be in one place on a given node.
In Workspaces & placements, the same drags work on the cards: placement rows reorder within a card, node chips at the top can be dropped on a card to place that machine there, and a card that cannot take what is being dragged says why on the card rather than silently refusing.
Sessions only reorder among their own node's list. A session is a live agent process on one machine, holding that machine's files, so there is nowhere else for it to go.
A finished turn plays a short rising tone; an agent blocked on a permission plays a lower one, twice. They are different on purpose: a fleet is watched out of the corner of an eye, and "it needs you" should be distinguishable from "it is done" without looking at the screen. The speaker button in the top bar mutes them, and the choice is remembered.
Both are synthesised in the browser rather than shipped as audio files, so they work on a Host that has never been online. Nothing sounds on the first view of the fleet — opening a page onto ten finished sessions is not the same as watching ten agents finish — and several sessions finishing together produce one tone rather than a pile of them. A permission that is still waiting is announced once, not on every refresh.
Permissions are also announced outside the page, with a tab-title count and a desktop notification that survives until it is clicked, because a request blocks its agent until the node's timeout expires.
The composer takes files: paste a screenshot straight into the box, or use the paperclip to pick some. Each one appears as a chip that can be removed until the message is sent, and a prompt can carry up to six of them at 10 MB each.
How a file reaches the agent depends on what it is. Images go over as ACP image blocks; everything else is embedded as text, so the agent reads the contents without needing the file to exist on its own disk — which matters because the machine running the agent is usually not the machine the file came from. A binary that is neither, like a zip, is named in the prompt rather than embedded: decoding it as text would spend the context window on replacement characters and can read as instructions.
Bytes travel with the prompt in one piece rather than through an upload endpoint. The agent is often behind a tunnel, and handing it a URL to fetch would mean giving the Node credentials and a route back to the Host for something already in the operator's hand. The size ceilings are what keep that from becoming a WebSocket frame large enough to stall the other sessions sharing the connection.
Only the name, type and size are recorded in the transcript. The event log is stored on the Host and replayed to every browser watching a session, so keeping the bytes there would turn a few pasted screenshots into a liability; the attachment chips under a sent message are the trace that remains.
The composer offers Copilot's own slash commands: type / and a list appears,
filtered as you type. Arrow keys move the selection, Enter or Tab picks one, and
Escape closes the menu. A command that takes an argument (/review, /research)
leaves the caret waiting after it; one that does not (/usage, /context) runs
straight away. The list is whatever the agent reports for that session, including
skills and plugins, so a machine with extra skills installed shows them without
any change here.
Along the bottom of the composer sit the session's pickers — Model, Mode,
Reasoning Effort — as the agent reports them. Each shows only its current
value and opens a menu upwards; the setting's name lives in that menu rather
than on the strip, so the composer stays one compact object instead of a band of
labelled dropdowns. These are the settings a terminal Copilot opens a chooser
for, which is why /model on its own answers "no model is currently selected"
over a wire protocol: there is no terminal to open a chooser in. Changing one
takes effect on the live session without spending a turn, and works while the
agent is mid-run.
Copilot also reports an Allow All picker, and the strip leaves it out.
Permission policy is decided once when the session is launched, with or without
--allow-all, and is already shown as the session's YOLO badge. Offering it
again as a dropdown can only disagree with that badge — and on a session already
started with --allow-all, setting it back to "off" is answered with success and
then ignored, so the control moves and snaps back. Note that YOLO does not imply
Copilot's Autopilot Mode: a session launched with --allow-all still reports
mode agent, so Mode stays on the strip as the only way to reach Plan or
Autopilot.
Picking a value the agent rejects is reported as a notice and leaves the session
alone; it does not end the run. Nodes advertise session-config, and the Host
refuses the request rather than sending it to an older node that would not
understand the frame.
Copilot owns the defaults. A session is started with nothing but a working
directory, so the model, mode and effort a new session opens on are whatever
Copilot itself resolves for that machine and account — the fleet never sends one.
Changing a picker is scoped to that one session: a second session on the same
node, and the next copilot run in a terminal, both still start on Copilot's own
default. Resuming re-reads the live values through session/load rather than
trusting what was stored, so a session that comes back shows what it is actually
running on.
Choosing a model can change the other pickers, because not every model offers every setting — switching to a model without reasoning levels removes the Reasoning Effort control. The agent's whole option list is republished on every change for that reason, so the bar never keeps a control the current model has stopped offering.
The fleet is two kinds of state, so there are two files — and one of them now comes in two versions, because moving a Host's data and moving its identity are different operations with different risks.
Host data (version 1) — Settings → General → Export fleet data. The JSON
file holds workspaces, placements, nodes (identity hashes, not plaintext
secrets), sessions, transcripts, defaults, any legacy enrollment token, and
tunnel provider/enabled. It is data only: import on the new machine
replaces the catalog, but deliberately preserves the security envelope of
the Host it lands in — administrators, authentication mode and Entra
configuration, the Host signing key, the CSRF and lead-token keys, and password
mode all survive. A data restore can never return a secured Host to unclaimed
or silently hand it a different identity, which is exactly why it cannot move a
Host on its own.
Host identity (portable, version 2) — Settings → Security → Move this Host. This is the file that moves a Host to a new machine intact. Its security section — administrators, Entra configuration, the Host private key, CSRF and lead-token keys, Node public keys, and whether mutual Node authentication is enforced — is encrypted with a passphrase you supply (scrypt + AES-256-GCM, minimum 14 characters, never persisted). Exporting it, and importing it into an already claimed Host, both require a Microsoft authorization-code sign-in from the last ten minutes; importing into a fresh Host instead takes that Host's console claim code plus the passphrase, and creates no session — an administrator signs in afterwards through the restored configuration.
A fleet that had already retired the shared Node secret restores that way too: enforcement travels in the sealed section, and the fleet-wide enrollment token it retired is not written back.
Restoring revokes every browser session and closes browser and Node sockets, and either applies whole or not at all. Stop the old Host before starting the moved one: two processes sharing one Host identity is a fingerprint two machines can sign for, and Nodes cannot tell them apart.
Existing nodes reconnect with the node.json they already have, as long as they
can still reach the Host. A named hostname / FLEET_PUBLIC_URL / Tailscale Funnel
address is copied into the archive; a rotating quick-tunnel URL (*.trycloudflare.com,
free ngrok, bore) is not — those nodes would have to be retargeted by hand.
Node — the local config page (http://127.0.0.1:8788) → Export identity.
That file is node.json plus settings.json for this machine. Import on the new
box replaces this process's identity and reconnects. Placement paths stay whatever
the Host already stored for that node id; update them if the checkout lives
somewhere else. Copilot's own session files are not in the archive, so Resume
only works if those files are on the machine that runs the agent.
Both files contain secrets. Do not commit them.
A dropped transport says nothing about the agent behind it, so the Host asks
rather than assumes. The Node reports its inventory and which of those
sessions are mid-turn, which is what stops a returning session from landing on
idle while its agent still has a prompt in flight.
Sessions survive both processes going down. The Host keeps them in its SQLite
file and the node keeps its identity in node.json, so after both come back:
- The Host marks everything it had running
offline("Host restarted"). - The reconnecting node reports which sessions it still has. A restarted node has none, so the rest settle as "Node reconnected without this session".
- Resume re-attaches through Copilot's
session/load, and the transcript continues where it stopped rather than starting over.
A session in that state is shown as resumable rather than failed, stays in the sidebar, and is skipped by Clear ended — that button only removes sessions with nothing left to re-attach to. Use Dismiss on a session to drop a resumable one deliberately.
By default the Host re-attaches those sessions itself as soon as the node is back, so a restart does not leave a row of buttons to click. It takes only the sessions settled by that reconnect, newest first, and stops at the node's capacity — so a restart never resurrects conversations abandoned days ago, and a resume that fails is left for a person instead of retried every heartbeat. Re-attaching sends no prompt: the agent lands on idle waiting for input, so nothing runs until you ask it to. Turn it off under Settings → General if you would rather press Resume yourself.
Three things have to hold for that to work: the Host's DATABASE_PATH file is
intact, the node starts with the same node.json identity, and Copilot on that
machine still has the agent session on disk. A session that died before its agent
ever started has nothing to re-attach to — it settles as "it never reached the
agent" and offers no Resume.
A node keeps its agents running while the Host is away and buffers the events they produce, so a Host restart mid-turn no longer costs that part of the transcript. If the outage outlasts the buffer the Host records the gap and keeps going; it never refuses the events that follow, because a session that cannot report its own state again is a session nobody can use.
Each node serves a small settings page at http://127.0.0.1:8788 (override the
port with FLEET_NODE_CONFIG_PORT). Use it to retarget the node when a tunnel
hands out a new URL — the node reconnects in place, so no restart is needed and
running sessions survive.
It also edits the node name, session capacity, Copilot executable path, and
permission timeout. Values are stored in settings.json beside the credentials
and take precedence over the environment variables, so an edit here is not
undone by a stale .env on the next start. Command-line flags outrank both.
The listener binds to loopback only and is deliberately not exposed: anything that can repoint a node at a different Host can run commands on that machine. Reach a remote node's page over SSH port forwarding rather than binding wider.
Each provider runs on its own, so more than one can be up at a time; the one marked for enrollment is the address handed to new nodes.
That address is the Host, not a separate handshake channel. The tunnel forwards
to http://127.0.0.1:8787 (or PORT): /api, /ws/node, /ws/browser, and
the built UI when one is there. In npm run dev the page you click is Vite on
http://127.0.0.1:5173; the tunnel does not point at that. Opening the public
URL still hits the Host, so /api/health answers and everything else still
asks for a Microsoft sign-in.
When the Host's public address changes — a tunnel comes up, rotates, or is switched to another provider — it tells the nodes that are still connected. Each one records the new address, keeps the old one as a fallback, and does not drop the connection it already has: the running sessions on it are unaffected, and the new address is what the next reconnect dials.
This closes the gap where a rotated tunnel URL left every node dialing an
address that had stopped existing, with no way back except editing
settings.json on each machine.
What it does and does not cover:
- A node reached over an address that outlives the change — a LAN address, a named tunnel — is told and follows along.
- A node reached through the tunnel that just rotated cannot be told: that socket died with the tunnel. It keeps retrying its known addresses, so it recovers on its own if one of them still answers.
- A private Dev Tunnel is advertised for enrollment but never pushed as a public
Host URL. Its nodes use
--devtunnel=<id>, keep a localdevtunnel connectforward alive, and dial the loopback port that client reports. - Loopback is never announced. When no tunnel is up and no
FLEET_PUBLIC_URLis set, the Host's idea of its own address ishttp://127.0.0.1:8787, which on another machine points at that machine. Nodes are left on the address they have instead. - A node running an older agent is skipped rather than sent a message it would reject, so a mixed fleet keeps working.
If an announced address turns out to be unreachable from a particular machine, that node dials it, fails, and rotates to the previous address on the next attempt — so an announcement can never strand a machine. Whichever address answers becomes the one it leads with. The node config page lists the fallbacks under the Host URL field.
The shape of that diagram is the whole feature: there is exactly one path that ends in a restart, and every guard that fails leaves the machine running what it was already running.
The Nodes tab compares each machine's commit with the Host's and marks it Up
to date, Update available, or Manual update. Update on a row — or
Update all above the table — tells those machines to git fetch --prune,
git reset --hard onto the branch they track, npm install,
npm run build:node, and restart into the new build. Progress appears in the row
as it happens.
The commit is compared, not the package version: 0.1.0 never moves between
deploys, so comparing it would report every machine as current no matter how far
behind it was.
What it will not do:
- Update a machine that is running sessions without being told to. A restart takes every agent on that node with it, so a busy node is refused — but the refusal names the sessions in the way, and Update then offers to stop them and go ahead. Each keeps its transcript and can be resumed afterwards. Update all never does this: it skips busy machines rather than deciding for you across the fleet.
- Keep local work on a node. The checkout is reset hard onto the branch it
tracks, so local commits and local edits to tracked files are discarded — the
remote is what that machine is meant to be running, and
--ff-onlyused to mean one stray commit froze a machine behind the fleet until someone logged into it. Untracked files are left alone, so the.envnaming the Host survives. A node is a deployment; do the work somewhere else. - Move a machine off the branch it is on. The reset target is the branch's
own upstream, not
origin/main, and a branch with no upstream stops with that as the reason. - Restart into a build that does not compile.
npm run build:noderuns before anything is torn down; if it fails the node stays up on the code it already had and reports the error. - Update a node whose agent predates this feature. It has no
update_nodein its copy of the message union and would close the connection on receiving one, so it is marked Manual update and skipped. Update those machines by hand once — with the three commands under Windows Node — and every update after that can be done from the Host.
A node reports "" for its commit when its directory is not a git checkout — a
tarball deploy, say. Those show as Unknown rather than being guessed at, and
are left out of Update all.
npm run node and npm run start:node both put a small supervisor in front of
the node (apps/node/supervisor.mjs). The node never replaces itself: it exits
with status 75 to ask for a restart, and the supervisor — which had nothing to
do with the update and is therefore still alive — starts the new build in the
same terminal. Nothing is detached and no window appears.
This exists because a process cannot reliably replace itself on Windows. The
version that tried spawned a detached successor, which arrives with a console
window of its own and has to win a race for the instance lock. Under tsx watch
it lost that race every time: the pull changed the source, the watcher restarted
its own child, and the successor found the lock taken and exited — which looked
like a terminal flashing open and vanishing, with the node coming back only by
the watcher's accident.
npm run dev:watch still runs the node under tsx watch for iterating on node
code. Do not use it for a machine you rely on: a watcher does not restart a
child that exits, so an update under one leaves the machine with nothing
running.
The supervisor restarts on status 75 and nothing else — a node that crashes exits with the code it crashed with, so a broken build is visible instead of looping. It also gives up if the node asks to restart five times in twenty seconds.
The built-in supervisor does not survive a reboot and will not restart a node that crashes. A machine you rely on is better run under something that does — PM2, NSSM, a systemd unit.
Set FLEET_RESTART_MODE=exit and an update stops the process instead of
launching a successor, leaving the restart to the supervisor. Point it at
apps/node/dist/main.js directly, not at supervisor.mjs; two supervisors is
one more than the job needs.
# PM2, on any platform
FLEET_RESTART_MODE=exit pm2 start apps/node/dist/main.js --name copilot-fleet-node -- --url=https://fleet.example.com
pm2 save# Windows, as a service, with NSSM
nssm install copilot-fleet-node "C:\Program Files\nodejs\node.exe" "Q:\Repos\copilot-fleet\apps\node\dist\main.js"
nssm set copilot-fleet-node AppDirectory Q:\Repos\copilot-fleet
nssm set copilot-fleet-node AppEnvironmentExtra FLEET_RESTART_MODE=exit
nssm start copilot-fleet-nodeAn update exits 75 in this mode too. PM2 and NSSM restart on any exit, so that
is already what you want; a unit file that restarts only on failure needs
RestartForceExitStatus=75 or Restart=always.
Run the Host in terminal 1:
cp .env.example .env
npm install
npm run hostClaim it: open http://localhost:8787, enter the code the Host printed, and
sign in with Microsoft. Then mint a connect command from Settings → Nodes
and run a deterministic no-login Node in terminal 2:
npm run node -- --url=http://localhost:8787 \
--host-id="<host-id>" \
--host-fingerprint="<sha256>" \
--enrollment-grant="<id>.<secret>" \
--name=mock-node \
--max-sessions=2 \
--mock-agentThen open http://localhost:5173:
- Create a workspace under Workspaces.
- Add a placement for
mock-nodeusing an existing absolute directory. - Start two sessions with New session. Give one a name in the dialog; the other is listed by its prompt until you rename it from the session header.
- Open either card to observe independent streamed events, send a follow-up, cancel a turn, or stop the process.
The automated equivalent is:
npm testapps/node/src/router.test.ts starts two mock sessions concurrently and proves
that each receives its own ordered event stream without Copilot authentication.
The vertical split is the whole design: the Host owns desired state and history, the Node owns execution. Copilot credentials, child processes, and local paths never cross it, and the Node is the side that dials out.
- The Node generates its own key pair, pins the Host fingerprint, and enrols with a one-time grant; the Host stores only its public key.
- The Node authenticates its outbound WebSocket by signing the whole handshake, and both ends derive per-direction AEAD keys for it. Heartbeats report active session inventory.
- The browser creates a session from a stored placement. The Host never accepts a path in the session-create request.
- The Host dispatches a deduplicated command. The Node validates and resolves the placement directory, enforces capacity, and starts one isolated ACP connection.
- The official
@agentclientprotocol/sdkperformsinitialize,session/new, prompt/update streaming, follow-up prompts, andsession/cancel. Stop closes ACP and terminates the child. - Node events carry a UUID plus a per-session monotonic sequence. SQLite ignores duplicates and records sequence gaps rather than rejecting everything after an outage; normalized sessions/events are broadcast to browsers and rebuild the transcript after refresh.
- ACP permission requests become persisted events. Browser allow-once/deny
decisions round-trip to the waiting ACP request. Timeout or Node/Host
disconnect denies pending requests. Cancel also denies pending requests before
session/cancel. - A transient Host WebSocket disconnect leaves local agent processes running.
The Node buffers their events and re-announces active and busy sessions when it
reconnects. The Host keeps them
offlinemeanwhile and settles only sessions missing from the returning inventory as failed-but-resumable. An explicit Node shutdown still stops its local agents.
Two distinctions carry the model. Cancel ends the turn and keeps the process,
so the session lands back on idle ready for a follow-up; stop ends the
process and is terminal. And failed is not one thing: a session that reached
the agent keeps its agent session id and is offered as resumable, while one
that never got that far is simply over.
There are two ways to put several agents on one job.
Talk to an orchestrator. The sidebar's first row is Orchestrator, above the workspaces, because it is the fleet's own surface rather than any one repository's. Start one and you get a session you chat with, which does not write code itself — it starts other agents that do. Ask it for something and it picks a machine, dispatches a worker, and ends its turn. When that worker finishes, the Host wakes the orchestrator with a summary, and it decides what happens next. Ask it for a review and it dispatches one onto the same checkout the work happened in, so the reviewer sees the actual changes.
The orchestrator is not a special kind of process. It is an ordinary session on an ordinary node, and the only thing that makes it a lead is that the Host hands it a tool surface — an MCP server, with a bearer token scoped to that one session. Workers are given no tools at all: not denied them, never handed them, which is what stops orchestration nesting.
Two seams are worth naming, because they are what the awkward cases hang off. The scheduler is pure — a snapshot of runs, steps, sessions and nodes goes in, a list of actions comes out — so a Host restart mid-dispatch, a node that vanished, or two steps settling at once are all unit tests rather than situations you have to reproduce on a real fleet. The engine does nothing but carry those actions out, and it ticks on events plus a 15-second sweep, so a machine that loses power leaves a step overdue rather than stranded.
The wake — the coral path above — is the whole design. The orchestrator never sits and waits: it dispatches, ends its turn, and is woken when there is something to decide. The conversation is durable, so a worker that takes twenty minutes costs nothing while it runs, and a Host restart does not lose the thread.
You see all of this in three places. The sidebar lists your conversations; the Orchestrator board shows every conversation's tasks, because "what is the fleet doing" is a fleet-wide question; and a conversation carries its own tasks in a panel beside it, so what you just asked for is next to where you asked. Clicking a dispatched step opens that worker's transcript.
An orchestrator picks where each worker runs, and Chats is one of the
choices: naming it as the workspace sends that worker to the node's home
directory instead of a checkout. That is how a task that is a question — look
something up, read around a problem, compare two approaches — gets dispatched at
all, without inventing a project for it to be asked in first.
It is the one destination the Host refuses work for. A step that writes or reviews is never sent there, because a change made in a home directory would pin the whole task to it, and every later step — the review most of all — would then be sent somewhere the work has never been. The refusal says so, and names the alternative: send research to Chats, name a workspace for the repository.
Or write the plan yourself. A run is an objective plus a budget with a fixed list of steps, approved once. There is no UI for this; it is the engine's own fixture, and it is reachable over REST:
curl -X POST http://127.0.0.1:8787/api/runs \
-H 'content-type: application/json' \
-d '{"workspaceId":"<id>","name":"audit","objective":"audit, fix, then test"}'
curl -X POST http://127.0.0.1:8787/api/runs/<runId>/plan \
-H 'content-type: application/json' \
-d '{"steps":[
{"stepKey":"audit","title":"Audit","prompt":"Find the flaky test","category":"explore"},
{"stepKey":"fix","title":"Fix","prompt":"Fix it","category":"implement","dependsOn":["audit"]},
{"stepKey":"test","title":"Test","prompt":"Run the suite","category":"test","dependsOn":["fix"]}
]}'
curl -X POST http://127.0.0.1:8787/api/runs/<runId>/approveEither way the Host runs it: it picks a placement, waits for turn_complete and
then idle before calling a step done, pins the whole run to the first checkout
it wrote to, and stops the sessions it still holds when the run ends. A restart
mid-run does not mis-settle anything, because offline is read as unknown
rather than as failure.
Approving is deliberately the only gate. A human authorises the objective and its budget; individual dispatches are not re-approved, and the budget is what stops a run rather than a prompt each time.
- The web UI and the whole
/apisurface require a Fleet session belonging to a live administrator. A Fleet session is issued only after Microsoft Entra ID has authenticated the person and this Host's own administrator table has authorized them: a valid account from the right tenant that nobody added is refused with a named403and gets no session. Sessions are opaque 256-bit values stored as SHA-256 digests,HttpOnly,SameSite=Strict,Secureon a configured HTTPS endpoint, with a seven-day idle and 30-day absolute life. No Microsoft access, refresh, ID or device token is ever persisted./api/healthand/api/auth/statusstay unauthenticated so a tunnel URL can be probed without becoming an administrator. - Claiming a fresh Host takes two independent proofs: a 128-bit one-time code
printed only to the Host's console, and a Microsoft sign-in. Neither is
sufficient alone, the claim is a single atomic transaction, and a second
identity racing it gets
409rather than a second administrator. Request IP, apparent loopback,x-forwarded-protoand caller-suppliedHostvalues are not security inputs — every supported tunnel relays into loopback, so all of them describe the relay. - Every state-changing browser request carries an
X-CSRF-Tokenderived from the session with an HMAC, so nothing per-session is stored to leak. - High-impact changes — removing an administrator, disabling the password, minting an enrollment grant, exporting a portable backup — additionally require an authorization-code sign-in from the last ten minutes. A device sign-in does not satisfy it, because an attacker can start a device flow and have an administrator finish it.
- Removing an administrator revokes their sessions and closes their live browser sockets in the same operation; a 60-second sweep re-checks every open socket against the live session and administrator rows.
- Legacy password sign-in is opt-in, off on a fresh Host, and retired
automatically by the first Microsoft claim. Disabling it
deletes the verifier and records the choice, so a stale
FLEET_OPERATOR_PASSWORDcannot re-enable it. - The Host answers only to names it knows: loopback,
FLEET_PUBLIC_URL, the live tunnel URL, and anything listed inFLEET_ALLOWED_HOSTS. Requests arriving under any otherHost, or from anotherOrigin, are refused — which is what keeps a page the operator happens to visit from reaching the fleet through a rebound DNS name.FLEET_ALLOWED_HOSTS=*disables the check. - A session or bootstrap grant is issued only over loopback or an endpoint this
Host itself published as HTTPS. A plain-HTTP relay such as
boreis refused for the operator console by the Host, not merely disabled in the UI. /mcpis a separate machine principal, not an operator-cookie exception. It accepts only a signed lead token bound to a live lead session, run and node, rejects browserOriginheaders, and audits every refusal without recording the bearer value.- A node's own credentials reach only the workspace and placement endpoints its config page relays through, and a node can only create or repoint placements on itself.
- New enrollment sends no reusable credential to an unauthenticated Host. A one-time grant authorises exactly one Node public key for fifteen minutes; the node pins the Host fingerprint before it completes, both ends sign the whole handshake, and the connection derives per-direction AES-256-GCM keys with sequenced frames — so a relay can carry the traffic without reading, forging, or replaying it.
- The legacy fleet-wide enrollment token exists only for machines that predate Node keys. A fresh Host never has one, does not persist one, and refuses token registration; Settings shows how many machines are left, and enforcement — which deletes the stored secrets and retires the token — is refused while any node still needs one. There is no automatic upgrade off a shared secret: that secret has already reached whatever relays the connection, so a machine migrates by running a fresh Connect command, which reclaims its own node row against a key.
- Copilot authentication and tokens remain on the Node and are never included in Fleet messages.
- Session requests reference preconfigured placement IDs. Nodes also require an existing absolute directory and resolve it before process creation.
- Copilot is spawned directly with argument arrays,
shell: false, and the selected placement ascwd. - Permissions are explicit and auditable in the UI (allow-once / deny only).
YOLO is off by default, on the Host and on each new session. For unattended
runs, set
FLEET_YOLO=1on the Node so Copilot starts with--allow-all(tools, paths, and URLs). Unanswered and disconnected requests still fail closed when YOLO is off. - Security-relevant decisions are recorded in a local audit kept to the newest 10,000 rows, readable from Settings → Security. Claim codes, authorization codes, device codes, Microsoft tokens, Fleet cookies, invitations, enrollment grants, lead tokens and private keys are never logged.
- The node's local config page is bound to loopback and additionally refuses
requests that do not name
127.0.0.1(orlocalhost) on its own port, come from another origin, or write withoutcontent-type: application/json. It does not defend against another user signed in to the same machine. - An internet-exposed Host should still use HTTPS/WSS, and putting one behind an authenticated reverse proxy or access policy (for example Cloudflare Access) remains a good second layer.
npm run dev
npm run dev:tunnel
npm test
npm run typecheck
npm run build
npm run verify # everything CI runs, in CI's ordernpm run verify is the one to run before pushing: CI also checks formatting
(prettier --check), which lint does not cover, and a red build there has
more than once been nothing but unformatted source.
Startup is seed-free. SQLite creates its schema and empty data file on first launch.










