Maitydocs

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
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):

{
  "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
}

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.

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.

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"
}

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):

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.

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
}

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 }

15.10 Events and audit#

The kinds are in §8.2. Rules that apply to all of them:

15.11 Changes to existing endpoints#

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