Sai HTTP API
Send tasks to your Sai agent programmatically over HTTPS. The same REST surface that powers the sai CLI, available for CI pipelines, devices, and custom integrations.
Overview
The Sai HTTP API exposes your agent over HTTPS. It is the same REST surface the sai CLI is built on — use it directly to integrate Sai into CI pipelines, devices, or your own applications.
All endpoints share a single base URL:
https://api.sai.simular.aiAgent endpoints live under /v1/agents and account (API key) endpoints under /v1/account. All requests and responses are JSON unless noted otherwise.
Authentication
Every request requires a bearer token in the Authorization header. Two credential types are accepted:
- API key — a long-lived secret prefixed
sapi_. Best for servers, CI, and devices. Generate one withsai key generateor from Settings → API Keys in the desktop app. - Firebase ID token — a short-lived token from an interactive Google sign-in. Required for managing API keys.
curl https://api.sai.simular.ai/v1/agents/auth \
-H "Authorization: Bearer sapi_your_api_key_here"{ "ok": true, "userId": "abc123", "authType": "apiKey" }/v1/account/keys) reject API-key auth — they require a Firebase session so that a leaked key cannot enumerate or revoke other keys.Conventions
- Base path — agent routes are under
/v1/agents; account routes under/v1/account. - Content type — send and receive
application/json, exceptPOST /v1/agents/upload(raw binary) andPOST /v1/agents/message(an SSE response stream). - Ownership — every
machineIdis verified against the authenticated user. Passing another user’s machine returns403or404. - Scoping — sessions are scoped to the authenticated account, not just to the machine. A machine can be registered to more than one account, so
GET /v1/agents/sessionsfilters on both and lists only your own conversations. Nowhere do you name a session: no endpoint takes a session id, and the one every session-aware endpoint acts on is resolved server-side from a record no client can read or write. Another account’s conversation on the same machine isn’t addressable rather than being refused. - Errors — non-2xx responses return
{ "error": "…" }. See error responses.
Sessions
A session is a conversation — a transcript plus the address replies are sent back to. The API gets one dedicated session per user, per machine, created lazily on your first POST /v1/agents/message and reused by every call after it. You never send a session id: the server resolves it for you, and every session-aware endpoint below acts on that same session.
One session per surface, not per client
Sessions are keyed by the surface a message arrived on, not by who sent it. Each surface gets its own conversation on a machine, and everything reaching the agent through /v1/agents counts as one surface. The surfaces you can reach today:
| Surface | Who lands in it |
|---|---|
| API | The sai CLI, your curl scripts, cron jobs, CI runners — everything on this page |
| Desktop app | Whatever the person at the keyboard is doing |
| Telegram | That channel’s own conversation |
| iMessage | That channel’s own conversation |
The sai CLI and direct API use are deliberately the same surface: they hit the same routes, the surface is decided server-side from the route rather than claimed by the caller, and they’re the same person on the same workspace either way. Splitting them would take a client hint the server can’t verify. As new surfaces are added they get their own conversation, on the same rule — a surface is a lane, a client isn’t.
There is no way to thread separate clients within a surface, and none is scheduled — treat the API session as shared account-wide state per workspace.
Separate from the desktop app
What you send over the API never interleaves with what someone types into the desktop, and a desktop user navigating away no longer aborts a task your API call started. In the desktop sidebar the API conversation appears in the Messaging group alongside Telegram and iMessage, with a terminal icon and a read-only composer — one row per workspace that has one, however many workspaces the account has.
Continuity across surfaces doesn’t require a shared transcript: the agent can search your other sessions on demand, so asking about something you started on the desktop still works.
sai or your API client to continue” — so a curl or server-SDK integration isn’t misdescribed there. One piece of legacy naming remains: a new conversation on this surface is titled CLI, because the CLI was its first client. The agent replaces that with a real title once the conversation has content, so it’s cosmetic and short-lived.Starting a new one
POST /v1/agents/new-session rotates the session: a fresh one becomes the API’s, and the previous one becomes an ordinary past conversation — kept and searchable, transcript and title intact. It is also the only way to move the API onto a different conversation: no endpoint accepts a session id, so there is nothing to “switch to”.
/new-session rotates it for every API client on that machine. A CI job starting fresh also moves the human’s sai conversation to a new one. Its 20/hour limit is the only thing bounding that, so don’t call it at the top of every automated run.Verify the caller’s credentials and return their identity. Useful as a health check before storing a credential.
{ "ok": true, "userId": "abc123", "authType": "apiKey" }List all machines registered to the user, sorted by name.
{
"machines": [
{ "machineId": "m_1a2b", "name": "MacBook Pro", "updatedAt": 1750000000000 }
]
}Resolve the user’s most-recently-active machine and its API session. Returns 404 if no machine is registered.
{ "machineId": "m_1a2b", "sessionId": "s_9z8y" }sessionId is omitted until an API session exists on that machine — it is created by your first POST /v1/agents/message, so a fresh machine returns { "machineId": "m_1a2b" } alone. This endpoint never creates one.
List your 20 most-recent sessions on a machine, newest first — desktop conversations included, other accounts’ never. Requires a machineId query parameter. active: true marks your API session, not whatever the desktop is currently showing.
curl "https://api.sai.simular.ai/v1/agents/sessions?machineId=m_1a2b" \
-H "Authorization: Bearer sapi_..."{
"sessions": [
{ "sessionId": "s_9z8y", "title": "Email action items", "updatedAt": 1750000000000, "active": true }
]
}The listing is read-only, and the ids in it aren’t an address: no endpoint takes a session id, so nothing here repoints the API at an older conversation. POST /v1/agents/new-session is the only thing that moves it. To reach an older conversation, ask the agent about it.
Rotate the machine’s API session: a fresh session is created and becomes the one /message, /context, and /abort act on. The previous session is not deleted — it stops being the API’s conversation and becomes an ordinary past one, so it stays readable in the desktop app and searchable by the agent, transcript and title intact.
The desktop app’s own conversation is untouched: this does not move the machine’s active session, so a desktop user isn’t pulled out of what they had open. Every API client on the machine is moved, though — the session is shared across them, so this also rotates the conversation the sai CLI is using.
Body
{ "machineId": "m_1a2b" }{ "sessionId": "s_new123" }Fetch recent messages from your API session — so it reflects what you sent over the API, not what the desktop user has been doing. Query params: machineId (required) and limit (default 30, max 100 — a missing, non-numeric, or non-positive value falls back to the default).
{
"messages": [
{ "role": "user", "content": "list my unread emails", "timestamp": 1750000000000 },
{ "role": "assistant", "content": "You have 3 unread emails...", "timestamp": 1750000005000 }
]
}Returns { "messages": [] } when no API session exists yet on the machine. A GET never creates one — send a message first.
Upload a file to attach to a message. Send the raw file bytes as the request body and the filename in an x-filename header (URL-encoded). The MIME type is derived server-side from the extension. Call this before POST /v1/agents/message and pass the returned object in attachments.
curl -X POST https://api.sai.simular.ai/v1/agents/upload \
-H "Authorization: Bearer sapi_..." \
-H "x-filename: workflow.sim" \
--data-binary @./workflow.sim{
"path": "uploads/workflow-1750000000000.sim",
"name": "workflow.sim",
"mime": "text/plain",
"size": 1024,
"downloadUrl": "https://firebasestorage.googleapis.com/v0/b/.../o/...?alt=media&token=..."
}Send a message to the agent. The response is a Server-Sent Events stream (the Vercel AI SDK v6 UI Message Stream protocol) that emits the agent’s narrations, tool activity, approval requests, and final text until the task completes.
The message lands in this machine’s API session, creating it on the first call. There is no session parameter — each message continues the previous one.
Body
{
"machineId": "m_1a2b",
"message": "list my unread emails from today",
"attachments": [
{
"path": "uploads/workflow-1750000000000.sim",
"name": "workflow.sim",
"mime": "text/plain",
"size": 1024,
"downloadUrl": "https://firebasestorage.googleapis.com/..."
}
]
}attachments is optional and accepts the objects returned by /v1/agents/upload.
Request
curl -N -X POST https://api.sai.simular.ai/v1/agents/message \
-H "Authorization: Bearer sapi_..." \
-H "Content-Type: application/json" \
-d '{"machineId":"m_1a2b","message":"list my unread emails"}'Response (SSE)
data: {"type":"start"}
data: {"type":"data-status","data":{"text":"Sai is working on this..."}}
data: {"type":"reasoning-start","id":"r1"}
data: {"type":"reasoning-delta","id":"r1","delta":"Opening Gmail..."}
data: {"type":"reasoning-end","id":"r1"}
data: {"type":"text-start","id":"t1"}
data: {"type":"text-delta","id":"t1","delta":"You have 3 unread emails: ..."}
data: {"type":"text-end","id":"t1"}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]See streaming events for the full event catalogue, and consuming the stream for a typed schema and readUIMessageStream / useChat integration.
Messages handled as commands
A short list of exact phrases is interpreted by the server instead of being delivered to the agent, so they never enter the transcript: restart agent / restart sai, restart machine / restart computer / full restart, the auto-approve toggles (always yes, always approve, auto approve, always, auto yes and stop auto, disable auto, ask me, stop always), and — only while an approval or a question is pending — replies like yes, no, or a numbered choice. Matching is on the whole message, case-insensitively. For approvals prefer POST /v1/agents/approve, which names a specific approvalId instead of relying on what happens to be pending.
These still return a well-formed stream: data-status carrying what happened, then finish. If the command failed, you get an error event instead — that distinction is what lets sai -m "restart agent" && deploy exit non-zero on a restart that didn’t happen. restart agent restarts the agent running your API session, not whichever one the desktop has open.
Errors
Failures before the stream opens are plain JSON with a status code — 403 for a machine that isn’t yours, 400 for an attachment URL that isn’t one of your uploads, 429 over the hourly limit. Once headers have flushed, every failure arrives as a terminal error event instead, so a fault while the message is being routed ends the stream rather than hanging it.
Resolve a pending approval surfaced by a data-approval-request event during a message stream. The original stream stays open and the agent continues once resolved.
Body
{ "approvalId": "ap_123", "response": "yes" }response is one of yes, no, or always (only valid where the event set allowAlways — non-dangerous shell commands). Requests with isLinkOnly can’t be resolved here at all; they need the desktop app.
{ "ok": true, "status": "approved" }
// status is "approved", "approved_always", or "denied"Abort the task running in your API session. It stops what the API started and leaves a task the desktop user launched running. Idempotent — safe to call when nothing is running.
Body
{ "machineId": "m_1a2b" }{ "ok": true, "aborted": true }
// or, when the API session is idle or doesn't exist yet:
{ "ok": true, "aborted": false, "reason": "no active session" }Restart the agent process or the full cloud machine. Returns 422 if restart is not supported for the workspace type.
Body
{ "machineId": "m_1a2b", "target": "agent" }target is agent (just the Sai process) or machine (full VM, slower).
{ "ok": true, "action": "restarted" }Generate a long-lived API key. The plaintext key is returned once — only its hash is stored. Maximum 10 keys per account.
Body
{ "name": "ci-pipeline" }{ "key": "sapi_AbC123...", "keyId": "k_456", "name": "ci-pipeline" }List API keys for the authenticated user (IDs and labels only — never the plaintext secret).
{
"keys": [
{ "keyId": "k_456", "name": "ci-pipeline", "createdAt": 1750000000000, "lastUsedAt": 1750000500000 }
]
}Revoke an API key by ID. Returns 204 No Content on success.
curl -X DELETE https://api.sai.simular.ai/v1/account/keys/k_456 \
-H "Authorization: Bearer <firebase-id-token>"Streaming events
POST /v1/agents/message streams newline-delimited SSE frames (data: {...}\n\n). Each frame is a JSON object with a type. Unknown event types should be ignored for forward compatibility.
Every stream carries exactly one terminal event — finish or error — followed by a literal data: [DONE] sentinel that closes the body. Take the outcome from the terminal event, not from [DONE]: [DONE] isn’t JSON, so a parser that only handles JSON frames never observes it, and a stream that ends without a terminal event is a dropped connection rather than a success.
| Event type | Meaning |
|---|---|
start | Stream opened. |
data-status | Informational status line (data.text). The first one arrives right after start; it also carries the result of a message handled as a command. |
text-start / text-delta / text-end | The agent’s final response text, streamed in deltas. |
reasoning-start / -delta / -end | Mid-turn narration emitted between tool calls. |
tool-input-start | A tool invocation began (toolName, toolCallId, toolMetadata.retrying). |
tool-input-available | Tool input is ready. |
data-progress | A progress line from a running tool (data.text, data.tool). |
tool-output-error | A tool errored (errorText). The task may still retry or recover. |
data-approval-request | The agent needs permission. Resolve via POST /v1/agents/approve using data.approvalId. |
finish | Terminal. The turn completed (finishReason). |
error | Terminal. The turn failed (errorText) — no finish follows. |
: keepalive) every 20 seconds during long tasks so proxies don’t close idle connections. Ignore comment lines in your parser.Consuming the stream
The response follows the Vercel AI SDK UI Message Stream protocol, so you can consume it two ways: parse the raw SSE frames yourself in any language, or — in a TypeScript app — hand the stream to the AI SDK (readUIMessageStream or the useChat React hook) and read fully-typed message parts.
Typed schema
The custom data-* parts are the Sai-specific extension to the protocol. Model them as a data-parts record keyed by the part name without its data- prefix. Each part arrives on the wire as { type: "data-<key>", data, id? }, and the AI SDK surfaces it under that same type in message.parts.
import type { UIMessage } from "ai";
/**
* Sai's custom data parts, keyed by the part name without its `data-`
* prefix. Each is streamed as { type: "data-<key>", data, id? } and shows
* up under that `type` in message.parts.
*
* Declared as a `type` (not `interface`) so it satisfies the AI SDK's
* `UIDataTypes` (Record<string, unknown>) constraint on UIMessage.
*/
export type SaiDataParts = {
/**
* Informational status line. One arrives right after `start`, and one
* carries the result of a message the server handled as a command.
*/
status: {
text: string;
};
/** Progress line from a running tool. */
progress: {
text: string;
/** Tool that emitted the line, or "tool" when it can't be identified. */
tool: string;
};
/**
* The agent needs permission to continue. Resolve it with
* POST /v1/agents/approve using `approvalId`; the stream stays open
* and the agent resumes once you respond.
*/
"approval-request": {
approvalId: string;
title: string;
/** Why the approval is needed; "" when the agent gave no reason. */
description: string;
/** The agent's approval category. */
approvalType:
| "exec"
| "action"
| "browser_action"
| "desktop_action"
| "api_call"
| "service_connect"
| "service_auth"
| "user_input"
| "choice";
/**
* True for requests needing browser or form interaction — an OAuth
* connection (`service_connect`, `service_auth`) or a credential form
* (`user_input`). POST /approve can't resolve these; send the user to
* the desktop app.
*/
isLinkOnly: boolean;
/** Whether "always" is an allowed response (non-dangerous `exec` only). */
allowAlways: boolean;
};
}
/** A fully-typed Sai message. No custom metadata, hence `never`. */
export type SaiUIMessage = UIMessage<never, SaiDataParts>;data-* part without an id, so they accumulate: each status, progress line, and approval request is its own part. The protocol reserves a repeated id for “replace the earlier part with this one”, so write your rendering to append and you’ll stay correct if in-place updates start arriving.Framework-free (any language)
Read the response body, split on the blank line between SSE frames, and JSON.parse each data: payload. Skip comment lines (keepalives) and stop at [DONE]. The same shape works from Python, Go, or a shell — this is just fetch:
const res = await fetch("https://api.sai.simular.ai/v1/agents/message", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
machineId: "m_1a2b",
message: "list my unread emails",
}),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? ""; // keep the trailing partial frame
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue; // ": keepalive" comment lines have no data:
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
const event = JSON.parse(payload);
switch (event.type) {
case "text-delta":
process.stdout.write(event.delta);
break;
case "data-status":
console.error("·", event.data.text);
break;
case "data-approval-request":
if (event.data.isLinkOnly) console.error("Finish this in the desktop app.");
else await approve(event.data.approvalId); // POST /v1/agents/approve
break;
case "finish": // terminal — the turn succeeded
console.error(`\n[${event.finishReason}]`);
break;
case "error": // terminal — the turn failed
console.error(`\n[error] ${event.errorText}`);
process.exitCode = 1;
break;
}
}
}With readUIMessageStream
In TypeScript, feed the SSE frames to readUIMessageStream and iterate accumulated SaiUIMessage snapshots — each iteration is the same message re-emitted as new parts arrive. A small transform turns the SSE body into the chunk stream the reader expects:
import { readUIMessageStream, type InferUIMessageChunk } from "ai";
import type { SaiUIMessage } from "./sai";
// SSE response body -> ReadableStream of typed UI message chunks.
function toChunks(res: Response) {
let buffer = "";
return res.body!
.pipeThrough(new TextDecoderStream())
.pipeThrough(
new TransformStream<string, InferUIMessageChunk<SaiUIMessage>>({
transform(text, controller) {
buffer += text;
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const payload = line.slice(5).trim();
if (payload && payload !== "[DONE]") {
controller.enqueue(JSON.parse(payload));
}
}
},
}),
);
}
const res = await fetch("https://api.sai.simular.ai/v1/agents/message", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ machineId: "m_1a2b", message: "list my unread emails" }),
});
for await (const message of readUIMessageStream<SaiUIMessage>({
stream: toChunks(res),
})) {
for (const part of message.parts) {
// part.type and part.data are fully narrowed from SaiDataParts.
if (part.type === "text") render(part.text);
else if (part.type === "data-status") console.error(part.data.text);
else if (part.type === "data-progress")
console.error(`[${part.data.tool}] ${part.data.text}`);
else if (part.type === "data-approval-request" && !part.data.isLinkOnly)
void approve(part.data.approvalId);
}
}With useChat (React)
useChat renders the conversation for you. The API expects a { machineId, message } body rather than the SDK’s default { messages }, so reshape the request in a DefaultChatTransport. Type the hook with SaiUIMessage to get narrowed data-* parts in render:
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import type { SaiUIMessage } from "./sai";
const transport = new DefaultChatTransport<SaiUIMessage>({
api: "https://api.sai.simular.ai/v1/agents/message",
headers: { Authorization: `Bearer ${apiKey}` },
// Map the outgoing body to what /v1/agents/message expects.
prepareSendMessagesRequest({ messages }) {
const last = messages[messages.length - 1];
const message = last.parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
return { body: { machineId: "m_1a2b", message } };
},
});
export function Chat() {
const { messages, sendMessage, status } = useChat<SaiUIMessage>({ transport });
return (
<>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((part, i) => {
switch (part.type) {
case "text":
return <span key={i}>{part.text}</span>;
case "data-status":
case "data-progress":
return <em key={i}>{part.data.text}</em>;
case "data-approval-request":
return part.data.isLinkOnly ? (
<em key={i}>Finish “{part.data.title}” in the desktop app</em>
) : (
<button
key={i}
onClick={() => approve(part.data.approvalId, "yes")}
>
Approve: {part.data.title}
</button>
);
default:
return null;
}
})}
</div>
))}
<button
disabled={status !== "ready"}
onClick={() => sendMessage({ text: "list my unread emails" })}
>
Send
</button>
</>
);
}approve() helper above) — not through the SDK’s built-in tool-approval flow. The original message stream stays open and the agent continues once you respond.Rate limits
| Action | Limit |
|---|---|
| Send a message | 60 / hour |
| Upload a file | 30 / hour |
| Abort a task | 20 / hour |
| Start a new conversation | 20 / hour |
| Restart agent or machine | 10 / hour |
| Generate an API key | 5 / hour |
Limits are per user on a rolling one-hour window and shared across all clients. Exceeding one returns 429 with a message naming the limit.
Error responses
Errors use standard HTTP status codes with a JSON body of the form { "error": "message" }.
| Status | Meaning |
|---|---|
400 | Bad request — missing or invalid parameters. |
401 | Missing, invalid, or expired credential. |
403 | User not active, machine not owned, or an API key used on a Firebase-only endpoint. |
404 | Machine, approval request, or key not found. Sessions never appear here — you can’t name one. |
409 | Approval request is no longer pending. |
413 | Uploaded file exceeds 25 MB. |
422 | Key limit reached, or restart not supported. |
429 | Rate limit exceeded. |
503 | Auth service temporarily unavailable — retry shortly. |