Synchronous & Asynchronous Execution
Some SOAT operations take longer than a request should be held open: an LLM generation, a document ingestion, an orchestration run. Every one of them is controlled by a single toggle, wait.
The contract is one sentence: an operation runs in the background by default and answers immediately with a handle; wait=true blocks until it settles and answers with the result.
This page is the canonical definition. Module pages describe what their own handle contains and link here for the rule.
The toggle
| Value | Response | Use when | |
|---|---|---|---|
| Default | wait omitted or false | 202 Accepted — or 201 Created when a run is created — plus a handle to poll (see Status codes) | The work may take a while and you have somewhere to put the result: a poll loop, a webhook, a UI that refreshes |
| Blocking | wait=true | 200/201 + the settled result | A script that needs the answer on the next line, or any flow that must observe requires_action |
wait is a query parameter on the generation and ingestion endpoints, and a body field on the run endpoints (start-orchestration-run, start-eval-run) — the same name and the same meaning either way.
# Background (default): returns a handle immediately
soat create-agent-generation --agent-id agent_01 \
--messages '[{"role":"user","content":"Summarize Q1"}]'
# Blocking: returns the finished generation
soat create-agent-generation --agent-id agent_01 --wait true \
--messages '[{"role":"user","content":"Summarize Q1"}]'
Where it applies
| Operation | Handle returned by default | Poll it with |
|---|---|---|
POST /agents/{agent_id}/generate | generation_id, trace_id | GET /generations/{generation_id} |
POST /sessions/{session_id}/generate | session_id | GET /conversations/{conversation_id}/messages |
POST /conversations/{conversation_id}/generate | conversation_id | GET /conversations/{conversation_id}/messages |
POST /documents/ingest and POST /documents/{document_id}/ingest | the document, in status: pending | GET /documents/{document_id}/status |
POST /orchestration-runs | the run, in status: queued | GET /orchestration-runs/{orchestration_run_id} |
POST /evals/{eval_id}/runs | the run, in status: queued | GET /evals/{eval_id}/runs/{eval_run_id} |
What the default does not change
Backgrounding defers the slow part, never the checks. Everything that can reject a request still runs before the accepted response:
- Authentication and permissions — a caller without the IAM action gets
401/403, not an accepted job that fails later. - Input validation — a malformed body is
400 VALIDATION_FAILED. - Resource resolution — an unknown agent, session, or conversation is
404. - Admission control — a breached quota is
429, and the agent-to-agent call-depth guard still fires. - The record write — the generation record exists before the response is written, so the
generation_idyou receive is immediately readable. It reportsin_progressuntil the run reachescompletedorfailed.
The practical consequence: an accepted response means admitted, and the only failures you have to discover by polling are the ones that happen during the model call itself.
Status codes
The accepted response is not the same status everywhere, because the two families of endpoint are doing different things:
| Family | Background | Blocking | Why |
|---|---|---|---|
| Work on an existing resource — agent, session and conversation generation, document ingestion | 202 Accepted | 200 (201 for ingestion) | The request is accepting work; there is no new resource whose creation the status could report |
| Run creation — orchestration runs, eval runs | 201 Created | 201 Created | A run row is created either way and is immediately readable; the mode shows up in its status (queued), not in the status code |
So branch on wait and on the run's own status field, never on 202 alone — a queued
orchestration or eval run answers 201.
The rule that is uniform: the response always carries something you can poll, and a caller
that omitted wait never receives a settled result.
A trigger firing has no wait of its own: it always starts an
eval run in the background, because a cron tick cannot hold a request open for a whole
dataset. The firing record names the evrun_… to poll.
Two combinations that are resolved for you
Streaming implies waiting. stream: true holds the response open by definition, so it is a blocking call whether or not you pass wait. Asking for both a stream and a background run (stream: true with ?wait=false) is contradictory and returns 400 VALIDATION_FAILED rather than silently dropping one of the two.
A builtin tool call always waits. When an agent calls another agent through a builtin tool, the nested call blocks regardless of the default: a tool call is one request returning one result, with no channel to poll a background run later. The field is not offered on the tool surface at all — the same treatment as stream, and for the same reason. See Agent-to-Agent Calls.
Choosing a mode
Reach for wait=true when the result is the next thing you need: a shell script, a smoke test, a tutorial step, or any client-tool flow — requires_action is only observable in a blocking response.
Stay on the default when the work is genuinely detached: a UI that can render a pending state, a batch ingestion, a run you will inspect later. Two things make this comfortable rather than a polling chore:
- Webhooks deliver generation lifecycle events, so you can react to completion instead of asking for it. Chat with an LLM wires this up end to end.
- Status endpoints are cheap.
GET /documents/{id}/statusreturns only lifecycle fields rather than the assembled document, and it advances during processing — see Polling Ingestion Status.