Integrate as a dispatcher
A dispatcher has two ways in. Serve the execution-pull contract as a source (the rest of this page): you run your own server, own its queue and scheduling, and a worker polls you directly — full control, at the cost of operating a server that speaks the whole protocol. Or submit work runs to the hub (Run work through the hub: work runs): you call two hub routes, the hub queues the run against an operator-configured profile and dispatches it to a worker already polling the hub — no execution-pull server to run — but you’re not server-free either: the hub reports each terminal result back by calling YOUR own HTTPS endpoint on its own initiative (a callback, not a poll — see The report callback), and refuses to even start work runs at all unless that callback is fully configured. You’re still limited to whatever profiles your operator configured, and the hub owns the queue. Pick the first for full control over routing, provider choice, or your own queue semantics; pick the second when a fixed shape of unattended work, handed back to you when it’s done, is worth running one narrow callback endpoint instead of the full protocol.
You are an execution source
A dispatcher is a server a worker polls — never the other way around. In
this project’s own vocabulary that server is an execution source: the
thing that owns durable work state and decides what runs, while a worker
(aya-helper, or any implementation of the same contract) owns nothing
durable and only knows how to execute against a local harness. Aya Hub
itself is simply the first execution source this project built; your
server implements the identical wire contract, at your own base URL, and
an operator points a worker at it with one [[execution_sources]] entry in
the worker’s own config (see Run your own). The
worker always initiates the connection — poll, then push results — so your
server needs to be reachable by outbound HTTP from wherever the worker
runs; nothing ever needs to dial back into the worker itself.
What to serve
Only three routes are actually required — the turn lease. Everything
else in the table below is called conditionally, on the worker’s own
initiative, independent of anything you advertise (see the next section).
These are the same paths, unchanged, aya-helper’s reference client sends
to the hub today. What’s actually shared across every execution source
is narrower than the hub’s own behavior: the request and response shapes
below (ClaimedTurn, EventAppendRequest, CompletionRequest) are a shared, versioned wire
contract every execution source and worker implement against, whose own
explicit design principle is that routing, authentication, storage, and
scheduling remain owned by each execution source. That shared contract
defines no success-response shape at all for events or done, so “answer
200” below means “answer any 2xx status”; the hub happens to reply
{"ok": true}, but that’s the hub’s own convention — the reference
worker never inspects the response body, only that the request
succeeded. The hub is one reference implementation of that contract, not
the spec for your own 401/403/503 behavior — those are the hub’s
own choices to make, and yours are entirely up to you.
Reference’s Turns and Workers groups
document the hub’s own instance of this contract in full. Two hub-only
route groups are not part of this contract and your source never
needs them: Jobs and Index are extras the primary hub connection alone
gets, never a configured [[execution_sources]] entry.
| Method | Path | Required? | Purpose |
|---|---|---|---|
GET | /api/agent/turns/next | Always | Worker claims one pending turn. |
POST | /api/agent/turns/{id}/events | Always | Worker appends events (or a keepalive). |
POST | /api/agent/turns/{id}/done | Always | Worker reports the terminal outcome. |
PUT | /api/agent/workers/self/heartbeat | Only if the worker is configured against you with OIDC | Worker reports presence. |
POST | /api/agent/workers/self/commands/claim | Only for an OIDC worker that also has local provider-auth-control capability | Worker claims one pending provider-auth-control command. |
POST | /api/agent/workers/self/commands/{id}/events | Same as .../claim | Worker reports control-command progress. |
POST | /api/agent/workers/self/commands/{id}/heartbeat | Same as .../claim | Worker renews the control-command lease. |
GET | /api/agent/workers/self/commands/{id}/code | Same as .../claim | Worker reads the delivered one-time code. |
POST | /api/agent/workers/self/commands/{id}/complete | Same as .../claim | Worker reports the control command’s terminal outcome. |
The turn lease, in full
GET /api/agent/turns/next?providers=&roots=&profile_id=&purpose= — all
four query params optional; on the hub itself only providers/roots
actually filter anything — profile_id/purpose exist for sources that
want to offer worker-routed or purpose-scoped queues (see below and
Integrate as a worker), and the hub simply
doesn’t implement that kind of routing on its own queue. Answer 204 with
no body when nothing is pending; otherwise 200 with a ClaimedTurn:
{"turn_id": "42", "provider": "claude", "native_id": null, "text": "...",
"model": null, "claim_token": "fence-abc",
"permission_mode": null, "fork_of_native_id": null,
"working_directory": null, "effort": null, "root_id": null,
"claim_generation": 1}
turn_id, text, and claim_token are required, plain, always-present
strings; claim_generation should also always be present (a real claim’s
generation counts from 1) though the worker treats a missing key there as
the legacy sentinel 0. provider, native_id, and model are the only
three fields whose KEY must always be present even when the value is
null — the worker’s deserializer rejects a dropped key on exactly these
three (an explicit null is fine, a missing key is not). Every other
field (permission_mode, fork_of_native_id, working_directory,
effort, root_id, and tier/candidates below) may simply be omitted
when you have nothing to say — the worker’s own defaults cover a missing
key for those, and an unrecognized extra key in your response is silently
ignored rather than rejected.
POST /api/agent/turns/{id}/events — body
{"claim_token", "batch_id", "events": [...]}; answer any 2xx on
success (the body isn’t inspected). batch_id idempotency is keyed on
(turn_id, claim_generation, batch_id), and “identical retry” means the
same parsed events array reserialized canonically, not byte-identical
JSON — differing whitespace or key order in a resend doesn’t matter, only
the actual event content. The same batch_id with genuinely different
event content is a conflict — answer that case distinctly from an
ordinary success (the hub uses 409) so a worker never mistakes a
silently-dropped mismatched retry for the batch it actually meant to
send. A write against an already-done turn is rejected the same way a
claim_token mismatch is — reject both with 409.
POST /api/agent/turns/{id}/done — body
{"claim_token", "ok", "error", "created_native_id", "created_root_id", "requeue", "park_until_ms", "resolved_provider", "resolved_model"}; only
claim_token and ok are required, the rest default to null/false
when omitted. Answer any 2xx on success. Only the worker holding the
current winning claim can ever record an outcome — an exact retry (same
claim_token, matching whoever you last granted the claim to) against an
already-done turn should still answer success (idempotent, so a worker
whose response got lost can safely resend), but any other caller against
an already-done turn — a stale token, or a different claimant — gets
409, the same as against a live claim it doesn’t hold; that idempotency
check only needs to compare the token and claimant identity, never the
request body. requeue: true is how a worker parks or retries instead of
completing — see “Park and requeue” below.
Heartbeat and provider-auth-control: whether the worker calls them isn’t your choice
This is decided by which credential mode your users configure their
worker with, not by whether you implement these routes: a worker
configured against you with an OIDC credential heartbeats
(PUT .../heartbeat) automatically, on its own fixed interval, for as
long as it’s configured — it never asks first, and it doesn’t matter
whether your server does anything useful with the calls. The same OIDC
worker additionally polls for provider-auth-control commands
(POST .../commands/claim) whenever it locally has a provider credential
capable of interactive login and that capability is available on its
current machine — again, independent of anything your server advertises.
A worker configured against you with a plain static bearer token sends
neither: it only ever calls the turn-lease routes above. If you don’t want
heartbeat/control traffic, tell your users to configure the static-token
mode; if you do want it, the cheapest correct implementation of the
heartbeat route is “accept it, 204, and do nothing else.”
PUT /api/agent/workers/self/heartbeat — establishes or refreshes the
binding itself (unlike every commands/* route, it does NOT require a
pre-existing “self” to resolve — the first heartbeat for a given
instance_id is what creates that binding). Answer 204 No Content on
success, no body. A representative body:
{"instance_id": "worker-1", "version": "1.0.0", "profiles": [],
"capabilities": [], "slots": {"total": 4, "busy": 0}, "health": "ok",
"provider_sessions": [], "sent_at": "2026-09-04T12:00:00Z"}
profiles (when a worker-routed source is configured with a purpose)
is [{"profile_id", "purpose", "provider"}]; provider_sessions is
[{"provider", "provider_instance_id", "label", "state", "expires_at", "last_verified_at", "error_code", "account_email", "account_name", "organization_name", "billing_type", "rate_limit_tier", "extra_usage_enabled"}] (most fields nullable) — the hub accepts
whatever shape a worker sends here without validating it structurally, so
treat both as informational rather than something you must parse
correctly to stay compliant.
Presence and “self.” A heartbeat binds an instance_id (in the
request body) to whichever credential/principal sent it — the first
heartbeat you see for an instance_id establishes that binding, and every
/api/agent/workers/self/commands/* route after that resolves “self” from
the CALLING credential, never from anything in the request itself (there’s no
instance_id anywhere in a commands/* path). Two consequences worth
building in from the start: reject a heartbeat for an instance_id you’ve
already bound to a different credential, and answer every
workers/self/* route with a fencing conflict if the calling credential
has never heartbeated at all, or its last-known instance is no longer
“present” by your own bookkeeping — there is no “self” to resolve.
Worker-routed sources
An operator configuring a worker against your server can choose to make it
worker-routed: a local setting on the worker’s own side that tells it
“supply only prepared text and an opaque profile_id affinity; never trust
this source’s own opinion of which provider, root, model, effort, or
permission mode to use.” This is a promise your server must keep, enforced
by the worker itself: if it’s configured this way and your
GET /api/agent/turns/next response sets provider,
model, permission_mode, fork_of_native_id, working_directory,
effort, or root_id to anything but null, the worker refuses the claim
outright rather than silently overriding your policy or silently applying
it. Leave every one of those fields null and set only text (the
prepared prompt) — the worker fills in its own locally-configured provider
and root itself, entirely locally, before ever running anything.
The worker applies that local overlay BEFORE tier handling runs, so a
worker-routed claim carrying a non-null tier still gets tier-resolved
exactly like a provider-pinned claim (see
Integrate as a worker): with no
profile-supplied model, done reports the overlaid provider in
resolved_provider and the selected model in resolved_model (possibly
null for the harness default); without a tier, or with an explicit
profile model, both stay null. If you want to know which provider a
worker-routed claim ran on and it isn’t reported back this way, that’s
local knowledge only the worker’s operator has.
Authentication
A worker authenticates to you exactly one of two ways, chosen once per
[[execution_sources]] entry on its own side — you only need to support
whichever one your users’ workers are configured for:
-
Static bearer token — the worker sends a fixed shared secret as
Authorization: Bearer <token>on every request. Simplest to implement; treat it like any API key. -
OIDC client-credentials — the worker discovers your issuer’s token endpoint from
<issuer>/.well-known/openid-configuration, then requests an access token withgrant_type=client_credentials, its configuredclient_id/client_secret, aresourceparameter (the audience you told your users to configure), and ascopethat depends on which route group it’s calling for:Scope requested For execution:claimThe turn lease — claim, events, and done all reuse this one credential. execution:heartbeatPUT /api/agent/workers/self/heartbeatonly.execution:controlEvery /api/agent/workers/self/commands/*route.These come from separate token requests against the same issuer/client/
resourceyour operator configured — the reference worker requests exactly one scope per credential, never more than one on the same token, and requests up to three of these credentials in total: the turn-lease one always, and the heartbeat/control ones only when applicable at all (see “whether the worker calls them isn’t your choice” above) — a static-token worker requests none of the three, and even an OIDC worker with no local provider-auth-control capability never requestsexecution:control. Note the asymmetry from the hub’s own worker-facing routes: it splits ordinary turn work intoagent:claim(claim only) andagent:report(everything else, including read-only introspection) — a generic execution source gets one combinedexecution:claimcovering the whole turn lease instead, because introspection routes (GET /api/agent/turns,.../history) are hub-only and have no equivalent here. Checking these scope strings server-side is defense in depth, not a requirement of the contract — a source that issues one token good for everything, or that runs no OIDC server at all and uses the static-token mode instead, is equally valid as long as it answers the routes above.
Either way, every request in this document carries a bearer credential in
an Authorization: Bearer header — a worker never does anything fancier
than that one header. But under OIDC specifically, don’t expect ONE
token to show up everywhere: the reference worker requests up to three
separately scoped tokens against the same issuer/client — one good for
the whole turn lease (execution:claim), and, only when applicable, a
second good only for the heartbeat route (execution:heartbeat) and a
third good only for the control-command group (execution:control) —
cached and refreshed independently, each on its own loop. A single worker
process configured for all three can legitimately present you three
different bearer values depending on which route it’s calling. The
static-token mode is the one case that’s genuinely a single shared
credential everywhere.
Turn lifecycle from your side
sequenceDiagram
participant You as Your server (execution source)
participant W as Worker (aya-helper)
You->>You: enqueue a pending turn, mint turn_id
loop worker polls
W->>You: GET turns/next with provider and root filters
You-->>W: 204, or 200 ClaimedTurn, mint claim_token, generation 1
end
loop while running
W->>You: POST turns/id/events claim_token batch_id events, or empty for a keepalive
You-->>W: 2xx, reject with 409 if claim_token is stale
end
W->>You: POST turns/id/done claim_token ok created_native_id resolved_provider
You-->>W: 2xx, record the terminal outcome, further writes under this claim_token now 409 (except an exact retry of this same done call, which stays a 2xx no-op)
Note over You: if the worker goes silent past your staleness window, a later claim may reclaim the row with a fresh claim_token and generation plus one
What you must persist per turn, mirroring what the hub itself tracks:
the current claim_token (a fresh, unguessable value minted on
every claim and every reclaim) and claim_generation (an integer that
only ever goes up, once per claim/reclaim); last_activity_ms or
equivalent, bumped by the original claim and by every subsequent events
call including a bare keepalive, so you can tell a genuinely silent worker
from one still legitimately mid-tool-call; every appended event tagged
with the claim_generation that wrote it, so a reclaimed worker’s stale
partial output never gets mixed into the new attempt’s stream; and the
turn’s terminal state once done lands, sealed against every future write
except an exact retry from the SAME claim_token.
Fencing is not optional. Every write (events, done) must present
the exact claim_token you most recently issued for that turn; reject
anything else with 409. This is what makes a reclaim safe: once you mint
a fresh token, every write under the old one is rejected, so a worker that
comes back from the dead after you’ve moved on can never silently
overwrite a fresher attempt’s results.
How long to wait before reclaiming is entirely your call — the
protocol doesn’t mandate a number. The hub’s own choice is 5 minutes of
total inactivity, comfortably above the reference
worker’s own 60-second keepalive cadence, which is a reasonable default to
copy; a worker that’s still genuinely running never trips it just because
the turn is long-running, and crossing the window doesn’t revoke anything
by itself — the old claim stays valid until a fresh GET .../next call
actually reclaims the row. See Integrate as a worker
for every timing number used anywhere on this site, collected in one table.
Park and requeue ride the done call, not a separate route:
requeue: true means “don’t complete this, put it back to pending” rather
than success or failure, and an optional park_until_ms lets a worker
name a future time it knows candidates will reopen (a known rate-limit
reset, for instance). park_until_ms is a LOWER bound on your ordinary
short retry backoff, not a replacement for it: the hub’s own rule is
max(now + <its own short backoff>, park_until_ms) — a park_until_ms
in the past or only moments away doesn’t extend that backoff (the
ordinary short window already covers it), but it still takes the park
branch rather than the ordinary-requeue one: attempts are NOT incremented
either way, so it never itself moves a turn closer to the requeue attempt
cap. The hub does not validate that park_until_ms is actually
future-dated — any non-null value suppresses attempt accounting, whether
or not it’s later than “now.” Send a genuinely, meaningfully future
timestamp if that distinction (capped ordinary retries vs. uncapped
parking) matters to your implementation; a near-past or near-now value
still parks, it just doesn’t buy you any extra wait beyond the ordinary
backoff. See Integrate as a worker for the
full semantics from the worker’s side, which apply to your implementation
unchanged.
Provider auth control
If you implement the workers/self/commands/* group, you’re the one
enqueuing a command in the first place — your own product decides when a
person needs to (re)authenticate a provider on a worker, and surfaces the
result to them. The wire shape a worker speaks back to you is fixed:
sequenceDiagram
participant You as Your server
participant W as Worker (aya-helper)
participant P as Person
You->>You: enqueue a control command (login), mint operation_id
W->>You: POST .../commands/claim {}
You-->>W: 200 ClaimedControlCommand (claim_token, claim_generation, lease_expires_at)
W->>You: POST .../commands/id/events state=awaiting_user verification_url user_code
You->>P: surface the verification URL / code however your product does that
P->>You: supplies the one-time code (your own channel, not part of this contract)
You->>You: store the code, ready for delivery
W->>You: GET .../commands/id/code (fenced by X-AYA-Claim-Token/-Generation headers)
You-->>W: 200 code (exactly once — 204 on every read after)
W->>You: POST .../commands/id/complete ok
You-->>W: 200
Request/response bodies and status matrix, matching the hub’s own implementation field-for-field:
The reference worker only checks that the response succeeded on the
three POST routes below — it never inspects their response body — so
“any 2xx” is the real requirement; the hub’s own {"ok": true} is its
own convention, shown because it’s what you’ll see if you talk to the
hub itself, not something your server must reproduce.
| Route | Request body | Success | Failure |
|---|---|---|---|
POST .../commands/claim | {} | 200 ClaimedControlCommand (below), or 204 if nothing pending | 409 if no present self instance |
POST .../commands/{id}/events | {"claim_token", "claim_generation", "event": {"state", "verification_url", "user_code", "expires_at", "error_code"}} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
POST .../commands/{id}/heartbeat | {"claim_token", "claim_generation"} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
GET .../commands/{id}/code (fenced by X-AYA-Claim-Token/X-AYA-Claim-Generation headers, not the body) | — | 200 {"code": "..."} once, 204 every read after | 409 fence mismatch or expired lease |
POST .../commands/{id}/complete | {"claim_token", "claim_generation", "ok", "error_code"} | any 2xx (hub: 200 {"ok": true}) | 409 fence mismatch or expired lease |
The complete row’s 409 column has one exception the others don’t:
once a complete call has actually succeeded, an exact retry of it (same
claim_token/claim_generation) stays a 2xx no-op instead — safe to
resend if a response was lost. events, heartbeat, and the code read
never get that exception, and neither does a complete call made BEFORE
the command ever succeeded: once a live claimed command’s lease expires,
every one of those, including that first complete attempt, is 409
until (or unless) the same worker instance reclaims the command.
{"operation_id": "op-1", "provider": "claude", "provider_instance_id": "default",
"action": "login", "claim_token": "cmd-fence-1", "claim_generation": 1,
"lease_expires_at": "2026-09-04T12:00:30Z"}
The lease is a plain absolute deadline, not the turn lease’s rolling
activity window — the hub uses 30 seconds (lease_expires_at, returned
on claim) — so a worker must renew it on its own cadence
(POST .../heartbeat, every 5 seconds in the reference client) rather than
just staying busy. Unlike the turn lease, an expired control-command lease
takes effect immediately, on its own, with no reclaim required first:
every fenced write (events, heartbeat, complete, the code read) checks the
presented claim_token/claim_generation against the row’s current claim
first, THEN separately checks whether that claim’s own lease has already
expired — a token/generation that still matches exactly is rejected
409 anyway if the deadline has simply passed, even before any other
worker has reclaimed the command. Once expired, the command also becomes
reclaimable, but only by the SAME instance that held it — your claim query
for a given command should stay scoped to whichever worker instance it
was originally addressed to, never opened up to any caller. That
token-then-lease check is skipped entirely for a complete call against a
command that’s already recorded as done: an exact retry (matching
claim_token/claim_generation) short-circuits straight to a 2xx
no-op regardless of whether the lease has since expired, because the hub
checks “already done, same fence” before it ever looks at the lease at
all.
Validating the one-time code has two layers, on both sides. The worker checks it twice, at two different points in its own pipeline: once on read (non-empty, at most 4096 bytes) and again just before feeding it to the login process (the same two rules plus no ASCII control characters), so by the time a code is actually used, all three rules have been applied worker-side regardless of which point rejects a bad one. On this hub, the browser-facing submission route applies the identical three-rule check (non-empty, at most 4096 bytes, no ASCII control characters) before it ever stores a code — validate whatever rules you choose to enforce the same way, before you store the code, not when the worker reads it back. That hub-side check means a well-behaved caller never sees a code fail the worker’s own re-check; a different execution source that stores a code without validating it first has no such guarantee.
See Integrate as a worker
for the complete state sequence (waiting_for_idle → awaiting_user →
verifying) and the 10-minute completion budget a worker holds itself to
once it reaches awaiting_user.
Run work through the hub: work runs
If you want more than a fixed read-only review — real work performed,
with a pull request opened or updated, and the result reported back to
you — without running a full execution-pull server,
/api/integrations/work-runs* queues a single work run against one of a
fixed set of work profiles your operator configures on the hub. Not a
general turn-creation API, and not something you implement yourself. You
still need to operate ONE thing: an HTTPS endpoint that accepts the
hub’s own result callback (see The report callback)
— the hub refuses to queue any work run at all unless that callback is
configured, so this is narrower than the review surface below, not
server-free.
What a work profile is
A work profile is a named, operator-configured target: a provider, a
root, a working directory, and a permission mode, all pre-approved ahead
of time for unattended execution — never anything the request itself
chooses. You select one by its opaque target_ref; the hub resolves
everything else. How many profiles exist, and what each one points at, is
entirely your operator’s call — ask them which target_ref values are
available (see Run your own).
Authentication
Same OIDC client-credentials shape as every other bearer credential on
this page, scoped work:run instead of review:run — and, on this hub,
sharing the identical resource/audience review:run tokens use; there is
no separate work-run audience to configure:
curl -s -X POST "$OIDC_TOKEN_ENDPOINT" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d resource="$API_RESOURCE" \
-d scope=work:run
# {"access_token": "...", "expires_in": 3600}
The token’s sub claim must identify a service principal (it starts
with client-) — a human user’s token, even with the work:run scope,
is rejected the same way the review surface rejects one.
Create a work run
curl -s -X POST https://hub.example.test/api/integrations/work-runs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"request_id": "req-42",
"input_version": 1,
"process_id": "process-77",
"target_ref": "ship-it",
"title": "Add a retry to the export job",
"brief": "The nightly export silently drops on a transient network error. Add one retry with backoff, and a test for it."
}'
# 202 {"request_id": "req-42", "state": "queued", "replayed": false}
Validation:
| Field | Rule |
|---|---|
input_version | Must be 1. |
request_id | Non-blank, at most 200 bytes, unreserved characters only (letters, digits, ., _, ~, -). |
process_id | Non-blank, at most 200 bytes — your own opaque reference for this work item; the hub never interprets it. |
target_ref | Non-blank, at most 500 bytes, and must name a work profile your operator actually configured. |
title | Non-blank, at most 500 bytes. |
brief | Non-blank, at most 65,536 bytes — the instructions the run actually receives. |
branch | Optional. The branch the run commits to: at most 200 bytes of ASCII letters, digits, ., _, - and /, a valid git branch name (no .., //, leading -, component starting with . or ending in .lock, trailing / or ., never HEAD), starting with one of the work profile’s branch prefixes (aya-task/ unless your operator configured others). Absent: the hub derives work/<hash of process_id>, as it always has. |
base_ref | Optional. What the branch starts from; same name rules as branch, without the prefix rule. Absent: the work profile’s own default base, else the repository’s default branch. |
An unrecognized field anywhere in the body is rejected outright, and a
body over 128 KB is rejected before it’s even parsed. branch and
base_ref are additive: input_version stays 1, and a request without
them hashes (for idempotency) and runs exactly as it did before they
existed. When given, they are part of the input, so resubmitting a
request_id with a different branch is a conflict.
request_id idempotency: replay versus conflict
Resubmitting the SAME request_id with the identical input is always a
202 idempotent replay — safe after a lost response, and distinguishable
by replayed:
# identical body, resent
# 202 {"request_id": "req-42", "state": "queued", "replayed": true}
Resubmitting the SAME request_id with ANY different field is a 409
instead of a silent overwrite:
curl -s -X POST https://hub.example.test/api/integrations/work-runs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"request_id": "req-42", "input_version": 1, "process_id": "process-77",
"target_ref": "ship-it", "title": "A different title", "brief": "..."}'
# 409 {"error": "work request conflicts with prior input"}
Polling the result
curl -s https://hub.example.test/api/integrations/work-runs/req-42 \
-H "Authorization: Bearer $TOKEN"
# {"request_id": "req-42", "state": "queued", "report_state": "pending"}
state moves queued → running → completed or failed.
report_state is present on EVERY response, in every state — it tracks
the SEPARATE result callback below, not the run itself. The
result/failure fields are STATE-DEPENDENT, never both and never
neither for a given state: queued/running carry neither field at
all; completed carries result and never failure; failed carries
failure and never result.
# once a worker has claimed it
# {"request_id": "req-42", "state": "running", "report_state": "pending"}
# once it finishes and its output parses as a valid result
# {"request_id": "req-42", "state": "completed", "report_state": "delivered",
# "result": {"status": "completed", "summary": "Added a retry with backoff and a test.",
# "pr": {"url": "https://git.example.test/org/repo/pull/9", "number": 9,
# "head_sha": "0123456789abcdef0123456789abcdef01234567", "state": "open"}}}
# if the run itself failed outright
# {"request_id": "req-42", "state": "failed", "report_state": "dead",
# "failure": {"code": "execution_failed", "message": "work execution failed", "retryable": true}}
# if the run finished, but its output didn't parse as a valid result (see below)
# {"request_id": "req-42", "state": "failed", "report_state": "dead",
# "failure": {"code": "invalid_result", "message": "work execution produced an invalid result", "retryable": true}}
failure is always exactly one of those two code/message pairs —
retryable is always true today; there’s no failure mode this route
currently reports as non-retryable.
What counts as a valid result. A work run’s own instructions require
it to open or update a pull request and reply with exactly one JSON
object, and nothing else in its final output — anything short of every
rule below reports state: "failed", failure.code: "invalid_result",
even though the run itself technically finished without error:
- The model’s total collected output text is at most 128 KiB. Longer output is treated as invalid before it’s even parsed.
- The JSON object has exactly these fields, no others:
{"status", "summary", "pr": {"url", "number", "head_sha", "state"}}. statusmust be exactly"completed".summarynon-blank, at most 65,536 bytes.pr.urlanhttps://URL, non-blank, at most 2,000 bytes, with a host, and no embedded credentials, query string, or fragment.pr.numbera positive integer.pr.head_shaa full 40-character SHA-1 or 64-character SHA-256 hexadecimal object id; abbreviated ids are rejected.pr.statenon-blank, at most 100 bytes.
The report callback
Work profiles are only half of enabling work runs — the hub also needs
somewhere to deliver each terminal result on its OWN initiative, so you
never have to poll forever. That’s a second, independent piece your
operator configures — see Run your own.
Once a run reaches a terminal state, the hub itself, acting as its own
OAuth2 client-credentials client (scope work:report, against the
configured report resource), POSTs the result, with a bearer token it
obtained for itself, to a fixed path under your configured
WORK_REPORT_URL — never the base URL alone:
<WORK_REPORT_URL>/api/dispatches/<request_id>/result, with request_id
percent-encoded as one path segment (so a / or ? inside a legacy
opaque id, for instance, can never be mistaken for part of the path):
{"process_id": "process-77", "result_id": "aya-<input-hash>",
"status": "completed", "summary": "Added a retry with backoff and a test.",
"pr": {"url": "https://git.example.test/org/repo/pull/9", "number": 9,
"head_sha": "0123456789abcdef0123456789abcdef01234567", "state": "open"}}
or, for a failed run:
{"process_id": "process-77", "result_id": "aya-<input-hash>",
"status": "failed", "summary": "", "error": "work execution failed"}
result_id is stable across every delivery attempt for the same
request_id — use it, not process_id, as your own idempotency key,
since the hub retries a lost or rejected delivery. Answer 2xx to accept
it. A 409 tells the hub to stop retrying permanently (recorded on this
end as report_state: "dead"); 401/403 make the hub refresh its own
token once and retry with that; 404/408/429/a 5xx retry on a
growing backoff that starts at exactly 10s and doubles each attempt
(10s, 20s, 40s, 80s, …), capped at one hour; any other status is treated
as a permanent rejection, the same as 409. None of this callback
traffic requires YOU to authenticate to the hub — it’s the hub
authenticating to you, the opposite direction from every other exchange
on this page.
Error codes specific to work runs
| Status | Route | Meaning |
|---|---|---|
202 | POST | Queued, or an idempotent replay of an identical prior request. |
400 | POST | Malformed request, a field fails validation, or target_ref names no configured work profile. |
401 | Both | Missing or invalid bearer credential. |
403 | Both | Missing work:run scope, or the subject isn’t a service principal. |
404 | GET | No work run was ever queued under this subject/request_id. |
404 | Both | Work runs aren’t configured on this hub at all — checked before authentication even runs (see below). |
409 | POST | Same request_id, different input. |
413 | POST | Body over 128 KB. |
500 | Both | Internal error — {"error": "work request could not be queued"} on POST, {"error": "work request could not be read"} on GET. |
503 | POST | Work runs are configured, but no worker on this hub could ever execute the resulting run. |
curl -s https://hub.example.test/api/integrations/work-runs/req-unknown \
-H "Authorization: Bearer $TOKEN"
# 404 {"error": "work request not found"}
If work runs aren’t configured on this hub at all, both routes answer
404 unconditionally, even with no token supplied — the same
“unconfigured means gone, not unauthorized” shape the review surface
below uses.
The hub’s own review surface
If you don’t want to run a server at all, Aya Hub itself offers one narrow,
already-running execution source: /api/integrations/* queues a single
fixed read-only idea review against a profile your operator configures
on the hub — not a general turn-creation API, and not something you
implement yourself.
curl -s -X POST https://hub.example.test/api/integrations/turns \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"request_id": "req-42",
"purpose": "idea_review_v1",
"input_version": 1,
"context": {
"idea": {"id": "idea-1", "title": "Ship dark mode",
"description": "Add a dark theme to the settings page."},
"messages": [],
"omitted_message_count": 0,
"projects": [{"id": "project-1", "name": "Web app", "description": "The main product."}],
"linked_project_ids": ["project-1"]
}
}'
# 202 {"request_id": "req-42", "state": "queued", "replayed": false}
curl -s https://hub.example.test/api/integrations/turns/req-42 \
-H "Authorization: Bearer $TOKEN"
# {"request_id": "req-42", "state": "ready", "result": {"summary": "...", ...}}
Authenticated with an OIDC client-credentials token scoped review:run,
from a subject that is itself a service principal (sub starting with
client-) — see Authentication for
the credential and Reference for its own
404-vs-503 unavailability split. Reach for this only when the fixed review
shape is actually what you need; anything else — an open-ended chat, a
tier/provider choice per request, your own turn text — means being an
execution source yourself, as this page describes.