Structured output
Advanced. Add options to a message to get typed output, checked results and a record of each step.
View as MarkdownA plain message is all most tasks need: Sai does the work and text holds its answer. When your code has to use the result, add options to the same POST /message call. Sai still works the same way; the turn also returns a typed result.
A first example
Ask for the top story on Hacker News as JSON. The only difference from a plain message is options.output_schema:
{
"message": "Open https://news.ycombinator.com and read the title of the top story.",
"options": {
"output_schema": {
"type": "object",
"properties": { "title": { "type": "string" } },
"required": ["title"]
}
},
"wait": false
}The 202 includes a turnId:
{
"sessionId": "cs_01HZY",
"machineId": "m_7f2a",
"queued": false,
"turnId": "turn_abPDn3htwBVxbWOhuLPL"
}Poll GET /v1/agents/turns/{turnId} every few seconds until status is succeeded or failed:
{
"turnId": "turn_abPDn3htwBVxbWOhuLPL",
"status": "succeeded",
"output": { "title": "Show HN: A tiny database in 500 lines" },
"reason_code": null,
"evidence": [],
"usage": { "inputTokens": 18450, "outputTokens": 210 }
}output matches your schema, so your code can read output.title directly. The curl example runs this from a shell.
Advanced options
Everything below is optional and goes inside options. An unknown key, or one of these sent next to message instead of inside options, is a 400.
output_schema, inputs or success_criteria make the message structured. idempotency_key and recordTrajectory work on plain messages too.
A fuller example, with every option. The request:
{
"message": "Sign in to the {{vendor}} portal and list the invoices dated in {{month}}.",
"options": {
"inputs": { "vendor": "Acme", "month": "September 2026" },
"output_schema": {
"type": "object",
"properties": {
"invoices": {
"type": "array",
"items": {
"type": "object",
"properties": {
"number": { "type": "string" },
"date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" },
"total": { "type": "number" }
},
"required": ["number", "date", "total"]
}
}
},
"required": ["invoices"]
},
"success_criteria": [
{ "id": "signed_in", "check": "Sai signed in and saw the invoice list." },
{
"id": "all_listed",
"check": "Every invoice dated in the month is in the answer.",
"returnEvidence": true
}
],
"idempotency_key": "acme-invoices-2026-09",
"recordTrajectory": true
},
"wait": false
}The POST answers 202 at once with the turnId. Once the turn has succeeded, GET /v1/agents/turns/{turnId} returns its result (shortened here):
{
"turnId": "turn_vzjz62w7j7jvOilZ6zBL",
"status": "succeeded",
"output": {
"invoices": [
{ "number": "INV-1043", "date": "2026-09-04", "total": 1280.5 },
{ "number": "INV-1051", "date": "2026-09-18", "total": 312 }
]
},
"reason_code": null,
"evidence": [
{ "id": "signed_in", "passed": true, "score": 0.97 },
{
"id": "all_listed",
"passed": true,
"score": 0.91,
"evidence": "The list filtered to September showed INV-1043 and INV-1051, and both are in the answer."
}
],
"usage": { "inputTokens": 48210, "outputTokens": 1630 },
"template": "Sign in to the {{vendor}} portal and list the invoices dated in {{month}}.",
"inputs": { "vendor": "Acme", "month": "September 2026" }
}The full response is on the Turns page.
Templates and inputs
Each {{name}} in message is replaced by inputs.name before Sai sees the message. Values are strings, numbers or booleans. Every placeholder needs a value and every input has to be used; otherwise the request fails with 400 invalid_template and nothing is sent.
Keep the values in inputs rather than in the message text, so the same template serves every run. The turn keeps both the template and its inputs.
Output
With output_schema, Sai is asked to state the schema's top-level properties in its final answer. After the turn, a model reads what Sai did and said and writes JSON constrained by your schema. The API validates it against the schema and, on failure, retries with the validator's errors: 3 attempts in all.
When no attempt passes, the turn fails with output_invalid, and raw_answer and output_errors show what Sai said and why it did not match. You never get a partial object.
- The schema is standard JSON Schema, at most 32 KB.
patternuses RE2 syntax: no lookahead, lookbehind or backreferences. A schema that does not compile fails with400 invalid_output_schema.- Ask for values Sai can see on the screen or state in its answer. For a large table, have Sai save a file on the computer and return its path.
Without output_schema, output is Sai's final answer as a string.
Success criteria
success_criteria is checked after the output, against the record of the turn (the task, Sai's steps and its final answer). It takes one of two forms:
success_criteria: string // a sentence, 1 to 4,000 characters
success_criteria: Check[] // 1 to 20 checks
interface Check {
id: string // letters, digits, _ or -; 1 to 64 characters; names the check in the result
check: string // a yes/no statement about the result, 1 to 1,000 characters
returnEvidence?: boolean // default false; true adds one sentence of evidence (one more model call)
}-
A string. A model decides whether the result meets it.
evidenceholds one entry, with the reason in one or two sentences.json"success_criteria": "Every invoice dated in the month is listed, with its number, date and total." -
A list of checks. Each check is scored from 0 to 1 (
score) and passes at 0.5 or above.evidenceholds one entry per check, with itsid,passedandscore, plusevidencefor a check that setreturnEvidence.json"success_criteria": [ { "id": "signed_in", "check": "Sai signed in and saw the invoice list." }, { "id": "all_listed", "check": "Every invoice dated in the month is in the answer.", "returnEvidence": true } ]
A failed check makes the turn failed with criteria_not_met. When the checks could not run at all, the reason is criteria_unchecked.
The checks read the turn's record, which includes text Sai read on the screen, so a page can influence them. Treat a pass as evidence, and verify high-stakes results in your own system.
Status
agent_error means the task itself failed; criteria_not_met means it finished but the result is wrong. Handle them differently.
Sessions
A structured message goes where a plain one would: to the session you pass as sessionId, or to your API conversation on that computer. So it can build on what came before; for example, sign in with a plain message, then send a structured step in the same session, then ask a plain follow-up about its result.
When a result should not depend on earlier conversation, start a fresh session first with POST /new-session. Most of what a later step needs is on the computer (a signed-in browser, downloaded files), so a new session loses little.
A session runs one turn at a time. A plain message sent while Sai is busy joins the running turn and gets that turn's turnId. A structured message can't join a turn that was started for something else, so it gets 409 session_busy; send it again when the session is idle. To run tasks in parallel, use separate computers.
Idempotency
idempotency_key is unique per account. Sending the same key again returns 200 with the earlier turn and "idempotent_replay": true, and sends nothing to Sai. Use one key per unit of work (one invoice, one claim), so a retry after a crash does not do the work twice.
Trajectory
Set recordTrajectory: true to record each step of the turn: its title, the code Sai ran, Sai's reason for it, and a screenshot. Read them with GET /v1/agents/turns/{turnId}/trajectory. It is off by default, and is decided when the turn starts: sent to a busy session whose running turn does not record, it gets 409 session_busy.
Usage
usage counts Sai's model calls in the turn. The calls made after the turn (output extraction, checks, evidence) are billed to your account but not counted in usage.