Workflows
Run dependent inference inside one batch record. Your callback returns the next requests and OpenRelay runs them, bounded by steps, requests, and tokens.
A workflow record is a batch record whose requests are decided by your own
endpoint as the record runs. OpenRelay posts an event to your callback, runs
the inference requests the callback returns, posts their results back, and
repeats until the callback answers completed or failed. Use it when later
requests depend on earlier answers (extract, then reconcile, then summarize)
and you want all of that work to stay inside one batch, at the batch rate.
Every dependent request runs inside the original record, on the batch's model,
and never creates another batch. The record produces one result line: your
callback's final output, or an error.
Access
Workflow records are enabled per organization. Until an operator enables
them for yours, each workflow record fails validation with
workflow_not_enabled and the rest of the batch runs unaffected.
The record
A workflow record is a /v1/chat/completions record whose body carries
openrelay.workflow, the same reserved openrelay object that holds
tool_config:
{"custom_id": "contract-42", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "openrelay/gemma-4-31b", "messages": [{"role": "user", "content": "contract-42"}], "openrelay": {"workflow": {"url": "https://your-app.example.com/api/openrelay-workflow", "authorization": "Bearer <token>", "context": {"contractId": "contract-42"}, "max_steps": 16, "max_requests": 64, "max_tokens": 2000000, "callback_timeout_ms": 60000}}}}The record's own messages are not sent to the model. The first requests come
from your callback's answer to the start event, so put whatever the callback
needs to begin in context.
| Field | Type | Description |
|---|---|---|
url | string (required) | Your callback endpoint. HTTPS only. Internal and private addresses are rejected. |
authorization | string (required) | Sent verbatim as the Authorization header on every callback request. |
context | object | Opaque JSON echoed to the callback on every event. |
max_steps | integer | Callback answers of continue or wait allowed. Default 128, range 1 to 512. |
max_requests | integer | Inference requests allowed across all steps. Default 512, range 1 to 4096. |
max_tokens | integer | Prompt plus output tokens allowed across all of the record's requests. Default 10,000,000, range 1 to 100,000,000. |
callback_timeout_ms | integer | Timeout for one callback HTTP attempt. Default 120000, range 1000 to 600000. |
timeout_ms | integer | Deprecated alias of callback_timeout_ms, same meaning. Accepts up to 1800000 and applies at most 600000. Sending both with different values fails the record with invalid_workflow. |
Omitted fields take their defaults. A value outside its range, a non-HTTPS
url, or a missing authorization fails that record at validation with
invalid_workflow, before any capacity is provisioned.
The callback
OpenRelay sends POST <url> with your authorization value and a JSON body:
| Field | Type | Description |
|---|---|---|
event_id | string | Deterministic id of this event. A retried or resumed delivery of the same event carries the same event_id. |
event | string | start (first call), results (the previous step's requests finished), or wait (the pause you asked for is over). |
workflow_id | string | <batch_id>:<custom_id>. |
record_id | string | The record's custom_id. |
step | integer | Callback answers consumed so far, starting at 0. |
attempt | integer | Always step + 1. |
context | object | Your context, unchanged. |
results | array | On results events: one entry per request of the previous step, in the order you returned them. |
Each entry of results:
| Field | Type | Description |
|---|---|---|
id | string | Your request id, or a generated <workflow_id>:step:<n>:req:<index> when you sent none. |
stage_id | string | Your stage_id, defaulting to the request's custom_id. |
attempt | integer | Your attempt, defaulting to 1. |
custom_id | string | The request's custom_id. |
status_code | integer | HTTP status of the inference. 400 when the request failed OpenRelay's checks, 500 when the inference failed, or the status of the OpenRelay gateway answer it failed on, such as a 503 that kept coming for 30 minutes. |
body | object | The full endpoint response, for example a chat completion. |
error | string | Why the request failed, when it did. |
usage | object | { "input_tokens", "output_tokens" } for this request. |
Answer 200 with one of four states:
state | Other fields | What happens |
|---|---|---|
continue (or omitted) | requests: 1 to 32 requests | OpenRelay runs them, then sends a results event. |
wait | retry_after_ms (default 1000, max 60000) | OpenRelay pauses, then sends a wait event. |
completed | output: a JSON object | The record succeeds with output as its result body. |
failed | error: a message | The record fails with workflow_failed and your message (first 1,000 characters). |
Each entry of requests is one inference:
| Field | Type | Description |
|---|---|---|
custom_id | string (required) | Your name for this request. Unique within the step. |
body | object (required) | The request body, as for the batch's endpoint. Up to 2 MiB. |
id | string | Your stable id for the request. Unique within the step. |
stage_id | string | Your label for the stage this request belongs to. |
attempt | integer | Your retry counter for this request, default 1. |
method | string | POST if present. |
url | string | Must match the batch's endpoint if present. |
A request body may name only the batch's model (or omit model), cannot
stream, and cannot carry openrelay.workflow. It may carry
openrelay.tool_config, and its tool rounds count as that one request. A
request that breaks these rules does not run: it comes back in results with
status 400.
Your answer can be up to 4 MiB. A 429, a 5xx, a timeout, or a connection
error is retried, up to four attempts per event with callback_timeout_ms on
each. Any other non-2xx answer fails the record with workflow_callback_rejected,
and an answer that breaks the rules above fails it with
workflow_invalid_response.
Make your callback idempotent
The same event can reach your callback more than once: after a lost
response, or when a batch worker restarts and resumes the record from its
last saved step. Key your state on event_id and give a repeated event the
same answer. Repeating the same request id and attempt in the same step
lets OpenRelay reuse a result it already has instead of running and billing
the request again.
Budgets
A record is bounded by the work it does, never by a per-record clock (each request it runs has its own time limit, see Time limits):
| Budget | Default | Range | Checked | Failure code |
|---|---|---|---|---|
max_steps | 128 | 1 to 512 | after each callback answer | workflow_step_limit |
max_requests | 512 | 1 to 4096 | before a step starts | workflow_request_limit |
max_tokens | 10,000,000 | 1 to 100,000,000 | as each request finishes | workflow_token_limit |
- Steps. Every
continueorwaitanswer is one step. The limit is checked after the answer, so your callback can still answercompletedorfailedon its last step. Awaitpauses at most 60 seconds, so a record that only waits stops after aboutmax_stepsminutes. - Requests. Before a step runs, OpenRelay adds its requests to the ones the
record has already run. If that passes
max_requests, none of the step's requests run and the record fails. Every request counts, including one that comes back400or500. - Tokens. Prompt plus output tokens, summed over the record's requests.
This is a record budget, separate from the
max_tokensa request body sets for one completion. When a finished request takes the total past the limit, the step's requests still running are cancelled and not billed, and the record fails. The total can end slightly above the limit, by the requests that finished in that step.
Concurrency
- Requests in one step run concurrently. Return independent requests in the same step to run them side by side.
- Steps run in order. The next event is sent only when every request of
the step has finished, and its
resultsare in the order you returned the requests. - Capacity is per model, not per record. OpenRelay sets how many requests
run at once for each model, and every record in the batch shares that
capacity. Returning 32 requests in a step does not reserve 32 slots; they
run as slots free up, alongside other records. A record that is waiting on
your callback or on a
waitholds no model capacity.
Billing
Every request that returns a successful response is billed at the batch rate,
on the tokens the model reported for it, when it finishes. That holds even if
the record later fails, hits a budget, or expires: you pay for the requests
that ran, and nothing for the ones that did not. Requests that come back with
an error, and requests cancelled by max_tokens or the end of the batch
window, are not billed.
The record's result line carries a usage object summed over its requests,
and the batch's usage includes the tokens and cost of every workflow record,
failed and expired ones included. request_counts still counts records.
Time limits
A workflow record has no deadline of its own: it can run for as long as it
keeps doing work, up to its budgets, inside the batch's
24-hour window. When the window ends, a record
that has not finished gets an error line with batch_expired. Requests it
completed before then stay billed; requests still running are cancelled.
Each request the record runs has the batch's
30-minute limit per model call. A request that
has not returned its response within 30 minutes is stopped, is not retried, and
is not billed. It comes back to your callback with status_code 500 and an
error that starts with request_time_limit, and the record keeps running, so
your callback decides what to do next. A request whose model server failed under
it is the exception: it runs again on new capacity.
callback_timeout_ms bounds one HTTP call to your callback and nothing else.
If all four attempts to deliver one event fail, the record fails with
workflow_callback_unreachable, keeps the bill for the requests that ran, and
the rest of the batch keeps running.
Result and failure codes
A completed record's output line has status_code 200 and your output as
its body, with a usage object added:
{
"id": "batch_req_contract-42",
"custom_id": "contract-42",
"response": {
"status_code": 200,
"body": {
"summary": "Net 30, auto-renews yearly, 90-day termination notice.",
"usage": { "prompt_tokens": 35595, "completion_tokens": 416, "total_tokens": 36011 }
}
},
"error": null
}A record that fails gets an error line with one of these codes:
| Code | Meaning |
|---|---|
invalid_workflow | The openrelay.workflow object is malformed. Found at validation. |
workflow_not_enabled | Workflow records are not enabled for your organization. Found at validation. |
workflow_step_limit | The callback kept answering continue or wait past max_steps. |
workflow_request_limit | The next step would have taken the record past max_requests. |
workflow_token_limit | The record's requests used more than max_tokens. |
workflow_callback_unreachable | All four delivery attempts of one event failed. |
workflow_callback_rejected | The callback answered a non-2xx status other than 429 or 5xx. |
workflow_invalid_response | The callback's answer broke the contract (for example completed without an output object, or more than 32 requests). |
workflow_failed | The callback answered failed. |
batch_expired | The batch window ended before the record finished. |
Worked example
A contract record extracts three clauses in parallel, then asks for a summary built from them.
1. Start. OpenRelay sends the first event:
{
"event_id": "wf_evt_3c1d9a0e5b7f42c8a6e1d0b9f4c2a7e3",
"event": "start",
"workflow_id": "batch_9f8e7d6c5b4a:contract-42",
"record_id": "contract-42",
"step": 0,
"attempt": 1,
"context": { "contractId": "contract-42" }
}The callback returns three independent requests, which run concurrently:
{
"state": "continue",
"requests": [
{ "id": "payment", "custom_id": "payment", "body": { "model": "openrelay/gemma-4-31b", "messages": [{ "role": "user", "content": "Extract the payment terms: ..." }] } },
{ "id": "renewal", "custom_id": "renewal", "body": { "model": "openrelay/gemma-4-31b", "messages": [{ "role": "user", "content": "Extract the renewal terms: ..." }] } },
{ "id": "termination", "custom_id": "termination", "body": { "model": "openrelay/gemma-4-31b", "messages": [{ "role": "user", "content": "Extract the termination terms: ..." }] } }
]
}2. Results. When all three finish, the callback gets them in the same order (bodies shortened):
{
"event_id": "wf_evt_8b2e4f6a1c3d5e7f9a0b2c4d6e8f0a1b",
"event": "results",
"workflow_id": "batch_9f8e7d6c5b4a:contract-42",
"record_id": "contract-42",
"step": 1,
"attempt": 2,
"context": { "contractId": "contract-42" },
"results": [
{ "id": "payment", "stage_id": "payment", "attempt": 1, "custom_id": "payment", "status_code": 200, "body": { "choices": [{ "message": { "content": "Net 30." } }] }, "usage": { "input_tokens": 11820, "output_tokens": 140 } },
{ "id": "renewal", "stage_id": "renewal", "attempt": 1, "custom_id": "renewal", "status_code": 200, "body": { "choices": [{ "message": { "content": "Auto-renews yearly." } }] }, "usage": { "input_tokens": 11790, "output_tokens": 96 } },
{ "id": "termination", "stage_id": "termination", "attempt": 1, "custom_id": "termination", "status_code": 200, "body": { "choices": [{ "message": { "content": "90 days written notice." } }] }, "usage": { "input_tokens": 11805, "output_tokens": 118 } }
]
}The callback returns one dependent request:
{
"state": "continue",
"requests": [
{ "id": "summary", "custom_id": "summary", "body": { "model": "openrelay/gemma-4-31b", "messages": [{ "role": "user", "content": "Summarize: Net 30. Auto-renews yearly. 90 days written notice." }] } }
]
}3. Done. On the next results event (step 2), the callback answers:
{ "state": "completed", "output": { "summary": "Net 30, auto-renews yearly, 90-day termination notice." } }The record used 2 steps, 4 requests, and 36,011 tokens, well inside the
budgets in its config, and produced the output line shown above. All four
requests are billed. Had the callback answered failed at step 2 instead, the
record would get a workflow_failed error line and all four requests (the three
extractions and the summary, which had already run) would still be billed.
Results
The Batch API output and error file formats: one JSONL line per request, joined back to your inputs by custom_id, and how to reconcile usage.
Dedicated endpoints
Serve a model on capacity reserved for your organization, call it with its private model id, and monitor its requests, errors, latency, and tokens.