15. Runners#
A runner is an optional service the operator installs and runs: on a
machine, reaching ssh hosts, or in a cluster. It keeps one signed
long-poll to the hub and starts or stops agents when the hub's desired
state for it says so — harness X for agent N in clone Z of the project's
repository, and stop; never an arbitrary command. What a runner may do
at all (projects, repositories, harnesses, how many agents) is its own
configuration, which the hub cannot widen. The hub stores desired state
(the placement) and reported state and never starts, owns or reaches
into a process; a project without a runner works exactly as before. The
decision is context/scope.md, "A runner the operator runs may start
agents; the hub still never does".
15.1 The runner as a caller#
A runner is a third kind of caller next to human and agent, owned by one
human. It is not an agent profile and not a membership: it has no trust,
no role, no scopes, no git identity, and is in no project's member list, org
chart or all audience. It serves every project it is attached to
(§15.4) with one credential and one stream.
| Rule | |
|---|---|
| Credential | mty1.<credential_id>.<secret> as in §2.2, with ids rcrd-…; issued by the pairing exchange (§15.2). The hub stores the verifier only. One live credential per runner: a re-pair revokes the old one |
| Presentation | signed only (Authorization: Maity-HMAC rcrd-…, the canonical string, timestamp, nonce and verification rules of §2.2 unchanged). The same credential as Bearer is verified first (unknown stays unauthenticated, revoked stays credential_revoked) and then refused with signature_required, whatever any project's signing says, before any side effect |
| Leak | a runner credential in a body or query string is refused and, when real, revoked like an agent's (§2.2) |
| Routes it may call | /runner/* (§15.8). The unauthenticated surfaces (/help, /openapi.json, /health, /languages, the join page and both exchanges) are as for anyone |
| Routes it may not call | every /projects/* route answers not_found, as for a project the caller is not a member of, so a runner sees no card, message, request or member by construction; every other authenticated route (/me, /me/resume, /runners/*, /agents/*, /requests, /auth/*, GET/POST /projects) answers forbidden without details.scope |
Who may call /runner/* |
runners only: a human or agent caller is forbidden there |
| Idempotency | PATCH /runner carries Idempotency-Key (§1.5); hello, status and join are exempt (§15.8) |
| Presence | computed like a member's (§5.3): online while its long-poll is open or after a call in the last 120 s, away up to 2 h, then offline. Never stored, never an event |
15.2 Pairing#
Pairing is the join mechanism for the instance instead of a project:
token plus code, single use, an exchange that answers with the
credential as text/plain, a file the CLI writes and never prints.
| Endpoint | Caller | Effect |
|---|---|---|
POST /runners/enrollments |
human | {name?, ttl_seconds?, attach?: [full_name], runner_id?}; 201 with {id, runner_id, pair_url, code, pair_line, expires_at} |
GET /runners/enrollments |
human | the caller's own enrollments, {id, runner_id, name, attach, created_at, expires_at, used_at, burned, revoked_at}, nothing that opens one; newest first; paged |
DELETE /runners/enrollments/{id} |
the human who created it | revokes an unused enrollment; idempotence and refusals as for a join (§3.1): 200 with revoked_at, a revoked one answers 200 as it is, a used, burned or expired one is invalid_transition |
POST /runners/pair/{token} |
unauthenticated | the exchange, below |
- A new runner:
name([a-z0-9][a-z0-9-]{1,30}) is required and must not be the name of a runner the caller already owns (invalid,details.fields.name: "taken"); the runner is created at the exchange, sorunner_idin the201isnull. A re-pair:runner_idnames a runner the caller owns,nameis omitted (the runner keeps its name), and the201carries thatrunner_id. ttl_secondsis 60–900, default 900.attachlists projects to attach at the exchange; the caller needsmembers:adminin each at creation (forbidden,details.scope), and again at the exchange: a project where the creator no longer holds it is not attached, andMaity-Attachedbelow says which were.pair_url,codeandpair_lineappear in the201only. Token (128 bits) and code (60 bits, Crockford base32 in groups of four) work like a join's: single use, expire withexpires_at, the enrollment is burned after three wrong codes, and the exchange is rate limited per source address (10 per minute).- Pairing lives on the public URL only:
pair_urlandpair_lineare always built onMAITY_PUBLIC_URL, and a join host (§1.1) answers aGETof a pairing path with the302to the public start page and every other method withnot_found.
POST /api/v1/runners/enrollments
{ "name": "home-laptop", "ttl_seconds": 900, "attach": ["oliver/maity"] }
201
{ "id": "renr-0002",
"runner_id": null,
"pair_url": "https://maity.team/r/Xb7…",
"code": "K3PF-9QZD-1MWA",
"pair_line": "maity runner pair https://maity.team/r/Xb7… --code K3PF-9QZD-1MWA",
"expires_at": "2026-10-22T14:15:00Z" }
The exchange, run by maity runner pair in a terminal on the runner
host, never in an agent session:
POST /api/v1/runners/pair/Xb7…
Content-Type: application/json
{ "code": "K3PF-9QZD-1MWA", "client": "maity-cli/0.3.0" }
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Cache-Control: no-store
Maity-Credential-Id: rcrd-0002-0001
Maity-Runner-Id: rnr-0002
Maity-Attached: oliver/maity
Maity-Hub: https://maity.team
Maity-Signing: required
mty1.rcrd-0002-0001.…
The body is the credential and nothing else. Maity-Attached lists the
full names of the projects attached at this exchange, comma-separated, empty when none. A
re-pair revokes the runner's previous credential in the same
transaction (revoked_reason: repaired); attachments and placements
stay. Errors as for the join exchange (§3.3): insecure_transport under
the same rule and the same MAITY_ALLOW_PLAIN_HTTP flag (§2.5; the code
is not consumed), code_invalid, not_found (unknown, used, expired,
burned or revoked token), rate_limited; a name taken since the
enrollment was created is invalid and leaves the enrollment unused.
There is no pairing page: whatever the token, GET /r/{token} answers
not_found and GET /api/v1/runners/pair/{token} answers
method_not_allowed, so a URL opened in a browser leaks nothing.
Maity-Hub is the public URL, and maity runner pair checks it by the
rule of §13 before it writes anything.
15.3 Runners and the runner view#
| Endpoint | Caller | Notes |
|---|---|---|
GET /runners |
human | the runners the caller owns, and the runners attached to projects where the caller has members:admin; ordered by name; {items} |
GET /runners/{id} |
the owner, or members:admin in an attached project |
the runner view |
PATCH /runners/{id} |
the owner | {name}; invalid when the caller owns a runner of that name |
POST /runners/{id}/revoke |
the owner | {"reason"?, "agents"?}; revokes the live credential, ends its long-poll; returns the runner view with on_runner_revoke (below). What happens to its agents follows on_runner_revoke per attached project, or agents (below) |
DELETE /runners/{id} |
the owner | {"agents"?}; revokes the credential, applies on_runner_revoke to its agents (below) per attached project, closes every active placement of the runner at once (runner_removed, §15.5), detaches it from every project; 200 with the runner view, removed_at set, and still_running: the ids of the placements whose last report was starting, running or stopping, now the operator's to end (maity runner stop-all); a second DELETE answers 200 as it is |
On revoking a runner. What happens to the agents a revoked runner was running is a setting, chosen per project within the instance default (the maintainer's decision of 2026-10-22: the operator and the project's owner choose):
- When it applies. On
POST /runners/{id}/revokeand onDELETE /runners/{id}. Never on a re-pair (revoked_reason: repaired): the new credential takes over the same placements. A revocation by the hub because the credential appeared in a payload (leaked_in_payload, §2.2) applieskeep: the runner's key leaked, not the machine; the owner revokes again withagents: "stop"when the machine is in doubt. Such a later revoke, on a key revoked already (by akeepor for a leak), still appliesstop: it revokes no runner key, and what it stops is added to what that key's revocation applied (the answer, its replay anddetails.stopbelow agree). - The setting.
on_runner_revokein the instance settings (§16,MAITY_ON_RUNNER_REVOKE:keep(default),stoporstop_floor) and in each project's policy (§6.1:keep,stoporinherit, defaultinherit).inherittakes the instance value;stop_floormeansstopand refuses a project'skeep(§6.1), so the operator can makestopa floor rather than a default. - The owner's override.
agentsin the body of the revoke or theDELETE:"policy"(default) applies each project's rule;"stop"appliesstopin every attached project whatever its policy says — the machine is the runner owner's, and a revocation is often the answer to a machine that is no longer trusted. keep: placements keepdesiredand show the runner as revoked; the dashboard flags them (runner.credential_state: revokedon the placement's runner). The agents run on with valid credentials until a human revokes those (§10) or stops the placement, which emitsagent_stop_requested(§15.10) that a cooperating agent honours. There is no time bound: the agent credentials live in the clones on the runner's machine, sokeeptrusts that machine with them for as long as nobody acts. Choosestopwhen the revocation answers a compromise. Nothing is lost mid-step.stop: in the same transaction as the revocation, every active placement of the runner in that project — closing ones included — getsdesired: stoppedand is closed at once (closed_reason: runner_revoked, §15.5; underDELETEit staysrunner_removed), because no live runner credential can report it closed; every unused join with thatrunner_id(§15.9) is revoked, and an exchange of a join whose placement is closed answersnot_found.agent_stop_requestedis emitted for each, and the agent credentials those placements named are revokedstop_grace_secondslater (revoked_reason: runner_revoked,revoked_bythe human), so an agent can park its work first; withagents: "stop"they are revoked at once, and so are those an earlierstopof this runner left to their grace (never another agent's). Each revocation emitscredential_revoked(§15.10,reason: runner_revoked, to the agent and the owners), once, however many calls find it due. After that the agent has lost the hub: every call answerscredential_revoked. The hub cannot end its process or its access to the forge; that stays the machine's (maity runner stop-all).- What the runner learns. A runner that is still honest learns it
from the answer to its next call:
credential_revoked— only after the signature verifies (§1.3) — carriesdetails.stop: [placement ids], the placements closed bystop, for as long as the revoked credential is the runner's latest; the runner stops those sessions before it exits. Underkeepthe list is empty. - Back after
stop. A re-pair brings the runner back, not its agents: each placement starts again by a new placement, or byrestartwithrejoin: trueon one that is not yet closed (§15.6), and picks up a new join; the old credentials stay revoked. - The answer of the revoke or
DELETEcarrieson_runner_revoke: [{project, rule, stopped: [placement ids]}], limited to the projects the caller is a member of (asprojectsin the runner view).
{
"id": "rnr-0002", "name": "home-laptop", "owner_id": "hum-maintainer",
"presence": "online", "listening": true, "last_seen_at": "2026-10-22T13:59:40Z",
"credential_state": "active",
"credential": { "id": "rcrd-0002-0001", "created_at": "2026-10-22T13:31:00Z",
"last_used_at": "2026-10-22T13:59:40Z", "revoked_at": null, "revoked_reason": null },
"version": "maity-cli/0.3.0",
"capabilities": {
"backend": "local",
"max_agents": 4, "running": 2,
"targets": [
{ "name": "local", "max_agents": 4, "running": 2,
"reachable": true, "unreachable_since": null,
"isolation": { "kind": "shared_user", "user": "oliver", "runner_separate": false } }
],
"harnesses": [
{ "id": "claude-code", "version": "2.1.308", "available": true, "reason": null,
"modes": ["interactive", "supervised"], "default_mode": "interactive",
"models": ["opus", "sonnet"], "targets": ["local"], "confinement": "allowlist", "sandbox": "off" },
{ "id": "opencode", "version": null, "available": false, "reason": "binary_not_found",
"modes": ["supervised"], "default_mode": "supervised",
"models": [], "targets": ["local"], "confinement": "allowlist", "sandbox": "off" }
],
"projects": ["oliver/maity", "oliver/sample"],
"repositories": ["github.com/oliver-zehentleitner/*"],
"require_local_approval": false,
"reported_at": "2026-10-22T13:40:00Z"
},
"projects": ["oliver/maity"],
"created_at": "2026-10-22T13:31:00Z", "updated_at": "2026-10-22T13:31:00Z", "removed_at": null
}
credential_state:active,revoked(the latest credential is revoked and no live one exists),none(never paired).credentialis the latest credential's record, never its value, and is shown to the owner only.presence,listening,last_seen_atare computed (§15.1).versionis theclientof the lasthello.capabilitiesis what the runner last advertised (hello,PATCH /runner, §15.8). It is shown for choosing and checked when a placement is created; it is never a grant — the runner's configuration decides and the runner checks again. Every string in it is printable ASCII of at most 200 characters and rendered as text.targets[].isolation.kind:shared_user(every agent on the target runs as one OS user;runner_separatesays whether the runner runs as another user),own_user(one OS user per agent), orpod. Which layout a runner uses on the operator's own machine is the runner's configuration (open: §12 1 of the design); the contract carries whichever it advertises.harnesses[].idis one ofclaude-code,codex,opencode,pi,hermes,kimi,cline,omp,stub.confinement:allowlist(the harness's own command allowlist),sandbox(no command allowlist, the harness's OS sandbox is the boundary —codex; a placement whose repository grant lists commands is refused for it,harness_refused),pod(no allowlist; the pod is the only boundary) ornone(stubonly). A runner advertises the confinement it actually enforces, never one it only configures. Whether the harnesses without an allowlist (pi,hermes,kimi,cline,omp) are offered at all, and where, is the runner's decision under (open: §12 4 of the design); the hub accepts any of them that a runner advertises.sandbox:on,off,n/a— the harness's own OS sandbox.available: falsecomes withreason(binary_not_found,version_failed).harnesses[].default_modeis the mode a placement gets when it names none. Which mode each adapter defaults to is the runner's (open: §12 5 of the design).modelsare names the runner offers; how the agents get model access (a login on the host, keys, a cluster Secret) is the runner's configuration and never reaches the hub (open: §12 2 of the design).projects(top level) are the projects the runner is attached to, limited to those the caller is a member of.
Instance-level actions — creating and using an enrollment, pairing,
re-pairing, renaming, revoking, removing — are recorded on the rows
themselves (created_at, used_at, revoked_at, revoked_by,
revoked_reason, updated_at, removed_at), not as events; only their
effects on attached projects are events (§15.10).
15.4 Attaching to projects#
A runner does nothing in a project until it is attached. An attachment is a row with its own id:
{ "id": "ratt-0004", "runner": { "…runner view…": "" }, "project": "oliver/maity",
"attached_by": "hum-maintainer", "attached_at": "2026-10-22T13:31:05Z", "detached_at": null,
"active_placements": 2 }
| Endpoint | Scope | Notes |
|---|---|---|
GET /projects/{owner}/{slug}/runners |
project:read for humans; for agents agents:propose, every other agent forbidden |
{items}: the active attachments, ordered by runner name. capabilities in the runner view only for members:admin and agents:propose; credential only for the runner's owner |
POST /projects/{owner}/{slug}/runners |
members:admin, and the caller owns the runner |
{runner_id}; 201 with the attachment; emits runner_attached. A runner already attached is already_attached (409, details.attachment) under a new idempotency key; a replay with the same key answers 201 as usual |
DELETE /projects/{owner}/{slug}/runners/{runner_id} |
members:admin, or the runner's owner |
detaches: detached_at set, runner_detached emitted, and every active placement of that runner in the project is closed the uniform way (detached, §15.5); 200 with the attachment; a second DELETE answers 200 as it is |
At most one active attachment per (project, runner). After a detach, new
placements on that runner are runner_not_attached, and the runner
keeps reading the project's closing placements (§15.8) until each is
closed.
While V1 has one human, the runner's owner and the project's admin are the same person; attaching someone else's runner is designed with invitations, not now.
15.5 Placements#
A placement says that agent N should run on runner R with harness X in clone Z. It is the only thing the hub asks of a runner.
{
"id": "plc-0003", "project": "oliver/maity", "project_id": "prj-7k2m9q",
"project_aliases": ["oliver/maity-old"],
"agent": { "id": "agt-b", "handle": "agent-b", "display_name": "Agent B",
"avatar_url": "/api/v1/avatars/agt-b?v=e4d90b12" },
"runner": { "id": "rnr-0002", "name": "home-laptop" },
"target": null, "harness": "claude-code", "mode": "interactive", "model": "opus",
"workspace": { "clone": "agent-b", "branch": null, "continues_existing": true },
"desired": "running", "generation": 4, "rejoin": false, "stop_grace_seconds": 120,
"approval": { "status": "approved", "request_id": null, "pending_change": null },
"credential_id": "crd-b-0007",
"reported_current": true,
"active": true, "closed_at": null, "closed_reason": null,
"created_by": "hum-maintainer", "proposed_by": null,
"created_at": "2026-10-22T13:39:02Z", "updated_by": "hum-maintainer", "updated_at": "2026-10-22T13:58:10Z",
"reported": {
"state": "exited", "reason": null, "detail": null, "exit_code": 1,
"observed_generation": 4, "restarts": 2, "retry_at": "2026-10-22T14:02:40Z",
"crash_count": 2, "crash_window_seconds": 1800, "deadline_at": null,
"since": "2026-10-22T14:02:00Z", "target": "local", "reported_at": "2026-10-22T14:02:01Z"
},
"session_hint": "tmux -S /var/lib/maity-runner/tmux.sock attach -t maity_maity_agent-b",
"logs_hint": "maity runner logs plc-0003"
}
| Field | Values / meaning |
|---|---|
agent |
{id, handle, display_name, avatar_url} of the membership's profile (§6.10). For a placement that creates a profile, id and avatar_url are null until the first pickup creates it (§15.9) |
runner |
{id, name}; the runner must be attached (runner_not_attached) |
target |
null (the runner chooses) or a target name the runner advertised |
harness, model |
from the runner's advertised list for the target (harness_not_offered, details.offered); model: null is the runner's default |
mode |
interactive (a session a human can attach to) or supervised (woken by events, no open session); omitted at creation: the harness's advertised default_mode |
workspace |
clone: [a-z0-9][a-z0-9-]{0,62}, default the agent's handle; branch: an optional starting branch. The repository is always the project's repository.url; a placement never names a repository or a path. continues_existing is true when a closed placement of the same membership on the same (runner, clone) exists, which is also when the credential is carried over. The runner keeps the workspace under the project's project_id and the clone (<workspaces>/<project_id>/<clone>), never under the full name, so a rename or transfer of the project (§6.25) keeps it |
project_id, project_aliases |
the project's fixed id and its earlier full names (§6.25), for the runner's workspace key and for matching its configured projects list, current name or alias |
desired |
running, stopped |
generation |
starts at 1; incremented on every change of the spec, every restart and every rejoin; the runner reports which generation it acted on |
rejoin |
set by a restart with rejoin: true after the clone's credential was revoked: the runner removes the old credential file and picks up a new join |
approval |
status: approved, pending, refused; request_id: the consent request while one exists; pending_change: an agent's proposed change of a running placement's spec waiting for consent, else null (§15.6). A runner never sees a placement that is not approved |
stop_grace_seconds |
0–900, default 120: how long the agent gets to park its work after a stop before the process is ended |
credential_id |
the agent credential issued at the pickup or carried over; tells the runner a credential file is expected in the clone |
active, closed_at, closed_reason |
a closed placement keeps its row, so the dashboard knows where an agent ran last. closed_reason: removed, refused, withdrawn, detached, project_archived, membership_removed, membership_isolated, runner_removed, runner_revoked |
created_by, proposed_by |
created_by is always a human: the human who created it, or for a proposal the agent profile's owner; proposed_by is the proposing member or null |
reported |
the runner's latest report (§15.7), null until the first one. Only the runner writes it: a new generation does not reset it |
reported_current |
computed, never stored: true when reported.observed_generation equals generation, false otherwise and while reported is null. The dashboard shows the placement as pending while it is false |
session_hint |
from the runner's report: what the operator types to look at the session; never a secret by construction |
logs_hint |
set by the hub: maity runner logs <placement id> |
Who sees what.
| Field | Who sees it |
|---|---|
| the placement, except the rows below | humans with project:read; an agent for its own placement; an agent with agents:propose for every placement of the project |
briefing (a new profile, until the first pickup) |
members:admin and the creator |
session_hint, logs_hint |
the runner's owner only — never an agent, agents:propose included |
proposed_by, approval.request_id |
everyone who sees the placement |
Server rules.
- One placement per membership. At most one active placement per
membership, so "where does agent-b run" has one answer. A second is
placement_exists. A placement that creates a profile counts as the membership it will create, keyed (project, new handle) while it is active: at creation the handle is checked against the existing profiles (invalid,details.fields.handle: "taken", as at a join, §3.1, §6.3) and against the active placements that create a profile with that handle (placement_exists). This is a placement rule: the hub still allows two clones of one agent with two credentials — two instances (§6.17) — when they are joined by hand (§10). - One placement per clone. At most one active placement per
(project, runner, clone), whatever the target: on one runner a clone
name in a project is one workspace and one identity. A second is
placement_exists. - A closed placement counts toward neither rule nor
max_placements; neither does a refused one, which is closed. - Carried-over credential. A new placement with the same
(membership, runner, clone) as the latest closed one, whose credential
is not revoked, inherits its
credential_id; the runner then expects the file and does not join again. - The project's repository host must match the runner's advertised
repositories(repository_not_offered). The runner checks again. - In a project that is
archived,desired: runningisproject_closed. Inplanandreviewcreating a placement needs areason(§6.1). - Revocation stops the placement. Revoking the agent credential
named by an active placement's
credential_id, by any path (§10, the leak path of §2.2, member removal), setsdesired: stoppedin the same transaction; it does not close the placement.
Closing. A placement closes in one of two ways, by cause:
| Cause | closed_reason |
How it closes |
|---|---|---|
DELETE of the placement |
removed |
uniform: desired: stopped; closed by the runner's next terminal report for it — stopped, failed, or exited with retry_at: null (a rate_limited placement is stopped, not waited for: §15.7) — or at once if the runner never acted on it (no report yet) or has no live credential (nobody can report) |
| detaching the runner from the project | detached |
uniform, as above |
the project moves to archived |
project_archived |
uniform, as above |
consent answered no |
refused |
at once: a pending placement was never seen by the runner |
| consent request withdrawn | withdrawn |
at once, for the same reason |
| the membership is removed | membership_removed |
at once. The runner no longer reads it and ends the session through its own handling of sessions it has no placement for (orphaned, §15.7) |
| the agent is isolated (§6.2) | membership_isolated |
at once, as for a removal. Lifting the isolation does not reopen it; a new placement does |
the runner is revoked under stop |
runner_revoked |
at once, closing ones included; the agent credentials follow after stop_grace_seconds (§15.3) |
| the runner is removed | runner_removed |
at once. The runner's credential is revoked in the same call; ending what still runs is the operator's (still_running, §15.3) and the runner's orphaned handling |
A placement closing the uniform way is still active and reads as
desired: stopped until a terminal report closes it: under desired:
stopped a failed or a final exit ends it as surely as stopped.
DELETE of a placement that is already closing closes it at
once (removed): the escape when a runner never reports. DELETE of a
placement whose consent request is open closes it at once (removed)
and withdraws the request. Workspaces are never deleted by
the hub or by a closed placement; they stay on the runner.
15.6 Placement endpoints, proposals and consent#
| Endpoint | Scope | Notes |
|---|---|---|
GET /projects/{owner}/{slug}/placements |
project:read; agents: their own, all with agents:propose |
{items}, active ones, ordered by agent handle; closed=true adds those closed in the last 30 days |
POST /projects/{owner}/{slug}/placements |
members:admin and ownership of the runner; or agents:propose under spawn_requests |
body below; 201 with the placement; approval.status is pending for a proposal under consent |
GET /projects/{owner}/{slug}/placements/{id} |
as the list | |
GET /projects/{owner}/{slug}/placements/{id}/reports |
as the list | the last limit (default 10, maximum 50) placement_reported events of this placement, newest first, as events (§8.1), to every caller who may see the placement — independent of the events' audience (§8.2) |
PATCH /projects/{owner}/{slug}/placements/{id} |
as POST; desired alone also by the agent profile's owner |
any of desired, harness, mode, model, target, stop_grace_seconds; a change of anything but desired bumps generation, and a running agent is restarted on the new spec |
POST /projects/{owner}/{slug}/placements/{id}/restart |
as PATCH |
{rejoin?}; bumps generation, sets desired: running, clears a crash_loop; rejoin: true is invalid (details.fields.rejoin: "credential_not_revoked") unless the placement's credential was revoked |
DELETE /projects/{owner}/{slug}/placements/{id} |
as POST |
closes the placement the uniform way (removed, §15.5); 200 with the placement. A DELETE of a placement already closing closes it at once (removed); one of a closed placement answers 200 as it is |
POST /api/v1/projects/oliver/maity/placements
{
"agent": { "handle": "agent-f", "display_name": "Agent F" },
"trust": "member", "role": "coder", "reports_to": "agt-a", "areas": ["web/"], "capabilities": [],
"briefing": "You own the dashboard's Runners view: …",
"runner": "rnr-0002", "target": null,
"harness": "claude-code", "mode": "interactive", "model": "opus",
"workspace": { "clone": "agent-f", "branch": "main" },
"desired": "running", "stop_grace_seconds": 120,
"reason": "second pair of hands on web/ for the runner work"
}
agentis{"id"}of an active member of the project, or — humans only —{"handle", "display_name"}for a new profile, withtrust,role,reports_to,areas,capabilitiesandbriefingas in a join (§3.1): validated at creation, stored, applied at the first pickup (§15.9). A cross-ownerreports_tois applied atgoof its own consent request to the boss's side (the creator owns the profile, so its consent is the creation), filed at the exchange of the placement's join (§6.2, "Across owners"); that request is separate from the placement's approval. With{"id"}those fields must be omitted (invalid); a placement never changes a membership. A storedreports_towhose member is removed before the membership exists becomesnull(§6.2); every pickup builds its join from the stored fields as they are then.desireddefaults torunning.reasonis required for a proposal and inplan/review.- Placement creation by a human against a runner without a live
credential, or setting
desired: runningon one, isrunner_revoked. - Beyond
max_placementsactive placements:placement_quota.
Who may do what.
| Caller | May |
|---|---|
a human with members:admin who owns the runner |
create (new profiles included), change, stop, start, restart, delete; the action is their own consent. On a placement with an open agent proposal it settles that proposal (below): their change of spec or restart withdraws a proposed change, their PATCH, start or restart approves a proposed creation |
| the owner of the agent profile | desired and restart on that profile's placement |
an agent with agents:propose |
propose a placement for an existing membership (never a new profile, never a trust, role, area or briefing), propose a change of spec; stop, start, restart and delete existing placements of the project |
| everyone else | nothing: forbidden |
Proposals. What an agent's creation or change of spec does follows
policy.spawn_requests (§6.1):
off:forbidden.consent: a creation makes the placement withapproval.status: pending; a change of a placement's spec leaves the placement running on its current spec and generation and stores the change asapproval.pending_change. Either way the hub files, in the same transaction, aconsentrequest (§7):asked_bythe proposer,to: owner, repliesgo/no, titleStart <handle> on <runner name>(Change <handle> on <runner name>for a change),task_idnull, and asummaryof onefield: valueline per field — agent, runner, target, tool, model, mode, isolation, reason.direct: approved at once; theplacement_createdorplacement_updatedevent carries thereason.
Stop, start, restart and delete are never proposals and are allowed to
agents:propose under every value of spawn_requests: stopping is the
safe direction, and a restart re-runs something already approved. While
a consent request is open, a further change of spec or a restart by an
agent or by the profile's owner without the right is approval_pending;
the same action by a human with the right settles the proposal instead
(below). desired: stopped and DELETE are never refused.
Settling. Answering or withdrawing a request that carries a
placement, or a human with members:admin who owns the runner acting on
it, settles it in the same transaction:
| Outcome | Creation | Change of spec |
|---|---|---|
go (only an answer with reply_id: go; any other answer, text-only included, settles as no) |
approval.status: approved; placement_updated reaches the runner |
the change is applied, generation bumped, pending_change cleared |
no |
refused; closed (refused) |
pending_change cleared; the placement runs on as it was |
withdrawn (by the proposer or requests:answer) |
closed (withdrawn) |
pending_change cleared |
| the human with the right acts (their own action is their consent) | their PATCH, start or restart approves it: approval.status: approved with their action applied, the request answered go in their name; placement_updated reaches the runner. A DELETE withdraws the request and closes the placement (removed) |
their change of spec or restart withdraws the proposal: pending_change cleared, the request withdrawn, their own action applied |
Who may answer a placement's consent request follows
policy.spawn_consent_by (§6.1): human (the default, the maintainer's
decision of 2026-10-22: starting an agent spends compute and model
budget, so a human gives the go) lets only humans holding
requests:answer answer it, and an agent's answer is forbidden with
details.reason: humans_only; role applies the rule for every other
consent request (§2.3): holders of requests:answer — trust owner and
admin, agents included, a proxy only by recording the owner's answer. Either way never the
proposer (self_answer). Which value a project uses is agreed between
its admins, human and agent, and set by a human with members:admin.
Requests do not expire; an unanswered proposal stays pending.
15.7 Reported state#
The runner reports what is actually there, one record per placement and
only when something changes. The hub stores the latest report on the
placement as reported; the history is the placement_reported events.
| Field | Values | Meaning |
|---|---|---|
state |
pending, starting, running, stopping, stopped, exited, failed, rate_limited |
always the runner's; pending comes with a reason (below). The hub never writes a state: whether the runner has acted on the current generation yet is reported_current (§15.5) |
reason |
null or a code from the table below |
|
detail |
null or a code from the detail list below |
never free text |
exit_code |
integer or null |
the harness process's own code |
observed_generation |
integer | the generation the report is about; a report for an older generation becomes an event and does not move reported |
restarts |
integer | restarts in this generation |
retry_at |
timestamp or null |
when the runner starts it again; null when it will not |
crash_count, crash_window_seconds |
integers or null |
quick exits within the window, for crash_loop and while counting toward it |
deadline_at |
timestamp or null |
with stopping: when the process is ended if it has not exited |
since |
timestamp | when state was entered; clamped by the hub into [the previous reported_at, hub time] |
target |
name | where it actually runs |
session_hint |
string ≤ 200, printable ASCII, or null |
stored on the placement, shown to the runner's owner only (§15.5) |
seq |
integer, per placement, increasing | the runner's report counter (§15.8) |
reported_at |
timestamp | hub time of the report; set by the hub |
reason |
state |
When |
|---|---|---|
capacity |
pending |
the runner or the target is at its agent limit; it starts the agent when a slot frees |
awaiting_operator |
pending |
the runner holds new placements until the operator approves them on the runner host |
harness_refused |
failed |
harness, mode or model not allowed by the runner's configuration |
repository_refused |
failed |
the project's repository is not in the runner's allowlist |
project_refused |
failed |
the project is not in the runner's allowlist |
harness_missing |
failed |
the binary or image is not there or does not run |
clone_failed |
failed |
git clone failed; exit_code from git |
workspace_in_use |
failed |
the clone path exists and is not recorded for this agent |
join_failed |
failed |
the pickup or the agent-side join failed; detail holds the hub's error code |
crash_loop |
failed |
the restart cap was reached |
quota |
rate_limited, exited, failed |
the harness's quota or rate-limit exit, as far as the runner recognises it: rate_limited when the runner starts the run again at retry_at (below), exited or failed when it will not |
agent_credential_revoked |
failed |
the agent's own calls answer credential_revoked |
oom_killed, evicted, image_pull |
failed, exited |
Kubernetes only |
stopped_by_hub |
stopped |
desired: stopped was carried out |
adopted |
running |
the runner found the session after its own restart and took it over |
orphaned |
stopped |
the runner found a session it has no placement for and ended it |
detail |
With |
|---|---|
binary_not_found, version_failed |
harness_missing |
credential_missing |
join_failed: a credential was expected (credential_id set) and the file is not there |
code_invalid, expired, not_found, rate_limited, insecure_transport, pickup_not_allowed, pickup_used, network |
join_failed |
origin_mismatch, other_agent, workspace_unrecorded, locked_by_process |
workspace_in_use |
stale_index_lock |
starting: a stale .git/index.lock in the clone |
sandbox_unavailable |
running: the harness's own OS sandbox could not be enabled and the runner's configuration allows running without it |
hot_loop |
crash_loop in supervised mode |
reset_unknown |
rate_limited: the harness named no reset time; retry_at is the runner's own backoff |
mode_unsupported, model_unsupported, target_unknown |
harness_refused; target_unknown: the placement names a target the runner no longer has (the operator removed it from the configuration after the placement was made) |
The lists are closed; a code outside them, or a reason/state pair
not in the table, is invalid. Adding a code is a contract change.
A rate limit is not a crash. When the supervised loop sees the
harness exit for its provider's limit, the runner reports state:
rate_limited, reason: quota, exit_code, and retry_at = the reset
time the harness gave (or its own backoff, with detail: reset_unknown);
retry_at is required with rate_limited (invalid without it,
details.fields.retry_at: "required_with_rate_limited") and never more
than 7 days ahead (too_far). It starts the run again at retry_at, under
the same generation, and reports starting then. The exit counts toward
neither crash_count nor the restart cap; restarts counts the restart
as any other. The hub stores the report as the agent's limit (§6.12):
until = retry_at, source: runner, harness the placement's, and
clears it with the placement's next report that is not rate_limited.
A harness exit the runner does not recognise as a limit stays a crash.
rate_limited is its own state rather than exited with a retry_at
(the restart policy's wait after an ordinary exit, §15.5) because it
means something to the hub beyond the restart: it sets the member's limit,
which drives availability and cover (§6.12), and it is outside the crash
count; folding the two together would make every restart a limit or
every limit a crash.
- Recognition is the harness adapter's and stays
implementation-defined: which exit code, output line or file says
"provider limit" and where the reset time is read from differ per
harness and version. An adapter reports
rate_limitedonly for a signal verified on a real run of that harness; anything else is reported as it would be without this section (exited,failed, counted as a crash). What the contract fixes is the report:state: rate_limited,reason: quota,exit_codethe process's own,retry_atas below. retry_atis the reset time the harness named, in UTC, when it named one that is in the future and at most 7 days ahead; a named time further out is capped at 7 days. When it named none (or one in the past),detail: reset_unknownandretry_atis the runner's fixed backoff, independent of the restart policy: 15 minutes after the exit for the first consecutivereset_unknownlimit of the placement, doubling with each further one (30, 60 minutes) up to 2 hours, and 2 hours from then on. The count resets when a run of the placement ends any other way, and with a new generation.- Interactive mode. A harness in an
interactivesession usually stays open at its limit instead of exiting. The runner does not read the session for limits: the placement staysrunning, and the limit is declared from inside the session — by the agent withmaity limit(§13), or by the human in the pane running the same command — which sets the member's limit withsource: self(§6.12) and leaves the placement's state alone. If an interactive harness does exit and the adapter recognises the exit as its limit, the runner reportsrate_limitedas insupervisedmode. - Stopping.
desired: stoppedon arate_limitedplacement (DELETE, detach, archive, revocation understop) stops it normally: the runner cancels the pending restart, reportsstopped(stopped_by_hub), and that terminal report closes the placement as any closing does (§15.5) — nothing waits forretry_at. A placement closed at once (membership_removed,runner_removed, …) is closed the same way it would be from any other state. Either way the hub clears the limit record the placement set, and emitsmember_availability_changedwhen that changes the member's state. - Capacity. A
rate_limitedplacement keeps its slot: it counts towardmax_agentsof the runner and of its target, as a running placement does, until it starts again or is stopped, so another placement waits withpending/capacityand the restart atretry_atnever does. Its member islimited, neveravailable(§6.12).
Reachability is not a reason: an unreachable target keeps its
placements' last reported state, and the runner advertises
targets[].reachable and unreachable_since instead. A runner that is
offline changes nothing stored either; its placements keep their last
report.
The owner is asked when an agent stops. A report that moves
reported (§15.8; a report of an older generation opens nothing) and
stops the agent without the runner starting it again — state: failed with any
reason, or state: exited with reason: quota — opens an outage
of the placement, unless one is open already. For each outage the hub
files one personal request (§7) to the agent profile's owner (to:
"profile_owner:<agent id>"), member of the project or not, which
reaches them as an inbox item (§18.1): class info, one option
seen, asked_by the agent's member with instance null and
filed_by: "hub" (§7: its events reach the owner only and wake
nobody), no task_id; title <agent handle> on <runner name or
placement id> stopped: <reason>, the runner's name when the placement
has one, else the placement's id, so the requests of two placements of
one agent are told apart;
summary Placement <placement id> on runner <runner name> reported
<state> (<reason>[, <detail>]) at <reported_at> (UTC); restarts
<restarts>. Later reports of the same outage — the same failure
reported again, another failing reason — file nothing and change
nothing on the request. The outage ends, and the hub withdraws the
request when it is still open, with the placement's next report of
state: running, when the placement is stopped (desired:
stopped carried out) or deleted, or when the project is archived or
deleted; the next failing report after that opens a new outage and a
new request. When the agent profile's owner changes during an open
outage, the hub withdraws the old owner's request, if still open, and
files a new one to the new owner for the same outage. The owner may answer seen or
withdraw the request; that closes the request and not the outage, so
no second request follows until the placement has run again.
rate_limited opens no outage: the runner starts the agent again at
retry_at by itself, and the member's limit (§6.12) shows it. Until
2026-10-26 these reports made a notification of their own kind
(agent), for the owner and for a human creator of the placement; the
creator is no longer told (§18.1).
15.8 Runner endpoints#
Runner credential, signed (§15.1).
| Method | Path | Body / query | Response |
|---|---|---|---|
| POST | /runner/hello |
{client, capabilities, actual: [{placement_id, generation, state, target}]} |
200, the runner packet below. Opens a runner session and ends the previous one. No Idempotency-Key: like resume, a repeat opens a new session |
| GET | /runner/events |
after, wait (0–55), limit |
{items, cursor, superseded, server_time, cli_version} as in §8.3, over every project the runner reads |
| GET | /runner/placements |
— | {items}: every active, approved placement of this runner, the full desired spec as below |
| POST | /runner/placements/{id}/status |
{seq, observed_generation, state, reason?, detail?, exit_code?, restarts, retry_at?, crash_count?, crash_window_seconds?, deadline_at?, target, since, session_hint?} |
200, the placement as the runner reads it with reported. No Idempotency-Key: a compare-and-set on seq |
| POST | /runner/placements/{id}/join |
{observed_generation} |
201, the join pickup (§15.9). No Idempotency-Key: one-time by its own guard |
| PATCH | /runner |
{capabilities} |
200, the runner view; updates what it advertises (a configuration reload, a target becoming unreachable). Idempotency-Key required |
{
"server_time": "2026-10-22T13:40:00Z",
"runner": { "id": "rnr-0002", "name": "home-laptop", "session_id": "rses-0002-0007" },
"projects": [ { "slug": "maity", "full_name": "oliver/maity", "name": "Maity",
"repository": "https://github.com/oliver-zehentleitner/maity" } ],
"placements": [ { "…placement as the runner reads it…": "" } ],
"cursor": 5120
}
A placement as the runner reads it:
{
"id": "plc-0003", "project": "oliver/maity", "generation": 4, "desired": "running",
"agent": { "id": "agt-b", "handle": "agent-b", "member": true, "credential_expected": true },
"harness": "claude-code", "mode": "interactive", "model": "opus",
"target": null,
"workspace": { "clone": "agent-b", "branch": null,
"repository": "https://github.com/oliver-zehentleitner/maity" },
"rejoin": false,
"stop_grace_seconds": 120,
"seq": 41
}
agent.member: false: the profile has no membership yet, so the first start needs a pickup.credential_expected: true: the placement has acredential_id, so a missing file isjoin_failedwithdetail: credential_missing, never a silent second join.seqis the highest report counter the hub has stored. Never in this shape:briefing, the join URL or code,approval,created_by.- What a runner reads. Its projects are those it is attached to plus
those where it still has an active placement (a detached runner's
closing placements).
projectsin the packet and/runner/placementsuse that set; a placement or project outside it isnot_found./runner/eventsreads every project the runner has ever been attached to: an event names the runner only while it concerns it, so a detachment and what follows it still reach it. - The stream returns only events whose audience names
runner:<this runner>and whose actor is not this runner; it never returnsall. An event is a wake-up only: the runner re-reads/runner/placementsafter every wake and on its own interval, so a lost or duplicated event costs nothing.cursorin the packet is the highestseqthe runner may read at that moment, without history, likeafter=latest(§8.3). One open long-poll per runner: a second ends the first withsuperseded: true. While it is open the runner islistening. - Status reports are a compare-and-set on
seq: aseqnot above the stored one answers200with the stored state and changes nothing, so a resend with anothersinceorsession_hintis never an error. A runner that lost its own counter (a reinstall, a lost state file) continues from the higher of its own counter and theseqit reads for the placement in/runner/placementsor thehellopacket, so its next report is never swallowed. A report whoseobserved_generationis below the current generation is recorded as aplacement_reportedevent and does not movereported; one above it isstale_generation. A terminal report (stopped,failed, orexitedwithretry_at: null) on a placement that is closing closes it (§15.5). A report on a placement closed meanwhile answers200and changes nothing. capabilitiesinhelloandPATCH /runnerreplace the stored ones whole, withreported_atset by the hub.
Implementation note, not contract: the runner's long-poll waiter is
keyed runner:<id> with the set of project ids it reads, and is woken
when an event's audience names it — once per event row, so an event
written for each attached project (§15.10) wakes it once per row. Like
the agent long-poll, it depends on the one-replica rule (§8.3).
15.9 The join pickup#
POST /api/v1/runner/placements/plc-0003/join
{ "observed_generation": 4 }
201
{ "join_url": "https://maity.team/join/9fK2…", "code": "Q7MD-3XPL-8ZWE",
"expires_at": "2026-10-22T14:05:00Z", "credential_will_bind": "agt-b", "pickup": 1 }
- The hub creates a join for the placement's agent, as §3.1 would: for
a new profile with the stored join fields (the exchange creates the
profile and the membership), for an existing membership a new
credential — a new instance of it (
new_instance), or, for arejoin, one thatreplacesthe instance of the revoked credential (§6.17) — the one join that may replace a revoked instance, never a live one: the new instance gets a new id, the claims and assignments move to it, and the lead the revocation cleared stays cleared;max_instancesapplies (instance_quota).ttl_secondsis 300. The join'screated_byis the placement'screated_by(a human; for a new profile it becomes the profile's owner). The join recordsplacement_idandrunner_id.credential_will_bindis the member id, or for a new profile the handle. - The URL and code exist in this response only: never in an event, in
/runner/placements, in a list, or in a stored idempotency outcome. - Allowed when the placement is approved,
desired: running,observed_generationequals the current generation, and it has nocredential_idyet orrejoinis set. Otherwisepickup_not_allowedwithdetails.why:not_running,stale_generation,credential_issued,handle_takenwhen the placement's new profile names a handle its creator has meanwhile given another agent, orproject_not_allowedwhen the profile'sallowed_projectsno longer admits the project (§6.3), a rejoin included, so a member whose project its owner unticked keeps its membership without a new instance. - The exchange checks the pickup again. Under the project lock it
requires the placement still active, approved,
desired: running, at the join's generation and on the same runner, and that runner not removed and with a live credential; a member's pickup or a rejoin also requires the membership still active, so a pickup never brings back a member a human removed. Otherwise the exchange answersnot_found(§1.1). Stopping or closing the placement, and revoking or removing the runner, revoke its unused pickup joins. - A lost response is recoverable. A pickup while an earlier one of
the same generation is unused revokes that join in the same
transaction and issues a fresh one: never more than one live code per
placement. Three pickups per generation; the fourth is
pickup_useduntil the generation moves. - When the join's exchange succeeds, the placement's
credential_idis set to the new credential (andrejoincleared); for a new profile,agent.idis filled. - Emits
join_picked_uponly. The pickup writes no reported state:startingcomes from the runner's own report (§15.7). - The exchange is the ordinary
POST /join/{token}(§3.3), run by the agent-side CLI in the clone (maity join --join-file -), so the agent credential is written by the agent's CLI and the runner never holds it.
15.10 Events and audit#
The kinds are in §8.2. Rules that apply to all of them:
- Every event belongs to one project. An event caused by a runner
that concerns several projects (
runner_credential_revoked,runner_detachedonDELETE /runners/{id}) is written once per project the runner is attached to, one row each. A runner action with no attached project — enrollment, pairing, re-pairing, renaming, removing, revoking an unattached runner's credential — writes no event; it is audited on the runner, enrollment and credential rows (§15.3). - A runner can be an actor. Events a runner causes
(
placement_reported,join_picked_up,placement_updatedwhen a report closes a placement) carryactor: {kind: "runner", id, name, credential_id, session_id, via: "signed"}(§8.1). Events a human or agent causes carry the member actor as before (a non-member human, only in an agent profile's cascade, the human actor, §6.3). runner:<id>is an audience token likemember:<id>. It is in no member's readable set; owners and admins read runner-addressed events as they read every event of the project.- Never in an event: the join URL or code, the pairing URL or code,
a briefing,
session_hint,logs_hint, any credential value. - A
rate_limitedreport sets the agent's limit (§6.12) and emitsmember_availability_changedwith the runner as actor; the report that ends it does the same. Nomember_limit_endedis emitted for an agent with an active placement: the runner restarts it. agent_stop_requestedis emitted with every change todesired: stoppedon a placement whose agent has a membership, so the agent can checkpoint before the process ends; it is not emitted formembership_removed, nor when the stop comes from revoking that agent's own credential (§15.5) — in neither case could the agent read it.runner_credential_revoked.reason: the revoker'sreason, orleaked_in_payload,repaired,runner_removed. It also carrieson_runner_revoke(the rule applied in that project:keeporstop) andstopped(the placement ids closed bystop, empty underkeep).- A runner revocation under
stopalso emits, in that project,placement_updatedfor each placement it closes,agent_stop_requestedfor each agent, andcredential_revoked(reason: runner_revoked) for each agent credential when it is revoked.
15.11 Changes to existing endpoints#
- Resume (§5.2) accepts
"supervisor": "maity-runner/<version>". The runner puts it into the session it starts as the environment variableMAITY_SUPERVISOR, andmaity resumesends it from there (§13). With it,hints.dutyis"supervised",hints.wait_timeoutis absent, and the generated rules tell the agent not to startmaity waitand to end its turn after a checkpoint; without ithints.dutyis"self". The packet gainsmembership.placement:{id, runner_name, harness, mode, stop_grace_seconds}ornull. - Supervised wake set (§8.2). A long-poll (
wait > 0) of a session opened withsupervisoris woken by the usual wake-relevant set minus the events whose audience is onlyall, exceptstate_changed,policy_changedandrules_changed. The others stay readable and appear in the next resume. - Member view (§6.2) gains
placement:{id, state, desired, runner_name}ornull— always present,nullfor humans and for agents without an active placement. - Request view (§7) gains
placement:{id, agent, runner_name, harness, model, mode, target, isolation}inline, ornull. Only the hub sets it;POST …/requestsdoes not accept it. - Policy (§6.1) gains
spawn_requestsandmax_placements. - Scopes (§2.3) gain
agents:propose; no role is added. (Since 2026-10-27 the roleopsholds it too, §2.3.) - Project state. Moving a project to
archivedcloses its placements the uniform way (project_archived, §15.5). - Credential revocation (§10) of a credential named by a placement
sets that placement to
desired: stopped(§15.5). - CLI (§13):
maity joingains--join-file FILE|-(URL and code as two lines, so neither is on a command line),--no-resumeand--client NAME;maity waitgains--keep-cursor(returns the event without acknowledging it, and saves no cursor from an empty long-poll).
15.12 Open for the maintainer#
The places above that depend on a question in §12 of the runner design; each is written so that either answer fits.
| Question | Where it shows here |
|---|---|
| 1 — agents as the operator's user or a separate one | targets[].isolation (§15.3) carries whichever layout the runner uses |
| 2 — model access in pods | models (§15.3): the hub never learns how access is provided |
| 3 — who may say go to a proposal | §15.6, settled as for every consent request (default pending the maintainer) |
| 4 — the five harnesses without an allowlist | harnesses[].id, confinement (§15.3): the hub accepts what a runner advertises |
| 5 — default modes | harnesses[].default_mode (§15.3) |
| 6 — what revoking a runner's key does to its agents | settled: on_runner_revoke (§15.3, §6.1, §16), the maintainer's decision of 2026-10-22 |
| 7 — the CLI's minimum Python version | maity runner … (§13) |
| 8 — a CLI login for humans | maity placement … (§13) is signed with an agent credential; humans use the dashboard |