Install
One command, on the machine the agents will live on. It asks nothing and ends by printing one address — which is the console, and the key to it.
One command, and it asks nothing
$ curl -fsSL https://squad.mormon.garden/install.sh | sh
Docker if the machine has none, two images pulled rather than built, a config with one agent and a ceiling of five dollars a day, and a plane running. Half a minute on a machine that already has Docker.
It asks for no keys. A key is not something you need before the thing runs — it is what this plane can pay for, it changes, and a plane takes one while it is running. Three secrets in the first minute made them look like prerequisites, which is the one thing they are not. They are given in the console, on the screen that exists for it, and the plane is using the next one from the moment it is typed.
Running it again is the update: it pulls, swaps the plane in, and leaves config.yaml alone along with every key and edit in .env. Only the lines that say what this install is — which images, which domain — are brought up to date, because a file that disagrees with the plane that is running is a docker compose up by hand that quietly starts last month's code.
Nothing published to pull is not an error. A registry that is unreachable, a tag that does not exist yet, a fork that publishes nothing — the installer says so and builds from the sources instead, which are right there. That path is slower and it still works, which is the point of keeping it.
The plane serves its own console
Getting in
Open this, or paste it into a console you host yourself:
http://127.0.0.1:8789/?t=MekEy-WJ4RPyWP3PjCEntVGhlJN56bE0uNffl2Obhls
Open it and the console is there — agents, conversations, what each one is doing and what it has spent. It is served by the plane itself, out of its own container, so there is nothing else to install and nothing of ours between you and it.
That address is the key. The plane writes a token on the way up, in a 0600 file only root can read, and holding it is being the operator — the protocol has no login of its own and does not want one. So it is pasted, not posted. The page trades it for a cookie and cleans the URL, because an address is copied and left in a history and a cookie is not.
Keys go in from there: the environment picker, then Keys. Each provider says whether this plane holds one and whether it was typed here or exported by the machine, and none of them ever shows a value — the plane answers with the name of the key it set and never with the key. A plane holding none says so above the conversation, since the alternative is a turn that dies at the model for a reason the screen already knew.
There is a copy of the same console at https://squad.mormon.garden/app/. It holds several environments at once and moves between them, which is the only thing it does that the one your plane serves does not. It is a convenience, not the way in: what it knows lives in your browser and nowhere else, including not here.
Give it a name, or forward the port
The console's port is published on the server's loopback and nowhere else. That is deliberate: the plane holds the Docker socket, so it is root-equivalent on that machine, and publishing it would be root on the internet behind a token travelling in the clear. There are two ways to reach it, and the first one is better.
$ curl -fsSL https://squad.mormon.garden/install.sh | sh -s -- --domain=agents.example.com
Point the DNS at the machine first, then run that. A proxy in front of the plane obtains a certificate and renews it, and the install ends with a single https://agents.example.com/?t=…. Nothing to forward, nothing to keep open, and it works from any browser.
The proxy is a compose profile, so a machine that was never given a domain does not run it and never takes port 80 waiting for a certificate that is not coming. The plane's own exposure does not change: what is published is the proxy.
And if it has neither a domain nor a terminal you want to keep open, a plane can dial out instead: --relay=https://relay.example.com has it meet a console at a rendezvous, which works from behind a NAT because nothing is published at either end. It ends by printing a code rather than an address, since there is no address to give.
What crosses a relay is sealed with a key both ends derive from this plane's token. The relay is handed a room number derived one way from that same token — enough to put two sockets together and not enough to recover anything — so it carries the traffic and cannot read it. It is off unless asked for, and the one we run is the one in the repository, which is the only reason to believe any of that.
With no domain, a server answers on the address it has. That is the default there, because the alternative is a forwarded port somebody has to keep holding. It means the token is the only thing between a stranger and these agents, and over http it crosses the internet where it can be read — so the install prints, directly underneath, the line that fixes it without buying anything:
$ curl -fsSL https://squad.mormon.garden/install.sh | sh -s -- --domain=74-207-235-42.sslip.io
sslip.io resolves any address-shaped name to that address, so Let's Encrypt will issue for it and there is nothing to register. Its rate limit is shared with everyone using it, so it can refuse where a name of your own never does — which is the whole of the difference. --open=no puts the console back on loopback.
$ ssh -N -L 18789:127.0.0.1:8789 you@your-server
Run that on your own computer — not on the server, where that port is already the plane's. Then open http://127.0.0.1:18789/?t=… with the token the server printed: your port, its key. Any free local port does.
The address says 127.0.0.1 because after the forward that is what the server is. Nothing is opened on the server — check it with ss -ltn there — and there is nothing new to log into, because the bytes cross the connection you already had.
Five flags, and no configuration file to write first
--name=casa | a second deployment on one machine — its own containers, volumes, networks, ports and agents |
--domain=agents.example.com | put it behind that name, with a certificate it renews itself |
--domain= | give the name back: the proxy is removed and the plane is on loopback again |
--relay=https://relay.example.com | reach it through a rendezvous instead: nothing published, nothing forwarded, no domain |
--open=no | keep the console on loopback, which is what a server without a domain undoes |
--verbose | every path, every file it wrote, and the build streamed rather than buffered |
--build | build the images here instead of pulling them, which is what working on it needs |
$ curl -fsSL https://squad.mormon.garden/install.sh | sh -s -- --name=casa
Flags rather than only environment variables, because of the shape this is run in: SQUAD_NAME=casa curl … | sh puts the variable on curl, and the shell that reads the script never sees it. The install goes to the default name and says nothing about it, which looks exactly like success until there are two deployments and the second one wrote over the first. A flag crosses the pipe. The variables still work when there is no pipe.
A second deployment shares nothing with the first but the Docker daemon — its own containers, volumes, networks, state and agents, on ports derived from its name. SQUAD_VERSION pins which published build to run; left alone it is the newest release.
The machine is five dollars a month
One vCPU, a gigabyte of memory and ten gigabytes of disk is enough for a few agents, and that is the bottom of every provider's list. It needs a Linux with SSH on it and nothing else — the installer brings Docker. An old laptop under the desk works too.
| Hetzner | the most machine for the money — around €4.50 buys two cores and 4 GB, if a European or US region suits you |
| Vultr | from about $5, and in more places than the other two put together |
| DigitalOcean | a few dollars more, and the most written about — worth it if this is your first server |
Prices move; the shape of the bill does not. The machine is a flat monthly number, and the only other cost is what the agents think with — metered by whichever model provider you give a key to, and capped by limitUsd at five dollars a day per agent. There is nothing to pay for squad itself.
A gigabyte is enough because the install pulls rather than builds. Building on that machine means a dependency tree and a bundler running on it, which at the bottom of the list is a build killed for memory — and killed in the middle, which is the worst place for an install to stop.
Say what an agent may reach
/opt/squad/deploy/config.yaml is the whole surface: agents, what each may reach, which models there are to think with, when each wakes up, which webhooks exist, and — under defaults — what an agent made later at the keyboard starts from. The installer writes a working one; this is the shape of it.
models:
- id: deepseek-v4-flash # naming the provider says the rest
provider: deepseek
- id: sonnet
provider: anthropic
model: claude-sonnet-4-6
- id: gpt-5
provider: openai
defaults:
model: deepseek-v4-flash # /model moves one agent onto another
limitUsd: 5 # dollars a day, reset at midnight UTC
grants:
- id: web # the road: npm, PyPI, git, anywhere
host: "*"
injection:
kind: none # and no key of yours goes down it
- id: search
host: api.openai.com
pathPrefix: /v1/responses # the one endpoint that searches
methods: [POST]
injection:
kind: bearer
token: { ref: OPENAI_API_KEY }
What may be reached and what may be spent are two questions, and only the first is answered "anywhere". A grant on * that carried a credential is refused when the file is read: the road is open, the keys are given to somewhere by name. Delete the web grant and the plane is deny-by-default again, host by host.
No secret is in it. It names environment variables and the process holds the values, so the file describing what an agent can reach is committable and diffable — a grant nobody noticed being added is the failure mode.
All three models are listed whether or not this plane holds their keys, because listing one is the approval and the key is only what makes it answer. Keys in the console is where one is pasted in, and it holds from the next turn with nothing restarted and this file untouched.
It is read when the plane starts, so an edit takes hold on docker compose restart control-plane from /opt/squad/deploy.
The ceiling is already there. Leave it. An agent can book its own next turn, so without limitUsd the first anyone knows of a loop is the bill. It sits under defaults so it also covers the agents made later at the keyboard, which are exactly the ones nobody remembers to put a ceiling on.
Waking an agent with a webhook
Port 8787 is the one thing published to the network, and it takes signed requests only. The installer generates the secret and puts it in .env as HOOK_SECRET. The signature covers ${timestamp}.${body} and is compared in constant time within a freshness window; an unknown hook id answers exactly like a bad signature, and only after the body has been read, so the endpoint does not enumerate.
BODY='{"text":"the nightly build failed"}'
TS=$(date +%s)
SIG="sha256=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$HOOK_SECRET" -r | cut -d' ' -f1)"
curl -X POST https://your-vps:8787/hooks/ping \
-H "x-squad-timestamp: $TS" \
-H "x-squad-signature: $SIG" \
-d "$BODY"
A webhook may not carry operator trust, however well signed. The secret proves which system sent the request, never that a human meant what is inside it — so the body arrives fenced, as data. Events queue per agent and are folded into one turn, and a turn that fails leaves its events queued rather than acknowledging them, so a bad API key costs a retry instead of the message.
The other two ways in need nothing published at all, because they reach out instead of being reached: /telegram <token> connects a bot to the agent you are looking at, and /email <address> connects one mailbox to every agent on the plane. Both are paired to a person by a phrase, and both may instruct once they are.
The console has always been a command too
Same plane, same protocol, different screen. The installer leaves squad on the PATH of the machine it ran on, and this puts the same console on the computer you sit at — where it asks which plane to drive and keeps the answer.
$ curl -fsSL https://squad.mormon.garden/client.sh | sh
$ squad
It needs Node 22.18 or newer and nothing else — no Docker, wherever the plane is. A plane on a server is reached down ssh vps squad relay, which is the same protocol over the connection you already have, so nothing is opened there for this either.
/serve 3000 is that connection read backwards: the sandbox network is unrouted, so the console opens the port on your loopback instead and prints http://scout.localhost:3000 — a link that works on the machine it was printed on and nowhere else, and closes when the console does.
Or try the whole thing on your laptop
The demo builds the images, starts a control plane on a throwaway network, shows what the agent can and cannot reach, wakes it with a signed webhook and prints the turn. It asks for a model key when it gets to the part that needs one.
$ git clone https://github.com/mormonnegro/squad
$ cd squad
$ ./deploy/demo.sh up
./deploy/demo.sh down removes the containers, the networks, the volume and the state. The only difference from a real deployment is where the state lives: under the working tree, because /var/lib needs root and is not shared with Docker Desktop on macOS.
The same install, by hand
The repository, the two files the installer would have written, and one up. Everything above still applies — this is only the part that fetches and starts.
$ git clone https://github.com/mormonnegro/squad /opt/squad && cd /opt/squad/deploy
$ cp .env.example .env # ports, the webhook secret, the origins
$ cp config.example.yaml config.yaml # what each agent may reach
$ docker compose up -d
SQUAD_IMAGE and SQUAD_SANDBOX_IMAGE in .env name which images to run; left alone they are the locally built ones, and docker compose build makes those. Point them at the published ones to skip the build.
config.example.yaml is the reference, with every option commented, and its example agent reaches hosts that are not yours — read it through before starting rather than after. Without the installer there is no squad on the PATH either, so the console is docker compose exec control-plane squad.
Two things are load-bearing
- The control plane runs on the agents' network, not on the host. Containers on an internal network cannot reach the host at all, so a proxy on the host is one the agents cannot use.
- The state directory is bind-mounted at its own path. The plane hands the daemon that path when mounting the CA into a sandbox, and the daemon resolves bind sources on the host, so a convenient container path produces mounts the daemon cannot find.