Everything needed to take a Mac from zero to a 24/7 agent server. Steps are
in dependency order; each block is copy-pasteable. Placeholders like
/Volumes/YourDrive and your-machine.your-tailnet.ts.net are yours to
substitute.
brew install node gh colima docker
gh auth login # host-side GitHub auth — used to push branches + open PRs
colima start # Docker runtime (no Docker Desktop needed)
brew services start colima # restart colima automatically at bootVerify: node --version (need >= 20.12), gh auth status, docker ps.
git clone https://github.com/sinameraji/groundcontrol && cd groundcontrol
npm install
cp .env.example .env # fill in as the next steps produce values
cp config/agents.example.json config/agents.json # edit to tasteEvery step below drops a value into .env — keep it open.
npm i -g hotcell
hotcell start # starts the daemon on 127.0.0.1:4750
hotcell keys add openrouter # paste your OpenRouter API keyColima users: the daemon needs the Docker socket. Colima keeps it in your home dir, so start the daemon with
DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" hotcell start(the launchd install in step 7 bakes this in automatically).
The key is stored in the macOS keychain and used only by hotcell's egress
gateway — sandboxes receive a short-lived hc-… token, never the real key.
Optional hardening: set an API key on the daemon and put the same value in
HOTCELL_API_KEY in .env, so only authenticated local clients can create
sandboxes. See hotcell start --help.
Each agent is its own bot identity. For each agent in
config/agents.json (the example ships scout and codey):
- Go to https://discord.com/developers/applications → New Application, name it after the agent.
- Bot tab → under Privileged Gateway Intents enable
MESSAGE CONTENT INTENT (required to read
@mentiontext). - Bot tab → Reset Token → copy it into
.envas the variable named by that agent'stokenEnv(e.g.DISCORD_TOKEN_CODEY=...). - OAuth2 → URL Generator: scopes bot + applications.commands; bot permissions View Channels, Send Messages, Create Public Threads, Send Messages in Threads, Read Message History, Embed Links, Attach Files. Open the generated URL and invite the bot to your private server.
Then, once:
- In the Discord client: Settings → Advanced → Developer Mode on.
Right-click yourself → Copy User ID →
OWNER_DISCORD_USER_IDin.env. Only this user can command the agents. - Right-click your server icon → Copy Server ID →
DISCORD_GUILD_IDin.env(guild-scoped slash commands register instantly). - Register the slash commands:
npm run register- Install Tailscale (
brew install --cask tailscaleor the App Store app), thentailscale upand make sure MagicDNS is enabled in the admin console. - Front the built-in file server (loopback-only on port 4760) with a tailnet-private HTTPS URL:
tailscale serve --bg http://127.0.0.1:4760- It prints an
https://your-machine.your-tailnet.ts.net/URL — put that in.envasPUBLIC_BASE_URL(no trailing slash).
By design these links resolve only for devices on your tailnet. Do not
use tailscale funnel — that would make artifacts public.
mkdir -p /Volumes/YourDrive/groundcontrol/missionsSet in .env:
ARTIFACTS_ROOT=/Volumes/YourDrive/groundcontrol/missions
APFS is recommended for the drive (fast, snapshot-friendly). Optionally exclude the folder from Spotlight indexing (System Settings → Siri & Spotlight → Spotlight Privacy) so mission dumps don't churn the indexer.
sudo pmset -c sleep 0 # never system-sleep on AC power
sudo pmset -c disksleep 0 # keep the external drive spinningdisplaysleepcan stay on — the screen sleeping is fine.- Keep the lid open on a hard, ventilated surface.
- Enable Optimized Battery Charging (System Settings → Battery), or use an 80% charge limiter such as AlDente, since the machine lives on AC.
Reassurance for the worried: Apple Silicon throttles itself long before heat is a problem, the LLM runs on OpenRouter's servers (not this Mac), and hotcell's admission control refuses new sandboxes rather than oversubscribe memory. This is a light workload with occasional Docker bursts.
npm run build
ops/install.shThe installer renders the three plists in ops/ with your real paths,
copies them to ~/Library/LaunchAgents/, and (re)starts them:
| Label | What | Schedule |
|---|---|---|
com.groundcontrol.hotcelld |
hotcell daemon | always on, restarts on crash |
com.groundcontrol.orchestrator |
the bots + engine + file server | always on, restarts on crash |
com.groundcontrol.janitor |
docker prune + artifact pruning | Sundays 04:30 |
Logs land in ~/Library/Logs/groundcontrol/:
tail -f ~/Library/Logs/groundcontrol/orchestrator.logCheatsheet (labels as in the table above):
# restart a service now
launchctl kickstart -k gui/$(id -u)/com.groundcontrol.orchestrator
# stop + unload a service
launchctl bootout gui/$(id -u)/com.groundcontrol.orchestrator
# load it again
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.groundcontrol.orchestrator.plist
# inspect state
launchctl print gui/$(id -u)/com.groundcontrol.orchestratorUpdating to a new version:
cd /path/to/groundcontrol
git pull
npm install
npm run build
launchctl kickstart -k gui/$(id -u)/com.groundcontrol.orchestrator(Re-running ops/install.sh is also safe — it is idempotent and re-renders
the plists, which you need if the repo or node paths changed.)