Skip to content

Creating a fleet and enrolling a machine

joshdaugherty edited this page Sep 26, 2026 · 1 revision

Creating a fleet and enrolling a machine

Describes robot-council/cli v0.4.27.

Creating a fleet service

robot-council new my-fleet

It creates a Laravel application in the current directory, requires robot-council/core, runs the package's installer, and migrates. Then it prints the exact OAuth callback URL to paste into GitHub, takes the client ID and secret back, and asks who may sign in.

The allowlist is asked for as logins and stored as numeric IDs. The service checks numeric GitHub account IDs, because a login can be renamed and then claimed by somebody else -- but nobody should have to go and find a number, so each login is resolved once, here, and the mapping is printed before it is written.

--app-url overrides where the application will be served, which decides the callback URL; it defaults to Herd's http://<name>.test. --skip-github scaffolds without asking anything.

Getting a machine onto a fleet

Before anything else: the developer's GitHub account has to be on the fleet's allowlist. The service checks it on the enrollment page and on the approve and deny routes, so somebody who is not on it cannot approve their own enrollment, or anybody else's. An administrator adds them to ROBOT_COUNCIL_DEVELOPERS and redeploys; until that has happened, everything below stops at a code nobody can approve.

Run this yourself, in your own terminal. It is half a browser task, so handing it to an agent leaves the agent blocked on a code only a person can approve.

robot-council enroll \
  --service=https://your-fleet.example.com \
  --harness=claude \
  --machine-label=your-machine

It prints a code and a URL, and waits. An allowlisted developer signed in to that fleet opens the URL, enters the code, and approves; enrollment finishes on its own. Then wire a harness to the bridge, below.

Enroll once per harness, not once per machine. A credential belongs to one harness, so a machine running both Claude Code and Cursor enrolls twice:

robot-council enroll --service=https://your-fleet.example.com --harness=claude
robot-council enroll --service=https://your-fleet.example.com --harness=cursor

Re-enrolling a harness replaces that harness's credential and leaves the others alone. Worktrees do not need one each: several checkouts under one harness share its credential and are told apart by the repository and work location each session reports, which are read from the checkout (see joining the fleet).

Pass --machine-label, and pass the same one for every harness on the machine. It is how a person tells sessions apart on the dashboard, so it should be the name you would say out loud -- josh-office, josh-home. Omitted, it is derived from gethostname() with a trailing .local removed and every character outside [A-Za-z0-9._-] silently dropped, which is the case worth avoiding: a machine called Josh's MacBook Pro.local enrolls as JoshsMacBookPro, which is plausible enough that nobody questions it and is not what anyone would have chosen. Two harnesses given different labels appear as two machines. The bound is 64 characters.

On macOS, this needs v0.4.0 or later. Before it, security prompted on the terminal for the keychain password, ignored what the command piped to it, and the write timed out after fifteen seconds -- so enrolling from an interactive shell could not store a credential at all, and the only path that worked was one with no controlling terminal. #207 has the measurement; robot-council --version says which you have.

robot-council enroll --help lists the other options.

Giving the service its secrets

This command line does not handle them, on purpose. Every credential it touches stays on the machine or goes to the fleet that machine is enrolled against, and nowhere else -- and a harness launches this binary as a child process, so a command that took a secret and posted it to a third party would be an exfiltration path sitting in an executable agents already run. Use the host's own tooling. On Laravel Cloud that is:

Enter these one at a time, not as a pasted block. The reason is the first line: read takes its input from standard input, and when a multi-line block is pasted, standard input is the rest of the paste. Pasted together, read swallows the following line and stores it as part of the value.

read -rs URL

Run that alone, then paste the URL and press Enter. Check it before sending it anywhere:

printf 'len=%s starts=%s\n' "${#URL}" "${URL:0:12}"

starts=https://hook and a length around 78. The prefix is not the secret part, so this is safe to read aloud, and it is the step that catches a value that arrived with something on the front of it.

printf '%s' "$URL" | cloud secret:create --name=ROBOT_COUNCIL_SLACK_WEBHOOK_URL; unset URL
cloud environment-secret:attach production <the id it printed> -n
cloud deploy -n

read -rs does not echo and keeps the value out of shell history; piping it to stdin keeps it out of the process list, which --value= would not. -n skips the CLI's own interactive prompts. The last line matters: a secret reaches the running application only on the next deploy.

Type the id, not the angle brackets. <the id> at a shell prompt is input redirection, and the shell answers No such file or directory for a file named after whatever is inside them.

A webhook the service cannot reach fails as a retrying job, not as silence. The mirror bounds retries by a one-hour deadline rather than an attempt count, so a wrong value leaves jobs pending and writes robot-council could not reach the Slack webhook. to the log -- without the URL in it. Read the log rather than the channel: an empty channel looks the same whether the mirror is off, broken, or working and nobody has narrated.

The same two values are all the service needs: ROBOT_COUNCIL_SLACK_WEBHOOK_URL for the event mirror, and the GitHub OAuth client id and secret, which robot-council new writes to .env when it scaffolds. Everything else is ordinary configuration.

Clone this wiki locally