Workers
Source: sheptra.core/docs/management-api.md § The worker environment block · sheptra.core/docs/http-api.md § Authentication, Long-poll job activation, Multiplexed activation, Lease extension, Rate limiting · sheptra.core/authentication.md § Rejection shapes · sheptra.core/docs/capabilities.md § Job polling, Incidents, worker ThrowError
A worker is a service you run, in your own infrastructure, that picks up jobs from a Sheptra environment, does the work, and reports back. It speaks plain HTTP: mint a token, call POST /v1/jobs/activate, complete or fail each job. This page covers the environment block you paste into it, the two ways it can find its engine, how it authenticates, and the job loop. There is no SDK yet; what follows is the contract.
Where the block comes from#
In Drive, open API clients and create one. A client is an OAuth2 machine credential bound to your organization. The create response is the only time the secret is shown — the listing carries a hint, never the value — and below the secret Drive prints a Worker environment block with a copy button. Paste it into your worker’s .env and you are configured.
Revoking a client refuses new tokens immediately; a token the worker already holds keeps working until it expires, at most an hour later. That is inherent to stateless bearer tokens, and Drive’s revoke dialog says exactly that.
The environment block#
Six keys, in the order Drive prints them:
SHEPTRA_TOKEN_ENDPOINT=https://<your-authkit-domain>/oauth2/token
SHEPTRA_CLIENT_ID=client_…
SHEPTRA_CLIENT_SECRET=<shown once, at creation>
SHEPTRA_API_URL=https://<your-environment-host>
SHEPTRA_DISCOVERY_URL=https://<management-host>/v1/discovery/cell
SHEPTRA_ENVIRONMENT=<cluster-id>| Key | Value | Read in |
|---|---|---|
SHEPTRA_TOKEN_ENDPOINT | Where the worker mints its token: the OAuth2 client_credentials endpoint of the identity environment this deployment is bound to, as published by GET /v1/discovery/auth. | both modes |
SHEPTRA_CLIENT_ID | The API client’s id. Its subject starts with client_. | both modes |
SHEPTRA_CLIENT_SECRET | The API client’s secret — shown once, at creation. | both modes |
SHEPTRA_API_URL | The environment’s engine origin. Every job call is this origin plus a /v1 path. | config mode |
SHEPTRA_DISCOVERY_URL | The absolute URL of GET /v1/discovery/cell on the management host — where a worker resolves its engine origin at startup instead of being told it. | discovery mode |
SHEPTRA_ENVIRONMENT | The environment’s cluster id, sent to discovery as ?cluster= so an organization with several environments names the one this worker serves. | discovery mode |
Two variants of the first line exist, and both keep the block valid dotenv because they are # comments. On a deployment running with authentication off, Drive prints:
# authentication is off on this deployment — nothing to mint from; leave blank
SHEPTRA_TOKEN_ENDPOINT=and if the management host did not answer the auth discovery, it prints a placeholder endpoint with a comment asking you to fill it in. In both cases the other five keys are as above.
Config mode and discovery mode#
The block carries the keys for both modes; a worker reads one set or the other, and config mode is the default.
Config mode#
Read SHEPTRA_API_URL and poll it directly. There is no discovery machinery: after minting, the worker never needs the management host again. This is what Sheptra’s own dev scripts do, and what most shared-environment customers should do.
Discovery mode#
Read SHEPTRA_DISCOVERY_URL and SHEPTRA_ENVIRONMENT. At startup, call GET {SHEPTRA_DISCOVERY_URL}?cluster={SHEPTRA_ENVIRONMENT} with the same bearer token you present to the engine; the environment it returns carries the baseUrl to poll — the same value config mode would have been given — along with its cluster, name, environment and status. An unknown cluster id, or an organization with no environments, answers 404.
Cache the answer. Re-resolve only on a 421 Misdirected Request from the engine or on persistent connection failure — never per request. Discovery is a bootstrap and recovery surface: a management-plane outage must never stop a worker that is already configured. This mode exists so an environment can be moved between cells without the worker being reconfigured; it is recommended for dedicated environments.
Minting a token#
Standard OAuth2 client credentials, form-encoded, against SHEPTRA_TOKEN_ENDPOINT:
POST {SHEPTRA_TOKEN_ENDPOINT}
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id={SHEPTRA_CLIENT_ID}&client_secret={SHEPTRA_CLIENT_SECRET}{ "access_token": "eyJ…", "expires_in": 3600, "token_type": "Bearer" } The token is a one-hour RS256 JWT. It carries your organization in org_id and the audience the engine verifies in aud — that audience is the same value GET /v1/discovery/auth publishes, and you do not send it; the identity provider stamps it. There is no refresh token: cache the access token and mint a new one shortly before expires_in runs out (the dev scripts renew a minute early). Present it as Authorization: Bearer … on every engine call.
A machine credential needs no role assignment. The engine recognises a client_… subject structurally and grants it the worker role, which is what the job endpoints require. It carries no other permission unless an administrator grants it a capability (instances:start, messages:publish, signals:throw, definitions:deploy) on that environment.
The job loop#
Activate, work, report. The engine parks each service task as a job of the type the diagram names; your worker claims a batch under a lease, runs your code, and completes or fails each one.
Activate#
POST {SHEPTRA_API_URL}/v1/jobs/activate
Authorization: Bearer <access_token>
Content-Type: application/json
{
"types": ["charge-payment", "send-receipt"],
"maxJobs": 10,
"leaseSeconds": 60,
"waitSeconds": 30
}{
"jobs": [
{
"id": "…",
"instanceId": "…",
"elementId": "ChargePayment",
"type": "charge-payment",
"retries": 3,
"lockExpiresAt": "2026-09-10T12:01:00Z",
"variables": { "orderId": "2481", "amount": 4200 }
}
]
}typeortypes— one job type, or every type this process handles in one request. Supplying both is a400. Prefertypes: one held request per worker process rather than one per type, and jobs arrive oldest-first across the whole set; dispatch on each job’stype.waitSeconds— long-poll. The request is held until a job appears or the window expires (server cap 30 s). Omitted or zero is a short poll that returns immediately.leaseSeconds— how long each returned job is yours. UntillockExpiresAtnobody else is handed it; after that the engine gives it to the next activation and counts a retry. Size it for a typical handler, not the slowest one — a handler that runs long extends its lease as it goes.maxJobs— the batch size.workerIdis optional. The engine derives the worker identity from the token’s subject ({sub}, or{sub}/{workerId}when you pass one as a label for several pollers on one credential), so one worker can never report another’s jobs.tenantIds— only if your organization partitions work into sub-tenants; omitted means thedefaultpartition.
Each job carries the process variables it can see in variables, the elementId of the task, its remaining retries, and the W3C traceparent of the process span that queued it. Set that as the parent of your handler’s span, or forward it on outbound calls, and your work appears inside the process trace; ignoring it is safe.
Complete, fail, or throw an error#
| Call | Body | What happens |
|---|---|---|
POST /v1/jobs/{id}/complete | { "variables": { … } } | The task finishes, the variables merge into the instance, and the token moves on. First completion wins: if your lease lapsed and another worker already reported the job, you get 409 — drop it. |
POST /v1/jobs/{id}/fail | { "errorMessage": "…", "retryBackoffSeconds": 30 } | A technical failure. Spends one retry and requeues the job after the backoff. When the budget is exhausted the engine raises an incident; the token waits where it is until an operator resolves it, which requeues the job with a fresh budget. |
POST /v1/jobs/{id}/throw-error | { "errorCode": "PAYMENT_FAILED", "errorMessage": "…", "variables": { … } } | A business error the diagram is expected to handle. The engine matches the task’s error boundary events by code, then a code-less catch-all, and follows that path with the error payload merged; the code and message arrive as sheptra:errorCode / sheptra:errorMessage. Nothing catching it raises an incident and the token keeps waiting. |
Every variables-bearing request is capped at 1 MB of names plus JSON values; past it the answer is 413.
Long handlers: extend the lease#
A handler that cannot know its own duration up front does not guess a huge lease — that hides a crashed worker’s job for as long as the guess. It heartbeats instead: POST /v1/jobs/{id}/extend-lease with { "leaseSeconds": 60 } every N seconds, well inside the lease (a third to a half of it). Each call re-anchors lockExpiresAt to now plus leaseSeconds and returns it — the time is measured from the call, never added to the old expiry, so a heartbeat that stops lets the job lapse on its normal schedule. The lease you ask for here is bounded like the activation one (at least 1; omitted means the same default).
Only the current holder may extend, and a lapsed lease nobody has taken over yet still counts as yours. A 409 means you lost the job: another worker acquired it after the lease lapsed, or it is back in the queue. Stop working on it — its completion would be 409 too.
Delivery is at least once#
A lease that expires mid-handler is requeued with retry counting, so the same job can reach a handler twice. Make handlers idempotent — key side effects on job.id or on a business identifier from the variables — and treat a 409 on complete as “someone else finished this”, not as a failure to retry.
When the engine says no#
Every rejection is RFC 9457 application/problem+json with a title. The ones a job loop meets, and the move for each:
| Status | Title | Cause | Your move |
|---|---|---|---|
401 | Unauthorized | No token, expired, wrong issuer or audience, bad signature. | Re-mint. If it persists, the credential and the token endpoint belong to different identity environments — not a transient fault, so do not loop on it. |
403 | Forbidden | Authenticated, but the endpoint’s permission guard was not satisfied. | An administrator grants the capability; retrying will not help. |
403 | No organization | The token carries no org_id. | Use an organization-bound API client from Drive. |
403 | Machine credential not bound | The organization enforces machine-to-environment binding and this credential is not bound to this environment. | An administrator binds it (POST /v1/machine-bindings). |
403 | Tenant not granted | Tenant-grant enforcement is on and the credential holds no grant for a tenant in tenantIds. | An administrator grants the tenant. |
409 | — | On complete, fail or extend-lease: the job is no longer yours (lease lapsed and someone else took it, or it is back in the queue) or is already terminal. | Drop the job and move on. |
421 | Misdirected request | Your organization is not resident on the cell you called — the environment moved, or SHEPTRA_API_URL is the wrong host. Carries a discoveryUrl extension when the cell has one. | Discovery mode: re-resolve, re-point, retry — this self-heals. Config mode: fix SHEPTRA_API_URL. |
429 | — | Over the organization’s request budget (6,000 per 60 s by default, shared by everything your organization calls). | Wait Retry-After. Long-polling with types is what keeps an idle fleet far under it. |
503 | — | The cell cannot serve your organization yet (a stale membership sync, or a dependency not ready). Never a reason to re-discover: there is no other cell to go to. | Wait Retry-After, then retry the same host. |
Not the whole list. The complete catalogue of every status a worker can receive, with retry guidance for each, is item 4 of the documentation plan and is not written yet.
A minimal worker#
A config-mode worker in plain Node — no SDK, no framework — following the shape of the dev scripts in Sheptra’s own repositories. It mints and caches a token, long-polls every type it handles in one request, and completes or fails each job.
// worker.mjs — run with: node --env-file=.env worker.mjs
const {
SHEPTRA_TOKEN_ENDPOINT,
SHEPTRA_CLIENT_ID,
SHEPTRA_CLIENT_SECRET,
SHEPTRA_API_URL,
} = process.env
// your handlers, keyed by job type: receive the job, return the variables to complete with
const handlers = {
'charge-payment': async (job) => ({ paymentOk: true }),
'send-receipt': async (job) => ({}),
}
// client_credentials has no refresh token — cache the mint, renew a minute early
let cached = null
async function token() {
if (cached && Date.now() < cached.expiresAt) return cached.token
const res = await fetch(SHEPTRA_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: SHEPTRA_CLIENT_ID,
client_secret: SHEPTRA_CLIENT_SECRET,
}),
})
if (!res.ok) throw new Error(`token mint failed: ${res.status}`)
const { access_token, expires_in } = await res.json()
cached = { token: access_token, expiresAt: Date.now() + (expires_in - 60) * 1000 }
return access_token
}
async function post(path, body) {
const res = await fetch(`${SHEPTRA_API_URL}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const error = new Error(`${path} answered ${res.status}`)
error.status = res.status
error.retryAfter = Number(res.headers.get('Retry-After')) || 0
throw error
}
const text = await res.text()
return text ? JSON.parse(text) : null
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
for (;;) {
try {
const { jobs } = await post('/v1/jobs/activate', {
types: Object.keys(handlers),
maxJobs: 10,
leaseSeconds: 60,
waitSeconds: 30,
})
for (const job of jobs) {
try {
const variables = await handlers[job.type](job)
await post(`/v1/jobs/${job.id}/complete`, { variables })
} catch (error) {
if (error.status === 409) continue // someone else finished it
await post(`/v1/jobs/${job.id}/fail`, {
errorMessage: String(error.message ?? error),
retryBackoffSeconds: 30,
})
}
}
} catch (error) {
if (error.status === 401) cached = null // re-mint on the next call
else if (error.status === 429 || error.status === 503) await sleep((error.retryAfter || 5) * 1000)
else if (error.status === 421) throw error // config mode: the host is wrong — fix SHEPTRA_API_URL
else await sleep(2000)
}
} What it leaves out on purpose: the discovery-mode startup (resolve baseUrl, and on 421 resolve again instead of stopping), throw-error for business failures, the heartbeat for handlers that outlive their lease, and forwarding traceparent. Each is a few lines against the calls above.