Integrate as a worker
A worker polls Aya Hub for work, executes it against a real agent CLI or
service on its own machine, and reports back. It owns nothing durable about
the work itself — only how it runs, and on which local machine. Everything
below is bearer-token or OIDC gated under /api/agent/* — see
Authentication for how to get a credential and what each scope
unlocks.
AYA ships a reference worker daemon that implements every sequence on this page end to end; where this guide is silent on an edge case, that daemon’s behavior is the specification.
Heartbeat
Report presence on a fixed interval so the hub (and anyone watching the worker registry) knows you’re alive:
curl -s -X PUT https://hub.example.test/api/agent/workers/self/heartbeat \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"instance_id": "worker-1",
"version": "1.0.0",
"profiles": [],
"capabilities": [],
"slots": {"total": 4, "busy": 0},
"health": "ok",
"provider_sessions": []
}'
# 204 No Content
Send this every 15 seconds (the reference client’s cadence); a worker is
reported “present” to anyone watching the registry while its last heartbeat
is within 3x that interval (45 seconds) — a read-time computation, not a
stored fact. instance_id is yours to generate (a restart should mint a
fresh one — see “Reclaim rules” below); a later heartbeat for the same
instance_id from a different credential is rejected (409), and a
credential can only own one present instance at a time. This route itself
never requires an already-present self — a heartbeat is what ESTABLISHES
or refreshes that binding, so it’s how you recover once presence has
lapsed; it’s every workers/self/commands/* route (claim, events,
heartbeat-renew, code, complete — the provider-auth-control group below,
a different heartbeat from this one) that requires a currently-present
self and answers 409 once it isn’t. Requires the execution:heartbeat
scope under OIDC — it cannot claim or drive anything, by design.
Claim a turn
curl -s "https://hub.example.test/api/agent/turns/next?providers=claude,hermes&roots=root-a,root-b" \
-H "Authorization: Bearer $TOKEN"
# 200 {"turn_id": "42", "provider": "claude", "native_id": "sess-1",
# "text": "...", "model": null, "permission_mode": null,
# "fork_of_native_id": null, "working_directory": null, "effort": null,
# "root_id": "root-a", "claim_token": "fence-abc", "claim_generation": 1}
# (204 with an empty body means nothing pending right now. `tier` and
# `candidates` are omitted entirely here — see "Tiered claims" below for
# when a claim actually carries them.)
The protocol defines four optional filters: providers, roots (both
comma-separated), profile_id, purpose (both opaque). On the hub
specifically, only providers and roots do anything — the hub’s own
queue has no concept of worker-routed or purpose-scoped claiming, so it
accepts profile_id/purpose on the wire but never filters on them;
they exist for OTHER execution sources that implement that routing
themselves (see Integrate as a dispatcher).
For providers/roots: an absent providers means “no opinion on
that axis”; roots absent means “I don’t do root-based routing at all,”
narrower than “every root is fine.” A present but empty value on
either means “serves nothing” — it is never silently reinterpreted as
“everything,” so a typo that empties your filter fails closed rather than
claiming work you can’t actually run. Requires agent:claim.
Tiered claims
A non-null tier is what marks a claim as tiered — not provider: null
by itself (a worker-routed claim also has that, for an unrelated reason).
A tiered claim comes with provider: null (you select provider and
model) or with provider already pinned (you select a model within that
provider, when no model is already explicit). Either way it also carries
candidates (an ordered [{provider, model?}, ...] list, narrowed to
just the pinned provider’s own candidates when one is pinned):
# 200 {"turn_id": "43", "provider": null, "native_id": null, "text": "...",
# "model": null, "claim_token": "fence-def", "claim_generation": 1,
# "tier": "standard", "candidates": [{"provider": "claude", "model": "sonnet"}, {"provider": "hermes"}]}
You can claim such a row as long as your providers filter intersects at
least one candidate, even if it doesn’t cover every one. Once claimed,
you — not the hub — pick the actual (provider, model) to run against
your own currently-advertised providers and usage-window state, before
driving anything.
Report events
Append events to a claimed turn’s stream. Every write from here on must
carry the exact claim_token you were handed:
curl -s -X POST https://hub.example.test/api/agent/turns/42/events \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "fence-abc",
"batch_id": "11111111-1111-1111-1111-111111111111",
"events": [{"type": "text", "delta": "Hello"}]}'
# 200 {"ok": true}
batch_id is a stable, opaque id for one logical append — mint one UUID per
logical call, reuse it only if you retry that exact same call (the hub
dedupes on (turn_id, claim_generation, batch_id) rather than double
appending), and never reuse it across two different batches. TurnEvent is
a tagged union on "type": started, text{delta}, thinking{delta},
tool_call{call_id,tool_name,input},
tool_result{call_id,output,is_error}, notice{text}, failed{message},
completed, rate_limit{rate_limit_type,status,resets_at_ms,overage_status,is_using_overage},
turn_result{cost_usd,model}. Requires agent:report.
Keepalives are an events call with an empty events array — same
batch_id rule, and their only purpose is bumping the turn’s last-activity
timestamp so a long-running, legitimately-silent turn (a long tool call)
never crosses the staleness window (see “Reclaim rules”) and gets pulled out
from under you. Send one on a cadence comfortably under that window — the
reference client defaults to 60 seconds.
Rate limits. A harness that exposes its own usage-window signal (Claude
Code’s own status stream, for one) surfaces it as an ordinary rate_limit
event — there’s no separate route or special handling for it, just relay it
like any other event. Upstream tier routing uses this to decide when all of
a tiered turn’s candidates are exhausted and, if it knows exactly when a
window resets, to park (see below) instead of failing outright.
Complete a turn
curl -s -X POST https://hub.example.test/api/agent/turns/42/done \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "fence-abc", "ok": true, "error": null,
"created_native_id": "sess-1", "created_root_id": "root-a",
"requeue": false, "park_until_ms": null,
"resolved_provider": null, "resolved_model": null}'
# 200 {"ok": true}
ok: truecompletes successfully. For a “new chat” turn (claimed withnative_id: null), setcreated_native_id/created_root_idto the session you actually created, so the hub can persist it under a real session row; an existing-session turn ignores both.ok: falsecompletes as failed;erroris the human-readable reason.resolved_provider/resolved_model— set both when you performed tier resolution: for a provider-unresolved tiered claim, and for a provider-pinned tiered claim whose model was unresolved. A claim with an explicit provider and model, or notierat all, leaves bothnull.resolved_provideris checked against the turn’s storedcandidatesonly when the turn’s own provider wasnull— a name outside that set is409; when the provider was already pinned, it’s accepted without being checked.resolved_modelis never validated againstcandidates, for either shape.
The first done to land wins. An exact retry — same claim_token, same
claiming credential — against an already-done turn is a no-op 200, so a
worker whose response got lost on the wire can safely resend the identical
call. Any other later call against an already-done turn (a stale or
superseded token, or a different credential) is 409. Requires
agent:report.
Park and requeue
requeue: true is a distinct third outcome from ok, meaning “this
session is busy, try again shortly” rather than success or failure — it
does not complete the turn (unless the attempt cap is exhausted, in which
case it does, as a failure). Add park_until_ms (a future epoch-millisecond
timestamp) alongside requeue: true when you know exactly when the reason
will resolve — today, when every candidate of a tiered claim is exhausted
with a known vendor reset time. The hub then holds the turn pending until
max(now + ordinary backoff, park_until_ms), and — critically — does not
count a parked cycle against the attempt cap, so a turn can park across many
reset cycles without ever failing outright the way an ordinary requeue loop
eventually would. Leave park_until_ms: null (the default) for every
ordinary requeue.
The hub does not validate that park_until_ms is actually future-dated.
Any non-null value — past, present, or future — takes the park branch and
suppresses attempt accounting the same way; a past or near-future value
just also fails to extend the backoff beyond what the ordinary short
window already gives, since the hub takes the max of the two. Sending a
timestamp that isn’t meaningfully in the future costs you nothing and buys
you nothing beyond the ordinary requeue backoff, but it still buys you the
uncapped-attempts treatment — send a genuinely future timestamp whenever
that distinction (capped vs. uncapped) actually matters to you.
Lease renewal and reclaim rules
A claimed turn becomes reclaimable once it has gone 5 minutes with no
activity at all — the original claim counts as activity, and so does every
events call including a bare keepalive. Crossing that window doesn’t
revoke anything by itself; the token is only replaced once another
GET /api/agent/turns/next call actually reclaims the row. A slow-but-alive
worker that finishes before anyone reclaims it completes normally, however
late it runs.
A reclaim fences the superseded worker completely. From the instant a
reclaim commits, every write under the old claim_token is rejected with
409, never partially applied — the hub cannot tell whether your old
process is still running, so it never lets a late done from the
superseded claim win over a fresher one. This means the same prompt can
genuinely execute twice in the real world; the lease guarantees one
coherent recorded outcome, never that duplicate execution is impossible.
Avoiding a spurious reclaim is entirely on you: keepalives and a sane poll
cadence.
A restarted worker should mint a fresh instance_id for the heartbeat/registry
surface below — a command addressed to the old instance is never picked up
by a new one, by design.
A worked 409: what a reclaim actually looks like from where you sit
- You claim turn 42 —
claim_token: "fence-a",claim_generation: 1— and start driving it. - Something on your end goes quiet for over 5 minutes: no
eventscall, not even a keepalive (a crash, a lost network link, a deploy that killed the process mid-turn). - A second worker (or a second instance of you, after a restart) polls
GET /api/agent/turns/nextand claims the same row — it’s been silent long enough to reclaim. The source mints a freshclaim_token: "fence-b"and bumpsclaim_generationto2. Yourfence-ais now dead; you have no way to know this happened yet. - Unaware, your original process finishes the work and calls
POST /api/agent/turns/42/donewithclaim_token: "fence-a". - That call comes back
409— not200. The source rejects it becausefence-ais no longer the current claim;fence-bis.
What to do with that 409: drop the work. Don’t retry the same call —
retrying done with fence-a will 409 forever, since that token is
permanently superseded, not temporarily busy. Don’t try to re-claim turn 42
by its id, either — there is no “claim this specific turn” operation, only
“claim whatever’s next,” and turn 42 already belongs to whoever holds
fence-b now. Log it, discard whatever output you were about to report,
and move on to your next poll. This is exactly why the lease exists: two
workers may genuinely have run the same prompt for a few minutes, but only
one of their outcomes — the reclaiming worker’s — ever gets recorded.
Jobs, index sync, and capabilities
Three smaller route groups round out a worker’s /api/agent/* surface,
independent of the turn lease above.
Generic jobs (the Jobs group in Reference)
are a second, simpler work queue for
one-shot tasks that aren’t a chat turn at all — today git_diff and
terminal_exec. The claim/complete shape mirrors turns, fenced the same
way by a claim_token a worker must present on every write, but without
turns’ claim_generation counter — one flat claim per job, no
reclaim-generation tracking. There is no keepalive route for jobs at all
(unlike the turn lease’s events call, which doubles as one) — a job is
either claimed and completed quickly, or it goes silent and waits out the
SAME reclaim window as a turn (5 minutes,
Timings and budgets below), after which a fresh
GET .../next call can reclaim it with a brand-new claim_token, exactly
like a stale turn claim:
curl -s "https://hub.example.test/api/agent/jobs/next?roots=root-a,root-b" \
-H "Authorization: Bearer $TOKEN"
# 200 {"job_id": "7", "kind": "git_diff", "params": {...}, "claim_token": "job-fence-1"}
# (204 means nothing pending — roots absent or empty both match nothing.)
curl -s -X POST https://hub.example.test/api/agent/jobs/7/result \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "job-fence-1", "ok": true, "result": {"diff": "..."}}'
Claiming needs agent:claim; reporting a result needs agent:report — the
same two scopes turns use, just on a different queue. 404 if the job
doesn’t exist, 409 if claim_token doesn’t match the current claim OR
the caller isn’t the same principal that holds it. Completion is
idempotent for an exact retry: a result post from the SAME claim_token
and principal against an already-done job succeeds as a silent no-op
(safe to resend if a response was lost) rather than a fencing error —
only a genuinely different token or principal against a done job is
409.
Index sync (the Index group) is how a worker with its own local session index publishes it upward, so the hub’s copy stays a faithful mirror without re-uploading everything on every cycle:
# 1. Ask what the hub already has for one root, keyed by native_id.
curl -s "https://hub.example.test/api/agent/index/fingerprints?root=root-1" \
-H "Authorization: Bearer $TOKEN"
# 200 {"native-1": [123, 4], "native-2": [null, 0]} # [fingerprint, message_count]
# 2. Publish only the sessions whose fingerprint actually changed.
curl -s -X POST https://hub.example.test/api/agent/index/sessions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"root": {"id": "root-1", "provider": "claude", "label": "Home"}, "sessions": [...]}'
# 200 {"applied": 3, "skipped_unchanged": 1}
Both routes need agent:report. The reference worker diffs its own
fingerprints against GET .../fingerprints before ever calling
POST .../sessions, so a healthy sync publishes only what actually
changed.
Capabilities (the Capabilities group) reports what one root’s harness can currently do — the write side of the same snapshot Integrate as a UI reads:
curl -s -X POST https://hub.example.test/api/agent/capabilities \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"root_id": "root-1", "capabilities": {"provider": "claude", "model": "sonnet",
"tools": [], "mcp_servers": [], "skills": [], "commands": [], "agents": []}}'
# 200 {"ok": true}
Needs agent:report. A later report for the same root_id fully replaces
the prior snapshot — there’s no partial update.
All five routes above answer 503 under the same condition as everything
else on this page (no agent authentication configured at all), and a
malformed body on any of the POST routes is 400/415/422 with a
plain-text body rather than this API’s usual {"error": "..."} JSON
shape (see Reference).
Provider auth control loop
Separate from the turn lease, this drives a worker’s own interactive
provider login/logout on request from whatever enqueued the command — the
hub’s own browser-facing worker registry, or your own execution source’s
equivalent (see Integrate as a dispatcher
for the other side). It is fenced the same way the turn lease is — a
claim_token plus a
claim_generation that both advance on every claim or reclaim — except
staleness here is a plain 30-second absolute deadline
(lease_expires_at, returned on claim) rather than a rolling activity
window, so you must renew it actively rather than just staying busy.
Once the lease expires on a command you haven’t yet completed, every
further write — events, heartbeat, the code read, and even your
first complete call — is rejected 409, the same as any other fence
failure. The one exception is complete itself once it has actually
succeeded: an exact retry (same claim_token/claim_generation) of a
complete call that already recorded the command as done stays a 200
no-op no matter how much time has passed since, because that idempotency
check runs before the lease is even consulted — safe to resend if you’re
ever unsure whether your last complete response actually made it back
to you.
# 1. Claim one pending command for this worker.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/claim \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
# 200 {"operation_id": "op-1", "provider": "claude",
# "provider_instance_id": "default", "action": "login",
# "claim_token": "cmd-fence-1", "claim_generation": 1,
# "lease_expires_at": "2026-09-03T12:00:30Z"}
# (204 means nothing pending — poll again shortly.)
# 2. Report progress as you drive the login — e.g. once you have a
# verification URL and user code to show the person.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/events \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1,
"event": {"state": "awaiting_user",
"verification_url": "https://provider.example.test/activate",
"expires_at": "2026-09-03T12:10:00Z"}}'
# 3. Renew the lease every 5 seconds while you wait — it does not renew
# itself just because you're active elsewhere.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/heartbeat \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1}'
# 4. Poll for the one-time code a person submits through the browser side.
# Note: fencing rides HEADERS here, not the body — the only place in
# this whole protocol that's true. On THIS hub, whatever code comes
# back is guaranteed non-empty, at most 4096 bytes, and free of ASCII
# control characters — the browser-facing submit route enforces that
# before ever storing it — but that's a hub-specific guarantee, not
# part of the shared contract; a different execution source may not
# validate before storing, which is exactly why the worker itself
# still re-checks the same rules before using a code (see Integrate as
# a dispatcher's own note on this).
curl -s https://hub.example.test/api/agent/workers/self/commands/op-1/code \
-H "Authorization: Bearer $TOKEN" \
-H "X-AYA-Claim-Token: cmd-fence-1" -H "X-AYA-Claim-Generation: 1"
# 204 until a code has been submitted; 200 {"code": "123-456"} exactly once
# after that; 204 on every read after that (never re-served).
# 5. Report the terminal outcome.
curl -s -X POST https://hub.example.test/api/agent/workers/self/commands/op-1/complete \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"claim_token": "cmd-fence-1", "claim_generation": 1, "ok": true}'
state progresses through waiting_for_idle → awaiting_user →
verifying. The 10-minute budget starts once you reach awaiting_user
— the instant you’ve successfully started the login attempt and posted that
event with a verification URL — not from the initial claim; nothing bounds
how long waiting_for_idle itself may take. That budget covers only
waiting for the one-time code, though: the moment a code actually arrives,
the reference client leaves that outer deadline behind and moves onto its
own separate, shorter budget for the rest of the flow — up to 30 seconds
waiting for the login process itself to report completion, then up to
another 15 seconds verifying the result — so submitting a code late but
still inside the 10-minute window doesn’t cut the completion/verification
step short by whatever time is left. The two failure modes report
different error_codes, not the same one: timing out (or shutting down)
while still WAITING FOR THE CODE completes the command as
{"ok": false, "error_code": "cancelled"}; failing (or shutting down)
during the completion/verification step after a code was already
submitted instead completes it as
{"ok": false, "error_code": "verification_failed"}. Report whichever one
actually applies rather than always using cancelled. A live claim’s own
lease does not exempt you from the
registry’s own presence check above — you must keep heartbeating presence
(§Heartbeat) even while holding a command’s lease, or its fenced writes
start failing regardless of how much time is left on the lease itself.
Every route here requires the execution:control scope under OIDC — it
cannot heartbeat presence or touch the turn/job queues, the mirror
restriction to execution:heartbeat above.
sequenceDiagram
participant Worker
participant Hub
loop poll
Worker->>Hub: GET /api/agent/turns/next?providers=&roots=
Hub-->>Worker: 200 ClaimedTurn or 204
end
Worker->>Hub: POST .../events claim_token batch_id events (repeated, incl. keepalives)
Worker->>Hub: POST .../done claim_token ok created_native_id
Note over Worker,Hub: a fresh GET .../turns/next after 5min silence reclaims and fences the old claim_token
Worker-routed sources
An execution source — the hub, or a separate product implementing this same
contract — can be worker-routed: it supplies only prepared text and an
opaque profile_id affinity, and leaves every local decision (provider,
root, model, effort, permission mode) to you. When claiming against such a
source, it must return provider, model, permission_mode,
fork_of_native_id, working_directory, effort, and root_id all
null — a well-behaved worker refuses the claim outright if even one of
them is set, since a worker-routed source has no business choosing local
policy. Multiple worker-routed sources can share one base URL and differ
only by the opaque purpose filter from the claim call above — one queue,
one local policy, per purpose string.
Timings and budgets
Every interval and deadline that shows up anywhere on this site, in one place:
| What | Value | If you’re late |
|---|---|---|
| Heartbeat interval | 15s | Nothing immediate — presence is a rolling window, not a single missed beat. |
| Presence window | 45s since the last heartbeat (3x the interval) | You stop showing as “present.” Every workers/self/commands/* route starts answering 409 (no self instance to resolve) — heartbeating again (the one route that doesn’t need presence) restores it. |
| Turn keepalive interval | 60s (reference client default) | Still comfortably under the reclaim window below on its own; a few losses in a row is what actually risks a reclaim. |
| Turn reclaim silence | 5 minutes with no activity at all | The row becomes reclaimable. Nothing is revoked automatically — but once another GET .../turns/next call actually reclaims it, your claim_token is fenced out and every further write from you is 409. |
| Ordinary requeue backoff | 20s | A requeue: true (busy-session) turn isn’t claimable again until this much time has passed. |
| Requeue attempt cap | 20 attempts — operator-configurable, TURN_REQUEUE_MAX_ATTEMPTS | The first 19 ordinary requeue: true calls on the same turn return it to pending; the 20th completes it as failed instead of requeuing again — a park (§Park and requeue) never counts toward this. |
| Pending-turn claim window | 10 minutes (hub default) — operator-configurable, PENDING_TURN_TTL_MS | An unclaimed turn expires to a terminal expired state — visibly failed, not silently lost, but gone; a claim that lands first always wins the race against this timer. |
| Control-command lease | 30s absolute deadline | The lease expires immediately on its own — no reclaim needed first. Every fenced write against a still-claimed command (events, heartbeat, code read, and a first attempt to complete) is 409 from that moment, and the command becomes reclaimable, but only by the same worker instance it was addressed to. An exact retry of a complete call that already succeeded stays a 200 no-op regardless of the lease — that idempotency check runs before the lease check, not after it. |
| Control-command lease renewal | Every 5s | Miss enough renewals and the 30s deadline above simply arrives — same consequence. |
| Control-command idle poll | 1s | Just how often you check for a new command or a delivered code — no consequence beyond latency. |
| Provider login discovery | 20s | Bounds finding the login process’s own verification URL before anything is reported back at all; exceeding it fails the command as login_start_failed. |
| Provider login completion budget | 10 minutes from awaiting_user | Bounds only the wait for the one-time code. Complete the command yourself as {"ok": false, "error_code": "cancelled"} — see Provider auth control loop for why a failure AFTER a code was submitted reports verification_failed instead, on a separate, shorter budget. |
| Post-code completion wait | 30s | Counts toward verification_failed, not cancelled — the outer 10-minute budget is no longer the relevant one once a code has been submitted. |
| Post-code verification check | 15s | Same verification_failed outcome as above. |
| Command-orphan expiry (pending) | 10 minutes unseen, or no registry row at all | The browser-facing side sweeps the command as failed (error_code: "worker_gone") so it stops waiting on an instance that’s gone for good, while still tolerating an ordinary sleep/network blip under that instance’s SAME id. |
| Worker’s own poll idle/error backoff | 2s after a pool-busy probe, or after a round that finishes all-idle or a mix of idle-and-error; a claimed turn keeps polling immediately with no sleep at all; consecutive ALL-error rounds back off 5s, 10s, 20s, then 30s, capped | Just polling cadence — no correctness consequence, only how quickly a newly-pending turn gets picked up. A round with at least one idle (non-error) source keeps the plain 2s cadence and resets the error-round count; only a round where EVERY source errors advances the backoff. |
| HTTP request timeouts (reference client) | 5s connect, 30s general request, 10s for the heartbeat route specifically | A request that blows through these fails locally (transient error, retried on the caller’s own cadence) rather than hanging forever. |
| OIDC token refresh margin | Refreshes 30s before the token’s own expiry; assumes a 60s lifetime when the hub’s token response omits expires_in | The margin means a token already close to expiry never gets handed to an in-flight request; the 60s fallback only matters against a hub that omits expires_in, in which case tokens are refreshed on that tight assumed cadence rather than risking reuse past an unknown real expiry. |
| UI turn-poll interval | 500ms | Purely a responsiveness tradeoff — polling slower just means the person waits longer to see new output; nothing times out because of it. |
| UI turn-poll overall deadline | 15 minutes (reference client’s own choice) | Stop polling and let the person check back later. The turn itself keeps running server-side regardless — this deadline only governs how long ONE browser tab keeps watching. |
| UI poll retry sequence | 500ms, 1s, 2s, 4s, 5s (capped) between attempts, 6 attempts total | Only a retryable outcome (network failure or 5xx) backs off at all — a 4xx surfaces immediately, never retried. After the 6th attempt the UI gives up and throws the last error to the caller exactly as a non-retried failure would. |
The turn-lease numbers (keepalive, reclaim silence, pending-claim window) are the hub’s own chosen values, not something the wire contract mandates — see Integrate as a dispatcher if you’re implementing your own source and deciding your own.