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 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 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.
  • Content type — send and receive application/json, except POST /v1/agents/upload (raw binary) and POST /v1/agents/message (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 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:

SurfaceWho lands in it
APIThe sai CLI, your curl scripts, cron jobs, CI runners — everything on this page
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 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.

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

Because the session is shared, /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.
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 }
  ]
}
GET/v1/agents/session

Resolve the user’s most-recently-active machine and its API session. Returns 404 if no machine is registered.

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

GET/v1/agents/sessions

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.

bash
curl "https://api.sai.simular.ai/v1/agents/sessions?machineId=m_1a2b" \
  -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-session20/hour

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

json
{ "machineId": "m_1a2b" }
json
{ "sessionId": "s_new123" }
GET/v1/agents/context

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

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

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.

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.

POST/v1/agents/approve

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

json
{ "ok": true, "status": "approved" }
// status is "approved", "approved_always", or "denied"
POST/v1/agents/abort20/hour

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

json
{ "machineId": "m_1a2b" }
json
{ "ok": true, "aborted": true }
// or, when the API session is idle or doesn't exist yet:
{ "ok": true, "aborted": false, "reason": "no active session" }
POST/v1/agents/restart10/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).

json
{ "ok": true, "action": "restarted" }
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-statusInformational 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-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. Resolve via POST /v1/agents/approve using data.approvalId.
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.

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 = {
  /**
   * 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>;
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-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.

Rate limits

ActionLimit
Send a message60 / hour
Upload a file30 / hour
Abort a task20 / hour
Start a new conversation20 / hour
Restart agent or machine10 / hour
Generate an API key5 / 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" }.

StatusMeaning
400Bad request — missing or invalid parameters.
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, or restart not supported.
429Rate limit exceeded.
503Auth service temporarily unavailable — retry shortly.