Docs/Structured output
Concepts

Structured output

Advanced. Add options to a message to get typed output, checked results and a record of each step.

View as Markdown

A 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:

json
{
  "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:

json
{
  "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:

json
{
  "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.

OptionTypeDefaultWhat it does
output_schemaobject (JSON Schema)noneThe shape the turn's output has to match. At most 32 KB. See Output.
inputs{ [name]: string | number | boolean }noneOne value for each {{name}} placeholder in message, for example a vendor and a month. See Templates and inputs.
success_criteriastring or Check[]noneA sentence for a model to judge the result against (at most 4,000 characters), or 1 to 20 checks. A check is an object with id, check and an optional returnEvidence. See Success criteria.
idempotency_keystringnone1 to 255 characters, unique per account. The same key again returns the earlier turn and sends nothing. See Idempotency.
recordTrajectorybooleanfalsetrue records a screenshot, the title, the code and the agent's reasoning for each step. See Trajectory.

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:

json
{
  "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):

json
{
  "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.
  • pattern uses RE2 syntax: no lookahead, lookbehind or backreferences. A schema that does not compile fails with 400 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:

ts
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. evidence holds 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. evidence holds one entry per check, with its id, passed and score, plus evidence for a check that set returnEvidence.

    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

statusMeaning
runningSai is working, or the computer is starting.
needs_inputAn approval is pending. See Approvals.
finalizingThe turn is over. The output and checks are being worked out, usually within seconds.
succeededoutput is ready and every check passed.
failedreason_code says why.
reason_codeMeaning
agent_errorSai's turn failed, or it finished without an answer. See error_code.
output_invalidNo answer matched output_schema after 3 attempts.
criteria_not_metAt least one check failed.
criteria_uncheckedThe checks could not be run.
machine_unavailableThe computer failed to start.
not_startedThe computer was ready, but Sai did not pick the message up within 10 minutes.

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.