SAPI · Developer docs

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:

text
https://api.sai.simular.ai

Agent endpoints live under /v1/agents, account (API key) endpoints under /v1/account, and the sai CLI’s own channel under /v1/cli. 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 with sai key generate or 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.
bash
curl https://api.sai.simular.ai/v1/agents/auth \
  -H "Authorization: Bearer sapi_your_api_key_here"
json
{ "ok": true, "userId": "abc123", "authType": "apiKey" }
API key management endpoints (/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; the CLI channel under /v1/cli.
  • Content type — send and receive application/json, except POST /v1/agents/upload (raw binary) and the two /message routes (an SSE response stream).
  • Ownership — every machineId is verified against the authenticated user. Passing another user’s machine returns 403 or 404.
  • 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/sessions filters 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 from the route. Which session a given route resolves is not uniform yet, which is the subject of the last part of this section.

One session per channel, not per client

Sessions are keyed by the channel a message arrived on, not by who sent it. Each channel gets its own conversation on a machine, and the channel is decided server-side from the route you posted to. The channels you can reach today:

ChannelWho lands in it
APIYour curl scripts, cron jobs, CI runners, voice clients — anything posting to /v1/agents/message
CLIThe sai command, via /v1/cli/message
Desktop appWhatever the person at the keyboard is doing
TelegramThat channel’s own conversation
iMessageThat channel’s own conversation
All of your API clients on one machine share one conversation. There is no way to ask for a separate one: the surface is assigned server-side from the route, and no endpoint lets you choose or address a different session. An API key does not split it — the key is recorded on each message, so you can attribute a message to it, but a second key lands in the same transcript. Two clients streaming at once will interleave there and can cross each other’s turn boundaries, because completion is read from shared session state. Serialise concurrent callers, or point them at different workspaces if you have more than one.

The sai CLI used to share this conversation; as of sai 0.2.0 it has its own. Your scripts no longer land in the middle of what a human is typing into the terminal, and the CLI’s spoken-style acks stay out of your stream. The rule that produced both is still server-side: the channel comes from the route, never from a client hint the server can’t verify, so as new channels are added they get their own conversation the same way.

There is no way to thread separate clients within a channel, and none is scheduled — treat the API session as shared account-wide state per workspace.

Name your channel, or get the CLI’s

The session-aware endpoints predate the split, so they answered about the CLI whoever asked. Three of them now take a channel cli or api — and the rest still don’t:

EndpointWhose session it acts on
GET /context, GET /sessionsYours, with ?channel=api
POST /new-sessionYours, with "channel": "api" in the body
GET /session, POST /abort, POST /restartAlways the CLI’s — no channel yet
channel defaults to cli, not to your own channel. Omitting it is how a caller reads the terminal’s transcript and rotates the terminal’s conversation while its own keeps growing — the pre-split behaviour, kept so an older sai still works. Pass channel=api on every one of those three calls. A value that is neither cli nor api is rejected with 400 rather than quietly falling back.
You still cannot abort or restart your own work. /abort stops the CLI’s task, or reports no active session while yours keeps running, and /restart with target: agent restarts the agent serving the CLI. There is no way to cancel an API turn, so bound it with a client-side timeout instead. /session likewise reports the CLI’s sessionId — for your own, read the data-session event at the head of your stream.

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.

The desktop’s read-only banner names both clients — “This conversation happens over the Sai CLI or API — send from 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 with "channel": "api" rotates your conversation: a fresh one takes over and the previous becomes an ordinary past conversation — kept and searchable, transcript and title intact. It is also the only way to move onto a different conversation, since nothing accepts a session id and there is nothing to “switch to”.

Leave channel out and it rotates the human’s sai conversation instead, leaving yours untouched — the opposite of what a CI job asking to “start clean” wants, with a 200 and a sessionId rather than an error to say so. Its 20/hour limit is the only thing bounding that, so don’t call it at the top of every automated run either way.
GET/v1/agents/auth

Verify the caller’s credentials and return their identity. Useful as a health check before storing a credential.

json
{ "ok": true, "userId": "abc123", "authType": "apiKey" }
GET/v1/agents/machines

List all machines registered to the user, sorted by name.

json
{
  "machines": [
    {
      "machineId": "m_1a2b",
      "name": "MacBook Pro",
      "updatedAt": 1750000000000,
      "status": "hibernated",
      "canWake": true
    }
  ]
}

status is one of active, hibernated, hibernating or wakingup, and is omitted for a machine that has never reported one — read that as offline. canWake says whether a hibernated machine can be brought back remotely at all; it follows from where the machine is hosted, not from what it is doing now.

Read both before telling a user anything. A machine that is hibernated with canWake: false is asleep and staying that way, so “it’ll be up in a minute” is a promise nothing can keep — status alone cannot tell you that. POST /v1/agents/wake is how you act on it.

GET/v1/agents/sessionCLI session

Resolve the user’s most-recently-active machine and the CLI session on it. Returns 404 if no machine is registered.

json
{ "machineId": "m_1a2b", "sessionId": "s_9z8y" }

sessionId is omitted until the CLI has a session on that machine, so a fresh machine returns { "machineId": "m_1a2b" } alone. This endpoint never creates one, and unlike /context and /sessions it takes no channel — it always reports the CLI’s. For your own, read the data-session event at the head of your stream.

GET/v1/agents/sessionschannel

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 the current session of the channel you name — not whatever the desktop is currently showing. Pass channel=api or the flag points at the CLI’s.

bash
curl "https://api.sai.simular.ai/v1/agents/sessions?machineId=m_1a2b&channel=api" \
  -H "Authorization: Bearer sapi_..."
json
{
  "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.

POST/v1/agents/new-sessionchannel20/hour

Rotate the named channel’s conversation on a machine: a fresh session is created and becomes the one that channel reads and writes. The previous session is not deleted — it 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.

Body

json
{ "machineId": "m_1a2b", "channel": "api" }
json
{ "sessionId": "s_new123" }
channel is optional and defaults to cli, so omitting it rotates the human’s sai conversation and returns a sessionId your own channel will never write to — with a 200, not an error.
GET/v1/agents/contextchannel

Fetch recent messages from the named channel’s conversation — with channel=api, what you sent and what came back, and never what the desktop user has been doing. Query params: machineId (required), channel (cli or api, defaults to cli), and limit (default 30, max 100 — a missing, non-numeric, or non-positive value falls back to the default).

bash
curl "https://api.sai.simular.ai/v1/agents/context?machineId=m_1a2b&channel=api&limit=10" \
  -H "Authorization: Bearer $SAI_API_KEY"
json
{
  "messages": [
    { "role": "user", "content": "list my unread emails", "timestamp": 1750000000000 },
    { "role": "assistant", "content": "You have 3 unread emails...", "timestamp": 1750000005000 }
  ]
}

Returns { "messages": [] } when that channel has no session yet on the machine. A GET never creates one. This is the endpoint a conversational client answers “what were we just doing” from, so getting channel right is the difference between its own history and a terminal transcript it never wrote to.

POST/v1/agents/upload25 MB max30/hour

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.

bash
curl -X POST https://api.sai.simular.ai/v1/agents/upload \
  -H "Authorization: Bearer sapi_..." \
  -H "x-filename: workflow.sim" \
  --data-binary @./workflow.sim
json
{
  "path": "uploads/workflow-1750000000000.sim",
  "name": "workflow.sim",
  "mime": "text/plain",
  "size": 1024,
  "downloadUrl": "https://firebasestorage.googleapis.com/v0/b/.../o/...?alt=media&token=..."
}
POST/v1/agents/messageSSE60/hour

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

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

bash
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)

text
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.

Every message reaches the agent

This endpoint is pure delivery: no phrase is intercepted and handled server-side. Words that read like instructions to the platform — restart agent, always approve, or a bare yes while an approval is pending — are written to the transcript and forwarded to the agent like any other text. Use the dedicated endpoints for those: POST /v1/agents/approve names a specific approvalId rather than relying on what happens to be pending, and POST /v1/agents/restart restarts the agent.

Chat channels (Telegram, iMessage) do classify messages as commands. This API deliberately does not, so that a script’s text can never be swallowed as a control phrase — and so a model reading your transcript can’t flip a guardrail approval or an account setting.

The stream opens with a data-session event carrying the resolved sessionId. That is the only place you learn which session the turn landed in, since GET /v1/agents/session reports the CLI’s.

There is no “Sai is working on this…” acknowledgement on this lane. That data-status frame is sent only on the CLI channel, where a terminal needs something to put beside its spinner. Here it would only restate the write you just made, which the open stream already tells you — so a client waiting for one before it reports progress waits forever.

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.

POST/v1/agents/approve20/minute

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

json
{ "approvalId": "ap_123", "response": "yes" }

response is one of yes, no, or always. Requests with isLinkOnly can’t be resolved here at all; they need the desktop app.

always is re-derived server-side rather than taken on trust, so sending it where the event didn’t set allowAlways doesn’t fail — it quietly resolves as a one-time approved instead. Check the returned status if the distinction matters: a dangerous shell command never becomes a standing approval.

json
{ "ok": true, "status": "approved" }
// status is "approved", "approved_always", or "denied"

Answering a choice

An approval with approvalType: "choice" is a question, not a permission — it needs the picks in selections, one inner array per question in the order the event listed them. Read the options from questions on the approval-request event and send option values, not labels:

json
{ "approvalId": "ap_123", "response": "yes", "selections": [["staging"]] }

Approving a choice without selections returns 422 and leaves it pending, so a bare yes is never enough to unblock the agent. Denying one resolves it with no picks — any selections you send alongside no are dropped.

Picks are validated against what was actually offered, since they’re handed to the agent as your answer. 422 comes back for a value that wasn’t among the options (unless the question set allowOther, or offered none at all, which makes it free text), for more than one pick on a question that isn’t multiple, for the wrong number of groups, and for selections on an approval that isn’t a choice. Payloads are bounded — at most 10 groups of 50 picks, each under 2000 characters — and exceeding that is a 400.

Send an empty group for a question the user didn’t answer. Because the groups are positional, omitting one shifts every later answer one question to the left and approves the card with picks the user never made. The empty group is refused with 422 instead, which is the outcome you want. This matters most when the answer arrives as free-form text — spoken, or from a model — since grouping it against questions is the caller’s job.
POST/v1/agents/abortCLI session20/hour

Abort the task running in the CLI session. It stops what sai started and leaves a task the desktop user launched running. Idempotent — safe to call when nothing is running.

It does not stop a task you started with /v1/agents/message. With the CLI idle you get aborted: false while your own task keeps going, so there is currently no way to cancel an API turn — bound it with a client-side timeout instead.

Body

json
{ "machineId": "m_1a2b" }
json
{ "ok": true, "aborted": true }
// or, when the CLI session is idle or doesn't exist yet:
{ "ok": true, "aborted": false, "reason": "no active session" }

Aborting also denies any approvals still pending in that session — the task is going away, so nothing is left waiting on an answer that would never come. Approvals raised in other sessions on the machine are untouched. Cleanup is best-effort: it can’t fail the abort, so a successful response doesn’t guarantee every approval was resolved.

POST/v1/agents/restartCLI session10/hour

Restart the agent process or the full cloud machine. Returns 422 if restart is not supported for the workspace type.

Body

json
{ "machineId": "m_1a2b", "target": "agent" }

target is agent (just the Sai process) or machine (full VM, slower).

agent restarts the agent serving the CLI session, leaving the desktop user’s agent alone. When the CLI has no session on the machine it falls back to restarting the agent for the whole machine — which is the one case where this does reach your API work, by restarting everything.

json
{ "ok": true, "action": "restarted" }
POST/v1/agents/wake10/minute

Wake a hibernated machine without sending it any work. Use it when you need the machine up before you have anything to give it — binding a voice call to one, or switching a session onto one — so the spin-up runs while the user is still talking instead of inside their first real request.

Body

json
{ "machineId": "m_1a2b" }
json
{
  "ok": true,
  "waking": true,
  "startingUp": true,
  "status": "hibernated",
  "canWake": true
}

Branch on startingUp, not waking. waking is true only when this call was the one that dispatched, so it is false for a machine already on its way up — where a second dispatch would be wrong but the user is still owed the minute. startingUp covers both. status is read before the dispatch, which is what lets you tell one waking: false from another: active means there was nothing to do, hibernated with canWake: false means it is not coming back.

Safe to call redundantly, and 404 — not 403 — for a machine that isn’t yours. A wake takes roughly a minute and nothing here waits for it: poll GET /v1/agents/machines for status, or just send the work and let the stream report it.

Don’t improvise a wake by sending a throwaway message. A message that arrives while a turn is running is folded into that turn, so the dummy and the user’s next real request end up sharing one — and the dummy’s completion closes it out from under the real work.
POST/v1/cli/messageSSE60/hour shared

The same send contract as POST /v1/agents/message — identical body, identical stream — on a second channel. The difference is whose conversation you join: this writes to the sai CLI’s session and gets the concierge side track below, while /v1/agents/message is the generic lane with its own session and no side track. The 60/hour message limit is counted per user across both, so switching channels buys no extra budget.

bash
curl -N -X POST "https://api.sai.simular.ai/v1/cli/message" \
  -H "Authorization: Bearer $SAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "machineId": "m_1a2b", "message": "run the test suite" }'
Use this only if you are building something that acts as the CLI. Two writers on one session interleave turns, so a script sending here lands in the middle of whatever the user is typing into sai. Integrations want /v1/agents/message.
GET/v1/cli/concierge-messages

Poll for conversational replies about your request — that the machine is waking up, for instance — as opposed to the agent’s own output, which arrives on the message stream. These are written after the message is delivered, so the send path never waits on them. Poll this while the stream is open and merge the two feeds.

These replies exist only for messages sent to POST /v1/cli/message. Polling after sending to /v1/agents/message returns an empty list forever — that lane doesn’t write them.

Query params: machineId (required), since (exclusive, unix ms), limit (default 20, max 50), and seed=1 on the first poll of a message.

bash
curl "https://api.sai.simular.ai/v1/cli/concierge-messages?machineId=m_1a2b&since=1750000000000&seed=1" \
  -H "Authorization: Bearer $SAI_API_KEY"
json
{
  "sessionId": "cs_9f8e",
  "serverNowMs": 1750000001234,
  "messages": [
    { "id": "cm_1", "role": "assistant", "content": "Waking your machine up...", "timestamp": 1750000000500 }
  ]
}

Use the returned serverNowMs as the since for your next poll. That keeps the cursor on the server’s clock, which is what these timestamps are written against — seeding since from your own clock is only reasonable on the first request, and only because seed=1 tells the server to tolerate the skew by clamping it to a two-minute window. Without that, a fast local clock silently filters out every reply and a slow one replays replies from an earlier call.

Before your first message the machine has no CLI session, so sessionId is null and messages is empty — a GET never creates one. An unknown machine is a 404; a missing machineId or a negative since is a 400. Replies are a side channel and not guaranteed: expect an empty list most of the time, and never block on one arriving.

POST/v1/account/keysFirebase only5/hour

Generate a long-lived API key. The plaintext key is returned once — only its hash is stored. Maximum 10 keys per account.

Body

json
{ "name": "ci-pipeline" }
json
{ "key": "sapi_AbC123...", "keyId": "k_456", "name": "ci-pipeline" }
GET/v1/account/keysFirebase only

List API keys for the authenticated user (IDs and labels only — never the plaintext secret).

json
{
  "keys": [
    { "keyId": "k_456", "name": "ci-pipeline", "createdAt": 1750000000000, "lastUsedAt": 1750000500000 }
  ]
}
DELETE/v1/account/keys/:keyIdFirebase only

Revoke an API key by ID. Returns 204 No Content on success.

bash
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 typeMeaning
startStream opened.
data-sessionThe session this turn landed in (data.sessionId), right after start. Sent on every channel, and the only place a caller learns it.
data-statusInformational status line (data.text), sometimes with a data.kind naming the case — stalled for an agent that hasn’t picked the task up. Delivery news about your machine, not task progress. The fixed “Sai is working on this…” acknowledgement is CLI-only.
text-start / text-delta / text-endThe agent’s final response text, streamed in deltas.
reasoning-start / -delta / -endMid-turn narration emitted between tool calls.
tool-input-startA tool invocation began (toolName, toolCallId, toolMetadata.retrying).
tool-input-availableTool input is ready.
data-progressA progress line from a running tool (data.text, data.tool).
tool-output-errorA tool errored (errorText). The task may still retry or recover.
data-approval-requestThe agent needs permission, or is asking you to choose. Resolve via POST /v1/agents/approve using data.approvalId. Carries data.command and data.cwd for shell approvals, and data.questions for a choice.
finishTerminal. The turn completed (finishReason).
errorTerminal. The turn failed (errorText) — no finish follows.
The server sends an SSE comment (: keepalive) every 20 seconds during long tasks so proxies don’t close idle connections. Ignore comment lines in your parser.
Ignore frames you don’t recognise; never throw on them. This stream carries more part types than any one client needs, and more will be added. Treating an unknown one as an error means a server newer than your client can end its turns.

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.

ts
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 = {
  /**
   * The session this turn landed in. Arrives right after `start` on every
   * channel, and is the only place a caller is told which one it was.
   */
  session: {
    sessionId: string;
  };
  /**
   * Informational status line — delivery news about your machine, not task
   * progress. The fixed "Sai is working on this..." acknowledgement is sent
   * on the CLI channel only; `/v1/agents/message` gets no such frame.
   */
  status: {
    text: string;
    /**
     * Names the case when there is one, so a client can treat it as more than
     * a line of prose. `stalled` = the agent hasn't picked the task up, and
     * the subject is the user's machine rather than the caller.
     */
    kind?: "stalled";
    /** The resolved session id; present on the CLI acknowledgement. */
    sessionId?: 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;
    /** The exact command to be run — `exec` approvals only. */
    command?: string;
    /** Directory the command would run in, when the agent gave one. */
    cwd?: string;
    /**
     * What a `choice` approval is asking. Send the chosen `value`s back as
     * `selections` on POST /v1/agents/approve — one array per question, in
     * this order. A question with no `options` takes free text, as does one
     * with `allowOther`.
     */
    questions?: Array<{
      message: string;
      options: Array<{ value: string; label: string }>;
      /** Whether more than one option may be picked. */
      multiple: boolean;
      /** Whether an answer outside `options` is accepted. */
      allowOther: boolean;
    }>;
    /**
     * Single-question `choice` shape, mirroring `questions[0]`. Retained for
     * older clients; prefer `questions`, which also covers multi-question asks.
     * `allowOther` is flattened here too: a client that reads only this shape
     * would otherwise reject the free-text answer the question permits.
     */
    options?: Array<{ value: string; label: string }>;
    multiple?: boolean;
    allowOther?: boolean;
  };
}

/** A fully-typed Sai message. No custom metadata, hence `never`. */
export type SaiUIMessage = UIMessage<never, SaiDataParts>;
Sai currently sends every 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:

ts
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-session": // the session this turn landed in
        console.error("session:", event.data.sessionId);
        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:

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

tsx
"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>
    </>
  );
}
Approvals are resolved out-of-band via POST /v1/agents/approve (the 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.

Untrusted text

Everything the agent sends you is untrusted input, including its own narration. Titles, summaries, tool errors and above all web content it read can contain text written by whoever controls the page it visited. If you feed any of it to a model — to summarise a result, decide what to do next, or speak it aloud — you are handing that author a channel into a session that can approve shell commands.

Put your instruction first and fence the agent’s text so the model treats it as data rather than as something to obey:

text
The agent is asking for approval. Tell the user what it wants and ask.
"""
<agent-supplied title and description go here, untouched>
"""

Drop the fence, or put the untrusted text before the instruction, and any page the agent reads becomes a prompt-injection channel. If your integration is a plain script that never puts agent output into a model, this doesn’t apply — but it applies to every assistant, bot, or voice client built on this API. The same caution covers text you fold in yourself: machine names and labels are user-supplied, so sanitize them where you use them rather than assuming someone upstream did.

Queueing & cancellation

If your client accepts new work while a task is running — an assistant, a bot, anything conversational — the sequencing is yours to own, and the API gives you less help than you might assume.

A task that arrives mid-turn should be held, not folded in. Every message lands in the same conversation, so one agent turn routinely carries several unrelated requests — and there is no way to stop just one. /v1/agents/abort acts on the CLI’s session rather than yours, so once a restaurant booking has been folded into a running email check, “cancel the booking” has nothing to call. Held separately, it is still yours to drop.

Don’t describe a held task as started. A user told “on it” waits for a result nothing is producing yet. Say what it is queued behind.

Nothing you hold is durable. The server learns about a task when you send it and not before, so it has no copy of your queue. A crash or a dropped connection loses everything waiting, and the user may have been promised it out loud. The compensation is that nothing can start a task you are holding behind your back, so your queue and the user’s expectation cannot drift apart.

Long-lived clients bound themselves

There is no server-side notion of a session lifetime and no socket for us to close, so nothing here will end an idle client. If yours holds an expensive resource open — a live speech or model session, most obviously — enforce a hard maximum duration that activity cannot extend, plus a shorter idle timeout reset only by genuine interaction. An hour and five minutes are reasonable defaults.

Input tokens are not activity. For a client holding a live audio or model session, they accrue continuously while a microphone is merely open — so counting them makes a walked-away session look alive, which is the exact case an idle timeout exists to end. Count a rise in response tokens, or a real user action.

Rate limits

ActionLimit
Send a message60 / hour
Upload a file30 / hour
Resolve an approval20 / minute
Abort a task20 / hour
Start a new conversation20 / hour
Restart agent or machine10 / hour
Wake a machine10 / minute
Generate an API key5 / hour

Limits are per user on a rolling window — one hour unless the table says otherwise — 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" }.

StatusMeaning
400Bad request — missing or invalid parameters, a channel that is neither cli nor api, or an oversized selections payload.
401Missing, invalid, or expired credential.
403User not active, machine not owned, or an API key used on a Firebase-only endpoint.
404Machine, approval request, or key not found. Sessions never appear here — you can’t name one.
409Approval request is no longer pending.
413Uploaded file exceeds 25 MB.
422Key limit reached, restart not supported, or invalid selections when resolving a choice — the approval stays pending.
429Rate limit exceeded.
503Auth service temporarily unavailable — retry shortly.