Repository navigation
Enrolling an Agent Machine
Describes robot-council/core v0.8.3.
A developer approves one harness on one machine once, and that installation starts a session per agent process from then on. Nothing pastes a long-lived secret into a config file: the machine displays a short code, and the developer types it into a page while signed in.
Most machines should use robot-council/cli rather than
implement any of this. It runs the flow below, stores the credential in the OS keychain, and then
serves the coordination tools to an agent harness over stdio:
robot-council enroll --service=https://your-fleet.example.com
claude mcp add robot-council -e ROBOT_COUNCIL_SERVICE=https://your-fleet.example.com -- robot-council mcpThe credential never enters a harness's configuration, which is the reason that bridge exists: a token in harness configuration is a token in every transcript that configuration is dumped into. The protocol below is documented for anyone writing their own client.
- The machine posts
harness,machine_label, the abilities it wants, and the SHA-256 of a verifier only it holds toPOST {prefix}/api/device/code, and is given auser_codeto display. - The developer opens
{prefix}/enroll, enters that code, reviews what the machine claims about itself, confirms the code is on a machine they control, and approves. - The machine polls
POST {prefix}/api/device/tokenwith the device code and the verifier, and is given an installation credential. That credential starts, renews and ends sessions, and renames its own installation. - Each agent process calls
POST {prefix}/api/sessionsfor a short-lived session token, andPOST {prefix}/api/sessions/{id}/renewto replace it without a restart and without a human.DELETE {prefix}/api/sessions/{id}ends one when its harness exits: its tokens stop working at once, and what it held is released by the next presence sweep, within about a minute, rather than after the thirty-minute gone threshold. Whileschedule.sweep_sessionsis off, nothing releases it. All three take the installation credential, because the token belonging to the process that just died is the one thing that may no longer work. Ending is idempotent. A start may carryplatform, withos_family(one of PHP'sPHP_OS_FAMILYvalues) and an optionalarch, as the bridge reports them; an older bridge that sends neither starts a session as before. A start may also carrycapacity, how many tickets the session will hold at once when it works through subagents. A number past 16 is taken as 16; 0, a negative number or anything that is not a whole number is refused with 422. What is in effect is the seat's Tasks at once, which its developer sets on the seats page and which is 1 until they do, or the session's own number where that is smaller. A session that sends none takes the seat's number (#564), so a seat nobody touched still gives it 1. The start answers both, ascapacity(in effect) anddeclared_capacity(asked for, within 16, or null where nothing was asked);GET {prefix}/api/agent/sessionanswers the same pair, read fresh, so a developer raising the seat reaches a running session without a restart. Thesession.joinedevent carries the declaration asmeta.declared_capacity; the capacity in effect is onGET {prefix}/api/lanes. A start may also carryephemeral: true, for a process the fleet need not be told about:robot-council apisends it for a read (robot-council/cli#298). An ephemeral session writes nosession.joined,session.stale,session.resumedorsession.goneevent however it ends, and is left out ofsessions_list,GET {prefix}/api/lanes, the free-lane and merge-behind lane conditions, and the dashboard's session views and totals. One that holds work and stops answering is still named by the unobserved-lane condition, because that work is somebody's to recover. The administration panel lists a live one, markedephemeral, so it can be revoked there, and counts no gone one. Everything else is an ordinary session's: it authenticates, reads the feed as its own developer's session, and whatever it claims or locks is released when it ends or is swept. JSONtrueandfalse,1and0, and"1"and"0"are accepted; anything else, the string"true"included, is refused with 422. Omitted, the session is an ordinary one. A joined session that moves to another checkout says so withPATCH {prefix}/api/agent/session(or thesession_movetool), sendingwork_location,repository, or both; a field it leaves out stays as it is. It keeps its id, role, tasks, locks and feed position,sessions_listand the lane board show the new place at once, and so does its seat, since a seat is the place. A value the start refuses is refused with the same message, a blank one is refused, and the move is recorded as asession.movedevent whose data carriesfrom_repository,from_work_location,to_repositoryandto_work_location. A move to another repository drops a lane hold naming the repository it left, since that hold says nothing is startable there. It moves only the session the token belongs to.work_locationis a short label such asa,ciorprimary, never a path. An installation renames itself withPATCH {prefix}/api/installationand its own credential (#590), sendingmachine_label; fixing a label chosen badly, a hostname orunknown-machine, needs no browser. It names no installation and renames only the caller; a session token and another installation's credential are refused with 401. It needs no ability. The label follows the rule enrollment applies (1 to 64 characters ofA-Z a-z 0-9 . _ -). A rename answers 200 withinstallation_id,harness,old_labelandnew_label, and is recorded as aninstallation.renamedevent whose data carriesold_labelandnew_label, as a rename from the dashboard is. A refused label, or one another live installation of the same developer and harness already holds, is 422 with the reason inerrors.machine_label; a label the installation already carries is 409 with amessage; a revoked installation is refused at the credential with 401.
Every response that carries a bearer token names it token, every expiry is an expires_in in
seconds, and abilities always describes the token beside it. Where a response also names
granted_abilities, that is what a different token will carry -- the sessions an installation
credential will start. Starting a session also names a feed_cursor, which is where the change feed
stood at that moment -- for an ordinary session its own session.joined event, and for an ephemeral
one the newest event, so either reads everything written after it started and nothing before; a renewal names the position the session has since acknowledged, restated
rather than moved, so a process that restarted can pick up where it was.
This flow is device-code shaped, not RFC 8628 conformant, and the differences are deliberate:
-
The token endpoint takes a verifier, not the RFC's
grant_typeandclient_id. That verifier is the whole reason a stolendevice_codeis useless, so no off-the-shelf device-flow client can complete this exchange — which is also why the success responses use this package's own names rather thanaccess_token, a name that would promise OAuth affordances this service does not have. -
slow_downis not returned. Poll throttling is out of scope for v1. -
verification_uri_completeis not returned. There is no QR-code form of the verification URL yet.
The error bodies do follow RFC 8628 section 3.5 exactly: HTTP 400 with authorization_pending,
access_denied, expired_token, or invalid_grant. Those names describe states this flow genuinely
has, and nothing better exists for them.
Abilities come from a fixed list — tasks:create, tasks:claim, locks:acquire, events:post —
and coordinator:direct, which enrollment can never request.
What a session may do comes from its role, not from its machine. Every session starts as
build, whatever its installation asked for at enrollment, and carries the four abilities above. A
session that needs to direct other agents asks to become a coordinator — POST {prefix}/api/agent/role
— and an administrator approves or denies it on the dashboard's administration page. Asking changes
nothing on its own: the token in the client's hand is untouched until somebody decides, which is what
stops any checkout from taking coordinator:direct by asserting it. The same page imposes a role
with no request outstanding, which is the emergency demotion.
A session withdraws a request by asking for the role it already holds. The pending request leaves
the administrator's queue and can no longer be approved, and the change feed records a
session.role_withdrawn event, which is not a denial: nobody refused anything. The response's
pending and requested_role always describe what is pending once the call returns, not the role
that was asked for.
There is deliberately no machine-level gate any more, and no console command for one. An administrator who does not want a machine coordinating declines its request, which is one action rather than two authorities that can disagree.
php artisan robot-council:revoke-installation <installation> # and every session token it issued
php artisan robot-council:revoke-session <session> # one process only
php artisan robot-council:doctor # reports misconfiguration; exits non-zero
php artisan robot-council:prune-device-codes # scheduled hourly
php artisan robot-council:sweep-sessions # scheduled every minute
php artisan robot-council:prune-events # scheduled daily at 03:10
php artisan robot-council:prune-tasks # scheduled daily at 03:20
php artisan robot-council:prune-locks # scheduled daily at 03:30
php artisan robot-council:prune-sessions # scheduled daily at 03:40Approving or imposing a role rewrites that session's token in the same transaction, so it takes effect on the next request rather than within the hour a session token lives.