Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 — /docs on the app host, which keeps working exactly as before whether or not a docs host is configured. Either way, /api/openapi.json and /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.

SurfaceSchemeWho uses it
Browser UI, /api/sessions/*, /api/roots, /api/workers/* (browser half)Session cookie via OIDC loginA UI integration
/api/agent/* on the hubPer-worker OIDC client-credentialsA 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 subjectAn 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 subjectA 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:

ScopeUnlocks
agent:claimGET /api/agent/turns/next, GET /api/agent/jobs/next — claim one pending turn or job.
agent:reportPOST /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:heartbeatPUT /api/agent/workers/self/heartbeat only — report worker presence. Cannot claim or drive anything.
execution:controlEvery /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:

ScopeUnlocks
chat:readEvery GET under /api/remote/v1/ except the public auth-info — machines, projects, sessions, messages, search, and a turn’s events.
chat:drivePOST /api/remote/v1/sessions/{pk}/messages and POST /api/remote/v1/chats — send to a chat, start a chat.
workers:reserveThe reservation routes and POST /api/remote/v1/sessions/{pk}/move (which also needs chat:drive). Only when workers are enabled.
workers:adminThe 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 iss is the hub is judged only by the hub’s own, revocation-aware verifier. If it does not verify the answer is 401, and the external issuer is never asked.
  • Any other token is checked against your external identity provider, as described above. AGENT_ACCEPT_EXTERNAL_ISSUER=false turns this off, leaving only hub-issued tokens.
  • An external token whose sub starts client-w- is 401 whatever 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/* — 401 for 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"}
    

    403 when the token is otherwise valid (a non-blank subject) but lacks the scope the route requires:

    {"error": "missing required scope"}
    
  • Browser routes — 401 for no valid session:

    {"error": "authentication required"}
    

    (a browser navigation instead gets redirected to /auth/login rather than shown this JSON body).

  • /api/integrations/* (both the review queue and the work-run queue) — a NARROWER 401 than /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"}
    

    403 for either of two distinct reasons, both requiring a token that verified successfully: missing the scope that route requires (review:run or work: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 (sub doesn’t start with client-) answers a different body instead of the 401 /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, the sub doesn’t start with client-, or the sub isn’t on REMOTE_ALLOWED_SUBJECTS — one body for all three, so a caller can’t probe the allowlist. When the feature isn’t configured, every path answers 404 {"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.

MethodPathRequired?Purpose
GET/api/agent/turns/nextAlwaysWorker claims one pending turn.
POST/api/agent/turns/{id}/eventsAlwaysWorker appends events (or a keepalive).
POST/api/agent/turns/{id}/doneAlwaysWorker reports the terminal outcome.
PUT/api/agent/workers/self/heartbeatOnly if the worker is configured against you with OIDCWorker reports presence.
POST/api/agent/workers/self/commands/claimOnly for an OIDC worker that also has local provider-auth-control capabilityWorker claims one pending provider-auth-control command.
POST/api/agent/workers/self/commands/{id}/eventsSame as .../claimWorker reports control-command progress.
POST/api/agent/workers/self/commands/{id}/heartbeatSame as .../claimWorker renews the control-command lease.
GET/api/agent/workers/self/commands/{id}/codeSame as .../claimWorker reads the delivered one-time code.
POST/api/agent/workers/self/commands/{id}/completeSame as .../claimWorker 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 with grant_type=client_credentials, its configured client_id/client_secret, a resource parameter (the audience you told your users to configure), and a scope that depends on which route group it’s calling for:

    Scope requestedFor
    execution:claimThe turn lease — claim, events, and done all reuse this one credential.
    execution:heartbeatPUT /api/agent/workers/self/heartbeat only.
    execution:controlEvery /api/agent/workers/self/commands/* route.

    These come from separate token requests against the same issuer/client/resource your 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 requests execution:control. Note the asymmetry from the hub’s own worker-facing routes: it splits ordinary turn work into agent:claim (claim only) and agent:report (everything else, including read-only introspection) — a generic execution source gets one combined execution:claim covering 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.

RouteRequest bodySuccessFailure
POST .../commands/claim{}200 ClaimedControlCommand (below), or 204 if nothing pending409 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 after409 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:

FieldRule
input_versionMust be 1.
request_idNon-blank, at most 200 bytes, unreserved characters only (letters, digits, ., _, ~, -).
process_idNon-blank, at most 200 bytes — your own opaque reference for this work item; the hub never interprets it.
target_refNon-blank, at most 500 bytes, and must name a work profile your operator actually configured.
titleNon-blank, at most 500 bytes.
briefNon-blank, at most 65,536 bytes — the instructions the run actually receives.
branchOptional. 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_refOptional. 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"}}.
  • status must be exactly "completed".
  • summary non-blank, at most 65,536 bytes.
  • pr.url an https:// URL, non-blank, at most 2,000 bytes, with a host, and no embedded credentials, query string, or fragment.
  • pr.number a positive integer.
  • pr.head_sha a full 40-character SHA-1 or 64-character SHA-256 hexadecimal object id; abbreviated ids are rejected.
  • pr.state non-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

StatusRouteMeaning
202POSTQueued, or an idempotent replay of an identical prior request.
400POSTMalformed request, a field fails validation, or target_ref names no configured work profile.
401BothMissing or invalid bearer credential.
403BothMissing work:run scope, or the subject isn’t a service principal.
404GETNo work run was ever queued under this subject/request_id.
404BothWork runs aren’t configured on this hub at all — checked before authentication even runs (see below).
409POSTSame request_id, different input.
413POSTBody over 128 KB.
500BothInternal error — {"error": "work request could not be queued"} on POST, {"error": "work request could not be read"} on GET.
503POSTWork 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: true completes successfully. For a “new chat” turn (claimed with native_id: null), set created_native_id/created_root_id to the session you actually created, so the hub can persist it under a real session row; an existing-session turn ignores both.
  • ok: false completes as failed; error is 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 no tier at all, leaves both null. resolved_provider is checked against the turn’s stored candidates only when the turn’s own provider was null — a name outside that set is 409; when the provider was already pinned, it’s accepted without being checked. resolved_model is never validated against candidates, 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

  1. You claim turn 42 — claim_token: "fence-a", claim_generation: 1 — and start driving it.
  2. Something on your end goes quiet for over 5 minutes: no events call, not even a keepalive (a crash, a lost network link, a deploy that killed the process mid-turn).
  3. A second worker (or a second instance of you, after a restart) polls GET /api/agent/turns/next and claims the same row — it’s been silent long enough to reclaim. The source mints a fresh claim_token: "fence-b" and bumps claim_generation to 2. Your fence-a is now dead; you have no way to know this happened yet.
  4. Unaware, your original process finishes the work and calls POST /api/agent/turns/42/done with claim_token: "fence-a".
  5. That call comes back 409 — not 200. The source rejects it because fence-a is no longer the current claim; fence-b is.

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:

WhatValueIf you’re late
Heartbeat interval15sNothing immediate — presence is a rolling window, not a single missed beat.
Presence window45s 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 interval60s (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 silence5 minutes with no activity at allThe 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 backoff20sA requeue: true (busy-session) turn isn’t claimable again until this much time has passed.
Requeue attempt cap20 attempts — operator-configurable, TURN_REQUEUE_MAX_ATTEMPTSThe 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 window10 minutes (hub default) — operator-configurable, PENDING_TURN_TTL_MSAn 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 lease30s absolute deadlineThe 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 renewalEvery 5sMiss enough renewals and the 30s deadline above simply arrives — same consequence.
Control-command idle poll1sJust how often you check for a new command or a delivered code — no consequence beyond latency.
Provider login discovery20sBounds 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 budget10 minutes from awaiting_userBounds 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 wait30sCounts 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 check15sSame verification_failed outcome as above.
Command-orphan expiry (pending)10 minutes unseen, or no registry row at allThe 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 backoff2s 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, cappedJust 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 specificallyA request that blows through these fails locally (transient error, retried on the caller’s own cadence) rather than hanging forever.
OIDC token refresh marginRefreshes 30s before the token’s own expiry; assumes a 60s lifetime when the hub’s token response omits expires_inThe 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 interval500msPurely 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 deadline15 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 sequence500ms, 1s, 2s, 4s, 5s (capped) between attempts, 6 attempts totalOnly 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 provider is ALSO named, to that provider’s own candidates). Naming both tier and provider pins 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 with 400 rather than creating a turn that would queue forever with no worker able to claim it — and unlike the plain-provider cold-start exception above, an entirely empty presence history is NOT treated leniently for a tiered create; it correctly reports nothing available.
  • Follow-up — tier narrows 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 a 400 — a follow-up to an already-open chat is never refused for lack of current advertisement, tier or not.
  • Fork — tier narrows to the SOURCE session’s own provider (the fork always keeps that provider, same as root_id/working_directory above) AND does intersect against worker presence, the same strict admission Create applies: an empty result after that intersection is a 400 “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:

  1. Start since = -1, knownGeneration = null.
  2. GET /api/turns/{id}/events?since={since}.
  3. Check reclaim BEFORE updating anything: it’s a reclaim if knownGeneration was not null AND this snapshot’s claim_generation differs from it.
  4. Set knownGeneration = claim_generation unconditionally, every single successful snapshot — not only on a reclaim, and not skipped on the very first poll (where it simply moves knownGeneration from null to 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.
  5. 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 advance since from 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.
  6. Otherwise (no reclaim), apply events in order (append to whatever you’re rendering) and set since = next_since — note this is a durable cursor from the server, not just events.length, precisely because a reclaim can leave earlier-generation rows hidden. If done: true, stop: ok/error are the terminal outcome, and session_pk is populated the first time a “new chat” turn’s session actually gets created.
  7. Whenever you didn’t just stop (a reclaim round, or an ordinary not-yet-done round), 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 same since, 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 is read-only; full-access, bypassPermissions, auto, plan and 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_id is required on both writes. Repeating it with the same input returns the same turn (replayed: true); different input is a 409. 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 /machines for online.
  • 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 get 429 with Retry-After.
  • Re-claims. If a turn’s claim_generation changes between polls, a helper took it over; discard what you showed and read again from since=-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-helper on 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.

  1. The pool’s backend creates or assigns a machine. A static pool hands out a fixed set of registered machines; a webhook pool asks the operator’s own provisioning service to create one.
  2. 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.
  3. 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.
  4. 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 variableBecomes, in the claude child
AYA_WORKER_ANTHROPIC_BASE_URLANTHROPIC_BASE_URL
AYA_WORKER_ANTHROPIC_AUTH_TOKENANTHROPIC_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:reserve and workers:admin are not implied by chat:read or chat: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 sub starts client-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-only unless you ask for allow-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

  1. Your operator runs a relay and turns it on in the hub (Run your own: the preview relay).

  2. Start the helper from a build with the relay cargo 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:connect scope 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.

  3. Declare the dev server in the helper config with [[relay.services]] (port = 5173) on an allowed port (default 3000-3999, 4173, 5173-5199 and 8000-8099), and start it. Set discover = true under [relay] to offer every listening allowed port instead. Harness sessions on that helper get AYA_PREVIEW_URL, AYA_PREVIEW_HOST, AYA_PREVIEW_DOMAIN and __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS in their environment, so a tool can print the right address.

  4. Open https://hub.example.test/api/relay/open?worker=<label>&service=http:5173 while 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_HOSTS to .preview.example.test) and server.hmr = { clientPort: 443, protocol: 'wss' }.
  • Next.js: list the host in allowedDevOrigins.
  • Create React App and webpack-dev-server: allowedHosts: 'all' (or the host) and client.webSocketURL pointing at port 443 over wss.
  • If a server insists on localhost, set host_mode = "loopback" for its port under [[relay.services]]. The worker then rewrites Host and Origin to localhost:<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 http services 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-http hub URL on another machine the helper cannot fetch ticket keys from the hub, so it refuses stream: services; set ticket_jwks_url under [relay] to an https key URL to enable them. A hub on the same machine (http://127.0.0.1, http://localhost) keeps working over plain http.
  • 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.

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

GroupMethodPathScopePurpose
CapabilitiesPOST/api/agent/capabilitiesagent:reportReports what a root’s harness can currently do — tools, MCP servers, skills, commands, and subagents — replacing any prior report for the same root_id.
IndexGET/api/agent/index/fingerprintsagent:reportPer-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.
IndexPOST/api/agent/index/sessionsagent:reportOne publish batch of sessions — the write path a worker calls after diffing its own sessions against GET /api/agent/index/fingerprints.
IntegrationsPOST/api/integrations/turnsreview:runQueues one read-only idea-review turn, idempotently re-reporting an identical resubmission under the same request_id.
IntegrationsGET/api/integrations/turns/{request_id}review:runPolls the state of a previously queued idea-review turn.
IntegrationsPOST/api/integrations/work-runswork:runQueues one service work run against a hub-configured work profile, idempotently re-reporting an identical resubmission under the same request_id.
IntegrationsGET/api/integrations/work-runs/{request_id}work:runPolls a queued or completed service work run, including the state of its own result callback to WORK_REPORT_URL.
JobsGET/api/agent/jobs/nextagent:claimClaims one pending generic job for the polling worker, or 204 when none is ready for the given roots.
JobsPOST/api/agent/jobs/{id}/resultagent:reportReports the terminal outcome of a claimed job.
TurnsGET/api/agent/reservationagent:reportReads the reservation the calling worker belongs to.
TurnsGET/api/agent/turnsagent:reportLists turns, newest-first, optionally filtered by status.
TurnsGET/api/agent/turns/nextagent:claimClaims one pending execution turn for the polling worker, or 204 when none is ready.
TurnsPOST/api/agent/turns/{id}/doneagent:reportReports success, failure, an ordinary capped requeue, or an uncapped parked requeue when park_until_ms is supplied.
TurnsPOST/api/agent/turns/{id}/eventsagent:reportAppends one batch of events to a claimed turn’s stream.
TurnsGET/api/agent/turns/{id}/historyagent:reportEvery recorded state transition for one turn, oldest-first (pending → claimed → done/expired, including any reclaims/requeues in between).
TurnsGET/api/agent/wakeagent:claimWaits for new claimable work, or for the caller’s reservation to end.
WorkersPOST/api/agent/workers/self/commands/claimexecution:controlClaims one pending control command for this principal’s resolved self instance, or 204 when none is pending.
WorkersGET/api/agent/workers/self/commands/{id}/codeexecution:controlReads a one-time code exactly once.
WorkersPOST/api/agent/workers/self/commands/{id}/completeexecution:controlReports the terminal outcome of a provider-auth-control command.
WorkersPOST/api/agent/workers/self/commands/{id}/eventsexecution:controlReports progress for a claimed provider-auth-control command.
WorkersPOST/api/agent/workers/self/commands/{id}/heartbeatexecution:controlRenews a provider-auth-control command’s lease.
WorkersPUT/api/agent/workers/self/heartbeatexecution:heartbeatUpserts this principal’s worker-registry row.
enrollmentPOST/api/enroll/v1/enroll—Enrolls a freshly provisioned worker against its reservation.
enrollmentPOST/api/enroll/v1/static/checkin—A static-pool machine collects the enrollment token of its assigned reservation.
issuerGET/.well-known/aya-webhook-keys.json—The Ed25519 public keys a provisioner verifies the hub’s signed webhooks against (Standard Webhooks v1a).
issuerGET/.well-known/jwks.json—The public keys that verify tokens this hub issued: the active key, any key published ahead of activation, and retiring keys.
issuerGET/.well-known/oauth-authorization-server—The same document as /.well-known/openid-configuration, for RFC 8414 clients.
issuerGET/.well-known/openid-configuration—The workload issuer’s OpenID Connect discovery document.
issuerGET/oauth/authorize—Always refused: this issuer only supports the client credentials grant.
issuerPOST/oauth/token—Exchanges client credentials for a short-lived access token for one resource.
provisioningPOST/api/provision/v1/reservations/{id}/enrollment-tokenworkers:provision:{pool}Fetches the single-use enrollment token for a reservation.
provisioningPOST/api/provision/v1/reservations/{id}/statusworkers:provision:{pool}Reports a provisioning status change.
relayGET/.well-known/aya-relay-keys.json—The Ed25519 public keys (JWKS) that verify relay tickets.
relayPOST/api/relay/auditrelay:auditStores a batch of relay audit events.
relayGET/api/relay/revocationsrelay:syncThe append-only revocation log after a cursor, 500 entries per page.
relayPOST/api/relay/sessions/logoutrelay:syncRevokes a relay session the relay logged out.
relayPOST/api/remote/v1/relay/ticketsrelay:openMints a short-lived bearer ticket for opening a worker service through the relay.
remoteGET/api/remote/v1/auth-info—Names the issuer, token resource and scopes a client needs.
remotePOST/api/remote/v1/chatschat:driveStarts a new chat on a root, idempotently per request_id.
remoteGET/api/remote/v1/machineschat:readLists machines, their roots and whether each is online, plus the known harnesses.
remoteGET/api/remote/v1/roots/{root_id}/projectschat:readLists a root’s project directories with session counts.
remoteGET/api/remote/v1/searchchat:readSearches message text across sessions.
remoteGET/api/remote/v1/sessionschat:readLists sessions.
remoteGET/api/remote/v1/sessions/{pk}chat:readFetches one session.
remoteGET/api/remote/v1/sessions/{pk}/messageschat:readReads a page of a session’s messages, newest first.
remotePOST/api/remote/v1/sessions/{pk}/messageschat:driveQueues a message as a session’s next turn, idempotently per request_id.
remoteGET/api/remote/v1/turns/{id}/eventschat:readReads (and long-polls) a turn’s events.
reservationsGET/api/remote/v1/reservationsworkers:reserveLists the caller’s reservations, newest first, at most 200.
reservationsPOST/api/remote/v1/reservationsworkers:reserveRequests an ephemeral worker from a pool.
reservationsGET/api/remote/v1/reservations/{id}workers:reserveReads one of the caller’s reservations.
reservationsDELETE/api/remote/v1/reservations/{id}workers:reserveCancels or releases a reservation.
reservationsPOST/api/remote/v1/sessions/{pk}/movechat:drive, workers:reserveMoves a session to an active reservation.
workers-adminGET/api/remote/v1/poolsworkers:adminLists pools.
workers-adminGET/api/remote/v1/pools/{id}workers:adminReads a pool document with its version.
workers-adminPUT/api/remote/v1/pools/{id}workers:adminCreates or replaces a pool (GitOps style: the whole document, optimistic version).
workers-adminDELETE/api/remote/v1/pools/{id}workers:adminRetires a pool: no new reservations, existing ones run to their end.
workers-adminPOST/api/remote/v1/principals/{sub}/deprovisionworkers:adminEnds everything a departed principal holds: reservations, workers, pending turns and tokens.
workers-adminPOST/api/remote/v1/principals/{sub}/reinstateworkers:adminLifts a deprovisioning so the principal can reserve workers again.
workers-adminGET/api/remote/v1/workers/clientsworkers:adminLists the issuer’s clients (never their secrets).
workers-adminPOST/api/remote/v1/workers/clientsworkers:adminRegisters a service or static-machine client of the workload issuer.
workers-adminPOST/api/remote/v1/workers/clients/{id}/disableworkers:adminDisables a client and revokes every token it holds.
workers-adminPOST/api/remote/v1/workers/clients/{id}/rotate-secretworkers:adminReplaces a service client’s secret; the old one stops working at once.
workers-adminPOST/api/remote/v1/workers/issuer/keys/rotateworkers:adminRotates the issuer’s signing key: the staged next key becomes active and a new one is staged.
workers-adminGET/api/remote/v1/workers/place-alias-historyworkers:adminEvery alias set, rename and remove, newest first.
workers-adminPUT/api/remote/v1/workers/place-aliases/{alias}workers:adminGives a bound machine an alias (a display and input name; the id never changes).
workers-adminDELETE/api/remote/v1/workers/place-aliases/{alias}workers:adminRemoves an alias.
workers-adminPOST/api/remote/v1/workers/place-aliases/{alias}/renameworkers:adminRenames an alias.
workers-adminGET/api/remote/v1/workers/placesworkers:adminLists every machine place the hub holds a binding, alias or conflict for.
workers-adminGET/api/remote/v1/workers/places/{name}workers:adminResolves a machine id, name or alias to its canonical place.
workers-adminPUT/api/remote/v1/workers/places/{name}/bindingworkers:adminBinds a machine name to an enrolled identity (the operator override of first-report binding).
workers-adminDELETE/api/remote/v1/workers/places/{name}/bindingworkers:adminForgets a machine name’s binding and its conflicts; the next report binds it again.
workers-adminPUT/api/remote/v1/workers/pools/{id}/machines/{mid}workers:adminRegisters or updates a machine of a static pool.
workers-adminPOST/api/remote/v1/workers/pools/{id}/machines/{mid}/reset-completeworkers:adminMarks a machine that needed a reset as clean and available again.
workers-adminPUT/api/remote/v1/workers/root-bindings/{root}workers:adminRebinds an ordinary root to a principal (the operator override of first-come binding).
workers-adminPUT/api/remote/v1/workers/teams/{principal}workers:adminAssigns a principal to a team, which the pool limits and budgets count against.
workers-adminDELETE/api/remote/v1/workers/teams/{principal}workers:adminRemoves a principal’s team assignment, and with it any pool access the team gave.

76 operations, generated from the OpenAPI document.

Error codes

StatusMeaning
400A field fails validation, a request isn’t valid JSON, or a semantically invalid combination (e.g. neither provider nor tier given on turn creation).
401Missing or invalid credential. See Authentication.
403Valid credential, insufficient scope, or (on either integration queue) a non-service-principal subject.
404The 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).
409A 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.
413Request body over the route’s size cap.
415Missing or non-JSON Content-Type where a route requires one.
422Syntactically valid JSON that doesn’t match the expected request shape.
500Internal error.
503Two 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) /docs on 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 shared AGENT_TOKEN is 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 with PUT /api/remote/v1/workers/root-bindings/{root} (source admin) is never rebound, unbound or promoted by ROOT_BINDINGS_JSON, a null value included. A root the object names, even with null, is pinned, and that PUT answers 409 for it; remove the key and restart to make the root rebindable there. ROOT_BINDING_FIRST_COME (default true) lets the first worker to touch an unbound root own it; set it to false so only ROOT_BINDINGS_JSON binds roots. GET /api/roots shows each root’s owner_principal. Turns that name no root go only to a worker listed in UNROOTED_POOL_PRINCIPALS or 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 from A-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 as unbind_config_removed with the previous owner), and an empty object unbinds every config binding. A null value 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 (audited pin_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 as res-r<20 characters>.claude) are bound only by enrollment; a root that merely starts with res- (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>] (defaults 30/10, 600/120, 300/60, 3000/600, 600/120, 600/120); 0 disables 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-ip reads the Fly-Client-IP header that Fly’s edge sets; if HUB_TRUSTED_PROXIES is also set, the header is believed only when the TCP peer is inside it. x-forwarded-for takes the right-most X-Forwarded-For hop that is not a trusted proxy, and only when the TCP peer is itself a trusted proxy; it needs HUB_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.toml sets fly-client-ip.
  • HUB_TRUSTED_PROXIES — comma-separated IP addresses or CIDRs of the proxies allowed to speak for a client, for example 10.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 for review:run OIDC tokens, distinct from AGENT_OIDC_RESOURCE above. 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 opaque target_ref a 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; provider non-blank and at most 200; root_id non-blank and at most 500; working_directory non-blank and at most 4096, and must be an absolute path; permission_mode non-blank and at most 100; model, when given, non-blank and at most 500. permission_mode must 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". provider must 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 own branch must 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. checkout is "workdir" (the default: runs use working_directory, as they always have) or "fresh", which gives every run its own new copy of repo and requires it. repo (only with "fresh", at most 4096 bytes) is a git URL (https, ssh, git or file, 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 plain 404 — and requires API_RESOURCE plus the full WORK_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_URL must be an absolute HTTPS URL (or an HTTP loopback URL, for local development only) with no embedded credentials, query, or fragment. WORK_REPORT_RESOURCE must 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 its chat:read and chat:drive tokens. Must differ from API_RESOURCE and AGENT_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-separated client-… subjects. No other subject is admitted even with a valid token, and the hub refuses to start with REMOTE_API_RESOURCE set 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, default 8: how many turns one caller may have waiting or running at once before the hub answers 429.

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 be https; loopback http is allowed for development.
  • Signing keys, one of two ways. Either set WORKLOAD_ISSUER_KEK to the base64 of exactly 32 random bytes and the hub generates and stores its RSA signing keys sealed under it, or point WORKLOAD_SIGNING_KEY_FILE at a PEM key (WORKLOAD_SIGNING_KEY_NEXT_FILE and WORKLOAD_SIGNING_KEY_PREVIOUS_FILE are 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 in docs/api/workers.md. Admins can also create pools at runtime with a workers:admin token.

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:

VariableDefaultMeaning
WORKLOAD_TOKEN_TTL_SECS600Lifetime of worker and service tokens, 60 to 3600.
WORKLOAD_EXTERNAL_TOKEN_TTL_SECS300Lifetime of tokens for external audiences, 30 to 900.
WORKLOAD_EXTERNAL_AUDIENCESemptyComma-separated https:// resources a pool grant may name beyond the hub’s own.
RELAY_RESOURCEnoneThe audience the $relay grant placeholder resolves to.
WORKERS_PROVISION_RESOURCE{issuer}/api/provision/v1The audience a provisioning service presents.
WORKERS_WEBHOOK_SIGNING_KEYnoneA whsk_... key for the signed webhook to a provisioning service. Needed only for webhook pools.
WORKERS_WEBHOOK_SIGNING_KEY_PREVIOUSnoneThe previous webhook key, kept while receivers rotate.
AGENT_ACCEPT_EXTERNAL_ISSUERtrueSet false so /api/agent/* accepts only hub-issued tokens.
WORKLOAD_KEY_ROTATION_DAYS30Automatic 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:

  1. Set WORKLOAD_ISSUER_KEK_PREVIOUS to the old KEK and WORKLOAD_ISSUER_KEK to the new one.
  2. Restart once. Every stored key that opens only under the old KEK is sealed again under the new one.
  3. 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:

VariableDefaultRoutes
RATE_LIMIT_WORKERS_RESERVE30/10POST /api/remote/v1/reservations, POST /api/remote/v1/sessions/{pk}/move
RATE_LIMIT_WORKERS_ADMIN60/20every /api/remote/v1/pools*, /workers/* and /principals/* route
RATE_LIMIT_TOKEN_ENDPOINT120/30POST /oauth/token, keyed by client id, or by caller address with no client
RATE_LIMIT_ENROLL30/10POST /api/enroll/v1/enroll and /static/checkin, keyed by caller address
RATE_LIMIT_PROVISION120/30/api/provision/v1/*, keyed by the provisioner’s subject
RATE_LIMIT_AGENT_WAKE120/30GET /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_preview and RELAY_ALLOW_SAME_SITE_PREVIEW=1 override 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-challenge record to create), or let the relay get one itself with tls.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 be rediss:// and carry a password or ACL user; the relay refuses to start otherwise. The shared ACME bundle is stored encrypted under a key derived from RELAY_PEER_SECRET. Replicas dial only addresses inside listen.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:

ScopeResourceGranted to
relay:connectthe relay (https://relay.example.test)each worker’s client
relay:syncthe hub’s relay endpoints (https://relay.example.test/hub)the relay’s own client
relay:auditthe same hub endpoints resourcethe relay’s own client
relay:openREMOTE_API_RESOURCEscripted callers of the bearer ticket route

Hub settings

  • RELAY_TICKET_SIGNING_KEY — an Ed25519 private key in PKCS#8 PEM (generate with openssl 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 example preview.example-preview.test.
  • RELAY_PUBLIC_URL — the relay’s public URL, for example https://relay.example.test.
  • RELAY_HUB_RESOURCE — the resource/audience of the relay’s calls to the hub (the relay:sync and relay:audit tokens). 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, default 24h (also 3600, 90m, 2d): the longest a share link may live.
  • RELAY_ANONYMOUS_SHARES — optional, default off: set to 1 to allow anyone shares that need no hub login.
  • RELAY_API_ALLOWED_SUBJECTS — optional, comma-separated client-… subjects allowed to mint tickets on POST /api/remote/v1/relay/tickets. Unset, that route admits no one. It also needs REMOTE_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).