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 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.