Skip to content

Repository files navigation

fleet-man

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).

fleet-man close up

fleet-man screenshot

What problem does fleet solve?

I made this tool to solve my own problem, and hopefully yours as well. I wanted a tool that allowed the following.

  1. Running unlimited parallel agents that don't step on each other. I mean this in a way beyond the git worktree I 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.
  2. 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.
  3. Not need to constantly setup new environments for agents. Environments need automated setup.
  4. 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!

Contents

Deep-dive docs (in doc/):

  • The Fleet Gateway — how the reverse tunnel relays both MCP and gRPC to your daemon

Install

sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | sh

To install a specific version:

sudo curl -sL https://raw.githubusercontent.com/BenjaminBenetti/fleet-man/main/install.sh | sh -s -- --version v0.2.0

Usage

Run 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)

Agent state and container lifecycle

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.

TUI Keybindings

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

MCP Server

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 is http://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 mode 0600, 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.

Fleet MCP inside instances

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 bash automation trigger runs its script on the host as you, and a file:// 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 staged fleet mcp-bridge relays 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 runs fleet mcp-env, which writes a Claude Code plugin (the MCP server plus the Fleet Admiral skill) under ~/.cache/fleet/mcp and exports CLAUDE_CODE_PLUGIN_DIRS. Nothing is written to ~/.claude or ~/.claude.json. So it reaches a claude started from a bash shell opened after the setting was turned on (a new session, an automation agent); a command that clears the environment (sudo without -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_down them when done.

Remote MCP

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.

Enable it (in the TUI)

In Settings → Fleet Remote (MCP):

  1. 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 port 50051; behind a proxy it's whatever host:port routes to that listener). It is not the public address agents use.
  2. 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-url flag) — that value is what you feed another fleet as FLEET_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 what ssh user@host already 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.

Use it from a remote agent

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>" }
    }
  }
}

Running a gateway

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.

Behind a TLS-terminating reverse proxy

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 HTTP IngressRoute (TLS-terminating).
  • --grpc-addr (gRPC, HTTP/2) → a Traefik gRPC route (h2c backend) — see Traefik's gRPC guide. This one carries both remote fleet control 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.

Run it with Docker

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.pem

Image 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.

Security model

  • 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 Authorization header 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 (mode 0600, 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.

Remote control (gRPC)

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 daemon

FLEET_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.token now 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-side Exec handler.

Remote control over SSH

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 tunnel

Fleet Armada (multiple remote fleets)

Instead 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.

Microphone

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 SoX rec; on macOS ffmpeg (brew install ffmpeg) — and streams 16 kHz mono PCM to the daemon. Each one announces its machine's name (the hostname; FLEET_MIC_CLIENT overrides 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.conf makes it the ALSA default too — so arecord, SoX, ffmpeg and native ALSA/Pulse clients all find a microphone with no configuration. Fleet installs pulseaudio, pulseaudio-utils, the ALSA pulse plugin and alsa-utils for this (apt, apk and dnf images; needs root or passwordless sudo, like fleet's other installs; log at ~/.fleet/startup/mic.log inside the instance). An existing /etc/asound.conf that fleet did not write is left alone — and if that leaves the ALSA default not reaching PulseAudio (so arecord would 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.

Themes

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.

Your SSH agent on a remote fleet

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.

Environment Variables

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).

Requirements

  • Linux (including Ubuntu on WSL2) or macOS
  • Docker
  • devcontainer CLI (npm install -g @devcontainers/cli)

Development

Building from source, tests, and the protobuf workflow: see DEVELOPMENT.md.

Windows WSL Setup

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/cli installed 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/cli

On 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=never

To persist that setting:

echo 'export FLEET_DEVCONTAINER_BUILDKIT=never' >> ~/.bashrc
echo 'export FLEET_DEVCONTAINER_UPDATE_REMOTE_USER_UID=never' >> ~/.bashrc

See Windows WSL notes for a full health check and disposable smoke-test workflow.

Devcontainer Customizations

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.

{
  "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
          }
        ]
      }
    }
  }
}

browser.initialUrl

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.

fleetLaunch.sites

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. an https URL or a data: 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).

fleetLaunch.apps

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's port is 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 like docker 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 iframes http://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
  }
]

Precedence: initialUrl vs. Fleet Launch

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 — initialUrl wins.
  • 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 (in-instance TUI)

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 graf

In 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.

Devcontainer BuildKit

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=never

Accepted values are auto and never.

Devcontainer UID Rewrite

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=never

Accepted values are default, never, on, and off.

About

Fleet Manager for devcontainer workspaces - simple and easy.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages