AYA integration guide
AYA is a hub that fronts agent sessions and a queue of execution turns. Workers execute those turns on real agent CLIs and services (Claude Code, Codex, and others) running on a machine you control, pulling work rather than having it pushed at them. Dispatchers are the servers a worker pulls that work from — your own service, or the hub’s own built-in review and work-run queues. UIs read session history and steer turns — sending a message, watching it stream back, forking a conversation — over a browser session.
Those are the three ways to integrate. Pick the one that matches what you’re building:
Integrate as a dispatcher
You have your own service that decides what work exists, and you want a
worker to pull it from you rather than push a fixed request at the hub.
You implement the same small execution-pull contract the hub itself
implements — a worker configures your server as one of its own
[[execution_sources]] and polls you directly, never the other way
around — or, if you don’t want to run a full execution-pull server,
submit work runs directly to the hub instead and just serve its one
result-callback endpoint. See Integrate as a dispatcher.
Integrate as a worker
You have a machine that can run an agent CLI, and you want it to pull work
from AYA rather than have work pushed at it. You poll for a claimable turn,
drive it locally, and report events and a terminal outcome back. This is
what the reference worker (aya-helper) does. See
Integrate as a worker.
Integrate as a UI
You want to show someone their agent sessions — browse history, search across it, send a chat message naming a provider that’s currently being served (or a configured tier), fork a conversation where the harness supports it, and watch the reply arrive — without caring how any of it actually runs. You read a tree-shaped session/message model and drive turns through a session-cookie-gated chat-and-poll surface. See Integrate as a UI.
Five-line quickstart
# 1. Get a credential — an OIDC client-credentials grant (see
# Authentication). You end up with a bearer token.
export TOKEN="..."
# 2. Call an authenticated agent route with it — this one needs the
# agent:report scope.
curl -s https://hub.example.test/api/agent/turns \
-H "Authorization: Bearer $TOKEN"
# {"turns": [...], "total": 0, "limit": 50, "offset": 0}
# 3. Read the full contract — the worker-facing and service-integration
# surface, not the browser routes a UI uses (see Reference).
# https://hub.example.test/api/docs # interactive OpenAPI reference
curl -s https://hub.example.test/api/openapi.json # the spec itself
See Reference for the complete operation table and Authentication for how to get a credential in the first place.
Your first turn in ten minutes
The fastest way to see the whole loop — enqueue, claim, run, complete,
read the result — is to play both sides of it yourself against a real
deployment, using the hub’s own built-in review queue as the concrete
enqueue point (a real dispatcher’s own enqueue step is private to its own
storage, so there’s nothing to curl for it in general — this queue is
the one exception that’s fully self-contained and runnable end to end).
Get two credentials first: a review:run-scoped token ($REVIEW_TOKEN)
and an agent token ($TOKEN) — see Authentication. Step 2
claims WHATEVER’S next in the queue for the given provider, not
necessarily the turn you just made — run this against a deployment with
no other pending turns for that provider, or you may claim someone else’s.
Step 2 also assumes the deployment’s configured review profile uses
claude — ask your operator, or check
Run your own, and adjust
?providers= if it’s something else. Every step but the first captures
what it needs from the previous response with jq rather than hardcoding
it, so this is meant to be pasted and actually run, not just read.
# 1. Enqueue — stands in for "your own enqueue" in this walkthrough.
curl -s -X POST https://hub.example.test/api/integrations/turns \
-H "Authorization: Bearer $REVIEW_TOKEN" -H "Content-Type: application/json" \
-d '{"request_id": "first-turn-1", "purpose": "idea_review_v1", "input_version": 1,
"context": {"idea": {"id": "i1", "title": "Try AYA", "description": "A test idea."},
"messages": [], "omitted_message_count": 0,
"projects": [{"id": "p1", "name": "Test project", "description": ""}],
"linked_project_ids": ["p1"]}}'
# 202 {"request_id": "first-turn-1", "state": "queued", "replayed": false}
# (resubmitting this exact body is safe — same request_id, same input,
# replays as "replayed": true rather than queuing twice; changing the
# body under the same request_id instead gets a 409)
# 2. A worker claims it — this is the same call any worker polling this
# hub already makes on its own; run it yourself to see the shape.
# 204 with no body means nothing is pending for this provider right now.
CLAIM=$(curl -s "https://hub.example.test/api/agent/turns/next?providers=claude" \
-H "Authorization: Bearer $TOKEN")
echo "$CLAIM"
# {"turn_id": "101", "provider": "claude", "native_id": null, "text": "...",
# "model": null, "claim_token": "fence-1", "claim_generation": 1, ...}
TURN_ID=$(echo "$CLAIM" | jq -r .turn_id)
CLAIM_TOKEN=$(echo "$CLAIM" | jq -r .claim_token)
# 3. Post one event against that claim — a real review result, so step 5
# below actually reaches "ready" instead of "failed". The shape is
# exactly ReviewResult (see Integrate as a dispatcher's review-queue
# section): summary, questions, evidence, proposals. Built with jq so
# the nested JSON-as-a-string doesn't need hand escaping.
REVIEW_RESULT='{"summary":"Looks reasonable.","questions":[],"evidence":[],"proposals":[]}'
BODY=$(jq -n --arg token "$CLAIM_TOKEN" --arg delta "$REVIEW_RESULT" \
'{claim_token: $token, batch_id: "b1", events: [{type: "text", delta: $delta}]}')
curl -s -X POST "https://hub.example.test/api/agent/turns/$TURN_ID/events" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "$BODY"
# 200 {"ok": true}
# 4. Complete it.
curl -s -X POST "https://hub.example.test/api/agent/turns/$TURN_ID/done" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"claim_token\": \"$CLAIM_TOKEN\", \"ok\": true}"
# 200 {"ok": true}
# 5. Read the result back through the review queue you enqueued into.
curl -s https://hub.example.test/api/integrations/turns/first-turn-1 \
-H "Authorization: Bearer $REVIEW_TOKEN"
# {"request_id": "first-turn-1", "state": "ready",
# "result": {"summary": "Looks reasonable.", "questions": [], "evidence": [], "proposals": []}}
Steps 2-4 are exactly what Integrate as a worker documents in full — a real worker just does them in a loop, automatically, against whatever’s actually pending. Step 1 and step 5 are the one hub-hosted shortcut Integrate as a dispatcher describes; building your own is the rest of that page.
The two hosts
A deployment normally answers on two hostnames:
- The app host serves the product itself: the browser UI, OIDC login,
and every
/api/*route — the agent-facing surface this guide covers, and the browser-facing surface a UI integrates against. - The docs host (optional) serves this site at its own root, in
addition to — not instead of —
/docson the app host, which keeps working exactly as before whether or not a docs host is configured. Either way,/api/openapi.jsonand/api/docs(the interactive reference) are reachable from both hostnames, so links in this guide use root-relative paths that work no matter which host served the page you’re reading. See Run your own for how an operator configures this split.
Authentication
AYA has four separate credential schemes, each gating a different surface (and, when workers are enabled, a second token issuer; see Workload tokens). They are never interchangeable — a browser session cookie doesn’t work on an agent route, and an agent bearer token doesn’t work on a browser route.
| Surface | Scheme | Who uses it |
|---|---|---|
Browser UI, /api/sessions/*, /api/roots, /api/workers/* (browser half) | Session cookie via OIDC login | A UI integration |
/api/agent/* on the hub | Per-worker OIDC client-credentials | A worker (a dispatcher serves this same shape of surface itself, at its own address — see Integrate as a dispatcher) |
/api/integrations/* | OIDC client-credentials, service-principal subject | An external system queuing one of the hub’s own fixed reviews (review:run) or work runs (work:run), with no worker of its own |
/api/remote/v1/* | OIDC client-credentials, its own audience, service-principal subject | A remote agent that cannot join the operator’s network, reading sessions and driving chats on the operator’s machines through the hub (chat:read, chat:drive) — see Remote API |
Browser sessions
Every browser-facing route (the app shell, /api/sessions/*, /api/roots,
the browser half of /api/workers/*) sits behind a signed session cookie
set after an OIDC login redirect (/auth/login → your identity provider →
/auth/callback). There is no bearer-token mode for this browser surface
(the machine-to-machine Remote API is a separate surface with
its own credentials, not a bearer mode for this one) — if
you’re building a UI that a person logs into, this is what you integrate
against, and same-origin cookie mode is the only supported way in (see
Integrate as a UI). A request with no valid session gets
redirected to /auth/login (a browser navigation) or a 401 JSON body
(an API call), depending on the route.
Agent credentials (/api/agent/*)
Every route under /api/agent/* requires a per-worker OIDC client-credentials
bearer token: an OAuth2 access token obtained from your identity provider’s
token endpoint, scoped to exactly what that worker needs. The token’s sub is
the worker’s identity. The shared static AGENT_TOKEN that older hubs accepted
is gone; presenting one is a plain 401.
A worker only sees and serves the roots bound to its own sub: the first
worker to touch a root owns it (or the operator pins it), and index, capability
and claim calls for a root bound to someone else are refused with 403. Each
principal is also rate limited per route class; an exhausted bucket answers
429 with a Retry-After header.
To get an OIDC token, POST to your issuer’s token endpoint (discovered
from <issuer>/.well-known/openid-configuration) as
application/x-www-form-urlencoded:
curl -s -X POST "$OIDC_TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$RESOURCE" \
-d scope="agent:claim agent:report"
# {"access_token": "...", "expires_in": 3600}
resource is the RFC 8707 resource indicator — a deployment-specific value
your operator gives you, checked against the token’s aud claim on every
request. There is no separate “audience” parameter name on the wire; resource
is it. A token whose aud doesn’t match, or whose issuer doesn’t match the
hub’s configured issuer, is rejected the same as a missing token — 401.
Scopes. Space-separate multiple scopes in one request; a token can carry more than it needs, but each route checks for its own minimum. This table is the authoritative route→scope map:
| Scope | Unlocks |
|---|---|
agent:claim | GET /api/agent/turns/next, GET /api/agent/jobs/next — claim one pending turn or job. |
agent:report | POST /api/agent/turns/{id}/events, POST /api/agent/turns/{id}/done, GET /api/agent/turns, GET /api/agent/turns/{id}/history, POST /api/agent/jobs/{id}/result, GET /api/agent/index/fingerprints, POST /api/agent/index/sessions, POST /api/agent/capabilities — everything that reports progress, completion, or state on already-claimed work, plus read-only turn introspection. |
execution:heartbeat | PUT /api/agent/workers/self/heartbeat only — report worker presence. Cannot claim or drive anything. |
execution:control | Every /api/agent/workers/self/commands/* route (claim, events, heartbeat, code, complete) — drive a worker’s interactive provider login/logout. Cannot report presence or touch the turn/job queues. |
A route not in this table is unreachable — a perfectly valid token still
gets 401, because the middleware has no scope to check it against.
A third, unrelated scheme gates /api/integrations/* — for an external
product that wants the hub to run something for it without operating a
worker at all. It shares one OAuth2 client-credentials setup across two
independent scopes: review:run gates
POST /api/integrations/turns/GET /api/integrations/turns/{request_id}
(one automated read-only review); work:run gates
POST /api/integrations/work-runs/
GET /api/integrations/work-runs/{request_id} (a work run against an
operator-configured profile — see
Integrate as a dispatcher).
Both additionally require the token’s sub claim to identify a service
principal (it must start with client-); a human user’s token, even with
the right scope, is rejected. On this hub, both scopes are issued against
the SAME resource/audience value — there’s no separate work-run audience
to configure.
A fourth scheme gates /api/remote/v1/* — for a remote agent (for example a
cloud session that cannot join the operator’s VPN) that reads sessions and
drives chats on the operator’s machines through the hub. It uses its own
audience, the hub’s REMOTE_API_RESOURCE, which is never the same value as
API_RESOURCE or AGENT_OIDC_RESOURCE: a token minted for the review API or
the agent API does not open it, and the reverse. It has two scopes, and
chat:drive does not imply chat:read:
| Scope | Unlocks |
|---|---|
chat:read | Every GET under /api/remote/v1/ except the public auth-info — machines, projects, sessions, messages, search, and a turn’s events. |
chat:drive | POST /api/remote/v1/sessions/{pk}/messages and POST /api/remote/v1/chats — send to a chat, start a chat. |
workers:reserve | The reservation routes and POST /api/remote/v1/sessions/{pk}/move (which also needs chat:drive). Only when workers are enabled. |
workers:admin | The pool, client, machine and issuer-key admin routes. Only when workers are enabled. |
workers:reserve and workers:admin are separate from the two chat scopes:
holding one never grants another. GET /api/remote/v1/auth-info still lists
only chat:read and chat:drive. See Workers.
The token’s sub must also start with client- and be on the operator’s
REMOTE_ALLOWED_SUBJECTS list. Each admitted subject is rate limited, and an
exhausted bucket answers 429 with Retry-After. Browser cookies never open
this surface. See Remote API for the flow and the rules the hub
enforces on what such a caller may do.
Workload tokens and the second issuer
When the operator enables workers (WORKLOAD_ISSUER, see
Run your own) the hub is also an OIDC issuer
itself, at PUBLIC_BASE_URL, with discovery and a JWKS under /.well-known/
and a token endpoint at /oauth/token. It signs short-lived RS256 access tokens
for reserved workers, for service clients and for provisioning services. A
worker obtains its token by proving possession of the Ed25519 key it enrolled
with (private_key_jwt), so no shared secret sits on the machine; a service
client uses a secret. Worker tokens last 10 minutes by default and carry
sub client-w-<worker id> plus wid, pool and rsv claims naming the
worker, pool and reservation.
/api/agent/* therefore accepts tokens from two issuers, with one rule:
- A token whose
issis the hub is judged only by the hub’s own, revocation-aware verifier. If it does not verify the answer is401, and the external issuer is never asked. - Any other token is checked against your external identity provider, as
described above.
AGENT_ACCEPT_EXTERNAL_ISSUER=falseturns this off, leaving only hub-issued tokens. - An external token whose
substartsclient-w-is401whatever it carries: that prefix belongs to the workers the hub enrolls.
Releasing a reservation revokes its worker’s tokens in the same transaction, so
the worker’s next call to any /api/agent/* route is 401. Two new agent
routes serve reserved workers: GET /api/agent/wake (scope agent:claim) and
GET /api/agent/reservation (scope agent:report). The full details are in
docs/api/workers.md and docs/api/execution-pull.md.
Token lifetime and refresh
Access tokens are ordinary OAuth2 client-credentials tokens — their lifetime
is whatever your issuer puts in the token response’s expires_in field, not
something the hub controls. The hub itself does no token refresh on your
behalf; refreshing before expiry is your integration’s job. The reference
worker client refreshes 30 seconds ahead of the token’s stated expiry — or,
if expires_in is absent, assumes a 60-second lifetime and so refreshes
after 30 seconds — caches the token in memory, and
retries exactly once on an unexpected 401 by fetching a fresh token and
replaying the same request — a reasonable pattern to copy. The hub also
maintains its own signing-key cache (JWKS) independently, refreshing it when
it sees a key id it doesn’t recognize; that’s the hub’s own key-rotation
handling and is invisible to a well-behaved client.
Error responses
The exact error body differs by which of the surfaces above you’re on — they’re three separate implementations, not one shared auth layer:
-
/api/agent/*—401for no bearer token, a malformed one, one that fails signature/issuer/audience/expiry checks, one with a BLANK OIDC subject, or a route this guide doesn’t list under any scope:{"error": "unauthorized"}403when the token is otherwise valid (a non-blank subject) but lacks the scope the route requires:{"error": "missing required scope"} -
Browser routes —
401for no valid session:{"error": "authentication required"}(a browser navigation instead gets redirected to
/auth/loginrather than shown this JSON body). -
/api/integrations/*(both the review queue and the work-run queue) — a NARROWER401than/api/agent/*above: only for a missing bearer token, a malformed one, or one that fails signature/ issuer/audience/expiry verification — never for the subject itself, however it turns out:{"error": "authentication required"}403for either of two distinct reasons, both requiring a token that verified successfully: missing the scope that route requires (review:runorwork:run) answers{"error": "missing required scope"}, the same shape/api/agent/*uses (though/api/integrations/*never reaches this check on an unverified token, unlike/api/agent/*’s single combined check above); a verified token whose subject is BLANK or isn’t a service principal (subdoesn’t start withclient-) answers a different body instead of the401/api/agent/*would give a blank subject:{"error": "service principal required"} -
/api/remote/v1/*—401{"error": "authentication required"}for a missing, malformed or unverifiable bearer (wrong signature, issuer, audience or expiry — including a token minted for a different surface).403{"error": "missing required scope"}for every other refusal of a verified token: the scope the route needs is absent, thesubdoesn’t start withclient-, or thesubisn’t onREMOTE_ALLOWED_SUBJECTS— one body for all three, so a caller can’t probe the allowlist. When the feature isn’t configured, every path answers404{"error": "remote API is unavailable"}before any auth check runs.
A third response is specific to /api/agent/*: if a hub has no agent
authentication configured at all, every route under /api/agent/* answers
503 unconditionally — no auth check is even attempted, so a misconfigured
deployment fails obviously rather than looking like a routing bug:
{"error": "agent routes are disabled — agent authentication is not configured"}
/api/integrations/* is configured independently and does not share
that 503 — see Reference
for its own two distinct unavailable states, checked separately for the
review queue and the work-run queue: a plain 404 (that particular queue
isn’t configured at all — before any auth check runs) versus a 503
only once authentication has already succeeded but nothing on the hub
could execute the resulting turn or run.
Integrate as a dispatcher
A dispatcher has two ways in. Serve the execution-pull contract as a source (the rest of this page): you run your own server, own its queue and scheduling, and a worker polls you directly — full control, at the cost of operating a server that speaks the whole protocol. Or submit work runs to the hub (Run work through the hub: work runs): you call two hub routes, the hub queues the run against an operator-configured profile and dispatches it to a worker already polling the hub — no execution-pull server to run — but you’re not server-free either: the hub reports each terminal result back by calling YOUR own HTTPS endpoint on its own initiative (a callback, not a poll — see The report callback), and refuses to even start work runs at all unless that callback is fully configured. You’re still limited to whatever profiles your operator configured, and the hub owns the queue. Pick the first for full control over routing, provider choice, or your own queue semantics; pick the second when a fixed shape of unattended work, handed back to you when it’s done, is worth running one narrow callback endpoint instead of the full protocol.
You are an execution source
A dispatcher is a server a worker polls — never the other way around. In
this project’s own vocabulary that server is an execution source: the
thing that owns durable work state and decides what runs, while a worker
(aya-helper, or any implementation of the same contract) owns nothing
durable and only knows how to execute against a local harness. Aya Hub
itself is simply the first execution source this project built; your
server implements the identical wire contract, at your own base URL, and
an operator points a worker at it with one [[execution_sources]] entry in
the worker’s own config (see Run your own). The
worker always initiates the connection — poll, then push results — so your
server needs to be reachable by outbound HTTP from wherever the worker
runs; nothing ever needs to dial back into the worker itself.
What to serve
Only three routes are actually required — the turn lease. Everything
else in the table below is called conditionally, on the worker’s own
initiative, independent of anything you advertise (see the next section).
These are the same paths, unchanged, aya-helper’s reference client sends
to the hub today. What’s actually shared across every execution source
is narrower than the hub’s own behavior: the request and response shapes
below (ClaimedTurn, EventAppendRequest, CompletionRequest) are a shared, versioned wire
contract every execution source and worker implement against, whose own
explicit design principle is that routing, authentication, storage, and
scheduling remain owned by each execution source. That shared contract
defines no success-response shape at all for events or done, so “answer
200” below means “answer any 2xx status”; the hub happens to reply
{"ok": true}, but that’s the hub’s own convention — the reference
worker never inspects the response body, only that the request
succeeded. The hub is one reference implementation of that contract, not
the spec for your own 401/403/503 behavior — those are the hub’s
own choices to make, and yours are entirely up to you.
Reference’s Turns and Workers groups
document the hub’s own instance of this contract in full. Two hub-only
route groups are not part of this contract and your source never
needs them: Jobs and Index are extras the primary hub connection alone
gets, never a configured [[execution_sources]] entry.
| Method | Path | Required? | Purpose |
|---|---|---|---|
GET | /api/agent/turns/next | Always | Worker claims one pending turn. |
POST | /api/agent/turns/{id}/events | Always | Worker appends events (or a keepalive). |
POST | /api/agent/turns/{id}/done | Always | Worker reports the terminal outcome. |
PUT | /api/agent/workers/self/heartbeat | Only if the worker is configured against you with OIDC | Worker reports presence. |
POST | /api/agent/workers/self/commands/claim | Only for an OIDC worker that also has local provider-auth-control capability | Worker claims one pending provider-auth-control command. |
POST | /api/agent/workers/self/commands/{id}/events | Same as .../claim | Worker reports control-command progress. |
POST | /api/agent/workers/self/commands/{id}/heartbeat | Same as .../claim | Worker renews the control-command lease. |
GET | /api/agent/workers/self/commands/{id}/code | Same as .../claim | Worker reads the delivered one-time code. |
POST | /api/agent/workers/self/commands/{id}/complete | Same as .../claim | Worker reports the control command’s terminal outcome. |
The turn lease, in full
GET /api/agent/turns/next?providers=&roots=&profile_id=&purpose= — all
four query params optional; on the hub itself only providers/roots
actually filter anything — profile_id/purpose exist for sources that
want to offer worker-routed or purpose-scoped queues (see below and
Integrate as a worker), and the hub simply
doesn’t implement that kind of routing on its own queue. Answer 204 with
no body when nothing is pending; otherwise 200 with a ClaimedTurn:
{"turn_id": "42", "provider": "claude", "native_id": null, "text": "...",
"model": null, "claim_token": "fence-abc",
"permission_mode": null, "fork_of_native_id": null,
"working_directory": null, "effort": null, "root_id": null,
"claim_generation": 1}
turn_id, text, and claim_token are required, plain, always-present
strings; claim_generation should also always be present (a real claim’s
generation counts from 1) though the worker treats a missing key there as
the legacy sentinel 0. provider, native_id, and model are the only
three fields whose KEY must always be present even when the value is
null — the worker’s deserializer rejects a dropped key on exactly these
three (an explicit null is fine, a missing key is not). Every other
field (permission_mode, fork_of_native_id, working_directory,
effort, root_id, and tier/candidates below) may simply be omitted
when you have nothing to say — the worker’s own defaults cover a missing
key for those, and an unrecognized extra key in your response is silently
ignored rather than rejected.
POST /api/agent/turns/{id}/events — body
{"claim_token", "batch_id", "events": [...]}; answer any 2xx on
success (the body isn’t inspected). batch_id idempotency is keyed on
(turn_id, claim_generation, batch_id), and “identical retry” means the
same parsed events array reserialized canonically, not byte-identical
JSON — differing whitespace or key order in a resend doesn’t matter, only
the actual event content. The same batch_id with genuinely different
event content is a conflict — answer that case distinctly from an
ordinary success (the hub uses 409) so a worker never mistakes a
silently-dropped mismatched retry for the batch it actually meant to
send. A write against an already-done turn is rejected the same way a
claim_token mismatch is — reject both with 409.
POST /api/agent/turns/{id}/done — body
{"claim_token", "ok", "error", "created_native_id", "created_root_id", "requeue", "park_until_ms", "resolved_provider", "resolved_model"}; only
claim_token and ok are required, the rest default to null/false
when omitted. Answer any 2xx on success. Only the worker holding the
current winning claim can ever record an outcome — an exact retry (same
claim_token, matching whoever you last granted the claim to) against an
already-done turn should still answer success (idempotent, so a worker
whose response got lost can safely resend), but any other caller against
an already-done turn — a stale token, or a different claimant — gets
409, the same as against a live claim it doesn’t hold; that idempotency
check only needs to compare the token and claimant identity, never the
request body. requeue: true is how a worker parks or retries instead of
completing — see “Park and requeue” below.
Heartbeat and provider-auth-control: whether the worker calls them isn’t your choice
This is decided by which credential mode your users configure their
worker with, not by whether you implement these routes: a worker
configured against you with an OIDC credential heartbeats
(PUT .../heartbeat) automatically, on its own fixed interval, for as
long as it’s configured — it never asks first, and it doesn’t matter
whether your server does anything useful with the calls. The same OIDC
worker additionally polls for provider-auth-control commands
(POST .../commands/claim) whenever it locally has a provider credential
capable of interactive login and that capability is available on its
current machine — again, independent of anything your server advertises.
A worker configured against you with a plain static bearer token sends
neither: it only ever calls the turn-lease routes above. If you don’t want
heartbeat/control traffic, tell your users to configure the static-token
mode; if you do want it, the cheapest correct implementation of the
heartbeat route is “accept it, 204, and do nothing else.”
PUT /api/agent/workers/self/heartbeat — establishes or refreshes the
binding itself (unlike every commands/* route, it does NOT require a
pre-existing “self” to resolve — the first heartbeat for a given
instance_id is what creates that binding). Answer 204 No Content on
success, no body. A representative body:
{"instance_id": "worker-1", "version": "1.0.0", "profiles": [],
"capabilities": [], "slots": {"total": 4, "busy": 0}, "health": "ok",
"provider_sessions": [], "sent_at": "2026-09-04T12:00:00Z"}
profiles (when a worker-routed source is configured with a purpose)
is [{"profile_id", "purpose", "provider"}]; provider_sessions is
[{"provider", "provider_instance_id", "label", "state", "expires_at", "last_verified_at", "error_code", "account_email", "account_name", "organization_name", "billing_type", "rate_limit_tier", "extra_usage_enabled"}] (most fields nullable) — the hub accepts
whatever shape a worker sends here without validating it structurally, so
treat both as informational rather than something you must parse
correctly to stay compliant.
Presence and “self.” A heartbeat binds an instance_id (in the
request body) to whichever credential/principal sent it — the first
heartbeat you see for an instance_id establishes that binding, and every
/api/agent/workers/self/commands/* route after that resolves “self” from
the CALLING credential, never from anything in the request itself (there’s no
instance_id anywhere in a commands/* path). Two consequences worth
building in from the start: reject a heartbeat for an instance_id you’ve
already bound to a different credential, and answer every
workers/self/* route with a fencing conflict if the calling credential
has never heartbeated at all, or its last-known instance is no longer
“present” by your own bookkeeping — there is no “self” to resolve.
Worker-routed sources
An operator configuring a worker against your server can choose to make it
worker-routed: a local setting on the worker’s own side that tells it
“supply only prepared text and an opaque profile_id affinity; never trust
this source’s own opinion of which provider, root, model, effort, or
permission mode to use.” This is a promise your server must keep, enforced
by the worker itself: if it’s configured this way and your
GET /api/agent/turns/next response sets provider,
model, permission_mode, fork_of_native_id, working_directory,
effort, or root_id to anything but null, the worker refuses the claim
outright rather than silently overriding your policy or silently applying
it. Leave every one of those fields null and set only text (the
prepared prompt) — the worker fills in its own locally-configured provider
and root itself, entirely locally, before ever running anything.
The worker applies that local overlay BEFORE tier handling runs, so a
worker-routed claim carrying a non-null tier still gets tier-resolved
exactly like a provider-pinned claim (see
Integrate as a worker): with no
profile-supplied model, done reports the overlaid provider in
resolved_provider and the selected model in resolved_model (possibly
null for the harness default); without a tier, or with an explicit
profile model, both stay null. If you want to know which provider a
worker-routed claim ran on and it isn’t reported back this way, that’s
local knowledge only the worker’s operator has.
Authentication
A worker authenticates to you exactly one of two ways, chosen once per
[[execution_sources]] entry on its own side — you only need to support
whichever one your users’ workers are configured for:
-
Static bearer token — the worker sends a fixed shared secret as
Authorization: Bearer <token>on every request. Simplest to implement; treat it like any API key. -
OIDC client-credentials — the worker discovers your issuer’s token endpoint from
<issuer>/.well-known/openid-configuration, then requests an access token withgrant_type=client_credentials, its configuredclient_id/client_secret, aresourceparameter (the audience you told your users to configure), and ascopethat depends on which route group it’s calling for:Scope requested For execution:claimThe turn lease — claim, events, and done all reuse this one credential. execution:heartbeatPUT /api/agent/workers/self/heartbeatonly.execution:controlEvery /api/agent/workers/self/commands/*route.These come from separate token requests against the same issuer/client/
resourceyour operator configured — the reference worker requests exactly one scope per credential, never more than one on the same token, and requests up to three of these credentials in total: the turn-lease one always, and the heartbeat/control ones only when applicable at all (see “whether the worker calls them isn’t your choice” above) — a static-token worker requests none of the three, and even an OIDC worker with no local provider-auth-control capability never requestsexecution:control. Note the asymmetry from the hub’s own worker-facing routes: it splits ordinary turn work intoagent:claim(claim only) andagent:report(everything else, including read-only introspection) — a generic execution source gets one combinedexecution:claimcovering the whole turn lease instead, because introspection routes (GET /api/agent/turns,.../history) are hub-only and have no equivalent here. Checking these scope strings server-side is defense in depth, not a requirement of the contract — a source that issues one token good for everything, or that runs no OIDC server at all and uses the static-token mode instead, is equally valid as long as it answers the routes above.
Either way, every request in this document carries a bearer credential in
an Authorization: Bearer header — a worker never does anything fancier
than that one header. But under OIDC specifically, don’t expect ONE
token to show up everywhere: the reference worker requests up to three
separately scoped tokens against the same issuer/client — one good for
the whole turn lease (execution:claim), and, only when applicable, a
second good only for the heartbeat route (execution:heartbeat) and a
third good only for the control-command group (execution:control) —
cached and refreshed independently, each on its own loop. A single worker
process configured for all three can legitimately present you three
different bearer values depending on which route it’s calling. The
static-token mode is the one case that’s genuinely a single shared
credential everywhere.
Turn lifecycle from your side
sequenceDiagram
participant You as Your server (execution source)
participant W as Worker (aya-helper)
You->>You: enqueue a pending turn, mint turn_id
loop worker polls
W->>You: GET turns/next with provider and root filters
You-->>W: 204, or 200 ClaimedTurn, mint claim_token, generation 1
end
loop while running
W->>You: POST turns/id/events claim_token batch_id events, or empty for a keepalive
You-->>W: 2xx, reject with 409 if claim_token is stale
end
W->>You: POST turns/id/done claim_token ok created_native_id resolved_provider
You-->>W: 2xx, record the terminal outcome, further writes under this claim_token now 409 (except an exact retry of this same done call, which stays a 2xx no-op)
Note over You: if the worker goes silent past your staleness window, a later claim may reclaim the row with a fresh claim_token and generation plus one
What you must persist per turn, mirroring what the hub itself tracks:
the current claim_token (a fresh, unguessable value minted on
every claim and every reclaim) and claim_generation (an integer that
only ever goes up, once per claim/reclaim); last_activity_ms or
equivalent, bumped by the original claim and by every subsequent events
call including a bare keepalive, so you can tell a genuinely silent worker
from one still legitimately mid-tool-call; every appended event tagged
with the claim_generation that wrote it, so a reclaimed worker’s stale
partial output never gets mixed into the new attempt’s stream; and the
turn’s terminal state once done lands, sealed against every future write
except an exact retry from the SAME claim_token.
Fencing is not optional. Every write (events, done) must present
the exact claim_token you most recently issued for that turn; reject
anything else with 409. This is what makes a reclaim safe: once you mint
a fresh token, every write under the old one is rejected, so a worker that
comes back from the dead after you’ve moved on can never silently
overwrite a fresher attempt’s results.
How long to wait before reclaiming is entirely your call — the
protocol doesn’t mandate a number. The hub’s own choice is 5 minutes of
total inactivity, comfortably above the reference
worker’s own 60-second keepalive cadence, which is a reasonable default to
copy; a worker that’s still genuinely running never trips it just because
the turn is long-running, and crossing the window doesn’t revoke anything
by itself — the old claim stays valid until a fresh GET .../next call
actually reclaims the row. See Integrate as a worker
for every timing number used anywhere on this site, collected in one table.
Park and requeue ride the done call, not a separate route:
requeue: true means “don’t complete this, put it back to pending” rather
than success or failure, and an optional park_until_ms lets a worker
name a future time it knows candidates will reopen (a known rate-limit
reset, for instance). park_until_ms is a LOWER bound on your ordinary
short retry backoff, not a replacement for it: the hub’s own rule is
max(now + <its own short backoff>, park_until_ms) — a park_until_ms
in the past or only moments away doesn’t extend that backoff (the
ordinary short window already covers it), but it still takes the park
branch rather than the ordinary-requeue one: attempts are NOT incremented
either way, so it never itself moves a turn closer to the requeue attempt
cap. The hub does not validate that park_until_ms is actually
future-dated — any non-null value suppresses attempt accounting, whether
or not it’s later than “now.” Send a genuinely, meaningfully future
timestamp if that distinction (capped ordinary retries vs. uncapped
parking) matters to your implementation; a near-past or near-now value
still parks, it just doesn’t buy you any extra wait beyond the ordinary
backoff. See Integrate as a worker for the
full semantics from the worker’s side, which apply to your implementation
unchanged.
Provider auth control
If you implement the workers/self/commands/* group, you’re the one
enqueuing a command in the first place — your own product decides when a
person needs to (re)authenticate a provider on a worker, and surfaces the
result to them. The wire shape a worker speaks back to you is fixed:
sequenceDiagram
participant You as Your server
participant W as Worker (aya-helper)
participant P as Person
You->>You: enqueue a control command (login), mint operation_id
W->>You: POST .../commands/claim {}
You-->>W: 200 ClaimedControlCommand (claim_token, claim_generation, lease_expires_at)
W->>You: POST .../commands/id/events state=awaiting_user verification_url user_code
You->>P: surface the verification URL / code however your product does that
P->>You: supplies the one-time code (your own channel, not part of this contract)
You->>You: store the code, ready for delivery
W->>You: GET .../commands/id/code (fenced by X-AYA-Claim-Token/-Generation headers)
You-->>W: 200 code (exactly once — 204 on every read after)
W->>You: POST .../commands/id/complete ok
You-->>W: 200
Request/response bodies and status matrix, matching the hub’s own implementation field-for-field:
The reference worker only checks that the response succeeded on the
three POST routes below — it never inspects their response body — so
“any 2xx” is the real requirement; the hub’s own {"ok": true} is its
own convention, shown because it’s what you’ll see if you talk to the
hub itself, not something your server must reproduce.
| Route | Request body | Success | Failure |
|---|---|---|---|
POST .../commands/claim | {} | 200 ClaimedControlCommand (below), or 204 if nothing pending | 409 if no present self instance |
POST .../commands/{id}/events | {"claim_token", "claim_generation", "event": {"state", "verification_url", "user_code", "expires_at", "error_code"}} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
POST .../commands/{id}/heartbeat | {"claim_token", "claim_generation"} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
GET .../commands/{id}/code (fenced by X-AYA-Claim-Token/X-AYA-Claim-Generation headers, not the body) | — | 200 {"code": "..."} once, 204 every read after | 409 fence mismatch or expired lease |
POST .../commands/{id}/complete | {"claim_token", "claim_generation", "ok", "error_code"} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
The complete row’s 409 column has one exception the others don’t:
once a complete call has actually succeeded, an exact retry of it (same
claim_token/claim_generation) stays a 2xx no-op instead — safe to
resend if a response was lost. events, heartbeat, and the code read
never get that exception, and neither does a complete call made BEFORE
the command ever succeeded: once a live claimed command’s lease expires,
every one of those, including that first complete attempt, is 409
until (or unless) the same worker instance reclaims the command.
{"operation_id": "op-1", "provider": "claude", "provider_instance_id": "default",
"action": "login", "claim_token": "cmd-fence-1", "claim_generation": 1,
"lease_expires_at": "2026-09-04T12:00:30Z"}
The lease is a plain absolute deadline, not the turn lease’s rolling
activity window — the hub uses 30 seconds (lease_expires_at, returned
on claim) — so a worker must renew it on its own cadence
(POST .../heartbeat, every 5 seconds in the reference client) rather than
just staying busy. Unlike the turn lease, an expired control-command lease
takes effect immediately, on its own, with no reclaim required first:
every fenced write (events, heartbeat, complete, the code read) checks the
presented claim_token/claim_generation against the row’s current claim
first, THEN separately checks whether that claim’s own lease has already
expired — a token/generation that still matches exactly is rejected
409 anyway if the deadline has simply passed, even before any other
worker has reclaimed the command. Once expired, the command also becomes
reclaimable, but only by the SAME instance that held it — your claim query
for a given command should stay scoped to whichever worker instance it
was originally addressed to, never opened up to any caller. That
token-then-lease check is skipped entirely for a complete call against a
command that’s already recorded as done: an exact retry (matching
claim_token/claim_generation) short-circuits straight to a 2xx
no-op regardless of whether the lease has since expired, because the hub
checks “already done, same fence” before it ever looks at the lease at
all.
Validating the one-time code has two layers, on both sides. The worker checks it twice, at two different points in its own pipeline: once on read (non-empty, at most 4096 bytes) and again just before feeding it to the login process (the same two rules plus no ASCII control characters), so by the time a code is actually used, all three rules have been applied worker-side regardless of which point rejects a bad one. On this hub, the browser-facing submission route applies the identical three-rule check (non-empty, at most 4096 bytes, no ASCII control characters) before it ever stores a code — validate whatever rules you choose to enforce the same way, before you store the code, not when the worker reads it back. That hub-side check means a well-behaved caller never sees a code fail the worker’s own re-check; a different execution source that stores a code without validating it first has no such guarantee.
See Integrate as a worker
for the complete state sequence (waiting_for_idle → awaiting_user →
verifying) and the 10-minute completion budget a worker holds itself to
once it reaches awaiting_user.
Run work through the hub: work runs
If you want more than a fixed read-only review — real work performed,
with a pull request opened or updated, and the result reported back to
you — without running a full execution-pull server,
/api/integrations/work-runs* queues a single work run against one of a
fixed set of work profiles your operator configures on the hub. Not a
general turn-creation API, and not something you implement yourself. You
still need to operate ONE thing: an HTTPS endpoint that accepts the
hub’s own result callback (see The report callback)
— the hub refuses to queue any work run at all unless that callback is
configured, so this is narrower than the review surface below, not
server-free.
What a work profile is
A work profile is a named, operator-configured target: a provider, a
root, a working directory, and a permission mode, all pre-approved ahead
of time for unattended execution — never anything the request itself
chooses. You select one by its opaque target_ref; the hub resolves
everything else. How many profiles exist, and what each one points at, is
entirely your operator’s call — ask them which target_ref values are
available (see Run your own).
Authentication
Same OIDC client-credentials shape as every other bearer credential on
this page, scoped work:run instead of review:run — and, on this hub,
sharing the identical resource/audience review:run tokens use; there is
no separate work-run audience to configure:
curl -s -X POST "$OIDC_TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$API_RESOURCE" \
-d scope=work:run
# {"access_token": "...", "expires_in": 3600}
The token’s sub claim must identify a service principal (it starts
with client-) — a human user’s token, even with the work:run scope,
is rejected the same way the review surface rejects one.
Create a work run
curl -s -X POST https://hub.example.test/api/integrations/work-runs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"request_id": "req-42",
"input_version": 1,
"process_id": "process-77",
"target_ref": "ship-it",
"title": "Add a retry to the export job",
"brief": "The nightly export silently drops on a transient network error. Add one retry with backoff, and a test for it."
}'
# 202 {"request_id": "req-42", "state": "queued", "replayed": false}
Validation:
| Field | Rule |
|---|---|
input_version | Must be 1. |
request_id | Non-blank, at most 200 bytes, unreserved characters only (letters, digits, ., _, ~, -). |
process_id | Non-blank, at most 200 bytes — your own opaque reference for this work item; the hub never interprets it. |
target_ref | Non-blank, at most 500 bytes, and must name a work profile your operator actually configured. |
title | Non-blank, at most 500 bytes. |
brief | Non-blank, at most 65,536 bytes — the instructions the run actually receives. |
branch | Optional. The branch the run commits to: at most 200 bytes of ASCII letters, digits, ., _, - and /, a valid git branch name (no .., //, leading -, component starting with . or ending in .lock, trailing / or ., never HEAD), starting with one of the work profile’s branch prefixes (aya-task/ unless your operator configured others). Absent: the hub derives work/<hash of process_id>, as it always has. |
base_ref | Optional. What the branch starts from; same name rules as branch, without the prefix rule. Absent: the work profile’s own default base, else the repository’s default branch. |
An unrecognized field anywhere in the body is rejected outright, and a
body over 128 KB is rejected before it’s even parsed. branch and
base_ref are additive: input_version stays 1, and a request without
them hashes (for idempotency) and runs exactly as it did before they
existed. When given, they are part of the input, so resubmitting a
request_id with a different branch is a conflict.
request_id idempotency: replay versus conflict
Resubmitting the SAME request_id with the identical input is always a
202 idempotent replay — safe after a lost response, and distinguishable
by replayed:
# identical body, resent
# 202 {"request_id": "req-42", "state": "queued", "replayed": true}
Resubmitting the SAME request_id with ANY different field is a 409
instead of a silent overwrite:
curl -s -X POST https://hub.example.test/api/integrations/work-runs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"request_id": "req-42", "input_version": 1, "process_id": "process-77",
"target_ref": "ship-it", "title": "A different title", "brief": "..."}'
# 409 {"error": "work request conflicts with prior input"}
Polling the result
curl -s https://hub.example.test/api/integrations/work-runs/req-42 \
-H "Authorization: Bearer $TOKEN"
# {"request_id": "req-42", "state": "queued", "report_state": "pending"}
state moves queued → running → completed or failed.
report_state is present on EVERY response, in every state — it tracks
the SEPARATE result callback below, not the run itself. The
result/failure fields are STATE-DEPENDENT, never both and never
neither for a given state: queued/running carry neither field at
all; completed carries result and never failure; failed carries
failure and never result.
# once a worker has claimed it
# {"request_id": "req-42", "state": "running", "report_state": "pending"}
# once it finishes and its output parses as a valid result
# {"request_id": "req-42", "state": "completed", "report_state": "delivered",
# "result": {"status": "completed", "summary": "Added a retry with backoff and a test.",
# "pr": {"url": "https://git.example.test/org/repo/pull/9", "number": 9,
# "head_sha": "0123456789abcdef0123456789abcdef01234567", "state": "open"}}}
# if the run itself failed outright
# {"request_id": "req-42", "state": "failed", "report_state": "dead",
# "failure": {"code": "execution_failed", "message": "work execution failed", "retryable": true}}
# if the run finished, but its output didn't parse as a valid result (see below)
# {"request_id": "req-42", "state": "failed", "report_state": "dead",
# "failure": {"code": "invalid_result", "message": "work execution produced an invalid result", "retryable": true}}
failure is always exactly one of those two code/message pairs —
retryable is always true today; there’s no failure mode this route
currently reports as non-retryable.
What counts as a valid result. A work run’s own instructions require
it to open or update a pull request and reply with exactly one JSON
object, and nothing else in its final output — anything short of every
rule below reports state: "failed", failure.code: "invalid_result",
even though the run itself technically finished without error:
- The model’s total collected output text is at most 128 KiB. Longer output is treated as invalid before it’s even parsed.
- The JSON object has exactly these fields, no others:
{"status", "summary", "pr": {"url", "number", "head_sha", "state"}}. statusmust be exactly"completed".summarynon-blank, at most 65,536 bytes.pr.urlanhttps://URL, non-blank, at most 2,000 bytes, with a host, and no embedded credentials, query string, or fragment.pr.numbera positive integer.pr.head_shaa full 40-character SHA-1 or 64-character SHA-256 hexadecimal object id; abbreviated ids are rejected.pr.statenon-blank, at most 100 bytes.
The report callback
Work profiles are only half of enabling work runs — the hub also needs
somewhere to deliver each terminal result on its OWN initiative, so you
never have to poll forever. That’s a second, independent piece your
operator configures — see Run your own.
Once a run reaches a terminal state, the hub itself, acting as its own
OAuth2 client-credentials client (scope work:report, against the
configured report resource), POSTs the result, with a bearer token it
obtained for itself, to a fixed path under your configured
WORK_REPORT_URL — never the base URL alone:
<WORK_REPORT_URL>/api/dispatches/<request_id>/result, with request_id
percent-encoded as one path segment (so a / or ? inside a legacy
opaque id, for instance, can never be mistaken for part of the path):
{"process_id": "process-77", "result_id": "aya-<input-hash>",
"status": "completed", "summary": "Added a retry with backoff and a test.",
"pr": {"url": "https://git.example.test/org/repo/pull/9", "number": 9,
"head_sha": "0123456789abcdef0123456789abcdef01234567", "state": "open"}}
or, for a failed run:
{"process_id": "process-77", "result_id": "aya-<input-hash>",
"status": "failed", "summary": "", "error": "work execution failed"}
result_id is stable across every delivery attempt for the same
request_id — use it, not process_id, as your own idempotency key,
since the hub retries a lost or rejected delivery. Answer 2xx to accept
it. A 409 tells the hub to stop retrying permanently (recorded on this
end as report_state: "dead"); 401/403 make the hub refresh its own
token once and retry with that; 404/408/429/a 5xx retry on a
growing backoff that starts at exactly 10s and doubles each attempt
(10s, 20s, 40s, 80s, …), capped at one hour; any other status is treated
as a permanent rejection, the same as 409. None of this callback
traffic requires YOU to authenticate to the hub — it’s the hub
authenticating to you, the opposite direction from every other exchange
on this page.
Error codes specific to work runs
| Status | Route | Meaning |
|---|---|---|
202 | POST | Queued, or an idempotent replay of an identical prior request. |
400 | POST | Malformed request, a field fails validation, or target_ref names no configured work profile. |
401 | Both | Missing or invalid bearer credential. |
403 | Both | Missing work:run scope, or the subject isn’t a service principal. |
404 | GET | No work run was ever queued under this subject/request_id. |
404 | Both | Work runs aren’t configured on this hub at all — checked before authentication even runs (see below). |
409 | POST | Same request_id, different input. |
413 | POST | Body over 128 KB. |
500 | Both | Internal error — {"error": "work request could not be queued"} on POST, {"error": "work request could not be read"} on GET. |
503 | POST | Work runs are configured, but no worker on this hub could ever execute the resulting run. |
curl -s https://hub.example.test/api/integrations/work-runs/req-unknown \
-H "Authorization: Bearer $TOKEN"
# 404 {"error": "work request not found"}
If work runs aren’t configured on this hub at all, both routes answer
404 unconditionally, even with no token supplied — the same
“unconfigured means gone, not unauthorized” shape the review surface
below uses.
The hub’s own review surface
If you don’t want to run a server at all, Aya Hub itself offers one narrow,
already-running execution source: /api/integrations/* queues a single
fixed read-only idea review against a profile your operator configures
on the hub — not a general turn-creation API, and not something you
implement yourself.
curl -s -X POST https://hub.example.test/api/integrations/turns \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"request_id": "req-42",
"purpose": "idea_review_v1",
"input_version": 1,
"context": {
"idea": {"id": "idea-1", "title": "Ship dark mode",
"description": "Add a dark theme to the settings page."},
"messages": [],
"omitted_message_count": 0,
"projects": [{"id": "project-1", "name": "Web app", "description": "The main product."}],
"linked_project_ids": ["project-1"]
}
}'
# 202 {"request_id": "req-42", "state": "queued", "replayed": false}
curl -s https://hub.example.test/api/integrations/turns/req-42 \
-H "Authorization: Bearer $TOKEN"
# {"request_id": "req-42", "state": "ready", "result": {"summary": "...", ...}}
Authenticated with an OIDC client-credentials token scoped review:run,
from a subject that is itself a service principal (sub starting with
client-) — see Authentication for
the credential and Reference for its own
404-vs-503 unavailability split. Reach for this only when the fixed review
shape is actually what you need; anything else — an open-ended chat, a
tier/provider choice per request, your own turn text — means being an
execution source yourself, as this page describes.
Integrate as a worker
A worker polls Aya Hub for work, executes it against a real agent CLI or
service on its own machine, and reports back. It owns nothing durable about
the work itself — only how it runs, and on which local machine. Everything
below is bearer-token or OIDC gated under /api/agent/* — see
Authentication for how to get a credential and what each scope
unlocks.
AYA ships a reference worker daemon that implements every sequence on this page end to end; where this guide is silent on an edge case, that daemon’s behavior is the specification.
Heartbeat
Report presence on a fixed interval so the hub (and anyone watching the worker registry) knows you’re alive:
curl -s -X PUT https://hub.example.test/api/agent/workers/self/heartbeat \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"instance_id": "worker-1",
"version": "1.0.0",
"profiles": [],
"capabilities": [],
"slots": {"total": 4, "busy": 0},
"health": "ok",
"provider_sessions": []
}'
# 204 No Content
Send this every 15 seconds (the reference client’s cadence); a worker is
reported “present” to anyone watching the registry while its last heartbeat
is within 3x that interval (45 seconds) — a read-time computation, not a
stored fact. instance_id is yours to generate (a restart should mint a
fresh one — see “Reclaim rules” below); a later heartbeat for the same
instance_id from a different credential is rejected (409), and a
credential can only own one present instance at a time. This route itself
never requires an already-present self — a heartbeat is what ESTABLISHES
or refreshes that binding, so it’s how you recover once presence has
lapsed; it’s every workers/self/commands/* route (claim, events,
heartbeat-renew, code, complete — the provider-auth-control group below,
a different heartbeat from this one) that requires a currently-present
self and answers 409 once it isn’t. Requires the execution:heartbeat
scope under OIDC — it cannot claim or drive anything, by design.
Claim a turn
curl -s "https://hub.example.test/api/agent/turns/next?providers=claude,hermes&roots=root-a,root-b" \
-H "Authorization: Bearer $TOKEN"
# 200 {"turn_id": "42", "provider": "claude", "native_id": "sess-1",
# "text": "...", "model": null, "permission_mode": null,
# "fork_of_native_id": null, "working_directory": null, "effort": null,
# "root_id": "root-a", "claim_token": "fence-abc", "claim_generation": 1}
# (204 with an empty body means nothing pending right now. `tier` and
# `candidates` are omitted entirely here — see "Tiered claims" below for
# when a claim actually carries them.)
The protocol defines four optional filters: providers, roots (both
comma-separated), profile_id, purpose (both opaque). On the hub
specifically, only providers and roots do anything — the hub’s own
queue has no concept of worker-routed or purpose-scoped claiming, so it
accepts profile_id/purpose on the wire but never filters on them;
they exist for OTHER execution sources that implement that routing
themselves (see Integrate as a dispatcher).
For providers/roots: an absent providers means “no opinion on
that axis”; roots absent means “I don’t do root-based routing at all,”
narrower than “every root is fine.” A present but empty value on
either means “serves nothing” — it is never silently reinterpreted as
“everything,” so a typo that empties your filter fails closed rather than
claiming work you can’t actually run. Requires agent:claim.
Tiered claims
A non-null tier is what marks a claim as tiered — not provider: null
by itself (a worker-routed claim also has that, for an unrelated reason).
A tiered claim comes with provider: null (you select provider and
model) or with provider already pinned (you select a model within that
provider, when no model is already explicit). Either way it also carries
candidates (an ordered [{provider, model?}, ...] list, narrowed to
just the pinned provider’s own candidates when one is pinned):
# 200 {"turn_id": "43", "provider": null, "native_id": null, "text": "...",
# "model": null, "claim_token": "fence-def", "claim_generation": 1,
# "tier": "standard", "candidates": [{"provider": "claude", "model": "sonnet"}, {"provider": "hermes"}]}
You can claim such a row as long as your providers filter intersects at
least one candidate, even if it doesn’t cover every one. Once claimed,
you — not the hub — pick the actual (provider, model) to run against
your own currently-advertised providers and usage-window state, before
driving anything.
Report events
Append events to a claimed turn’s stream. Every write from here on must
carry the exact claim_token you were handed:
curl -s -X POST https://hub.example.test/api/agent/turns/42/events \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "fence-abc",
"batch_id": "11111111-1111-1111-1111-111111111111",
"events": [{"type": "text", "delta": "Hello"}]}'
# 200 {"ok": true}
batch_id is a stable, opaque id for one logical append — mint one UUID per
logical call, reuse it only if you retry that exact same call (the hub
dedupes on (turn_id, claim_generation, batch_id) rather than double
appending), and never reuse it across two different batches. TurnEvent is
a tagged union on "type": started, text{delta}, thinking{delta},
tool_call{call_id,tool_name,input},
tool_result{call_id,output,is_error}, notice{text}, failed{message},
completed, rate_limit{rate_limit_type,status,resets_at_ms,overage_status,is_using_overage},
turn_result{cost_usd,model}. Requires agent:report.
Keepalives are an events call with an empty events array — same
batch_id rule, and their only purpose is bumping the turn’s last-activity
timestamp so a long-running, legitimately-silent turn (a long tool call)
never crosses the staleness window (see “Reclaim rules”) and gets pulled out
from under you. Send one on a cadence comfortably under that window — the
reference client defaults to 60 seconds.
Rate limits. A harness that exposes its own usage-window signal (Claude
Code’s own status stream, for one) surfaces it as an ordinary rate_limit
event — there’s no separate route or special handling for it, just relay it
like any other event. Upstream tier routing uses this to decide when all of
a tiered turn’s candidates are exhausted and, if it knows exactly when a
window resets, to park (see below) instead of failing outright.
Complete a turn
curl -s -X POST https://hub.example.test/api/agent/turns/42/done \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "fence-abc", "ok": true, "error": null,
"created_native_id": "sess-1", "created_root_id": "root-a",
"requeue": false, "park_until_ms": null,
"resolved_provider": null, "resolved_model": null}'
# 200 {"ok": true}
ok: truecompletes successfully. For a “new chat” turn (claimed withnative_id: null), setcreated_native_id/created_root_idto the session you actually created, so the hub can persist it under a real session row; an existing-session turn ignores both.ok: falsecompletes as failed;erroris the human-readable reason.resolved_provider/resolved_model— set both when you performed tier resolution: for a provider-unresolved tiered claim, and for a provider-pinned tiered claim whose model was unresolved. A claim with an explicit provider and model, or notierat all, leaves bothnull.resolved_provideris checked against the turn’s storedcandidatesonly when the turn’s own provider wasnull— a name outside that set is409; when the provider was already pinned, it’s accepted without being checked.resolved_modelis never validated againstcandidates, for either shape.
The first done to land wins. An exact retry — same claim_token, same
claiming credential — against an already-done turn is a no-op 200, so a
worker whose response got lost on the wire can safely resend the identical
call. Any other later call against an already-done turn (a stale or
superseded token, or a different credential) is 409. Requires
agent:report.
Park and requeue
requeue: true is a distinct third outcome from ok, meaning “this
session is busy, try again shortly” rather than success or failure — it
does not complete the turn (unless the attempt cap is exhausted, in which
case it does, as a failure). Add park_until_ms (a future epoch-millisecond
timestamp) alongside requeue: true when you know exactly when the reason
will resolve — today, when every candidate of a tiered claim is exhausted
with a known vendor reset time. The hub then holds the turn pending until
max(now + ordinary backoff, park_until_ms), and — critically — does not
count a parked cycle against the attempt cap, so a turn can park across many
reset cycles without ever failing outright the way an ordinary requeue loop
eventually would. Leave park_until_ms: null (the default) for every
ordinary requeue.
The hub does not validate that park_until_ms is actually future-dated.
Any non-null value — past, present, or future — takes the park branch and
suppresses attempt accounting the same way; a past or near-future value
just also fails to extend the backoff beyond what the ordinary short
window already gives, since the hub takes the max of the two. Sending a
timestamp that isn’t meaningfully in the future costs you nothing and buys
you nothing beyond the ordinary requeue backoff, but it still buys you the
uncapped-attempts treatment — send a genuinely future timestamp whenever
that distinction (capped vs. uncapped) actually matters to you.
Lease renewal and reclaim rules
A claimed turn becomes reclaimable once it has gone 5 minutes with no
activity at all — the original claim counts as activity, and so does every
events call including a bare keepalive. Crossing that window doesn’t
revoke anything by itself; the token is only replaced once another
GET /api/agent/turns/next call actually reclaims the row. A slow-but-alive
worker that finishes before anyone reclaims it completes normally, however
late it runs.
A reclaim fences the superseded worker completely. From the instant a
reclaim commits, every write under the old claim_token is rejected with
409, never partially applied — the hub cannot tell whether your old
process is still running, so it never lets a late done from the
superseded claim win over a fresher one. This means the same prompt can
genuinely execute twice in the real world; the lease guarantees one
coherent recorded outcome, never that duplicate execution is impossible.
Avoiding a spurious reclaim is entirely on you: keepalives and a sane poll
cadence.
A restarted worker should mint a fresh instance_id for the heartbeat/registry
surface below — a command addressed to the old instance is never picked up
by a new one, by design.
A worked 409: what a reclaim actually looks like from where you sit
- You claim turn 42 —
claim_token: "fence-a",claim_generation: 1— and start driving it. - Something on your end goes quiet for over 5 minutes: no
eventscall, not even a keepalive (a crash, a lost network link, a deploy that killed the process mid-turn). - A second worker (or a second instance of you, after a restart) polls
GET /api/agent/turns/nextand claims the same row — it’s been silent long enough to reclaim. The source mints a freshclaim_token: "fence-b"and bumpsclaim_generationto2. Yourfence-ais now dead; you have no way to know this happened yet. - Unaware, your original process finishes the work and calls
POST /api/agent/turns/42/donewithclaim_token: "fence-a". - That call comes back
409— not200. The source rejects it becausefence-ais no longer the current claim;fence-bis.
What to do with that 409: drop the work. Don’t retry the same call —
retrying done with fence-a will 409 forever, since that token is
permanently superseded, not temporarily busy. Don’t try to re-claim turn 42
by its id, either — there is no “claim this specific turn” operation, only
“claim whatever’s next,” and turn 42 already belongs to whoever holds
fence-b now. Log it, discard whatever output you were about to report,
and move on to your next poll. This is exactly why the lease exists: two
workers may genuinely have run the same prompt for a few minutes, but only
one of their outcomes — the reclaiming worker’s — ever gets recorded.
Jobs, index sync, and capabilities
Three smaller route groups round out a worker’s /api/agent/* surface,
independent of the turn lease above.
Generic jobs (the Jobs group in Reference)
are a second, simpler work queue for
one-shot tasks that aren’t a chat turn at all — today git_diff and
terminal_exec. The claim/complete shape mirrors turns, fenced the same
way by a claim_token a worker must present on every write, but without
turns’ claim_generation counter — one flat claim per job, no
reclaim-generation tracking. There is no keepalive route for jobs at all
(unlike the turn lease’s events call, which doubles as one) — a job is
either claimed and completed quickly, or it goes silent and waits out the
SAME reclaim window as a turn (5 minutes,
Timings and budgets below), after which a fresh
GET .../next call can reclaim it with a brand-new claim_token, exactly
like a stale turn claim:
curl -s "https://hub.example.test/api/agent/jobs/next?roots=root-a,root-b" \
-H "Authorization: Bearer $TOKEN"
# 200 {"job_id": "7", "kind": "git_diff", "params": {...}, "claim_token": "job-fence-1"}
# (204 means nothing pending — roots absent or empty both match nothing.)
curl -s -X POST https://hub.example.test/api/agent/jobs/7/result \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "job-fence-1", "ok": true, "result": {"diff": "..."}}'
Claiming needs agent:claim; reporting a result needs agent:report — the
same two scopes turns use, just on a different queue. 404 if the job
doesn’t exist, 409 if claim_token doesn’t match the current claim OR
the caller isn’t the same principal that holds it. Completion is
idempotent for an exact retry: a result post from the SAME claim_token
and principal against an already-done job succeeds as a silent no-op
(safe to resend if a response was lost) rather than a fencing error —
only a genuinely different token or principal against a done job is
409.
Index sync (the Index group) is how a worker with its own local session index publishes it upward, so the hub’s copy stays a faithful mirror without re-uploading everything on every cycle:
# 1. Ask what the hub already has for one root, keyed by native_id.
curl -s "https://hub.example.test/api/agent/index/fingerprints?root=root-1" \
-H "Authorization: Bearer $TOKEN"
# 200 {"native-1": [123, 4], "native-2": [null, 0]} # [fingerprint, message_count]
# 2. Publish only the sessions whose fingerprint actually changed.
curl -s -X POST https://hub.example.test/api/agent/index/sessions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"root": {"id": "root-1", "provider": "claude", "label": "Home"}, "sessions": [...]}'
# 200 {"applied": 3, "skipped_unchanged": 1}
Both routes need agent:report. The reference worker diffs its own
fingerprints against GET .../fingerprints before ever calling
POST .../sessions, so a healthy sync publishes only what actually
changed.
Capabilities (the Capabilities group) reports what one root’s harness can currently do — the write side of the same snapshot Integrate as a UI reads:
curl -s -X POST https://hub.example.test/api/agent/capabilities \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"root_id": "root-1", "capabilities": {"provider": "claude", "model": "sonnet",
"tools": [], "mcp_servers": [], "skills": [], "commands": [], "agents": []}}'
# 200 {"ok": true}
Needs agent:report. A later report for the same root_id fully replaces
the prior snapshot — there’s no partial update.
All five routes above answer 503 under the same condition as everything
else on this page (no agent authentication configured at all), and a
malformed body on any of the POST routes is 400/415/422 with a
plain-text body rather than this API’s usual {"error": "..."} JSON
shape (see Reference).
Provider auth control loop
Separate from the turn lease, this drives a worker’s own interactive
provider login/logout on request from whatever enqueued the command — the
hub’s own browser-facing worker registry, or your own execution source’s
equivalent (see Integrate as a dispatcher
for the other side). It is fenced the same way the turn lease is — a
claim_token plus a
claim_generation that both advance on every claim or reclaim — except
staleness here is a plain 30-second absolute deadline
(lease_expires_at, returned on claim) rather than a rolling activity
window, so you must renew it actively rather than just staying busy.
Once the lease expires on a command you haven’t yet completed, every
further write — events, heartbeat, the code read, and even your
first complete call — is rejected 409, the same as any other fence
failure. The one exception is complete itself once it has actually
succeeded: an exact retry (same claim_token/claim_generation) of a
complete call that already recorded the command as done stays a 200
no-op no matter how much time has passed since, because that idempotency
check runs before the lease is even consulted — safe to resend if you’re
ever unsure whether your last complete response actually made it back
to you.
# 1. Claim one pending command for this worker.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/claim \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
# 200 {"operation_id": "op-1", "provider": "claude",
# "provider_instance_id": "default", "action": "login",
# "claim_token": "cmd-fence-1", "claim_generation": 1,
# "lease_expires_at": "2026-09-03T12:00:30Z"}
# (204 means nothing pending — poll again shortly.)
# 2. Report progress as you drive the login — e.g. once you have a
# verification URL and user code to show the person.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/events \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1,
"event": {"state": "awaiting_user",
"verification_url": "https://provider.example.test/activate",
"expires_at": "2026-09-03T12:10:00Z"}}'
# 3. Renew the lease every 5 seconds while you wait — it does not renew
# itself just because you're active elsewhere.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/heartbeat \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1}'
# 4. Poll for the one-time code a person submits through the browser side.
# Note: fencing rides HEADERS here, not the body — the only place in
# this whole protocol that's true. On THIS hub, whatever code comes
# back is guaranteed non-empty, at most 4096 bytes, and free of ASCII
# control characters — the browser-facing submit route enforces that
# before ever storing it — but that's a hub-specific guarantee, not
# part of the shared contract; a different execution source may not
# validate before storing, which is exactly why the worker itself
# still re-checks the same rules before using a code (see Integrate as
# a dispatcher's own note on this).
curl -s https://hub.example.test/api/agent/workers/self/commands/op-1/code \
-H "Authorization: Bearer $TOKEN" \
-H "X-AYA-Claim-Token: cmd-fence-1" -H "X-AYA-Claim-Generation: 1"
# 204 until a code has been submitted; 200 {"code": "123-456"} exactly once
# after that; 204 on every read after that (never re-served).
# 5. Report the terminal outcome.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/complete \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1, "ok": true}'
state progresses through waiting_for_idle → awaiting_user →
verifying. The 10-minute budget starts once you reach awaiting_user
— the instant you’ve successfully started the login attempt and posted that
event with a verification URL — not from the initial claim; nothing bounds
how long waiting_for_idle itself may take. That budget covers only
waiting for the one-time code, though: the moment a code actually arrives,
the reference client leaves that outer deadline behind and moves onto its
own separate, shorter budget for the rest of the flow — up to 30 seconds
waiting for the login process itself to report completion, then up to
another 15 seconds verifying the result — so submitting a code late but
still inside the 10-minute window doesn’t cut the completion/verification
step short by whatever time is left. The two failure modes report
different error_codes, not the same one: timing out (or shutting down)
while still WAITING FOR THE CODE completes the command as
{"ok": false, "error_code": "cancelled"}; failing (or shutting down)
during the completion/verification step after a code was already
submitted instead completes it as
{"ok": false, "error_code": "verification_failed"}. Report whichever one
actually applies rather than always using cancelled. A live claim’s own
lease does not exempt you from the
registry’s own presence check above — you must keep heartbeating presence
(§Heartbeat) even while holding a command’s lease, or its fenced writes
start failing regardless of how much time is left on the lease itself.
Every route here requires the execution:control scope under OIDC — it
cannot heartbeat presence or touch the turn/job queues, the mirror
restriction to execution:heartbeat above.
sequenceDiagram
participant Worker
participant Hub
loop poll
Worker->>Hub: GET /api/agent/turns/next?providers=&roots=
Hub-->>Worker: 200 ClaimedTurn or 204
end
Worker->>Hub: POST .../events claim_token batch_id events (repeated, incl. keepalives)
Worker->>Hub: POST .../done claim_token ok created_native_id
Note over Worker,Hub: a fresh GET .../turns/next after 5min silence reclaims and fences the old claim_token
Worker-routed sources
An execution source — the hub, or a separate product implementing this same
contract — can be worker-routed: it supplies only prepared text and an
opaque profile_id affinity, and leaves every local decision (provider,
root, model, effort, permission mode) to you. When claiming against such a
source, it must return provider, model, permission_mode,
fork_of_native_id, working_directory, effort, and root_id all
null — a well-behaved worker refuses the claim outright if even one of
them is set, since a worker-routed source has no business choosing local
policy. Multiple worker-routed sources can share one base URL and differ
only by the opaque purpose filter from the claim call above — one queue,
one local policy, per purpose string.
Timings and budgets
Every interval and deadline that shows up anywhere on this site, in one place:
| What | Value | If you’re late |
|---|---|---|
| Heartbeat interval | 15s | Nothing immediate — presence is a rolling window, not a single missed beat. |
| Presence window | 45s since the last heartbeat (3x the interval) | You stop showing as “present.” Every workers/self/commands/* route starts answering 409 (no self instance to resolve) — heartbeating again (the one route that doesn’t need presence) restores it. |
| Turn keepalive interval | 60s (reference client default) | Still comfortably under the reclaim window below on its own; a few losses in a row is what actually risks a reclaim. |
| Turn reclaim silence | 5 minutes with no activity at all | The row becomes reclaimable. Nothing is revoked automatically — but once another GET .../turns/next call actually reclaims it, your claim_token is fenced out and every further write from you is 409. |
| Ordinary requeue backoff | 20s | A requeue: true (busy-session) turn isn’t claimable again until this much time has passed. |
| Requeue attempt cap | 20 attempts — operator-configurable, TURN_REQUEUE_MAX_ATTEMPTS | The first 19 ordinary requeue: true calls on the same turn return it to pending; the 20th completes it as failed instead of requeuing again — a park (§Park and requeue) never counts toward this. |
| Pending-turn claim window | 10 minutes (hub default) — operator-configurable, PENDING_TURN_TTL_MS | An unclaimed turn expires to a terminal expired state — visibly failed, not silently lost, but gone; a claim that lands first always wins the race against this timer. |
| Control-command lease | 30s absolute deadline | The lease expires immediately on its own — no reclaim needed first. Every fenced write against a still-claimed command (events, heartbeat, code read, and a first attempt to complete) is 409 from that moment, and the command becomes reclaimable, but only by the same worker instance it was addressed to. An exact retry of a complete call that already succeeded stays a 200 no-op regardless of the lease — that idempotency check runs before the lease check, not after it. |
| Control-command lease renewal | Every 5s | Miss enough renewals and the 30s deadline above simply arrives — same consequence. |
| Control-command idle poll | 1s | Just how often you check for a new command or a delivered code — no consequence beyond latency. |
| Provider login discovery | 20s | Bounds finding the login process’s own verification URL before anything is reported back at all; exceeding it fails the command as login_start_failed. |
| Provider login completion budget | 10 minutes from awaiting_user | Bounds only the wait for the one-time code. Complete the command yourself as {"ok": false, "error_code": "cancelled"} — see Provider auth control loop for why a failure AFTER a code was submitted reports verification_failed instead, on a separate, shorter budget. |
| Post-code completion wait | 30s | Counts toward verification_failed, not cancelled — the outer 10-minute budget is no longer the relevant one once a code has been submitted. |
| Post-code verification check | 15s | Same verification_failed outcome as above. |
| Command-orphan expiry (pending) | 10 minutes unseen, or no registry row at all | The browser-facing side sweeps the command as failed (error_code: "worker_gone") so it stops waiting on an instance that’s gone for good, while still tolerating an ordinary sleep/network blip under that instance’s SAME id. |
| Worker’s own poll idle/error backoff | 2s after a pool-busy probe, or after a round that finishes all-idle or a mix of idle-and-error; a claimed turn keeps polling immediately with no sleep at all; consecutive ALL-error rounds back off 5s, 10s, 20s, then 30s, capped | Just polling cadence — no correctness consequence, only how quickly a newly-pending turn gets picked up. A round with at least one idle (non-error) source keeps the plain 2s cadence and resets the error-round count; only a round where EVERY source errors advances the backoff. |
| HTTP request timeouts (reference client) | 5s connect, 30s general request, 10s for the heartbeat route specifically | A request that blows through these fails locally (transient error, retried on the caller’s own cadence) rather than hanging forever. |
| OIDC token refresh margin | Refreshes 30s before the token’s own expiry; assumes a 60s lifetime when the hub’s token response omits expires_in | The margin means a token already close to expiry never gets handed to an in-flight request; the 60s fallback only matters against a hub that omits expires_in, in which case tokens are refreshed on that tight assumed cadence rather than risking reuse past an unknown real expiry. |
| UI turn-poll interval | 500ms | Purely a responsiveness tradeoff — polling slower just means the person waits longer to see new output; nothing times out because of it. |
| UI turn-poll overall deadline | 15 minutes (reference client’s own choice) | Stop polling and let the person check back later. The turn itself keeps running server-side regardless — this deadline only governs how long ONE browser tab keeps watching. |
| UI poll retry sequence | 500ms, 1s, 2s, 4s, 5s (capped) between attempts, 6 attempts total | Only a retryable outcome (network failure or 5xx) backs off at all — a 4xx surfaces immediately, never retried. After the 6th attempt the UI gives up and throws the last error to the caller exactly as a non-retried failure would. |
The turn-lease numbers (keepalive, reclaim silence, pending-claim window) are the hub’s own chosen values, not something the wire contract mandates — see Integrate as a dispatcher if you’re implementing your own source and deciding your own.
Integrate as a UI
A UI reads session history, searches across it, and drives turns through an
open-ended chat-and-poll surface — with a person watching, so the
interesting part is the polling loop that turns a raw event stream into
something you can render live. Every route below is under /api/* and
session-cookie gated (see Authentication)
unless noted otherwise. This page covers only the READ and CHAT surface;
session preferences (the model/effort/permission-mode override routes), the
diff and terminal job routes, the worker registry’s browser-facing control
routes, and push subscriptions are also browser-app /api/* routes, but
outside this guide’s scope.
The session model
A session is one conversation with an agent, tied to a provider (which
harness ran it) and a root_id (which machine/account owns it) —
provenance rides on every session, and an unattributed session is a normal,
filterable state rather than an error.
Messages are tree-shaped, not a flat list. Each MessageNode carries its
own native_id and a parent_native_id — null marks a tree root. Claude
Code’s own “rewind” creates sibling branches rather than overwriting
history, and is_sidechain flags a subagent’s own branch — a client that
assumes linear history will render a rewound conversation wrong. A
MessageNode also carries role, kind, timestamp, text, a blocks
array (structured content), and an extra bag for provider-specific detail.
curl -s https://hub.example.test/api/sessions/42/messages -H "Cookie: $SESSION_COOKIE"
returns the full tree, in original order. For a long session, page newest-first instead:
curl -s "https://hub.example.test/api/sessions/42/messages/page?limit=50" \
-H "Cookie: $SESSION_COOKIE"
# {"messages": [...], "next_cursor": {"ts": 1699999999000, "pk": 41} }
Pass next_cursor.pk/.ts back as cursor_pk/cursor_ts to fetch the next
(older) page; next_cursor: null means you’ve reached the start of the
session.
Listing and searching sessions
# List, with optional filters: provider, root_id, account,
# unattributed_only, include_archived, project, title_contains,
# updated_after, updated_before, limit, offset.
curl -s "https://hub.example.test/api/sessions?provider=claude&limit=20" \
-H "Cookie: $SESSION_COOKIE"
# Full-text search across message content, same filters plus a required q.
curl -s "https://hub.example.test/api/search?q=migration&provider=claude" \
-H "Cookie: $SESSION_COOKIE"
# [{"session": {...}, "message_native_id": "...", "role": "assistant",
# "timestamp": 1699999999000, "snippet": "...the [match]migration[/match]..."}]
A search hit’s snippet wraps matched text in [match]...[/match] markers
for you to highlight. GET /api/sessions/{pk} fetches one session; GET /api/projects lists every distinct project directory seen, with a session
count each.
Sending a chat and polling turn events
Every route in this section is a browser-session-cookie route — the
same credential as everything else on this page, never a bearer token (see
Authentication). This is the general-purpose
chat surface: unlike the fixed, single-profile turn a dispatcher can queue
via /api/integrations/* (see Integrate as a dispatcher),
here the caller picks the provider (or a tier) on every request. The root
and working directory, though, are a NEW-SESSION-ONLY choice: only
Create below accepts root_id/working_directory at all — a
Follow-up or a Fork always inherits the session’s own root and
working directory and has no field to override either.
All three turn-creating shapes below — Create, Follow-up, and Fork —
check the same thing first, before any other validation runs: if agent
authentication isn’t configured on this hub at all, every one of them
answers 503 {"error": "agent authentication is not configured"}
unconditionally, before even checking that text is non-blank or that a
provider/tier was named. This mirrors the same unconditional
/api/agent/* gate Authentication describes
for the worker-facing surface, just reached from the browser side instead.
Sending a message is two calls: create the turn, then poll its event stream
until it’s done. Three shapes create a turn, all returning
{"turn_id": "<string>"}:
Create — start a brand-new session. Name either provider (a fixed
choice) or tier (let a worker pick from a candidate list — see below); at
least one is required.
curl -s -X POST https://hub.example.test/api/sessions/new \
-H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{
"provider": "claude",
"text": "Summarize this repositorys test coverage.",
"root_id": "root-1",
"working_directory": "/home/tester/work/repo-a",
"model": "sonnet",
"effort": "high",
"permission_mode": "acceptEdits"
}'
# {"turn_id": "42"}
Admission — every rule a Create request is checked against, in
order: an explicit provider must be a KNOWN one (a registered harness
capable of being driven — a name your own product invented isn’t) AND
CURRENTLY served — some worker must have advertised it within the last
10 minutes (exactly 600s), or you get a 400 “no connected machine currently serves
‘<provider>’ — the chat would queue forever” rather than a turn nothing
will ever claim. The one exception: on a hub that has NEVER seen a single
worker poll at all (a genuine cold start, not “nothing serves this
provider right now”), the currently-served check is skipped leniently for
a plain provider — this exception does NOT extend to tier (see “Tiers
and candidates” below, which has no such leniency). root_id, if given,
must be a root the hub knows about (see Roots below), and — only
when provider is ALSO given — must belong to that same provider (a
pure-tier create has no provider yet to check the root against, so this
match isn’t enforced). working_directory, if given, requires root_id
and must be a directory already seen under it. effort and
permission_mode, if given, must each be one of a fixed allowlist (400
naming every accepted value otherwise). Omitting provider and giving
tier instead leaves the provider unresolved — see “Tiers and candidates”
below.
Follow-up — continue an existing session. No provider field: the turn
inherits the session’s own provider, since native session continuity pins
it for the session’s lifetime.
curl -s -X POST https://hub.example.test/api/sessions/42/chat \
-H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
-d '{"text": "What changed in the last release?"}'
# {"turn_id": "43", "warning": null}
A follow-up is never refused for a root nobody is currently serving —
the turn queues either way (the pending TTL turns an unclaimed turn into
an honest terminal expired event rather than letting it hang forever).
Instead the response carries a warning: null when a worker advertised
the session’s owning root within the last 10 minutes (the ordinary case),
otherwise a short human-readable string such as "helper for this session hasn't checked in for 12m — message will queue" (or "no helper has ever reported serving this session's root — message will queue until one polls" on a hub that has never seen one). Show it next to the composer;
do not treat it as an error.
All shapes accept an optional model and effort override, on top of
whatever a session already has recorded.
Tiers and candidates — provider as a constraint set
Name tier (quick / standard / heavy) instead of provider and the
hub doesn’t pick a provider for you — it resolves an ordered list of
acceptable (provider, model) pairs (a TierCandidate array) and stores
the turn with provider: null. The claiming worker, not the hub, makes
the actual pick from that list against its own local availability — see
Integrate as a worker. The exact candidate
list, and whether admission applies at all, depends on which of the three
shapes above is doing the naming:
- Create (this section, as above) — the list is narrowed to whichever
providers a worker has recently advertised as servable (or, if a
provideris ALSO named, to that provider’s own candidates). Naming bothtierandproviderpins the provider but still leaves the model list open. Admission applies in full here: if nothing currently serves the requested tier (or tier+provider combination), enqueueing fails with400rather than creating a turn that would queue forever with no worker able to claim it — and unlike the plain-providercold-start exception above, an entirely empty presence history is NOT treated leniently for a tiered create; it correctly reports nothing available. - Follow-up —
tiernarrows the candidate list to the SESSION’s own provider (continuity already pins that), but does NOT intersect against worker presence at all — no admission check runs, and an empty result is not an error. An empty candidate list here just means “the claiming worker should fall back to its own locally configured tier table,” never a400— a follow-up to an already-open chat is never refused for lack of current advertisement, tier or not. - Fork —
tiernarrows to the SOURCE session’s own provider (the fork always keeps that provider, same asroot_id/working_directoryabove) AND does intersect against worker presence, the same strict admission Create applies: an empty result after that intersection is a400“would queue forever,” since a fork mints a genuinely new session just like Create does.
The turn poll loop
curl -s "https://hub.example.test/api/turns/43/events?since=-1" \
-H "Cookie: $SESSION_COOKIE"
# {"events": [...], "next_since": 5, "claim_generation": 1,
# "done": false, "ok": null, "error": null, "session_pk": 42, "attempts": 0}
The reference web client’s loop is exactly this, and it’s worth copying precisely — the two easy mistakes are dropping the generation check and re-polling too aggressively:
- Start
since = -1,knownGeneration = null. GET /api/turns/{id}/events?since={since}.- Check reclaim BEFORE updating anything: it’s a reclaim if
knownGenerationwas notnullAND this snapshot’sclaim_generationdiffers from it. - Set
knownGeneration = claim_generationunconditionally, every single successful snapshot — not only on a reclaim, and not skipped on the very first poll (where it simply movesknownGenerationfromnullto the real value). Do this before branching on step 3’s result, so step 6’s sleep-and-repeat always resumes with the latest generation already recorded. - If step 3 found a reclaim: the turn was claimed by a fresh worker
attempt. Discard every event rendered so far (emit a “reset” to your
own UI state), discard this snapshot’s OWN events too (they may be a
partial tail of the new generation, not its start), and set
since = -1— don’t advancesincefrom this snapshot. This does NOT skip the sleep in step 6; a reclaim round waits the same interval as an ordinary in-progress one before polling again, it does not fast-path retry. - Otherwise (no reclaim), apply
eventsin order (append to whatever you’re rendering) and setsince = next_since— note this is a durable cursor from the server, not justevents.length, precisely because a reclaim can leave earlier-generation rows hidden. Ifdone: true, stop:ok/errorare the terminal outcome, andsession_pkis populated the first time a “new chat” turn’s session actually gets created. - Whenever you didn’t just stop (a reclaim round, or an ordinary
not-yet-
doneround), sleep briefly (the reference client uses 500ms) and repeat from step 2, with a request-level retry (backing off 500ms, 1s, 2s, 4s, 5s across a handful of attempts) on a transient network error or 5xx before giving up — always resuming from the samesince, never skipping ahead. Give the whole exchange an overall deadline (15 minutes is a reasonable default) past which you stop polling and let the person check back later rather than polling forever.
A fork (POST /api/sessions/{pk}/fork, same body shape as chat) starts
a new turn the identical way. A 400 here has more than one cause, not
just one: an unsupported provider answers
"forking is not available for '<provider>' sessions" (forking is a
claude-CLI-only concept today — a Hermes session, for instance, gets
this same message); a malformed request (empty text) or an inadmissible
one (a named tier this hub has no tier table for, or nothing currently
serving the tiered candidate list — see “Tiers and candidates” above)
answers 400 too, with its own distinct message in each case.
Capabilities and roots
# What one root's harness currently reports it can do: tools, MCP servers,
# skills, slash commands, subagents, and which model it's running.
curl -s "https://hub.example.test/api/capabilities?root_id=root-1" \
-H "Cookie: $SESSION_COOKIE"
# {"provider": "claude", "model": "sonnet", "tools": [...],
# "mcp_servers": [...], "skills": [...], "commands": [...], "agents": [...],
# "observed_at_ms": 1699999999000, "source": "live_probe"}
# Every root's most recently reported snapshot at once, instead of one
# root at a time — a different route, not the same one with no root_id.
curl -s https://hub.example.test/api/capabilities/roots -H "Cookie: $SESSION_COOKIE"
# {"roots": [{"root_id": "root-1", "capabilities": {"provider": "claude", ...}}]}
Roots
curl -s https://hub.example.test/api/roots -H "Cookie: $SESSION_COOKIE"
# {"roots": [{"id": "root-1", "provider": "claude", "label": "Home", "account": "personal",
# "last_seen_ms": 1757154000000, "online": true}]}
curl -s https://hub.example.test/api/roots/root-1/projects -H "Cookie: $SESSION_COOKIE"
# {"projects": [{"project": "/home/tester/work/repo-a", "count": 12}]}
A root is a machine/account pair a worker has ingested sessions from or can
create sessions under. last_seen_ms is the wall-clock time of the last
worker poll that advertised the root as servable (null if none ever has),
and online is the hub’s own fold of that against the same 10-minute
window the follow-up warning above uses — so a picker can grey out a
root whose machine is asleep without re-deriving the threshold. Use
GET /api/roots and .../projects to populate a
“where should this new chat run” picker — these are the exact same values
root_id/working_directory on turn creation
above are validated against.
Every route on this page accepts only the session cookie — there is no
bearer-token mode. A 401 means the session expired; send the person to
/auth/login rather than trying to recover in place.
What the docs host exposes
If your deployment runs this documentation site on its own hostname (see
Run your own), that hostname serves only this site,
/healthz, and the public OpenAPI routes (/api/openapi.json, /api/docs)
— never the app shell, /api/sessions/*, or anything else on this page. A
UI always talks to the app host, not the docs host; the two are a
presentation split, not two views onto the same API.
Remote API
The remote API lets an agent that cannot join your network talk to a chat
running on one of your machines. The motivating case is a cloud agent session:
it can reach the public hub over HTTPS, but not your VPN, so it cannot reach an
aya-helper directly. Your helpers already connect out to the hub, so the hub
is the meeting point. The agent sends a message to a chat, and reads the reply
back, through /api/remote/v1.
This is a separate surface from the browser UI, the worker protocol and the
service integrations. It has its own audience, its own two scopes, and its own
limits. The authoritative wire contract is
docs/api/remote-api.md and docs/api/openapi.json
(tag remote); this page is the integrator’s walkthrough.
Examples use hub.example.test for the hub and issuer.example.test for its
identity provider.
What a caller can do
- List the machines (a machine is the group of roots one helper serves), their roots and whether each is online.
- List projects, sessions and messages, and search.
- Send a message to an existing session, or start a new chat in a root.
- Read the reply by polling a turn’s events.
What it cannot do, by design:
- Ask for more than
allow-edits. The default isread-only;full-access,bypassPermissions,auto,planand provider-native modes are refused. - Choose a model tier, or see a local file path.
- Read a turn someone else queued.
Everything it sends is stamped, so the chat’s agent can tell it is talking to an external agent and not to you.
Get a token
HUB=https://hub.example.test
AUTH=$(curl -s "$HUB/api/remote/v1/auth-info")
ISSUER=$(echo "$AUTH" | jq -r .issuer)
RESOURCE=$(echo "$AUTH" | jq -r .resource)
TOKEN_ENDPOINT=$(curl -s "$ISSUER/.well-known/openid-configuration" | jq -r .token_endpoint)
TOKEN=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$RESOURCE" \
-d scope="chat:read chat:drive" | jq -r .access_token)
Your operator registers the client in your identity provider and allows it to request this resource and these scopes. See Authentication for the scope table and the error bodies.
Send a message and read the reply
# Find a session (or machines/roots to start a new chat in).
curl -s -H "Authorization: Bearer $TOKEN" "$HUB/api/remote/v1/sessions?limit=5"
# Send. Reuse the same request_id to retry safely.
TURN=$(curl -s -X POST "$HUB/api/remote/v1/sessions/7/messages" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"request_id":"send-001","text":"Summarise the failing test."}' | jq -r .turn_id)
# Poll until done; each call holds up to 20 s waiting for something new.
SINCE=-1
while :; do
R=$(curl -s -H "Authorization: Bearer $TOKEN" \
"$HUB/api/remote/v1/turns/$TURN/events?since=$SINCE&wait_ms=20000")
echo "$R" | jq -c '.events[]'
SINCE=$(echo "$R" | jq -r .next_since)
[ "$(echo "$R" | jq -r .done)" = true ] && break
done
The repository ships a wrapper that does the token dance and the loop for you:
scripts/aya-remote.sh (machines, sessions, read, send, new,
wait), configured by AYA_HUB_URL, AYA_HUB_CLIENT_ID and
AYA_HUB_CLIENT_SECRET.
Things to know
- Retries are safe.
request_idis required on both writes. Repeating it with the same input returns the same turn (replayed: true); different input is a409. It is scoped to your client, so choose any unique string. - Turns can wait. If no helper has polled the chat’s root recently the
response carries a
warning. The turn is still queued, but a queued turn expires after ten minutes unclaimed, so check/machinesforonline. - Limits. Text is at most 64 KiB, a request body at most 256 KiB, and you
may have
REMOTE_MAX_ACTIVE_TURNS(default 8) turns waiting or running; past that you get429withRetry-After. - Re-claims. If a turn’s
claim_generationchanges between polls, a helper took it over; discard what you showed and read again fromsince=-1. - No ownership. The hub does not track who owns a session; a caller acts as the operator across everything the hub indexes. Treat a token for this resource as a credential to the operator’s chats, and grant it accordingly.
Run your own
The operator turns the surface on with REMOTE_API_RESOURCE and tunes it with
REMOTE_ALLOWED_SUBJECTS (required), REMOTE_MACHINE_ALIASES_JSON and
REMOTE_MAX_ACTIVE_TURNS; submits and reads are rate limited per subject
(429 with Retry-After). See Run your own.
Workers
A worker reservation gives a remote caller its own short-lived machine to
run chats on. You ask the hub for a worker from a pool the operator defined, a
fresh aya-helper boots and enrolls, you drive chats on it through the
Remote API, and when you release it the machine is destroyed and
every credential it held stops working.
It is off unless the operator turns it on (see
Run your own). With it off, the routes below
answer 404 {"error":"workers unavailable"}. The full reference, with every
field and status, is docs/api/workers.md.
Examples use hub.example.test for the hub and issuer.example.test for its
identity provider.
What it is
- A pool is something the operator defines: which harnesses a worker runs, who may reserve from it, how many can run at once, how long they may live, and how machines are created.
- A reservation is your claim on one worker from one pool. It has a state
(
queued,provisioning,enrolled,active,releasing,destroyed, and so on) and a time-to-live. - A worker is an
aya-helperon the machine behind it. It has its own identity on the hub, created when it enrolls and disabled when the reservation ends. - Only you can use your reservation. Its chats run on a reserved root,
res-<reservation_id>.<harness>, which no one else can address and which the browser UI never reaches.
Get a token
Reserving uses the same kind of token as the rest of the Remote API, with a new
scope. Ask for workers:reserve, plus chat:read and chat:drive to run and
read chats:
TOKEN=$(curl -s -X POST "$TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$RESOURCE" \
-d scope="workers:reserve chat:read chat:drive" | jq -r .access_token)
Your client’s sub must be on the operator’s REMOTE_ALLOWED_SUBJECTS list and
on the pool’s access list. See Authentication for the scopes.
GET /api/remote/v1/auth-info still lists only chat:read and chat:drive.
Reserve a worker
HUB=https://hub.example.test
RSV=$(curl -s -X POST "$HUB/api/remote/v1/reservations" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pool":"dev","request_id":"build-001","kind":"job"}' | jq -r .reservation_id)
kind is job or checkout. request_id makes the call safe to retry: the
same id with the same input returns the same reservation, and different input is
a 409. You may also give ttl_secs (it can only shorten the pool’s maximum),
a subset of the pool’s harnesses, labels the worker must carry, and a
source the pool’s schema accepts.
Poll GET /api/remote/v1/reservations/$RSV and read state. It moves through
queued and provisioning to enrolled, and to active once the worker has
sent its first heartbeat. 429 means a pool limit or budget was hit and carries
retry_after_secs. A failed or rejected reservation never becomes usable;
reserve again.
Chat on it
Start a chat on the reservation with reservation_id instead of root_id
(name exactly one of the two):
curl -s -X POST "$HUB/api/remote/v1/chats" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"request_id":"chat-001","provider":"claude","reservation_id":"'"$RSV"'","text":"Run the tests."}'
# 202 {"turn_id":7,"replayed":false,"warning":null}
Then poll /turns/{id}/events exactly as in Remote API. A reserved
root takes neither tier nor working_directory, and provider must be one of
the reservation’s harnesses. If the reservation belongs to someone else you get
403 not your reservation, and once it has ended you get 410 reservation ended.
You can also bring an existing session along with
POST /api/remote/v1/sessions/{pk}/move (needs workers:reserve and
chat:drive). seed is native to continue the session itself on the worker
(the worker must support cross-host resume), or summary to start a new chat
seeded from the transcript.
Release it
curl -s -X DELETE "$HUB/api/remote/v1/reservations/$RSV" \
-H "Authorization: Bearer $TOKEN"
Releasing is safe to repeat. The hub revokes the worker’s tokens in the same
step, ends its pending turns and destroys the machine. From then on the worker’s
own calls answer 401, and a new chat on the reservation answers 410.
How a worker enrolls
You do not do this part; the operator’s pool setup does. It is here so you can
tell what is happening while a reservation is provisioning.
- The pool’s backend creates or assigns a machine. A
staticpool hands out a fixed set of registered machines; awebhookpool asks the operator’s own provisioning service to create one. - The machine receives a single-use enrollment token and presents it to the hub together with an Ed25519 public key it generated in memory, and proof (an attestation) that it is the machine the pool expected. The private key never leaves the machine and no shared secret is stored on it.
- The hub checks the proof and the pool’s requirements, creates the worker
identity, binds the reserved roots to it, and moves the reservation to
enrolled. A replayed token, or one for a reservation in the wrong state, gets one constant refusal. - The worker signs short assertions with its key to obtain short-lived access
tokens from the hub’s own token endpoint, then heartbeats, which makes the
reservation
active. It keeps a wake long-poll open so it learns about your chat at once instead of waiting out its idle poll.
If the hub is unreachable for too long, or the reservation ends, the helper stops and kills every process it started. It is not restartable: the enrollment token is single-use.
Pointing a worker’s Claude at the AYA gateway
An option for the operator of a worker machine; the helper never sets these
itself. When the helper process has both of these set, every claude it
starts for a turn gets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN from them,
and nothing else changes:
| Helper variable | Becomes, in the claude child |
|---|---|
AYA_WORKER_ANTHROPIC_BASE_URL | ANTHROPIC_BASE_URL |
AYA_WORKER_ANTHROPIC_AUTH_TOKEN | ANTHROPIC_AUTH_TOKEN |
With neither set, claude runs exactly as before, on its own credentials. With
only one set (or one blank), nothing is passed and the helper logs a warning
naming the two variables. The token is never logged.
Fresh checkouts for work runs
When a work run’s profile on the hub sets checkout: "fresh", the helper that
claims it gives the run its own working copy instead of the profile’s working
directory: a new run-… folder under work_runs_dir in the helper’s config
(default <data_dir>/runs, created the first time a run needs it), holding a
clone of the profile’s repository (or a worktree of a local mirror) on the run’s
branch. When the turn ends, after its claude process tree has exited, the
helper posts a work checkout: notice on the turn with the branch, its head
commit, the commits the run made and whether anything was left uncommitted,
then removes the folder. Commits that were not pushed (clone mode) go with it;
the notice still lists them. Work runs whose profile keeps the default
checkout: "workdir" run exactly as before. Fresh checkouts are for Claude
turns only.
Security model
- Separate scopes.
workers:reserveandworkers:adminare not implied bychat:readorchat:drive, and do not imply them. - Your reservation is yours. The hub compares your identity with the
reservation’s requester on every chat route. Someone else’s reservation and one
that does not exist return the same
403. - Workers cannot be impersonated. The hub is a second OIDC issuer for worker
tokens. A hub-issued token is judged only by the hub’s own, revocation-aware
verifier. A token from the external issuer whose
substartsclient-w-is refused, because that prefix belongs to the workers the hub enrolls. - Workers only get their own work. A reserved worker claims only its reservation’s turns and never the shared pool; every other worker never sees a reserved turn.
- Short-lived, narrow tokens. Worker tokens last minutes (10 by default) and carry only the scopes the pool granted.
- A worker can only narrow its claims. Its self-description can drop capabilities and labels declared at enrollment, never add them.
- Release is final. Release revokes the worker’s tokens and disables its identity in one transaction.
- You stay read-only by default. Reserved chats follow the same permission
rules as any remote chat:
read-onlyunless you ask forallow-edits.
For the operator’s side (pools, backends, signing keys, rotation and rate limits) see Run your own.
Preview relay
The preview relay lets you open a dev server that runs on one of your machines
in a browser anywhere, without a VPN and without opening a port. Your helper
dials out to an aya-relay, the hub decides who may open what, and the relay
serves the dev server at an address of its own:
https://5173-kg724cafc4b2t3hz.preview.example.test
5173 is the port the dev server listens on, and kg724cafc4b2t3hz is a label
derived from the worker’s identity. The relay needs its own domain, the hub
mints short-lived tickets, and nothing about your machine is reachable except
the ports the helper agrees to forward.
Examples use hub.example.test, relay.example.test and
preview.example.test. The wire protocol is docs/relay/protocol.md in the
repository, and the security design is docs/relay/threat-model.md.
Use it
-
Your operator runs a relay and turns it on in the hub (Run your own: the preview relay).
-
Start the helper from a build with the
relaycargo feature and add a[relay]section to its config:[relay] url = "wss://relay.example.test" resource = "https://relay.example.test"The helper reuses its OIDC client credentials to request the
relay:connectscope on that resource. Use one OIDC client per worker: two helpers sharing a client share a label and would keep replacing each other. The helper keeps its relay instance id in its data directory (relay-instance-id), so a restarted helper is the same instance to the relay. -
Declare the dev server in the helper config with
[[relay.services]](port = 5173) on an allowed port (default3000-3999,4173,5173-5199and8000-8099), and start it. Setdiscover = trueunder[relay]to offer every listening allowed port instead. Harness sessions on that helper getAYA_PREVIEW_URL,AYA_PREVIEW_HOST,AYA_PREVIEW_DOMAINand__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTSin their environment, so a tool can print the right address. -
Open
https://hub.example.test/api/relay/open?worker=<label>&service=http:5173while signed in to the hub. The hub checks that you may open it and hands your browser to the preview, which sets its own session cookie and sends you to/. Visiting the preview address directly sends you to the same hub route. If you are not signed in, the hub asks you to sign in first and does not remember where you were going, so open the preview address again after signing in.
Most dev servers need one setting to accept the preview host; see the dev server notes below.
Share a preview
POST /api/relay/shares (signed in) creates a link to one worker’s service
that someone else can open:
curl -sS -X POST https://hub.example.test/api/relay/shares \
-H 'Content-Type: application/json' -b cookies.txt \
-d '{"worker":"kg724cafc4b2t3hz","service":"http:5173","ttl_secs":3600,"audience":"org"}'
The response carries url (https://hub.example.test/r/s/<token>), id and
expires_at (unix seconds). audience is org (the visitor signs in to the
hub first) or anyone. anyone is refused unless the operator set
RELAY_ANONYMOUS_SHARES=1. Visitors get a session of at most 12 hours, and
never longer than the share. GET /api/relay/shares lists shares (it never
shows the token again) and DELETE /api/relay/shares/{id} revokes one; the
relay applies a revocation within about 30 seconds. Both only see shares the
caller created; revoking someone else’s share answers 404. A hub operator
(see RelayPolicy::is_operator; on the single-owner hub every user is one) can
add ?all=true to the listing and can revoke any share.
Open a preview from a script
A machine caller (for example a cloud agent that has to look at the page) uses
the hub’s bearer route, with the same client-… credentials the
Remote API uses:
curl -sS -X POST https://hub.example.test/api/remote/v1/relay/tickets \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"worker":"kg724cafc4b2t3hz","service":"http:5173","cap":["open"]}'
The route is a remote API scope: the token needs relay:open on the remote
API resource, its subject must pass the remote API allowlist and must not be
deprovisioned, and each ticket costs one remote read from the same
per-principal budget as the remote API’s reads (429 with Retry-After when
it is spent). The subject must also be in RELAY_API_ALLOWED_SUBJECTS (the
route is closed until that is set). The answer is {ticket, url, expires_at}:
send the ticket to the preview host as Aya-Relay-Authorization: Bearer <ticket> on each request. A bearer ticket lives 5 minutes. The operation is in
the OpenAPI document under the relay tag.
A ticket with control on a stream: service carries a control lease that
the hub mints and returns as lease. Send that value as lease on the next
ticket request to keep the same lease. The hub refuses a lease it did not mint,
a lease minted for another subject or worker, and a revoked lease. After an
operator revokes a control lease, the hub also refuses that subject a new
control lease on that worker for one hour; the subject can still view, and
keeps control of its other workers. To keep a subject away for longer, revoke
the subject or deprovision the principal.
Reservation workers
A worker the hub enrolled for a reservation (its subject starts with
client-w-) authenticates to the relay with tokens from the hub’s own workload
issuer, and its label is derived from that issuer. Only the principal that
requested the reservation may open it, and only through the bearer route
above: a browser session gets 403, whoever is signed in. When the
reservation ends, nobody can open the worker.
An enrolled helper mints its relay token from its own workload key for the
relay’s audience and never uses the machine’s OIDC client. A broken [relay]
section disables the relay on that worker and is logged; it never stops the
worker.
Revocation
Revoking a share, session, worker, subject or control lease reaches every relay replica through the hub’s revocation feed. The hub also adds entries itself: disabling a worker client revokes that worker, and deprovisioning a principal revokes that subject. A subject the hub deprovisioned gets no new ticket on any route, and for one hour after a subject revocation the hub mints that subject nothing. When a viewer logs out of a preview, the relay reports it and the session ends on every replica.
Dev servers
The relay forwards plain HTTP and WebSocket traffic and never rewrites bodies.
The dev server sees a request for <port>-<label>.<preview-domain> and must be
willing to answer it:
- Vite:
server.allowedHosts(the helper already sets__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTSto.preview.example.test) andserver.hmr = { clientPort: 443, protocol: 'wss' }. - Next.js: list the host in
allowedDevOrigins. - Create React App and webpack-dev-server:
allowedHosts: 'all'(or the host) andclient.webSocketURLpointing at port 443 overwss. - If a server insists on
localhost, sethost_mode = "loopback"for its port under[[relay.services]]. The worker then rewritesHostandOrigintolocalhost:<port>. - Absolute
http://localhost:…URLs inside pages and bundles are not rewritten and will fail in the visitor’s browser. Keep them relative.
docs/relay/dev-servers.md has the full recipes and a troubleshooting table.
Limits worth knowing
- Only
httpservices open in a browser in v1; shares are HTTP only. - A visitor session ends after at most 12 hours. The relay checks the hub for revocations every 30 seconds, so revoking is not instant. The hub drops feed entries the relay no longer needs (sessions, leases and shares after 13 hours, workers after 2 hours).
- With a plain-
httphub URL on another machine the helper cannot fetch ticket keys from the hub, so it refusesstream:services; setticket_jwks_urlunder[relay]to anhttpskey URL to enable them. A hub on the same machine (http://127.0.0.1,http://localhost) keeps working over plainhttp. - The preview domain should be on the Public Suffix List, or dedicated to one
organization. Otherwise sibling previews share cookies. See
docs/relay/threat-model.md. - Reservation workers open only for their requester. Every other worker is open to every signed-in hub user, so enable the relay for those only on a hub with one human user.
Reference
The interactive reference and the raw spec are the source of truth — this page links to both and adds what a schema alone doesn’t carry: a quick operation index and error codes.
- Interactive reference — every operation, every schema, try-it-out against your own deployment.
- Raw OpenAPI document — the same spec as JSON.
Both links are root-relative and work identically whether you’re reading
this page from the app host’s /docs prefix or a dedicated docs host’s own
root (see Run your own) — both hosts serve /api/docs and
/api/openapi.json.
The spec covers the worker- and dispatcher-facing surface this guide walks through in prose: turns, the worker registry, and the hub’s own review and work-run queues. It does not cover the browser/UI surface (sessions, roots, search, the worker registry’s browser-facing half) — that surface is documented in Integrate as a UI instead, by design: it’s session-cookie gated, not a service contract meant for machine-to-machine consumption the way an OpenAPI document implies.
Every operation
| Group | Method | Path | Scope | Purpose |
|---|---|---|---|---|
| Capabilities | POST | / | agent:report | Reports what a root’s harness can currently do — tools, MCP servers, skills, commands, and subagents — replacing any prior report for the same root_id. |
| Index | GET | / | agent:report | Per-native_id [fingerprint, message_count] pairs for one root — what a worker diffs its own local sessions against before deciding what POST /api/agent/index/sessions needs to publish. |
| Index | POST | / | agent:report | One publish batch of sessions — the write path a worker calls after diffing its own sessions against GET /api/agent/index/fingerprints. |
| Integrations | POST | / | review:run | Queues one read-only idea-review turn, idempotently re-reporting an identical resubmission under the same request_id. |
| Integrations | GET | / | review:run | Polls the state of a previously queued idea-review turn. |
| Integrations | POST | / | work:run | Queues one service work run against a hub-configured work profile, idempotently re-reporting an identical resubmission under the same request_id. |
| Integrations | GET | / | work:run | Polls a queued or completed service work run, including the state of its own result callback to WORK_REPORT_URL. |
| Jobs | GET | / | agent:claim | Claims one pending generic job for the polling worker, or 204 when none is ready for the given roots. |
| Jobs | POST | / | agent:report | Reports the terminal outcome of a claimed job. |
| Turns | GET | / | agent:report | Reads the reservation the calling worker belongs to. |
| Turns | GET | / | agent:report | Lists turns, newest-first, optionally filtered by status. |
| Turns | GET | / | agent:claim | Claims one pending execution turn for the polling worker, or 204 when none is ready. |
| Turns | POST | / | agent:report | Reports success, failure, an ordinary capped requeue, or an uncapped parked requeue when park_until_ms is supplied. |
| Turns | POST | / | agent:report | Appends one batch of events to a claimed turn’s stream. |
| Turns | GET | / | agent:report | Every recorded state transition for one turn, oldest-first (pending → claimed → done/expired, including any reclaims/requeues in between). |
| Turns | GET | / | agent:claim | Waits for new claimable work, or for the caller’s reservation to end. |
| Workers | POST | / | execution:control | Claims one pending control command for this principal’s resolved self instance, or 204 when none is pending. |
| Workers | GET | / | execution:control | Reads a one-time code exactly once. |
| Workers | POST | / | execution:control | Reports the terminal outcome of a provider-auth-control command. |
| Workers | POST | / | execution:control | Reports progress for a claimed provider-auth-control command. |
| Workers | POST | / | execution:control | Renews a provider-auth-control command’s lease. |
| Workers | PUT | / | execution:heartbeat | Upserts this principal’s worker-registry row. |
| enrollment | POST | / | — | Enrolls a freshly provisioned worker against its reservation. |
| enrollment | POST | / | — | A static-pool machine collects the enrollment token of its assigned reservation. |
| issuer | GET | / | — | The Ed25519 public keys a provisioner verifies the hub’s signed webhooks against (Standard Webhooks v1a). |
| issuer | GET | / | — | The public keys that verify tokens this hub issued: the active key, any key published ahead of activation, and retiring keys. |
| issuer | GET | / | — | The same document as /.well-known/openid-configuration, for RFC 8414 clients. |
| issuer | GET | / | — | The workload issuer’s OpenID Connect discovery document. |
| issuer | GET | / | — | Always refused: this issuer only supports the client credentials grant. |
| issuer | POST | / | — | Exchanges client credentials for a short-lived access token for one resource. |
| provisioning | POST | / | workers:provision:{pool} | Fetches the single-use enrollment token for a reservation. |
| provisioning | POST | / | workers:provision:{pool} | Reports a provisioning status change. |
| relay | GET | / | — | The Ed25519 public keys (JWKS) that verify relay tickets. |
| relay | POST | / | relay:audit | Stores a batch of relay audit events. |
| relay | GET | / | relay:sync | The append-only revocation log after a cursor, 500 entries per page. |
| relay | POST | / | relay:sync | Revokes a relay session the relay logged out. |
| relay | POST | / | relay:open | Mints a short-lived bearer ticket for opening a worker service through the relay. |
| remote | GET | / | — | Names the issuer, token resource and scopes a client needs. |
| remote | POST | / | chat:drive | Starts a new chat on a root, idempotently per request_id. |
| remote | GET | / | chat:read | Lists machines, their roots and whether each is online, plus the known harnesses. |
| remote | GET | / | chat:read | Lists a root’s project directories with session counts. |
| remote | GET | / | chat:read | Searches message text across sessions. |
| remote | GET | / | chat:read | Lists sessions. |
| remote | GET | / | chat:read | Fetches one session. |
| remote | GET | / | chat:read | Reads a page of a session’s messages, newest first. |
| remote | POST | / | chat:drive | Queues a message as a session’s next turn, idempotently per request_id. |
| remote | GET | / | chat:read | Reads (and long-polls) a turn’s events. |
| reservations | GET | / | workers:reserve | Lists the caller’s reservations, newest first, at most 200. |
| reservations | POST | / | workers:reserve | Requests an ephemeral worker from a pool. |
| reservations | GET | / | workers:reserve | Reads one of the caller’s reservations. |
| reservations | DELETE | / | workers:reserve | Cancels or releases a reservation. |
| reservations | POST | / | chat:drive, workers:reserve | Moves a session to an active reservation. |
| workers-admin | GET | / | workers:admin | Lists pools. |
| workers-admin | GET | / | workers:admin | Reads a pool document with its version. |
| workers-admin | PUT | / | workers:admin | Creates or replaces a pool (GitOps style: the whole document, optimistic version). |
| workers-admin | DELETE | / | workers:admin | Retires a pool: no new reservations, existing ones run to their end. |
| workers-admin | POST | / | workers:admin | Ends everything a departed principal holds: reservations, workers, pending turns and tokens. |
| workers-admin | POST | / | workers:admin | Lifts a deprovisioning so the principal can reserve workers again. |
| workers-admin | GET | / | workers:admin | Lists the issuer’s clients (never their secrets). |
| workers-admin | POST | / | workers:admin | Registers a service or static-machine client of the workload issuer. |
| workers-admin | POST | / | workers:admin | Disables a client and revokes every token it holds. |
| workers-admin | POST | / | workers:admin | Replaces a service client’s secret; the old one stops working at once. |
| workers-admin | POST | / | workers:admin | Rotates the issuer’s signing key: the staged next key becomes active and a new one is staged. |
| workers-admin | GET | / | workers:admin | Every alias set, rename and remove, newest first. |
| workers-admin | PUT | / | workers:admin | Gives a bound machine an alias (a display and input name; the id never changes). |
| workers-admin | DELETE | / | workers:admin | Removes an alias. |
| workers-admin | POST | / | workers:admin | Renames an alias. |
| workers-admin | GET | / | workers:admin | Lists every machine place the hub holds a binding, alias or conflict for. |
| workers-admin | GET | / | workers:admin | Resolves a machine id, name or alias to its canonical place. |
| workers-admin | PUT | / | workers:admin | Binds a machine name to an enrolled identity (the operator override of first-report binding). |
| workers-admin | DELETE | / | workers:admin | Forgets a machine name’s binding and its conflicts; the next report binds it again. |
| workers-admin | PUT | / | workers:admin | Registers or updates a machine of a static pool. |
| workers-admin | POST | / | workers:admin | Marks a machine that needed a reset as clean and available again. |
| workers-admin | PUT | / | workers:admin | Rebinds an ordinary root to a principal (the operator override of first-come binding). |
| workers-admin | PUT | / | workers:admin | Assigns a principal to a team, which the pool limits and budgets count against. |
| workers-admin | DELETE | / | workers:admin | Removes a principal’s team assignment, and with it any pool access the team gave. |
76 operations, generated from the OpenAPI document.
Error codes
| Status | Meaning |
|---|---|
400 | A field fails validation, a request isn’t valid JSON, or a semantically invalid combination (e.g. neither provider nor tier given on turn creation). |
401 | Missing or invalid credential. See Authentication. |
403 | Valid credential, insufficient scope, or (on either integration queue) a non-service-principal subject. |
404 | The referenced turn, session, or job doesn’t exist — or, for either the review queue or the work-run queue, that neither is configured on this hub at all (checked before authentication, so this is the response with or without a token, independently for each of the two). |
409 | A fencing conflict (claim_token/claim_generation mismatch) or a reused batch_id with a different payload — or, once a turn/command is already terminal, one of two different rules depending on the write: an events/progress write against it is 409 for EVERYONE, including its own winning claimant (the stream is closed, permanently); a completion write (done/complete) against it is 409 for anyone except an exact retry from its winning claimant, which stays a 200 no-op instead. On the review queue’s or the work-run queue’s own creation route, 409 also covers the same request_id resubmitted with different input. |
413 | Request body over the route’s size cap. |
415 | Missing or non-JSON Content-Type where a route requires one. |
422 | Syntactically valid JSON that doesn’t match the expected request shape. |
500 | Internal error. |
503 | Two unrelated causes, telling apart which surface you’re on: on the worker-facing routes, agent authentication isn’t configured on this hub at all, and every one of them answers this unconditionally with no auth check performed; on the review queue’s or the work-run queue’s own creation route specifically, it means authentication already succeeded but no worker on this hub could ever execute the resulting turn/run (see Integrate as a dispatcher and Run work through the hub: work runs). |
Body shape. An application-level or authentication error is JSON,
{"error": "<message>"}. The one exception is a body-decoding failure
(bad JSON syntax, a wrong Content-Type, or a body that doesn’t match the
expected shape) on most worker-facing write routes: that class of failure
answers with a plain-text body instead of JSON, before any
application-level check runs — the review and work-run creation routes are
the write routes that stay JSON even for a malformed body. The
operation table above doesn’t carry per-status body types; the
interactive reference and the raw spec do, declaring
text/plain explicitly on exactly the status codes this affects,
operation by operation.
Versioning
The spec carries a single version string covering the whole surface as one unit — there’s no per-operation versioning. Additive changes (a new operation, a new optional field, a widened response type) ship without notice; a breaking change bumps the spec’s own version. Re-read the spec whenever you upgrade against a new one.
Run your own
Short version: one hub image, one config file for at least one worker, and this site ships inside the hub’s own build.
The hub
The hub is a single container image. It refuses to start unless every required environment variable is set — there’s no insecure default to fall back on. Names only below; your operator (or you, deploying it) supplies the values:
Required:
OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET— the identity provider browser logins go through.PUBLIC_BASE_URL— this deployment’s own externally-reachable URL.SESSION_SECRET— signs the browser session cookie.REQUIRED_GROUP— the identity-provider group a login must carry to pass.INDEX_PATH— where the session/message index database lives.
Optional:
PORT,STATIC_DIR— network and asset-serving basics.DOCS_DIR— where this site’s built output lives on disk; unset serves no docs at all.DOCS_HOST— a second hostname that serves this site at its own root, IN ADDITION TO (not instead of)/docson the app host, which keeps working exactly as before whether or not this is set (see Start here).AGENT_OIDC_RESOURCE— the resource/audience value for OIDC-authenticated agent tokens, reusing the same issuer as browser login with a distinct resource indicator (see Authentication). The old sharedAGENT_TOKENis retired; if it is still set the hub ignores it and logs a warning.ROOT_BINDINGS_JSON— optional JSON object{"<root id>": "<worker sub>"}pinning a root to the worker allowed to serve it. It overrides what the hub learned on its own (first-come and presence bindings), and each change is audited. Operator bindings are the exception: a binding made withPUT /api/remote/v1/workers/root-bindings/{root}(sourceadmin) is never rebound, unbound or promoted byROOT_BINDINGS_JSON, anullvalue included. A root the object names, even withnull, is pinned, and that PUT answers409for it; remove the key and restart to make the root rebindable there.ROOT_BINDING_FIRST_COME(defaulttrue) lets the first worker to touch an unbound root own it; set it tofalseso onlyROOT_BINDINGS_JSONbinds roots.GET /api/rootsshows each root’sowner_principal. Turns that name no root go only to a worker listed inUNROOTED_POOL_PRINCIPALSor one that owns a config binding or a binding bootstrapped from presence; a first-come or operator binding never grants them. Each key must be a valid root id: 1-128 characters fromA-Z a-z 0-9 . _ : -. The hub refuses to start on any other key and names it in the error. The object is the full set of config bindings: a root whose key is removed from it is unbound at the next restart (its row is deleted and audited asunbind_config_removedwith the previous owner), and an empty object unbinds every config binding. Anullvalue still unbinds that root whatever bound it, except an operator binding. A key that names a root already bound to the same worker (first-come or bootstrapped from presence) turns that binding into a config binding at the next restart (auditedpin_config), so removing the key later unbinds it. Only roots that are not pinned at all, whether bound first-come or bootstrapped, are outside the set and survive any edit of the object. Reservation roots (res-r, a 20-character reservation id, a dot and a harness, such asres-r<20 characters>.claude) are bound only by enrollment; a root that merely starts withres-(res-lab) is an ordinary root. The binding audit records every bind, rebind and unbind. Rejections (a worker naming a root bound to someone else, or any unbound root in strict mode) are coalesced to at most one row per worker and action per minute; the next row says how many similar rejections were suppressed and lists a few sample root ids. Rejection rows are pruned once they are older than 30 days or beyond the newest 100,000, at startup (after the listener is up) and every six hours. Bind and unbind rows are kept.RATE_LIMIT_REMOTE_SUBMIT,RATE_LIMIT_REMOTE_READ,RATE_LIMIT_AGENT_CLAIM,RATE_LIMIT_AGENT_EVENTS,RATE_LIMIT_AGENT_COMPLETE,RATE_LIMIT_AGENT_PUBLISH— per-principal token buckets written<per_minute>[/<burst>](defaults30/10,600/120,300/60,3000/600,600/120,600/120);0disables a class. The workers feature adds six more with the same syntax, listed under Reserved workers below.HUB_CLIENT_IP_SOURCE— which address a request is attributed to where a limit is keyed by caller address instead of by token subject (enrollment, static check-in, and the token endpoint when no client is named).socket(default) uses the TCP peer and ignores every header.fly-client-ipreads theFly-Client-IPheader that Fly’s edge sets; ifHUB_TRUSTED_PROXIESis also set, the header is believed only when the TCP peer is inside it.x-forwarded-fortakes the right-mostX-Forwarded-Forhop that is not a trusted proxy, and only when the TCP peer is itself a trusted proxy; it needsHUB_TRUSTED_PROXIES. Any other value stops the hub at startup. Set a header source only when a proxy you run overwrites that header, otherwise a client chooses its own bucket.deploy/fly/fly.tomlsetsfly-client-ip.HUB_TRUSTED_PROXIES— comma-separated IP addresses or CIDRs of the proxies allowed to speak for a client, for example10.0.0.0/8,fdaa::/16. A value that does not parse stops the hub at startup. Unset by default.TURNS_DB_PATH,TURN_REQUEUE_MAX_ATTEMPTS,PENDING_TURN_TTL_MS— turn queue storage and tuning.TIERS_CONFIG— path to a TOML file defining the model tiers (quick/standard/heavy) turn creation can name (see Integrate as a UI). Unset means the hub has no tier table at all — a NEW-session or fork request naming a tier is rejected outright,400"this hub has no tier table configured (TIERS_CONFIG unset)". An EXISTING session’s follow-up naming a tier is NOT rejected the same way: continuity already pins its provider, so the hub just narrows to that provider’s own candidates (an empty list on the hub’s side, since there’s no tier table to narrow from) and forwards it to whichever worker claims the turn — the worker then falls back to its OWN locally configured tier table to pick a candidate, rather than being told “no” by the hub.VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT— Web Push notifications on turn completion, if you want them.
The review profile
/api/integrations/turns* (Integrate as a dispatcher)
has its own independent set of environment variables, all optional as a
group but interdependent once you set any one of them:
API_RESOURCE— the resource/audience value forreview:runOIDC tokens, distinct fromAGENT_OIDC_RESOURCEabove. Shared with the work profiles below — there’s no separate work-run audience.REVIEW_PROFILE_PROVIDER,REVIEW_PROFILE_ROOT_ID,REVIEW_PROFILE_WORKING_DIRECTORY— the fixed provider, root, and directory every queued review runs against.REVIEW_PROFILE_MODEL,REVIEW_PROFILE_EFFORT— optional overrides.REVIEW_PROFILE_PERMISSION_MODE— optional, defaults to (and today must be)"plan".
Setting none of the REVIEW_PROFILE_* variables leaves
/api/integrations/turns* entirely unconfigured — every route under it
answers a plain 404. Setting any one of them requires API_RESOURCE
and the three non-optional REVIEW_PROFILE_* variables above, or the hub
refuses to start.
The work profiles
/api/integrations/work-runs* (Integrate as a dispatcher)
is a separate, independently configured surface layered on top of
API_RESOURCE above (the SAME resource/audience review:run tokens use
— there is no distinct work-run audience):
WORK_PROFILES_JSON— a JSON object mapping each opaquetarget_refa caller may name to one work profile:{"provider", "root_id", "working_directory", "model"?, "effort"?, "permission_mode", "branch_prefixes"?, "base_ref"?, "checkout"?, "repo"?}. Must contain at least one profile if set at all (an empty object is refused, same as leaving it unset in effect, but explicitly). Field limits, all in bytes:target_ref(the JSON key) non-blank and at most 500;providernon-blank and at most 200;root_idnon-blank and at most 500;working_directorynon-blank and at most 4096, and must be an absolute path;permission_modenon-blank and at most 100;model, when given, non-blank and at most 500.permission_modemust be one of"acceptEdits","auto", or"bypassPermissions"— the unattended subset; unlike the review profile,"plan"is not accepted here.effort, when given, must be one of"low","medium","high","xhigh","max".providermust additionally name a provider capable of being driven end to end (not every registered provider qualifies).branch_prefixes(default["aya-task/"]) lists the prefixes a caller’s ownbranchmust start with;[]means callers never choose one. Each must make a safe branch name with something after it.base_ref, when given, is the base a run starts from when the request names none; same name rules as a branch.checkoutis"workdir"(the default: runs useworking_directory, as they always have) or"fresh", which gives every run its own new copy ofrepoand requires it.repo(only with"fresh", at most 4096 bytes) is a git URL (https,ssh,gitorfile, no password) or an absolute path to a local mirror on the helper’s machine. The hub stores the resulting{repo, branch, base_ref}with the run; helpers do not act on it yet (the claim does not carry it until the helper side is wired). Setting this leaves/api/integrations/work-runs*unconfigured when unset — every route under it answers a plain404— and requiresAPI_RESOURCEplus the fullWORK_REPORT_*group below when set, or the hub refuses to start.WORK_REPORT_URL,WORK_REPORT_RESOURCE,WORK_REPORT_CLIENT_ID,WORK_REPORT_CLIENT_SECRET— all four together, or none: this is the credential and endpoint the hub uses to deliver a completed work run’s result back to you on its own initiative, a separate OAuth2 client-credentials client the HUB presents (not something a caller authenticates with) — see Run work through the hub: work runs.WORK_REPORT_URLmust be an absolute HTTPS URL (or an HTTP loopback URL, for local development only) with no embedded credentials, query, or fragment.WORK_REPORT_RESOURCEmust be an absolute HTTPS URI under the same restrictions — the resource/audience the hub requests for its own outbound token.
The remote API
/api/remote/v1/* (Remote API) lets a remote agent that
cannot join your network read sessions and drive chats on your machines
through the hub. It is off until REMOTE_API_RESOURCE is set; every path
under it answers a plain 404 until then.
REMOTE_API_RESOURCE— the resource/audience value for itschat:readandchat:drivetokens. Must differ fromAPI_RESOURCEandAGENT_OIDC_RESOURCE, or the hub refuses to start. In your identity provider, allow only the clients you mean to trust to request this resource and these scopes; that is the access control.REMOTE_ALLOWED_SUBJECTS— required, comma-separatedclient-…subjects. No other subject is admitted even with a valid token, and the hub refuses to start withREMOTE_API_RESOURCEset and this empty or missing.REMOTE_MACHINE_ALIASES_JSON— optional JSON object naming machines, keyed by the OIDC subject a machine’s worker authenticates as, for example{"client-abc":"Studio"}. Unnamed machines are listed by their subject.REMOTE_MAX_ACTIVE_TURNS— optional, default8: how many turns one caller may have waiting or running at once before the hub answers429.
A remote caller can ask a chat for read-only (the default) or
allow-edits and nothing stronger, however its token was issued.
Reserved workers
Workers lets a remote caller reserve a short-lived machine, which
enrolls with the hub and serves only that caller. It is off by default; with it
off every worker route answers 404 {"error":"workers unavailable"} and the hub
behaves as before. The reservation and admin routes sit under /api/remote/v1,
so the remote API above must be on too (REMOTE_API_RESOURCE and
REMOTE_ALLOWED_SUBJECTS), and workers authenticate on /api/agent/*, so
AGENT_OIDC_RESOURCE is needed as well. Every variable below is optional, and
a value that is set but invalid stops the hub at startup.
To turn it on:
WORKLOAD_ISSUER=on— the switch. The hub becomes a second OIDC issuer and signs worker tokens itself.PUBLIC_BASE_URL— becomes the issuer (iss) of those tokens, so it must be the URL workers and provisioners can reach. It must behttps; loopbackhttpis allowed for development.- Signing keys, one of two ways. Either set
WORKLOAD_ISSUER_KEKto the base64 of exactly 32 random bytes and the hub generates and stores its RSA signing keys sealed under it, or pointWORKLOAD_SIGNING_KEY_FILEat a PEM key (WORKLOAD_SIGNING_KEY_NEXT_FILEandWORKLOAD_SIGNING_KEY_PREVIOUS_FILEare optional and need the active file). Guard the KEK like any other secret; see KEK loss and rotation below. WORKER_POOLS_FILE— a JSON file with the pools, either an array of pool documents or{"pools":[...]}. Any error in it stops the hub at startup. The pool document (harnesses, who may reserve, limits, backend, attestation and token grants) is described indocs/api/workers.md. Admins can also create pools at runtime with aworkers:admintoken.
A minimal static pool, for one registered machine:
[{"name": "dev", "harnesses": ["claude"],
"access": {"principals": ["client-requester"]},
"backend": {"kind": "static"},
"attestation": {"kind": "static_psk"},
"grants": [{"audience": "$hub_agent",
"scopes": ["agent:claim", "agent:report", "execution:heartbeat"]}]}]
Other settings, all optional:
| Variable | Default | Meaning |
|---|---|---|
WORKLOAD_TOKEN_TTL_SECS | 600 | Lifetime of worker and service tokens, 60 to 3600. |
WORKLOAD_EXTERNAL_TOKEN_TTL_SECS | 300 | Lifetime of tokens for external audiences, 30 to 900. |
WORKLOAD_EXTERNAL_AUDIENCES | empty | Comma-separated https:// resources a pool grant may name beyond the hub’s own. |
RELAY_RESOURCE | none | The audience the $relay grant placeholder resolves to. |
WORKERS_PROVISION_RESOURCE | {issuer}/api/provision/v1 | The audience a provisioning service presents. |
WORKERS_WEBHOOK_SIGNING_KEY | none | A whsk_... key for the signed webhook to a provisioning service. Needed only for webhook pools. |
WORKERS_WEBHOOK_SIGNING_KEY_PREVIOUS | none | The previous webhook key, kept while receivers rotate. |
AGENT_ACCEPT_EXTERNAL_ISSUER | true | Set false so /api/agent/* accepts only hub-issued tokens. |
WORKLOAD_KEY_ROTATION_DAYS | 30 | Automatic rotation period for stored signing keys, 1 to 365. |
Key rotation: with stored keys the hub rotates automatically. A new key is
published in the JWKS two days before it becomes active, and the old one stays
published for seven days so tokens signed just before the switch still verify.
An admin can rotate on demand with POST /api/remote/v1/workers/issuer/keys/rotate
(it stages the next key), or with ?emergency=true to retire every older key and
activate a new one immediately. With file-backed keys the route answers 409;
replace the files instead.
Revocations reach identity-provider tokens too: with the issuer on, a token from
the external issuer on /api/agent/* is refused when its client is disabled or
its subject was revoked after the token was issued. A subject revocation is
pruned an hour after it takes effect (the hub’s own token lifetime cap), so
keep the identity provider’s access-token lifetime at one hour or less for the
clients that call the agent routes, or a long-lived token issued before a
deprovision would be accepted again once its revocation is pruned.
KEK loss and rotation
The KEK seals the stored signing keys at rest and nothing else can open them.
If WORKLOAD_ISSUER_KEK is lost or changed, the hub refuses to start with
the active signing key does not open under WORKLOAD_ISSUER_KEK (wrong KEK?).
Putting the original value back is the only way to keep the existing keys.
If it is truly gone, stop the hub, delete the rows of the
workload_signing_keys table in the database at TURNS_DB_PATH, and start it
again with a new KEK: the hub generates a fresh key set. Every token signed by
the old keys then fails verification, so workers and provisioners must mint
new ones, and any external verifier must refetch the JWKS.
To change the KEK while keeping the stored keys, re-seal them in place with
WORKLOAD_ISSUER_KEK_PREVIOUS:
- Set
WORKLOAD_ISSUER_KEK_PREVIOUSto the old KEK andWORKLOAD_ISSUER_KEKto the new one. - Restart once. Every stored key that opens only under the old KEK is sealed again under the new one.
- Remove
WORKLOAD_ISSUER_KEK_PREVIOUS.
WORKLOAD_ISSUER_KEK_PREVIOUS needs WORKLOAD_ISSUER_KEK and has the same
format. The KEK also keys the workers audit chain, so the hourly chain check
reports rows written under the old KEK as not verifying after a change; note
the head the hub logs before you rotate. Keep the KEK in your secret store,
never in the image or the repository.
Workers need an always-on hub
The issuer, the reconciler and the wake channels live in the hub process, so
a hub that is stopped, or scaled to zero when idle, stops reservations, wakes
and revocation. On Fly, with WORKLOAD_ISSUER on, set
auto_stop_machines = "off" and min_machines_running = 1; the shipped
deploy/fly/fly.toml keeps its defaults, so make that change in your own
deployment. The hub logs an error at startup when FLY_MACHINE_ID is set and
workers are on.
Rate-limit classes
Every class is written <per_minute>[/<burst>], with 0 disabling it, and
is keyed by the token subject unless the table says otherwise. The six classes
of the remote and agent surface are in the
list at the top of this page; these are the six the workers feature
adds, and the routes on each:
| Variable | Default | Routes |
|---|---|---|
RATE_LIMIT_WORKERS_RESERVE | 30/10 | POST /api/remote/v1/reservations, POST /api/remote/v1/sessions/{pk}/move |
RATE_LIMIT_WORKERS_ADMIN | 60/20 | every /api/remote/v1/pools*, /workers/* and /principals/* route |
RATE_LIMIT_TOKEN_ENDPOINT | 120/30 | POST /oauth/token, keyed by client id, or by caller address with no client |
RATE_LIMIT_ENROLL | 30/10 | POST /api/enroll/v1/enroll and /static/checkin, keyed by caller address |
RATE_LIMIT_PROVISION | 120/30 | /api/provision/v1/*, keyed by the provisioner’s subject |
RATE_LIMIT_AGENT_WAKE | 120/30 | GET /api/agent/wake |
Reading a reservation (GET /api/remote/v1/reservations, .../{id},
DELETE .../{id}) draws from RATE_LIMIT_REMOTE_READ. On the agent surface,
GET /api/agent/reservation and the worker heartbeat draw from
RATE_LIMIT_AGENT_CLAIM.
On the machine itself, the worker is aya-helper with an [enrollment] table
in its config, which is exclusive with the other root and hub-connection
settings; see docs/api/workers.md for the keys.
The preview relay
The preview relay serves dev servers that run on your machines at
https://<port>-<label>.<preview-domain>. It is a separate binary,
aya-relay, with a small set of hub settings. It is off until the hub has a
signing key; every /api/relay/* path, /r/s/* and the key document answer a
plain 404 until then.
What you need
- A preview domain that is not the hub’s registrable domain (the relay and
the hub refuse to start otherwise;
allow_same_site_previewandRELAY_ALLOW_SAME_SITE_PREVIEW=1override it and are unsafe) and not a subdomain of it, with a wildcard record (*.preview.example-preview.test) pointing at the relay. Ask for it to be added to the Public Suffix List, or dedicate one domain per organization, so sibling previews do not share cookies (docs/relay/threat-model.md). - A wildcard TLS certificate for
*.preview.example-preview.test. Either let Fly terminate it (fly certs add "*.preview.example-preview.test"prints the_acme-challengerecord to create), or let the relay get one itself withtls.mode = "acme_dns01"and a Cloudflare API token limited to DNS edit on that zone (RELAY_ACME_DNS_TOKEN). - A Valkey or Redis only if you run more than one relay replica
(
[directory] kind = "redis",RELAY_REDIS_URL). The URL must berediss://and carry a password or ACL user; the relay refuses to start otherwise. The shared ACME bundle is stored encrypted under a key derived fromRELAY_PEER_SECRET. Replicas dial only addresses insidelisten.internal_cidrs(private IPv4 and Fly 6PN by default).
In your identity provider
Create an API resource for the relay and one for the hub’s relay endpoints, and four scopes:
| Scope | Resource | Granted to |
|---|---|---|
relay:connect | the relay (https://relay.example.test) | each worker’s client |
relay:sync | the hub’s relay endpoints (https://relay.example.test/hub) | the relay’s own client |
relay:audit | the same hub endpoints resource | the relay’s own client |
relay:open | REMOTE_API_RESOURCE | scripted callers of the bearer ticket route |
Hub settings
RELAY_TICKET_SIGNING_KEY— an Ed25519 private key in PKCS#8 PEM (generate withopenssl genpkey -algorithm ed25519). Setting it turns the relay on; the three variables below are then required, or the hub refuses to start.RELAY_PREVIEW_DOMAIN— the preview domain, for examplepreview.example-preview.test.RELAY_PUBLIC_URL— the relay’s public URL, for examplehttps://relay.example.test.RELAY_HUB_RESOURCE— the resource/audience of the relay’s calls to the hub (therelay:syncandrelay:audittokens). Must differ from the other resources.RELAY_TICKET_SIGNING_KEY_PREVIOUS— optional: the key being rotated out. The key document lists both, so tickets signed with it keep verifying.RELAY_SHARE_MAX_TTL— optional, default24h(also3600,90m,2d): the longest a share link may live.RELAY_ANONYMOUS_SHARES— optional, default off: set to1to allowanyoneshares that need no hub login.RELAY_API_ALLOWED_SUBJECTS— optional, comma-separatedclient-…subjects allowed to mint tickets onPOST /api/remote/v1/relay/tickets. Unset, that route admits no one. It also needsREMOTE_API_RESOURCE.
The ticket key is the one secret that can forge a preview session; keep it out of the image and the repository like every other secret here.
Run the relay
deploy/relay/ holds a Dockerfile, a fly.toml, a commented
relay.example.toml and a compose.yaml that runs the relay with a
password-protected, TLS-only Valkey. The
relay reads its TOML from RELAY_CONFIG and its secrets from
RELAY_COOKIE_KEY, RELAY_PEER_SECRET, RELAY_HUB_CLIENT_SECRET,
RELAY_ACME_DNS_TOKEN and RELAY_REDIS_URL. On Fly, keep tls.mode = "off"
and expose the replica forwarding port only on the private network. With
several replicas, set the cookie key and peer secret to the same value on every
one.
Point workers at it with the helper’s [relay] section
(Preview relay).
The worker
aya-helper loads one TOML file and refuses to start without it — no
settings UI to fall back on. The smallest useful config points it at a
single execution source rather than a full local index. Two DIFFERENT
tokens are required here, not one: a top-level token_env/token_file
for the worker’s OWN local HTTP server (it refuses to start with no
resolvable token at all, even bound to loopback-only — this is unrelated
to talking to any execution source), and each [[execution_sources]]
entry’s own token_file/token_env for authenticating outbound to that
source:
token_file = "/run/secrets/worker-local-token"
[[execution_sources]]
id = "primary"
base_url = "https://hub.example.test"
token_file = "/run/secrets/agent-token"
data_dir is optional and omitted here on purpose: leave it unset and the
worker stores its data in a sibling data/ directory next to the config
file itself (e.g. ~/.config/aya-helper/config.toml gets
~/.config/aya-helper/data/) — set it explicitly only when you want that
data somewhere else entirely.
This is a plain source: the worker executes whatever provider, model,
and root each claimed turn’s own body specifies, exactly as
Integrate as a worker describes. Add an
[execution_sources.worker_profile] block only for a worker-routed
source — one that supplies prepared text and an opaque affinity instead of
a provider/root choice — and only once you’re ready for the worker to
enforce (and reject a claim that violates) the guarantee that such a source
never sets provider, model, root_id, or any other local-policy field
itself (see Integrate as a worker).