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

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.