TypeScript
v0.1.0The TypeScript SDK wraps the API on these pages, method for method like the Python SDK:
- Sessions: give Sai a task on a computer, and get the answer back. Sai decides every click.
- Direct control: your code drives a cloud computer directly with Simulang. No agent, no model cost.
Note
Current version is
0.1.0. The API may still change before1.0.0.
Install
npm install @simular-ai/sai-sdk
export SAI_API_KEY="sapi_..."Node.js 18.17 or later; it uses the built-in fetch. No runtime dependencies. ESM only, so the examples use top-level await. Create the key in the portal (see Quickstart).
Give Sai a task
import { Sai } from '@simular-ai/sai-sdk'
const sai = new Sai()
const [pc] = await sai.machines.list()
const session = await sai.sessions.create({ machine: pc })
const result = await session.run('Open Notepad, type hello, save it to the desktop as hello.txt')
console.log(result.status) // idle, needs_approval or error
console.log(result.text) // Sai's answer
console.log(result.usage) // tokens and cost of this taskmachinecan be left out when the account has one computer.- More
session.run(...)calls continue the same conversation.sai.sessions.create()starts a new one with no memory of the old. See Sessions. run()sends the task and waits. On a free account the first task of the day waits about two minutes while the computer starts.
Watch it work
send() resolves at once with a turn. Iterate its events, then read the result:
const turn = await session.send("Find last month's invoices in Downloads and total them")
for await (const ev of turn.events()) {
if (ev.kind === 'step') console.log(' ', ev.text)
else if (ev.kind === 'approval') await turn.respond(ev.approval!, 'yes')
}
const result = await turn.wait()turn.events({ raw: true }) also yields reasoning, tool-call and framing frames as other, with the wire event in ev.raw. See Events.
Approvals
Sai asks before risky actions (see Approvals). Without a handler, run() and wait() resolve with status === 'needs_approval' and the request in result.approval. To answer as it comes, pass onApproval:
const result = await session.run('Clean up the temp folder', {
onApproval: (approval) => {
if (approval.isLinkOnly) {
console.log('Approve in a browser:', approval.url)
return null // a person answers it; keep waiting
}
if (approval.type === 'choice') {
return ['yes', approval.questions.map((q: any) => [q.options[0].value])]
}
return 'yes'
},
})The handler returns 'yes', 'no', 'task' (allow the rest of this task), a [response, selections] pair for a choice, or null to leave the approval to someone else. A link-only approval cannot be answered over the API: respond() throws LinkOnlyApprovalError with approvalUrl.
For an unattended run with nobody to answer, pass autonomous: true. The turn runs as if 'task' had been answered up front, and the grant ends with the turn. Link-only approvals still stop it. See Unattended runs.
await session.run("Export last month's invoices to CSV", { autonomous: true })Files, models, stopping
const report = await sai.files.upload('report.pdf') // or { data: bytes, name: 'report.pdf' }
await session.run('Summarise the attached report', { attachments: [report] })
const allowed = (await sai.models.list()).filter((m) => m.allowed).map((m) => m.id)
await session.run('...', { model: 'anthropic/claude-opus-5-5' }) // sticks to the session
const turn = await session.send('...')
await turn.abort()A message sent while a turn is running steers that turn; turn.queued is then true.
Typed output (advanced)
Pass outputSchema and the turn's record comes back in result.record, with output matching the schema. See Structured output for every option.
const result = await session.run(
'Open https://news.ycombinator.com and read the title of the top story.',
{
outputSchema: {
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
},
},
)
if (result.ok) console.log((result.record?.output as { title: string }).title)
else console.log(result.record?.reasonCode) // e.g. output_invalidsend() and run() also take inputs, successCriteria (a string, or a list of checks), idempotencyKey and recordTrajectory. Every turn has an id, turn.id; read it later with sai.turns:
const turn = await session.send('...', { recordTrajectory: true })
const record = await sai.turns.wait(turn) // until succeeded, failed or needs_input
for (const step of await sai.turns.trajectory(turn)) {
console.log(step.index, step.title)
}Learn code from turns (research preview)
sai.learn turns recorded turns into Simulang code you run with a control lease, and repairs it. sai.judge checks recorded turns against criteria you choose afterwards. See Learned code.
const turn = await session.send('Open {{site}} and read the title of the top story', {
inputs: { site: 'https://news.ycombinator.com' },
recordTrajectory: true,
})
await sai.turns.wait(turn)
const learned = await sai.learn.create([turn]) // waits for the code; longer for a longer task
const lease = await sai.control.open({ machine: pc })
const r = await lease.run(learned, { site: 'https://lobste.rs' }) // inputs over the defaults
console.log(r.result)
await lease.close()
const fixed = await sai.learn.create([], {
initialCode: learned.code,
evaluations: [{ inputs: { site: 'https://lobste.rs' }, result: r, passed: false }],
})
const judgement = await sai.judge.create(
[turn],
[{ id: 'title', check: 'The agent reported a story title' }],
)learned.status is failed when the code still has learned.validation.errors.
With maxIterations, learn also runs the code on a computer, judges each run and repairs the code until it passes, starting from your turns or, without them, from an agent run of taskTemplate. The call answers at once with a learn job; wait reads it until it ends.
const job = await sai.learn.create([], {
taskTemplate: 'Open {{site}} and read the title of the top story',
inputs: { site: 'https://news.ycombinator.com' },
machine: pc,
criteria: 'The answer is the title of the top story on the page',
maxIterations: 3, // up to 3 code versions; 0 (the default) only writes the code
rollouts: 3, // each version must pass 3 runs in a row
reset: { code: 'await machine.browser.newtab("https://news.ycombinator.com")' },
continueWithAgent: true, // after a failed run, the agent finishes the task and the repair learns from it
})
const done = await sai.learn.wait(job) // can take an hour; timeoutMs to stop waiting
console.log(done.status, done.stopReason, done.testResult)
console.log(done.code) // the latest version
for (const it of done.iterations) console.log(it.index, it.changes, it.rollouts.map((r) => r.verdict))
const run = await sai.learn.rollout(job, 1, 1) // what one run returned and logged, and its judgement
await sai.learn.cancel(job) // stops at once and keeps the last complete resultstemplate and feedback are still accepted as the old names of taskTemplate and guidance.
Drive a computer yourself
A control lease holds one cloud computer and runs Simulang blocks on it. While it is open, Sai's own tasks on that computer wait, so always close it.
import { writeFile } from 'node:fs/promises'
const lease = await sai.control.open({ machine: pc })
try {
console.log((await lease.shell('hostname')).stdout)
await lease.writeFile('C:\\data\\note.txt', 'hello')
console.log(await lease.readFile('C:\\data\\note.txt'))
const r = await lease.exec(`
var page = await machine.browser.newtab('https://example.com')
var snap = await page.snapshot()
snap.title
`)
console.log(r.result) // Example Domain
await writeFile(`page.${r.screenshotMimeType!.split('/')[1]}`, r.screenshot!) // jpeg or png
} finally {
await lease.close()
}- With TypeScript 5.2 or later and a runtime with explicit resource management,
await using lease = await sai.control.open(...)closes it for you. exec()resolves with anExecResultwithresult,logs,error,screenshot,screenshotMimeTypeanddurationMs. A block that throws still resolves: checkr.error.kind(exception,timeout,interrupted).- Use
var, notletorconst, for values the next block needs. lease.screenshot()grabs the whole screen. It needs the computer's screen-capture service; where that is off it throwsBlockError, and a page or app snapshot is the way to get an image.lease.interrupt()stops a running block whileexec()is waiting.
Direct control is a research preview, open to every account; the model calls the code makes are billed as usual. See Control for limits and errors.
Other calls
await sai.account.get() // plan and spendable credit
await sai.models.list() // models, with `allowed` for this plan
await sai.machines.live(pc) // a view-only stream of the screen; see Live viewRun Sai for your customers
With an Organisation, each customer is a Tenant. Your backend manages Tenants with an Organisation Key, and acts as one Tenant through a Tenant token.
import { Sai } from '@simular-ai/sai-sdk'
const org = new Sai({ apiKey: process.env.SAI_ORG_KEY, orgId: 'org_3f9a1c0b7e2d4a6f8c10' })
const tenant = await org.tenants.create('Riverside Clinic', { externalId: 'clinic-4821' })
const enrollment = await org.tenants.createEnrollment(tenant) // hand enrollment.token to your installer
console.log(await org.tenantMachines.list(tenant))
const clinic = await org.tenants.client(tenant) // acts as the Tenant
const session = await clinic.sessions.create()
await session.run("Open the calendar and read today's appointments")org.tenants.client(tenant)exchanges the Organisation Key for a one-hour Tenant token, and exchanges again when it has less than five minutes left.org.tenants.token(tenant)is the exchange alone: passtoken.tokenasnew Sai({ apiKey }).orgIdis needed only for the exchange. It defaults toSAI_ORG_ID;token()andclient()also take{ orgId }.org.tenants.list({ status, externalId, limit, cursor })resolves with one page:page.tenantsandpage.nextCursor.externalIdfinds your customer's Tenant without storing its id.org.tenants.update(tenant, { name, metadata, status }).status: 'disabled'stops the Tenant,'active'brings it back.org.tenants.delete(tenant)disables it and revokes every machine.org.tenantMachines.get(tenant, machineId)andorg.tenantMachines.revoke(tenant, machine).
A Tenant can also have cloud machines that Sai hosts:
import { randomUUID } from 'node:crypto'
import { Sai } from '@simular-ai/sai-sdk'
const org = new Sai({ apiKey: process.env.SAI_ORG_KEY, orgId: 'org_3f9a1c0b7e2d4a6f8c10' })
const machine = await org.tenantMachines.create('Xk2p9QzR4tLm8vNa3bCd', {
os: 'windows',
name: 'Riverside intake',
requestId: randomUUID(),
wait: true,
})
const clinic = await org.tenants.client('Xk2p9QzR4tLm8vNa3bCd')
const session = await clinic.sessions.create({ machine: machine.id })
await session.run('Open Notepad and type hello')
await org.tenantMachines.delete('Xk2p9QzR4tLm8vNa3bCd', machine)org.tenantMachines.create(tenant, { os, name, requestId, wait, timeoutMs }).osis'windows'or'linux'. Pass the samerequestIdwhen you retry a create. Withwait: trueit gets the machine every 5 seconds until itsstateisready. Afailedordeletingmachine throwsSaiErrorwith the machine ine.body. NoreadywithintimeoutMsthrowsTimeoutError.org.tenantMachines.list(tenant)and.get(tenant, machineId)return both kinds. Each machine haskind,state,machineClass(cloud only) andcreatedAt.org.tenantMachines.delete(tenant, machine)resolves with'deleting'for a cloud machine and'revoked'for an enrolled one.org.tenants.update(tenant, { cloudMachineLimit, residentialProxy }).cloudMachineLimit: nullremoves the limit.residentialProxyhas no effect yet.- A refused create throws the usual
SaiErrorsubclass. Its details are ine.body:limitandused,scopeandresetsAt, orerrorId.
See Tenants for every field and error.
Errors
Every non-2xx response throws a SaiError subclass carrying status, code, message and retryAfter:
A task that ran out of credit is not an error. It ends with result.status === 'error' and result.errorCode set to insufficient_credits or free_computer_time_exhausted. Stop retrying for today. See Billing and limits.