A CLI/TUI tool for managing fleets of agents each inside there own isolated devcontainers. This tool enables hugly parallel development, especially if you utilize the fleet-admiral skill (auto added to your claude code on install).
I made this tool to solve my own problem, and hopefully yours as well. I wanted a tool that allowed the following.
- Running unlimited parallel agents that don't step on each other. I mean
this in a way beyond the
git worktreeI don't want ports to step on each other, I don't want one agent deleting the whole computer to step on another agent. I want full isolation. - Use my claude code (or any agent you want) subscription at the subscription price! Many similar tools exist to this, but require you to pay API rates. This tool just uses terminals so you will never need to pay API rates.
- Not need to constantly setup new environments for agents. Environments need automated setup.
- Give me a one screen dashboard to manage all my agents, including knowing when they need attention.
fleet solves all these problems. I use it every day. A few other senior developers at my company use it every day. You should use it every day!
- Install
- Usage
- TUI Keybindings
- MCP Server
- Remote MCP — expose MCP & gRPC to remote agents via a fleet gateway
- Microphone — talk to the agents in your instances (voice input in a container)
- Themes — color themes for the TUI (Gruvbox, Catppuccin, Tokyo Night, Solarized)
- Your SSH agent on a remote fleet — your keys go with you to remote fleets, like
ssh -A - Environment Variables
- Requirements
- Development
- Windows / WSL Setup
- Devcontainer Customizations
- Fleet Launch (in-instance TUI)
- Devcontainer BuildKit
- Devcontainer UID Rewrite
Deep-dive docs (in doc/):
- The Fleet Gateway — how the reverse tunnel relays both MCP and gRPC to your daemon
sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | shTo install a specific version:
sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | sh -s -- --version v0.2.0Run fleet with no arguments to launch the interactive TUI, or use subcommands directly:
# Launch TUI
fleet
# Spawn instances from the current repo
fleet up agent-1
fleet up agent-2
# Stop and restart an existing instance without removing it
fleet stop agent-1
fleet start agent-1
# List instances
fleet ls
# Exec into an instance
fleet exec agent-1 bash
# Open VS Code on an instance
fleet code agent-1
# Launch the in-instance link/app grid (run inside an instance)
fleet launch
# View logs
fleet logs agent-1
# Rebuild an instance's container in place (e.g. after editing devcontainer.json)
fleet rebuild agent-1
# Remove an instance
fleet down agent-1
# Remove a fleet and all its instances
fleet destroy my-project
# Spawn from anywhere with explicit repo
fleet up agent-1 --repo git@github.com:org/my-project.git
# Reference an existing fleet from anywhere
fleet up my-project/agent-3
# Fleet a local directory instead of a git remote: file:// marks a TEMPLATE dir
# that is copied (not cloned) into every new instance. A local path has no repo
# name, so the fleet must be named explicitly (the TUI prompts for it).
fleet up scratch/agent-1 --repo file:///home/me/scratch-project
# Configure automation: agents (workers) and triggers (what fires them)
fleet agent create my-project nightly-builder --system-prompt "Build and report"
fleet trigger create my-project nightly --agent nightly-builder --cron "0 0 * * *" --prompt "Run the nightly build"
# A 'bash' trigger polls a command on its cron and fires only when it exits 0
# (stdout becomes the event payload) — for sources without a webhook
fleet trigger create my-project new-issues --type bash --agent triager --cron "*/5 * * * *" \
--script 'gh issue list --label needs-triage --json number -q ".[].number" | grep -q .' --prompt "Triage the new issues"
fleet agent list my-project
fleet trigger list my-project
fleet trigger logs my-project nightly # inspect a trigger's recorded firings
# Virtual microphone (enable it under Settings -> Microphone first)
fleet mic devices # this machine's capture devices
fleet mic sources # every connected client's capture devices (* = the one recorded)
fleet mic attach # provide the microphone without a TUI open (records the Settings device; --device overrides)Enabling a fleet's Claude Code mount shares ~/.claude, ~/.claude.json,
and the project's .claude/settings.local.json across its devcontainer instances.
Project overrides survive instance deletion and recreation; other project
.claude files remain part of each workspace. Rebuild existing instances to
apply newly enabled mounts and project settings links.
Fleet runs the devcontainer's postStartCommand during creation and after each
stop/start cycle. Starting an already running instance does not repeat it.
Restart hook failures are logged while the container remains running.
| Key | Action |
|---|---|
j/k |
Navigate |
space |
Expand/collapse fleet |
enter/e |
Exec into instance |
s |
Stop/start instance |
o |
Open instance in new terminal |
a |
Add instance |
n |
New fleet |
d |
Delete instance/fleet |
c |
Open VS Code |
R |
Rebuild instance (preserves workspace) |
L |
View logs (instance logs, or a selected trigger's event logs in the automation view) |
→/l, ←/h |
Select / deselect the PR-status auto tag |
enter (on PR status) |
Open the PR in a browser |
A |
Switch armada (remote fleet) |
r |
Refresh |
q |
Quit |
The fleet daemon also runs an MCP server over Streamable HTTP, so AI agents (and any MCP client) can drive fleet programmatically. It starts automatically with the daemon — no extra command.
- It binds
127.0.0.1:6012, or the next free port if 6012 is taken. - The active port is written to
~/.fleet/mcp.port, so clients can discover the endpoint. The URL ishttp://127.0.0.1:<port>. - Requests must carry
Authorization: Bearer <token>, where the token is read from~/.fleet/mcp.token. The loopback port is reachable by any local user, so the token (file mode0600, same-user only) is the access boundary — matching the daemon's unix socket. The token is generated once and reused across restarts.
Claude Code needs no setup: launching the fleet TUI registers the server in
~/.claude.json as the user-scope fleet entry (URL and token included) and
re-syncs it on every launch, so a daemon restart that lands on a new port heals
itself. It also installs the Fleet Admiral skill (~/.claude/skills/fleet-admiral),
which teaches the agent how to drive these tools. (Local daemons only: when
FLEET_GATEWAY/FLEET_SERVER point the TUI at a remote daemon, registration
is skipped — point Claude Code at the Public MCP URL from the settings page
instead, see Remote MCP. Registration is best-effort and never
blocks startup.) The snippet below is for other MCP clients or manual setups.
For convenience the server also writes ~/.fleet/mcp.env (mode 0600) with the
endpoint as shell exports, and wires ~/.bashrc to source it, so new shells get:
FLEET_MCP_PORT=6012
FLEET_MCP_URL=http://127.0.0.1:6012
FLEET_MCP_TOKEN=<token>An MCP client config (mcp.json) can then reference them directly:
{
"mcpServers": {
"fleet": {
"type": "http",
"url": "${FLEET_MCP_URL}",
"headers": { "Authorization": "Bearer ${FLEET_MCP_TOKEN}" }
}
}
}(zsh users: add [ -f "$HOME/.fleet/mcp.env" ] && . "$HOME/.fleet/mcp.env" to
~/.zshrc.)
Tools mirror the non-interactive CLI: fleet_list, fleet_status,
fleet_version, fleet_logs, fleet_up, fleet_start, fleet_stop,
fleet_down, fleet_destroy_fleet, fleet_clone, fleet_rebuild, fleet_exec,
the tmux session tools fleet_session_spawn / fleet_session_exec /
fleet_session_read / fleet_session_list, and the automation CRUD tools
fleet_automation_list, fleet_agent_create / fleet_agent_update /
fleet_agent_delete, fleet_trigger_create / fleet_trigger_update /
fleet_trigger_delete, and fleet_trigger_logs (mirroring the fleet agent and
fleet trigger CLI commands).
Interactive, open-ended commands (fleet shell, log following) are intentionally
not exposed.
A fleet can also provide these tools to the coding agents running inside its
instances, so an agent there can take on work too big for one agent — spin up
more instances, run agents in them, collect what they produce — or act as a
coordinator for all of your fleets. Turn on Fleet MCP in the fleet's options
(e on the fleet). Off by default; it applies to every instance of that fleet,
however it was created (by you, a clone, or an automation trigger).
- It is the full fleet MCP, on purpose — every tool, every fleet. That
includes tools that reach the host: a
bashautomation trigger runs its script on the host as you, and afile://fleet copies host directories into an instance. Turning it on gives anything running in those instances — an agent, a prompt injection of it, the repo's own scripts — host-level access: an agent-escape risk you accept by turning it on. - The daemon serves MCP on a socket in each instance's control directory
(
/fleet-mounts/control/mcp.sock); the stagedfleet mcp-bridgerelays it to an agent over stdio. No token enters the instance: only instances of a fleet with the setting on get the socket, and only that instance's own processes can connect. - Agents pick it up from their shell, not from their config:
~/.fleet/fleet.rc(sourced by~/.bashrc) sees the socket and runsfleet mcp-env, which writes a Claude Code plugin (the MCP server plus the Fleet Admiral skill) under~/.cache/fleet/mcpand exportsCLAUDE_CODE_PLUGIN_DIRS. Nothing is written to~/.claudeor~/.claude.json. So it reaches aclaudestarted from a bash shell opened after the setting was turned on (a new session, an automation agent); a command that clears the environment (sudowithout-E,env -i) loses it. Claude Code is the only agent supported so far. - Devcontainer instances on Linux hosts only. Instances an agent creates are
not cleaned up for it — it is told to
fleet_downthem when done.
By default the MCP server is loopback-only. To let a remote agent drive your fleet, you can expose it through a fleet gateway — a small public relay you (or someone) runs. Your daemon dials out to the gateway (so it works behind NAT/firewalls) and the gateway routes inbound MCP requests back down that tunnel:
agent ──HTTPS──▶ fleet gateway ──reverse tunnel──▶ your fleetd ──▶ local MCP server
⚠️ Exposing fleet over the internet: the bearer token is the only thing gating access (and over gRPC it grants full daemon control), and the gateway operator can see your traffic — use a gateway you trust and keep the token secret. See The Fleet Gateway for the trust model.
In Settings → Fleet Remote (MCP):
- Set Gateway URL to the gateway's gRPC endpoint, e.g.
https://gateway.example.com:50051(this is where the daemon registers and dials out — the gateway's--grpc-addr, default port50051; behind a proxy it's whatever host:port routes to that listener). It is not the public address agents use. - Flip Enable Remote MCP on.
Once connected, the read-only Public MCP URL appears (e.g.
https://gateway.example.com/mcp/<id>) — that is the address external tools use.
The line shows the live connection state (connecting / connected / error).
A second, independent toggle — Enable Remote Fleet — exposes the daemon's
gRPC control surface so a remote fleet binary can drive this instance (see
Remote control). Turning it on reveals a Remote Fleet
via selector with two transports:
- Gateway (the default) — through the same gateway tunnel. With it, a
read-only Public GRPC URL appears under the Public MCP URL (computed by the
gateway from its
--public-grpc-urlflag) — that value is what you feed anotherfleetasFLEET_GATEWAY. With the toggle off, fleetd never negotiates the gRPC tunnel feature, so the gateway rejects any incoming gRPC commands aimed at the daemon. - SSH — no gateway at all. The daemon serves the same token-gated gRPC
server on a loopback port (shown on an SSH Listener row, and recorded in
~/.fleet/ssh.port), and a remote machine reaches it over a plain SSH tunnel that its fleet daemon maintains — see Remote control over SSH. Nothing is exposed beyond whatssh user@hostalready grants.
The toggles are independent: expose MCP, remote control, or both. Only remote fleet has the SSH transport; MCP and webhooks stay gateway-only.
A third, independent toggle — Enable Webhook — exposes this daemon's
automation webhook endpoint through the same gateway. With it on, a read-only
Public Webhook URL base appears (e.g. https://gateway.example.com/webhook/<id>),
served on the gateway's public HTTP listener — no extra gateway flag needed. A
remote system (CI, a SaaS webhook, curl) POSTs an event to
<public-webhook-url>/<name>, where <name> is a webhook trigger
you defined; the gateway relays it down the tunnel and fleetd fires that trigger's
agents if the event passes the trigger's filter (a regex over the body, or a JSON
path/value match). The trigger's create/edit dialog renders its full URL ready to
paste into the remote system. With the toggle off, fleetd never negotiates the
webhook feature, so the gateway 404s the route for this daemon.
Point the remote MCP client at the Public MCP URL, with the same bearer token
your daemon uses (from ~/.fleet/mcp.token):
{
"mcpServers": {
"fleet-remote": {
"type": "http",
"url": "https://gateway.example.com/mcp/<id>",
"headers": { "Authorization": "Bearer <token from ~/.fleet/mcp.token>" }
}
}
}The gateway is the same binary: fleet gateway. TLS is optional: give it a
publicly-trusted certificate (e.g. from Let's Encrypt) and it terminates TLS
itself, or omit the cert and run it behind a TLS-terminating reverse proxy (see
below).
fleet gateway \
--public-url https://gateway.example.com \
--tls-cert /etc/fleet/tls/fullchain.pem \
--tls-key /etc/fleet/tls/privkey.pem| Flag | Default | Purpose |
|---|---|---|
--public-url |
(required) | External base URL agents use; session URLs are <public-url>/mcp/<id>. Scheme (https/http) must match how the public endpoint is actually served |
--public-grpc-url |
(optional) | External base URL of the gRPC endpoint remote fleet clients dial, e.g. https://gateway.example.com:50051. Daemons that enable Remote Fleet are handed <public-grpc-url>/grpc/<id> as their Public GRPC URL (shown in the TUI, used as FLEET_GATEWAY). Unset = no URL is computed |
--tls-cert / --tls-key |
(optional) | TLS certificate + key (PEM). Provide both to serve HTTPS, or neither for plain HTTP behind a proxy. A lone cert or key is an error |
--public-addr |
:443 |
MCP + /healthz listener — HTTP/1.1 (HTTPS when a cert is set, else HTTP) |
--grpc-addr |
:50051 |
Native gRPC listener — HTTP/2 (h2c when cert-less, h2 under TLS). Hosts remote fleet control and fleetd registration. Empty disables both |
--max-sessions |
1024 |
Cap on concurrent tunnels |
--session-key |
(random per boot) | Secret key signing the session-resume tokens daemons present on reconnect. Set it (or FLEET_GATEWAY_SESSION_KEY) so daemons keep the same session URL across gateway restarts; left unset, a restart hands every daemon a fresh URL |
Every flag is also settable via environment variable — FLEET_GATEWAY_<FLAG> with
dashes as underscores (e.g. FLEET_GATEWAY_PUBLIC_URL,
FLEET_GATEWAY_MAX_SESSIONS) — which is handy for configuring the
Docker image in Kubernetes. A flag given on the command
line wins over its environment variable.
Two ports must be reachable: expose --public-addr to MCP agents and --grpc-addr
to remote fleet clients and your daemons (they register over it). A
GET /healthz on the public listener returns ok.
There is no separate control port. fleetd registers and carries its reverse tunnel over a long-lived gRPC bidi stream on
--grpc-addr(HTTP/2), so the whole gateway is plain HTTP/HTTP-2 — no raw TCP.
Both listeners are L7 — there's no raw-TCP port anymore, so a standard HTTP/gRPC ingress fronts the entire gateway:
--public-addr(MCP, HTTP/1.1) → a Traefik HTTPIngressRoute(TLS-terminating).--grpc-addr(gRPC, HTTP/2) → a Traefik gRPC route (h2c backend) — see Traefik's gRPC guide. This one carries both remotefleetcontrol RPCs and fleetd registration (the same long-lived bidi stream).
Run the gateway with no --tls-cert/--tls-key (plain HTTP/h2c) and let the
proxy terminate TLS. For example:
# MCP — L7 HTTP, TLS terminated at Traefik:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: fleet-gateway-mcp }
spec:
entryPoints: [websecure]
routes:
- match: Host(`gateway.example.com`) && PathPrefix(`/mcp`)
services: [{ name: fleet-gateway, port: 80 }] # gateway --public-addr
tls: { secretName: gateway-tls }
---
# gRPC (remote control + fleetd registration) — L7 gRPC, h2c to the backend:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: fleet-gateway-grpc }
spec:
entryPoints: [websecure]
routes:
- match: Host(`grpc.gateway.example.com`)
services: [{ name: fleet-gateway, port: 50051, scheme: h2c }] # gateway --grpc-addr
tls: { secretName: gateway-tls }Set --public-url https://gateway.example.com; the daemon's Gateway URL is the
gRPC endpoint, e.g. https://grpc.gateway.example.com (or https://gateway.example.com:50051
without a proxy). Clients (and daemons) verify TLS against the system roots at the
proxy edge; the proxy speaks plain HTTP/h2c to the cert-less gateway, which can then
bind unprivileged ports and needs no cert.
Every tagged release also publishes a gateway image to the GitHub Container
Registry, so you don't have to build the binary yourself. It's the same
fleet gateway, with the flags passed as docker run arguments (or as
FLEET_GATEWAY_* environment variables — see the table above):
docker run -d --name fleet-gateway \
-p 443:443 -p 50051:50051 \
-v /etc/fleet/tls:/tls:ro \
ghcr.io/benjaminbenetti/fleet-man/gateway:latest \
--public-url https://gateway.example.com \
--tls-cert /tls/fullchain.pem \
--tls-key /tls/privkey.pemImage tags track releases: :latest (newest stable), :X.Y.Z, and :X.Y.
Images are multi-arch (linux/amd64 + linux/arm64). To run behind a
TLS-terminating proxy, drop the -v .../tls mount and the --tls-cert/--tls-key
flags (and a --public-url http://… or proxy-fronted https://…); the container
then serves plain HTTP.
- No gateway authentication. Anyone can open a tunnel; the gateway just routes bytes. Isolation comes from the unguessable 256-bit id in the public URL.
- The bearer token is the real access boundary. The gateway forwards the
Authorizationheader untouched to your loopback MCP server, whose existing token check gates every request. Treat the Public MCP URL as a capability, but the token is the secret — share both only with agents you trust. - The id in the URL is not the reconnect credential: the daemon holds a separate secret (never placed in the URL), so a URL holder cannot hijack your tunnel.
- Session-resume tokens are bearer reclaim capabilities. Each registration
returns a JWT signed with
--session-key; the daemon stores it (mode0600, next to the secret) and presents it on reconnect, which is what keeps the URL stable across gateway restarts. Anyone holding the token can claim that session, so guard the signing key like any other server secret. - The public connection is TLS when the gateway holds a cert (the daemon verifies it against the system roots), or when a reverse proxy terminates TLS in front of it. Running the gateway itself on plain HTTP is only safe behind such a proxy (or on a trusted private network) — never expose a cert-less gateway directly.
The same gateway tunnel can also carry the daemon's gRPC API, so a remote
fleet client can drive your daemon directly — list/up/down, watch live status,
stream logs, change config, and so on. It rides the same tunnel as remote MCP
but has its own toggle: flip Enable Remote Fleet in Settings → Fleet MCP
(independent of Enable Remote MCP, so you can expose MCP, remote control, or
both). The gateway serves native gRPC on its dedicated --grpc-addr listener
(default :50051); the client dials it as an ordinary gRPC endpoint and sends the
daemon's session id (the same id as in the MCP URL) as the fleet-session
metadata header, so the gateway can route.
Point a fleet client at it with two env vars — the URL is the gRPC endpoint plus
the session id. When the gateway runs with --public-grpc-url, the settings page
shows this exact value as the Public GRPC URL (e.g.
https://gateway.example.com:50051/grpc/<id>); otherwise compose it from the
gateway's --grpc-addr (or whatever host:port your reverse proxy exposes for
gRPC) plus the session id:
export FLEET_GATEWAY="https://gateway.example.com:50051/<id>"
export FLEET_TOKEN="<token from ~/.fleet/mcp.token>" # omit on the same host as the daemon
fleet ls # …now talks to the REMOTE daemonFLEET_GATEWAY takes precedence over FLEET_SERVER and the local socket; a remote
endpoint is never auto-spawned, and a daemon/client version mismatch is a hard
error. Use http://… instead of https://… only when the gateway serves plain
h2c with no TLS in front of it (a trusted private network).
⚠️ Security — this is full remote control. The bearer token authorizes every gRPC call, so the same~/.fleet/mcp.tokennow grants full control of your daemon over the internet — not just the read-mostly MCP tools. The gateway terminates TLS, so its operator can see your traffic and token (the same trust model as remote MCP). Only enable this with a gateway you run or trust, and treat the token as a high-value secret.
Note: interactive
fleet exec(a remote shell) is not yet supported over the gateway — it currently runs the command on the client's host. Every other RPC works remotely today; remote interactive shell awaits a server-sideExechandler.
If you can already ssh to the machine running a fleet, you don't need a
gateway. On that machine, set Settings → Fleet MCP → Enable Remote Fleet on
and Remote Fleet via to SSH. On your machine, register the fleet in
Settings → Fleet Armada as an ssh:// URL — ssh://user@host,
ssh://host:2222, or an alias from your ~/.ssh/config — and that's it: no
token to paste, no session id.
Under the hood your local fleet daemon owns the connection. When you connect
(or ping) an ssh:// remote it runs your ssh binary — so keys, agents,
ProxyJump and every other ~/.ssh/config setting apply unchanged — to read
the remote's ~/.fleet/ssh.port and ~/.fleet/mcp.token, then keeps one
ssh -N -L port-forward to that port alive and hands the TUI (and every
fleet command or fleet shell child started from it) a loopback address to
dial. A remote daemon that has restarted on a new port, or a dropped tunnel, is
noticed on the next connection and rebuilt automatically. If the remote daemon
isn't running at all, the probe starts it for you (it looks for fleet on the
remote's PATH, then ~/.local/bin, ~/go/bin, /usr/local/bin).
The daemon has no terminal, so ssh runs in batch mode with strict host-key
checking: anything that would prompt — a passphrase-locked key with no agent,
say — fails with that reason in the remote's status column instead of hanging.
ssh inherits the daemon's environment, so an agent socket must be visible to
it (restart the daemon from a shell that has SSH_AUTH_SOCK if it isn't).
Unknown host keys are your decision, never fleet's. When a remote's host
key is not in your known_hosts, ssh refuses it; the daemon then fetches
the host's public keys with ssh-keyscan and their SHA256 fingerprints with
ssh-keygen -lf, and the TUI shows a prompt — the host and port it reached,
the key type and fingerprint, and the known_hosts file it would write to —
wherever the connection came up: the + Remote Fleet connection test, an
Armada switch, a boot with FLEET_SSH, or pressing enter on a registered
remote in Settings. Compare the fingerprint with the host's own (on the host:
ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub). Accept appends exactly that
one line to your known_hosts (creating ~/.ssh / the file with 0700 /
0600 if needed, never touching other lines) and retries the connection;
reject cancels with a status message and writes nothing. The prompt appears
once per key, not once per connection attempt. fleet never passes
StrictHostKeyChecking=accept-new (or no), and it runs ssh with
UpdateHostKeys=no so ssh cannot quietly learn the host's other key types
after the fact: no key is trusted without a person seeing its fingerprint.
A changed key — the host is known, but presents a different key — is the
man-in-the-middle warning, so it is never offered for acceptance. The
connection fails with a message naming the offending known_hosts file and
line reported by ssh; verify the host and fix that line by hand. A planned
host-key rotation looks the same to fleet (with UpdateHostKeys=no it never
learns new keys in advance), so after rotating a host's keys expect this
message and update the line yourself.
The plain CLI stays non-interactive: FLEET_SSH=ssh://user@host fleet ls
against a host with an unknown key fails fast, telling you the key is not
known and pointing you at the TUI (Settings → Fleet Armada) or a one-time
manual ssh user@host.
You can also drive an ssh:// fleet from a plain shell:
export FLEET_SSH="ssh://user@host"
fleet ls # resolved through your local daemon's tunnelInstead of juggling FLEET_GATEWAY/FLEET_TOKEN by hand, register remote fleets
once in Settings → Fleet Armada: press + Remote Fleet, paste the gateway
URL and the remote's bearer token — or just an ssh://user@host URL, which needs
no token — and fleet runs a connection test before saving. Each registered
remote shows its transport ([gtwy] or [ssh]), a live connection status
(pinged while the settings page is open) and a [ delete ] button (press twice
to confirm).
The main page's list border carries an Armada selector
(╭─ Armada [ local ] ───╮). It is part of the normal j/k (and arrow-key)
navigation — cycle up past the top row to land on it (continuing up wraps to the
bottom of the list) — or jump straight to it with the A key or a mouse
click. Selecting it opens a dropdown of local + every registered remote; pick
one to switch the TUI live: the connection, fleet list, sessions, and shells
all retarget to the chosen daemon. Registering a remote never switches to it;
every boot starts on local unless FLEET_GATEWAY, FLEET_SSH or
FLEET_SERVER is set, in which case that boot endpoint appears in the dropdown
as (env). The selector and the dropdown badge each remote with its transport —
╭─ Armada [ desktop ] [ssh] ──╮ — so you always know how you're connected.
Remotes are shown by hostname rather than the full URL; if two registered
fleets live on the same host, a gateway remote is disambiguated by the first 8
characters of its session id (fleet.example.com - 8e7d1f0a) and an SSH remote
by its user@host[:port].
The registry is stored on your own machine at ~/.fleet/armada.json (mode
0600 — it holds bearer tokens) and always round-trips through your local
daemon, even while the TUI is connected to a remote fleet.
Coding agents are growing voice input — Claude Code's voice mode transcribes what you say straight into the prompt — but an agent in a devcontainer has no sound hardware to listen to. Fleet's virtual microphone proxies the microphone of the machine you are sitting at into your instances, over the same gRPC connection everything else uses. It follows you: drive a remote daemon through a gateway or SSH and the microphone is the one on your laptop — every machine with a TUI on the daemon offers its microphones, and you pick which one is recorded.
Turn it on under Settings → Microphone:
| Setting | What it does |
|---|---|
| Enabled | Off by default. On: new instances get the audio packages at provision time (existing running instances get them the first time the microphone attaches), and each running instance gets a virtual capture device. Off: nothing is installed or injected, and the instances' virtual sound servers are shut down. |
| Source | Which microphone to record: a client — a machine with a TUI (or fleet mic attach) on this daemon — and a capture device on it. ←/→ cycles through Automatic, this machine's devices, then the devices of every other connected client (the same list as fleet mic sources). Automatic, the default, records the most recently connected client on its system default. A selected client that is not connected is stood in for by the most recently connected one — on its system default — until it returns; a device that is no longer there falls back to the system default. The selection (like Enabled) belongs to the daemon: change it in one TUI and every other open TUI shows it at once. |
Your microphone is only open while something is recording. The instance
reports when a recorder attaches to its virtual microphone, and only then does
fleet start capturing; it stops the moment the recorder detaches. While it is
open the TUI header shows ● MIC and the Settings status row names the
instances listening. The daemon also logs every change in who is recording, and
from which client, to its ~/.fleet/fleet.log (mic live / mic idle) — on
the daemon's machine, which with a remote fleet is not the machine whose
microphone opens.
Any client of the daemon can choose any connected client's microphone. The
selection is a daemon setting, so someone at another machine can make your
microphone the source — which is the point when both machines are yours. Your
TUI still shows ● MIC the moment it opens, and it only ever opens for a
recorder running in an instance. Only connect a machine to a daemon whose other
users you would hand a microphone to; turning Enabled off, or closing the
TUI, takes the machine out of the list.
How it fits together:
- On your machine the TUI (or
fleet mic attach, for running without one) records with whatever is installed —parec(PulseAudio / PipeWire / WSLg),arecord,ffmpeg, or SoXrec; on macOSffmpeg(brew install ffmpeg) — and streams 16 kHz mono PCM to the daemon. Each one announces its machine's name (the hostname;FLEET_MIC_CLIENToverrides it) and capture devices, which is what the Source selector on every other client lists. With several attached, exactly one supplies the audio: the selected client, else the most recently opened. The others keep their microphones closed. - The daemon relays it to the instances that are recording.
- In the instance a small private PulseAudio server exposes the stream as
the default source, and
/etc/asound.confmakes it the ALSA default too — soarecord, SoX, ffmpeg and native ALSA/Pulse clients all find a microphone with no configuration. Fleet installspulseaudio,pulseaudio-utils, the ALSA pulse plugin andalsa-utilsfor this (apt, apk and dnf images; needs root or passwordless sudo, like fleet's other installs; log at~/.fleet/startup/mic.loginside the instance). An existing/etc/asound.confthat fleet did not write is left alone — and if that leaves the ALSA default not reaching PulseAudio (soarecordwould record silence), the instance gets a warning saying so instead of a microphone that silently does nothing.
Devcontainer instances only: Codespaces and Coder workspaces are skipped.
The TUI ships with eight color themes — four dark, four light — chosen under
Settings → General → Theme (←/→ or enter cycles; the row previews each
theme's key colors). The default, Fleet, is the original look.
| Dark | Light |
|---|---|
| Fleet (default) | Gruvbox Light |
| Gruvbox Dark | Catppuccin Latte |
| Catppuccin Mocha | Tokyo Night Day |
| Tokyo Night | Solarized Light |
A theme recolors everything fleet itself draws — the TUI, the instance color tags, the banner — and, inside tmux, the pane dividers of fleet's window (the previous divider styles are put back when fleet exits; Fleet leaves them alone). It does not recolor what runs inside the panes: a shell or an agent draws with your terminal emulator's palette, so set the emulator to the matching scheme and fleet's accents will agree with it. Fleet never paints a page background either, so a light theme is a set of accents that read on a light terminal, not a light page.
The theme is a preference of the machine you sit at, not of a fleet: it is
stored on your local daemon (like the armada
registry) and stays put when you switch the TUI onto a remote fleet. Hex
colors need a truecolor terminal; inside tmux that means tmux must advertise
it (e.g. set -as terminal-features ",*:RGB"), otherwise the shades round to
the nearest of the 256 ANSI colors.
A remote host has its own SSH keys, not yours, so by default a remote fleet
cannot git clone your private repos over ssh. Turn on [ agent: on ] on the
remote's row in Settings → Fleet Armada (right arrow to reach it, enter to
toggle) and fleet forwards your local ssh-agent to that fleet while you are
connected to it, like ssh -A: the remote daemon's own clones and every process
in its devcontainer instances use your keys. A newly added ssh:// remote starts
with it on only when your ssh config says ForwardAgent yes (or
$SSH_AUTH_SOCK) for that host. The row shows whether your agent is in use.
The TUI streams agent requests over the fleet connection itself, so it works for
SSH and gateway remotes alike and needs nothing from the remote's sshd.
fleet up, clone, rebuild and fleet shell against that
remote carry your agent the same way for their duration; they step aside for a
TUI that is also providing, rather than taking over from it. On the remote, the
daemon listens on a relay socket in each instance's control directory
(/fleet-mounts/control/ssh-agent.sock inside the instance) and points its own
SSH_AUTH_SOCK at a host relay socket. Instances get SSH_AUTH_SOCK, and the
daemon redirects its own, only once something can answer there: the daemon was
started with an agent of its own, or a client has forwarded one to it before
(and Remote Fleet is on). Each connection goes to the newest connected
client that can serve it, else to the host's own agent, chosen afresh every
time: reconnecting or switching machines takes effect at once, with no rebuild,
and so does restarting your agent at the same socket path (if it moves, restart
the TUI). A client that stops answering — a laptop gone to sleep — is skipped
within half a minute. An instance's socket serves any process in that
instance's container (or a container nested in it), whatever its uid; other
users on the host are refused.
The trade-off is the one ssh -A has: while you are connected, anything on that
host that reaches the relay — root, its fleet user, every process in its
instances, automation that fires meanwhile — can use your agent. It can list
your keys and ask for signatures, nothing else: fleet refuses every other
request before it reaches your agent, so the remote cannot add or remove keys,
lock the agent, or load PKCS#11/security-key providers. Every relayed
connection is also bound as forwarded, the way ssh -A binds it, so your agent
applies its own rules for remote clients, and keys you added with destination
constraints (ssh-add -h) are not offered through fleet at all. The same rules
hold for instances on your own machine reaching the agent the daemon was
started with. The keys never leave your machine; use ssh-add -c to confirm
each use. With nobody connected, automation cannot use your agent: give the
host its own deploy key for unattended work. The host's owner can refuse
forwarding with FLEET_SSH_AGENT_SOCK=off. On a macOS host, instances keep
Docker Desktop's agent (a host socket cannot cross into its VM); the host-side
clone still uses yours.
When a daemon clone encounters an unknown git server's SSH host key, the
connected TUI shows its key type, SHA256 fingerprint, and the known_hosts
file on the fleet host. Verify the fingerprint with the git server's
administrator, then press a to save that key and retry the clone, or
r / Esc to reject it. fleet up on a terminal asks for the same
decision (type accept); piped input cannot approve a key. This also works
when adding a fleet and inspecting its repository, over SSH or a gateway.
Only the displayed, accepted key is saved. Changed or revoked keys remain a
hard failure. Rejecting, disconnecting, or running with nobody connected
leaves the clone failed with a command to trust the host manually. Custom
GIT_SSH_COMMAND, GIT_SSH, and core.sshCommand settings are preserved;
those transports, and SSH proxy/jump hosts that ssh-keyscan cannot follow,
use the manual-trust hint instead of an automatic prompt.
Variables fleet reads (set them to configure behavior):
| Variable | Values / format | What it does |
|---|---|---|
FLEET_GATEWAY |
https://gw:50051/<id> (or http://… for a cert-less/h2c gateway) |
Drive a remote daemon through a fleet gateway (full gRPC control). Takes precedence over FLEET_SERVER and the local socket. See Remote MCP. |
FLEET_TOKEN |
bearer token | Token for FLEET_GATEWAY. Defaults to ~/.fleet/mcp.token on the daemon's own host. |
FLEET_SSH |
ssh://[user@]host[:port] |
Drive a remote daemon over an SSH tunnel your local daemon maintains (no gateway, no token). See Remote control over SSH. |
FLEET_SERVER |
host:port |
Drive a remote daemon over plain TCP (no gateway). |
FLEET_DEVCONTAINER_BUILDKIT |
auto (default), never |
BuildKit mode for Fleet-managed devcontainers. See Devcontainer BuildKit. |
FLEET_DEVCONTAINER_UPDATE_REMOTE_USER_UID |
default, never, on, off |
Remote-user UID/GID rewrite mode. See Devcontainer UID Rewrite. |
FLEET_SSH_AGENT_SOCK |
absolute path, off, or none (case-insensitive) |
How instances reach the SSH agent. By default they use the daemon's relay socket on Linux (see Your SSH agent on a remote fleet) and Docker Desktop's VM-side /run/host-services/ssh-auth.sock on macOS (OrbStack and colima --ssh-agent are path-compatible). A path bind-mounts that socket instead — set it if your Docker backend exposes the agent elsewhere (default Colima, Podman machine, Rancher Desktop); instances then bypass the relay, so an agent forwarded by an Armada client reaches only the daemon's own git. off/none disables agent forwarding entirely, the relay and forwarding from Armada clients included. |
FLEET_OPENER |
program (+ args, whitespace-split) | Program fleet open / in-instance fo hands a copied file to instead of the desktop opener (xdg-open, open, wslview), e.g. imv -f. Read by whichever process opens the file: the CLI for fleet open, the TUI for fo. Executables are never opened. |
FLEET_MIC_CAPTURE |
shell command | Replace fleet's microphone recorder: the command's stdout must be raw 16 kHz mono signed 16-bit little-endian PCM. For audio stacks fleet can't drive itself (e.g. sox -t coreaudio "My Mic" -t raw -r 16000 -e signed -b 16 -c 1 -). Read by the process providing the microphone (the TUI / fleet mic attach). Fleet cannot pass your command a device, so the Device setting reaches it as FLEET_MIC_DEVICE (the raw configured id, empty for the system default) for it to honour or ignore. See Microphone. |
FLEET_MIC_CLIENT |
name | The name this machine's microphone provider announces to the daemon — what its devices are listed under in Settings → Microphone → Source and what the selection is stored against. Defaults to the short hostname; set it to tell apart two machines that share one. Read by the process providing the microphone (the TUI / fleet mic attach). See Microphone. |
CODER_URL |
URL | Coder deployment URL (Coder backend). |
CODER_SESSION_TOKEN |
token | Coder API token (Coder backend). |
CODER_CONFIG_DIR |
path | Override the Coder CLI config dir. |
Variables fleet exports for MCP clients (written to ~/.fleet/mcp.env, sourced from ~/.bashrc):
| Variable | What it does |
|---|---|
FLEET_MCP_URL |
The loopback MCP endpoint, http://127.0.0.1:<port>. |
FLEET_MCP_TOKEN |
The MCP bearer token. |
FLEET_MCP_PORT |
The MCP server's port. |
Fleet also respects standard environment when present: HOME (the ~/.fleet
location), TMUX (enables split-pane mode when run inside tmux), SSH_AUTH_SOCK
(the daemon's agent, relayed into instances for SSH/git; a client's, forwarded to
remote fleets with [ agent: on ]), and WSL_DISTRO_NAME / WSL_INTEROP
/ WAYLAND_DISPLAY (platform detection for clipboard and browser integration).
- Linux (including Ubuntu on WSL2) or macOS
- Docker
- devcontainer CLI (
npm install -g @devcontainers/cli)
Building from source, tests, and the protobuf workflow: see DEVELOPMENT.md.
Fleet is a Linux CLI, but it can run on Windows through WSL2. The confirmed setup is:
- Ubuntu on WSL2
- Docker Desktop with WSL integration enabled for the Ubuntu distro
- Node installed with
nvm @devcontainers/cliinstalled under the nvm-managed Node- Fleet installed somewhere on the WSL
PATH, such as~/.local/bin/fleet
Inside WSL:
nvm install 22
nvm alias default 22
nvm use default
npm install -g @devcontainers/cliOn Docker Desktop plus WSL, if devcontainer startup fails while building the
UID-adjustment or feature image, disable BuildKit for Fleet-managed
devcontainers. If the failure is specifically in updateUID.Dockerfile,
disable the devcontainer CLI's remote user UID rewrite as well:
export FLEET_DEVCONTAINER_BUILDKIT=never
export FLEET_DEVCONTAINER_UPDATE_REMOTE_USER_UID=neverTo persist that setting:
echo 'export FLEET_DEVCONTAINER_BUILDKIT=never' >> ~/.bashrc
echo 'export FLEET_DEVCONTAINER_UPDATE_REMOTE_USER_UID=never' >> ~/.bashrcSee Windows WSL notes for a full health check and disposable smoke-test workflow.
Fleet reads project-level settings from a customizations.fleet block in a
repo's devcontainer.json. This follows the standard devcontainer pattern
where each tool owns a namespaced sub-object under customizations (VS Code
uses vscode, GitHub uses codespaces, and so on) — Fleet reads fleet and
ignores the rest.
The address the built-in browser (b in the TUI) opens to instead of
about:blank. Handy for jumping straight to a dev server or app running
inside the instance.
A directory of links to the dev services running inside the instance. Each entry has:
title— the primary label for the link.subTitle— secondary descriptive text shown under the title (optional).url— the address the link navigates to.icon— an image shown before the title, used verbatim as an<img>source so any path the browser can load works, e.g. anhttpsURL or adata:URI (optional).healthCheck— an address polled to indicate whether the service is reachable; a healthy service shows a green heart, an unreachable one a red skull, and hovering reveals the HTTP status (optional).
Embedded apps shown as extra tabs on the Fleet Launch page, alongside the Links tab. Each app gets its own tab; opening it starts the app inside the instance and embeds it in an iframe. This is how you surface a web UI that lives in the instance — a log viewer, a database console, a build dashboard — without leaving Fleet Launch. Each entry has:
title— the label shown on the app's tab.command— a bash command that starts the app. It runs the first time the tab is opened, unless the app'sportis already answering (so a second open or a browser relaunch won't double-start it). The command is started in the background, so it can be a blocking server or a self-detaching one likedocker run -d. Optional — omit it for an app that is already running and only needs to be embedded.port— the localhost port the app serves on. Once it answers, the tab iframeshttp://localhost:<port>; if it never comes up, the tab shows an error instead.
The app is shown in an iframe, so it must allow being framed. An app that
sends X-Frame-Options: deny/sameorigin or a restrictive
Content-Security-Policy: frame-ancestors will render as a blank "refused
to connect" box; configure it to permit embedding. For example, Grafana
needs GF_SECURITY_ALLOW_EMBEDDING=true.
For example, to replace Fleet's former built-in Dozzle log viewer:
"apps": [
{
"title": "Logs",
"command": "docker run -d -p 16768:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest",
"port": 16768
}
]When a devcontainer.json sets both initialUrl and a Fleet Launch
block (any fleetLaunch.sites or fleetLaunch.apps), the per-fleet
Prefer Fleet Launch setting decides which the browser opens to:
- off —
initialUrlwins. - on — the Fleet Launch page wins.
The first time you open the browser on a fleet whose workspace has both
configured, the TUI prompts you to choose, and saves your answer as the
fleet's Prefer Fleet Launch setting. You can change it later by editing the
fleet (e in the TUI). When only one of the two is configured, that one is
used and the setting has no effect.
fleet launch is a small terminal UI you run inside an instance (over
fleet exec, in a fleet code terminal, or any shell in the container). It
reads the workspace's customizations.fleet.fleetLaunch block — the same
sites and apps described above — and lays them out as a navigable grid of
squares: a "Links" section for fleetLaunch.sites and an "Apps" section for
fleetLaunch.apps.
# auto-detect .devcontainer/devcontainer.json (or ./devcontainer.json) in cwd
fleet launch
# or point it at an explicit devcontainer.json
fleet launch --config ./path/to/devcontainer.json
# print the configured links and apps (and the names you can launch), then exit
fleet launch list
# open a link or app directly by name (a unique prefix is enough), as if clicked
fleet launch grafIn the grid, navigate with the arrow keys or hjkl, or click a square with the
mouse; enter or a click activates the selected square, and q/esc/ctrl+c
quits. Activating a link opens the host browser to its url; activating
an app first starts the app's command on its port inside the instance
(only if the port isn't already answering), waits for it to come up, then
opens the host browser to http://localhost:<port>.
fleet launch list prints the configured Links and Apps with their targets and
exits — handy for discovering the names. fleet launch <name> performs that
same link/app activation headlessly, without opening the grid. The name is
matched case-insensitively against the titles: an exact title wins, otherwise a
unique prefix is enough (so fleet launch graf opens "Grafana"). If a prefix
matches more than one, the candidates are listed so you can type more.
The browser lives on the host (it is proxied into the container by
privoxy), so the in-instance TUI can't open it directly. Instead it drives the
host browser over a control socket — a unix domain socket the host fleet
TUI creates per instance and bind-mounts into the instance (the same mechanism
as mounting docker.sock). The running host fleet TUI listens on that socket
and opens or navigates the browser when fleet launch sends it a request. If
the host fleet TUI isn't running, fleet launch still renders the grid so
you can browse the configured options, but shows a status line noting that
opening won't work until a host connection exists.
Only instances created or cloned after this feature was added get the control-socket mount; pre-existing instances need to be recreated for in-instance launch to drive the host browser.
Fleet uses the devcontainer CLI's default BuildKit behavior unless explicitly configured. If Docker Desktop on WSL fails while building the devcontainer UID-adjustment image, disable BuildKit for Fleet-managed devcontainers:
export FLEET_DEVCONTAINER_BUILDKIT=neverAccepted values are auto and never.
Fleet uses the devcontainer CLI's default remote-user UID/GID rewrite behavior
unless explicitly configured. On Docker Desktop plus WSL, that rewrite can fail
while building updateUID.Dockerfile. To disable it for Fleet-managed
devcontainers:
export FLEET_DEVCONTAINER_UPDATE_REMOTE_USER_UID=neverAccepted values are default, never, on, and off.


{ "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "customizations": { "fleet": { "browser": { "initialUrl": "http://localhost:3000" }, "fleetLaunch": { "sites": [ { "title": "API", "subTitle": "REST backend", "url": "http://localhost:3000", "healthCheck": "http://localhost:3000/healthz" } ], "apps": [ { "title": "Logs", "command": "docker run -d -p 16768:8080 -v /var/run/docker.sock:/var/run/docker.sock amir20/dozzle:latest", "port": 16768 } ] } } } }