Docs/Send a message
API reference

Send a message

POST /v1/agents/message

Sends a task to Sai on one computer. Returns at once with wait: false, or streams the turn with wait: true.

Every message gets a turnId; read the turn's result with GET /v1/agents/turns/{turnId}. For typed output and checked results, add options.

Request

Give Sai a task
curl -s "$SAI_API_URL/v1/agents/message" \
  -H "Authorization: Bearer $SAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Open Notepad, type hello, save it to the desktop as hello.txt",
    "machineId": "m_7f2a",
    "wait": false,
    "attachments": [
      {
        "path": "uploads/report-1790000000000.pdf",
        "name": "report-1790000000000.pdf",
        "mime": "application/pdf",
        "size": 48213,
        "fileId": "f_3kd9"
      }
    ]
  }'
FieldTypeRequiredNotes
messagestringyesThe task, in plain language.
machineIdstringnoOmit with one computer, or on a free account. With several computers the API answers 400 and lists the candidates.
waitbooleannoDefault true: stream the turn over SSE. false: return JSON at once and poll /events.
modelstringnoAn id from /models or auto. Written to the session before delivery and kept until you pass another. Omit to keep the session's current model.
attachmentsarraynoEach entry is the response of a /upload call. width and height are optional integers for images.
autonomousbooleannoDefault false. true runs the turn this message starts without approval cards. See Unattended runs.
sessionIdstringnoContinue this session. It has to be yours and on the same computer. Omit to continue your API conversation on that computer.
optionsobjectnoAdvanced. Typed output, checks, idempotency and step recording. See below.

Options (advanced)

A plain message needs none of these. Add options when your code has to use the result: the turn then also returns a typed output, checked against what you asked for. Structured output explains each one with examples.

The smallest useful one asks for JSON:

Give Sai a task with typed output
curl -s "$SAI_API_URL/v1/agents/message" \
  -H "Authorization: Bearer $SAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "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
  }'

Then poll GET /v1/agents/turns/{turnId} until status is succeeded, and read output.title.

OptionTypeNotes
output_schemaobjectJSON Schema the turn's output has to match. At most 32 KB. pattern uses RE2 syntax.
inputsobjectValues for the {{name}} placeholders in message: strings, numbers or booleans. Every placeholder needs a value, and every value has to be used.
success_criteriastring or arrayA string for a model to judge the result against (at most 4,000 characters), or up to 20 checks { id, check, returnEvidence? }.
idempotency_keystringAt most 255 characters, unique per account. The same key again returns the earlier turn and sends nothing.
recordTrajectorybooleanDefault false. true records each step with a screenshot, for /trajectory.
thinkingLevelstringoff, low, medium, high or xhigh: how long Sai reasons on this turn. Default: the account's Thinking strength setting.

output_schema, inputs or success_criteria make the message structured. idempotency_key, recordTrajectory and thinkingLevel work on plain messages too. An unknown key in options, or an option sent next to message instead of inside options, is a 400.

Unattended runs

Sai asks before risky actions, and a turn waits on each approval until someone answers it with /approve. A script with nobody to answer can send "autonomous": true instead:

Run a task without approvals
curl -s "$SAI_API_URL/v1/agents/message" \
  -H "Authorization: Bearer $SAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Export last month'\''s invoices to CSV",
    "autonomous": true,
    "wait": false
  }'

The turn this message starts runs as if "Trust for this task" had been answered up front, and the grant ends with the turn. A message that lands while a turn is running changes nothing for that turn. Approvals that only a person can complete in a browser (isLinkOnly, such as signing in to an account) still stop the turn. Only send it for work you would approve anyway: the agent acts without asking.

Response with wait: false

202 Accepted · Message sentJSON
{
  "sessionId": "cs_01HZY",
  "machineId": "m_7f2a",
  "queued": false,
  "turnId": "turn_abPDn3htwBVxbWOhuLPL"
}
FieldNotes
sessionIdPass to GET /events. The same id across messages: each one continues this API key's conversation on that computer, desktop included (every key has its own). /new-session starts a fresh one.
machineIdThe computer the message landed on. Useful when you omitted it.
queuedtrue when Sai was busy and the message was parked as a steer for the running task.
turnIdThe turn the message started, or with queued: true the running turn it joined. Pass to GET /v1/agents/turns/{turnId} for the result.

A repeated options.idempotency_key returns 200 with the earlier turn, in the shape of GET /v1/agents/turns/{turnId}, plus "idempotent_replay": true. Nothing is sent to Sai.

Refusals on this path are plain JSON, not stream events. A message the agent could not take (for example, the computer is unavailable) returns 409 { "error": "..." }. A message that was handled without starting a task, such as a plain acknowledgement, returns 200 { "error": "..." } with the reply text in error and no sessionId.

Response with wait: true

200 with Content-Type: text/event-stream. Each frame is data: {...} followed by a blank line; the stream ends with data: [DONE].

200 OK · Event streamText
data: {"type":"start","messageId":"msg_1"}

data: {"type":"data-session","data":{"sessionId":"cs_01HZY"}}

data: {"type":"data-turn","data":{"turnId":"turn_abPDn3htwBVxbWOhuLPL"}}

data: {"type":"text-start","id":"t_1"}

data: {"type":"text-delta","id":"t_1","delta":"Opening Notepad."}

data: {"type":"data-approval-request","data":{"approvalId":"ap_7Qw","title":"Command Approval Required","description":"","approvalType":"exec","isLinkOnly":false,"command":"notepad.exe"}}

data: {"type":"text-end","id":"t_1"}

data: {"type":"data-turn-result","data":{"turnId":"turn_abPDn3htwBVxbWOhuLPL","status":"succeeded","output":"Saved hello.txt to the desktop.","reason_code":null,"usage":{"inputTokens":18450,"outputTokens":2210}}}

data: {"type":"finish","finishReason":"stop"}

data: [DONE]

data-turn-result carries the turn as GET /v1/agents/turns/{turnId} returns it (shortened above). It comes just before finish, so a client that stops reading at finish still gets it. The stream waits up to 4 minutes after the turn for the output and checks; if they are not ready by then, the frame has status: "finalizing" and you read the result with GET /v1/agents/turns/{turnId}.

The stream stays open while an approval is pending. Answer it with /approve from another connection. The full frame list is on the Events page.

Errors

StatusBody
400Validation failed, or machineId was omitted and it cannot be resolved (below).
400{ "error": "unknown_model", "message": "Unknown model \"x\". See GET /v1/agents/models." }
400{ "error": "invalid_template", "message": "{{month}} has no value in inputs." }. Also for an unused input.
400{ "error": "invalid_output_schema", "message": "output_schema is not valid JSON Schema: ..." }
400{ "error": "session_machine_mismatch", "message": "Session cs_01HZY is on machine m_9c1e." }
403{ "error": "Machine not found or does not belong to your account." }
403{ "error": "model_not_allowed_for_plan", "message": "...", "allowedModels": ["auto"] } on the free plan. See Models.
403{ "error": "spend_cap_reached", "message": "...", "resetsAt": "2026-11-01T00:00:00.000Z" }: the API key reached its monthly spend limit. See Spend limits.
404{ "error": "session_not_found", "message": "No open session with this id on your account." }
409{ "error": "..." }: the message was refused (wait: false only).
409{ "error": "session_busy", "message": "..." }: a structured message, recordTrajectory on a turn that does not record, or thinkingLevel, sent while Sai is working. Retry when idle.
429{ "error": "Message rate limit exceeded. Maximum 600 messages per hour." } (60 on the free plan)

When machineId is omitted and several computers qualify, the 400 lists them:

400 Bad Request · Several computersJSON
{
  "error": "Several computers are linked to this account. Pass machineId. See GET /v1/agents/machines.",
  "machines": [
    { "machineId": "m_7f2a", "name": "Work Desktop" },
    { "machineId": "m_9c1e", "name": "Sai cloud computer" }
  ]
}

name is absent for a computer without one. A paid account with no computer at all gets a 400 asking you to create one in the Sai app or the portal.

Gotchas

  • Attachments need a fileId. Upload first; do not build attachment objects by hand.
  • The default is wait: true. A coding agent or a script should send wait: false explicitly.
  • With or without options, a message without sessionId continues this API key's conversation on that computer. Call /new-session first when a result should not depend on earlier messages.
  • Errors in options return before anything is sent.
  • On a free account the first request of the day starts the computer. Expect about two minutes before the first event.

Note

wait: false and optional machineId are available from October 1. Today's staging streams every message and requires machineId.

© 2026 Simular Inc. All rights reserved.