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
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"
}
]
}'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:
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.
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:
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
{
"sessionId": "cs_01HZY",
"machineId": "m_7f2a",
"queued": false,
"turnId": "turn_abPDn3htwBVxbWOhuLPL"
}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].
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
When machineId is omitted and several computers qualify, the 400 lists them:
{
"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 sendwait: falseexplicitly. - With or without
options, a message withoutsessionIdcontinues this API key's conversation on that computer. Call/new-sessionfirst when a result should not depend on earlier messages. - Errors in
optionsreturn 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: falseand optionalmachineIdare available from October 1. Today's staging streams every message and requiresmachineId.