Skip to content
OpenRelay is in early access, and the /v1 API is stable. New capabilities ship in the changelog.
GuidesInference APIsBatch API

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.

FieldTypeDescription
urlstring (required)Your callback endpoint. HTTPS only. Internal and private addresses are rejected.
authorizationstring (required)Sent verbatim as the Authorization header on every callback request.
contextobjectOpaque JSON echoed to the callback on every event.
max_stepsintegerCallback answers of continue or wait allowed. Default 128, range 1 to 512.
max_requestsintegerInference requests allowed across all steps. Default 512, range 1 to 4096.
max_tokensintegerPrompt plus output tokens allowed across all of the record's requests. Default 10,000,000, range 1 to 100,000,000.
callback_timeout_msintegerTimeout for one callback HTTP attempt. Default 120000, range 1000 to 600000.
timeout_msintegerDeprecated 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:

FieldTypeDescription
event_idstringDeterministic id of this event. A retried or resumed delivery of the same event carries the same event_id.
eventstringstart (first call), results (the previous step's requests finished), or wait (the pause you asked for is over).
workflow_idstring<batch_id>:<custom_id>.
record_idstringThe record's custom_id.
stepintegerCallback answers consumed so far, starting at 0.
attemptintegerAlways step + 1.
contextobjectYour context, unchanged.
resultsarrayOn results events: one entry per request of the previous step, in the order you returned them.

Each entry of results:

FieldTypeDescription
idstringYour request id, or a generated <workflow_id>:step:<n>:req:<index> when you sent none.
stage_idstringYour stage_id, defaulting to the request's custom_id.
attemptintegerYour attempt, defaulting to 1.
custom_idstringThe request's custom_id.
status_codeintegerHTTP 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.
bodyobjectThe full endpoint response, for example a chat completion.
errorstringWhy the request failed, when it did.
usageobject{ "input_tokens", "output_tokens" } for this request.

Answer 200 with one of four states:

stateOther fieldsWhat happens
continue (or omitted)requests: 1 to 32 requestsOpenRelay runs them, then sends a results event.
waitretry_after_ms (default 1000, max 60000)OpenRelay pauses, then sends a wait event.
completedoutput: a JSON objectThe record succeeds with output as its result body.
failederror: a messageThe record fails with workflow_failed and your message (first 1,000 characters).

Each entry of requests is one inference:

FieldTypeDescription
custom_idstring (required)Your name for this request. Unique within the step.
bodyobject (required)The request body, as for the batch's endpoint. Up to 2 MiB.
idstringYour stable id for the request. Unique within the step.
stage_idstringYour label for the stage this request belongs to.
attemptintegerYour retry counter for this request, default 1.
methodstringPOST if present.
urlstringMust 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):

BudgetDefaultRangeCheckedFailure code
max_steps1281 to 512after each callback answerworkflow_step_limit
max_requests5121 to 4096before a step startsworkflow_request_limit
max_tokens10,000,0001 to 100,000,000as each request finishesworkflow_token_limit
  • Steps. Every continue or wait answer is one step. The limit is checked after the answer, so your callback can still answer completed or failed on its last step. A wait pauses at most 60 seconds, so a record that only waits stops after about max_steps minutes.
  • 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 back 400 or 500.
  • Tokens. Prompt plus output tokens, summed over the record's requests. This is a record budget, separate from the max_tokens a 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 results are 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 wait holds 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:

CodeMeaning
invalid_workflowThe openrelay.workflow object is malformed. Found at validation.
workflow_not_enabledWorkflow records are not enabled for your organization. Found at validation.
workflow_step_limitThe callback kept answering continue or wait past max_steps.
workflow_request_limitThe next step would have taken the record past max_requests.
workflow_token_limitThe record's requests used more than max_tokens.
workflow_callback_unreachableAll four delivery attempts of one event failed.
workflow_callback_rejectedThe callback answered a non-2xx status other than 429 or 5xx.
workflow_invalid_responseThe callback's answer broke the contract (for example completed without an output object, or more than 32 requests).
workflow_failedThe callback answered failed.
batch_expiredThe 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.

On this page