npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@simular-ai/sai-sdk

v0.1.0

Published

TypeScript SDK for the Sai API: delegate desktop work to Sai, or drive a cloud computer directly.

Downloads

763

Readme

@simular-ai/sai-sdk (TypeScript)

TypeScript SDK for the Sai API. Hand desktop work to Sai on a computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a cloud computer yourself.

Status: alpha (0.1). The surface may still change before 1.0. It matches the Python SDK method for method.

| Layer | What it is | Status | | -------------- | --------------------------------------------- | ---------------- | | sai.sessions | A conversation with Sai on one computer | available | | sai.control | Drive a cloud computer directly with Simulang | research preview | | sai.turns | A turn's record: typed output, evidence | available |

Install

npm install @simular-ai/sai-sdk
export SAI_API_KEY=sapi_...   # create one at https://platform.simular.ai

Node 18.17 or later (it uses the built-in fetch). No runtime dependencies. ESM only.

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 | error
console.log(result.text) // Sai's answer
console.log(result.usage) // tokens and cost for this task

machine can 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.

Watch it work

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

ev.kind is one of step, message, approval, tool_error, error, finished. turn.events({ raw: true }) adds the wire frames (reasoning, tool calls) as other.

Approvals

run() / wait() resolve with status === 'needs_approval' unless you pass a handler:

const result = await session.run('Clean up the temp folder', {
  onApproval: (a) => {
    if (a.isLinkOnly) {
      console.log('Approve in a browser:', a.url)
      return null // a person answers it; keep waiting
    }
    if (a.type === 'choice') return ['yes', a.questions.map((q: any) => [q.options[0].value])]
    return 'yes'
  },
})

For an unattended run with nobody to answer, autonomous: true runs the turn without approval cards, as if 'task' (trust for the rest of the task) had been answered up front. The grant ends with the turn. Approvals that only a person can complete in a browser (isLinkOnly) still stop the turn.

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

Get typed results

Any message can also return structured data. Pass outputSchema and the turn's record comes back in result.record, with output matching the schema:

const session = await sai.sessions.create({ machine: pc })
const result = await session.run(
  'Open Notepad, type "hello" and save it on the Desktop as hello.txt. Return the saved file\'s path.',
  {
    outputSchema: {
      type: 'object',
      properties: { path: { type: 'string' } },
      required: ['path'],
    },
  },
)
if (result.ok) console.log((result.record?.output as { path: string }).path)
else console.log(result.record?.reasonCode) // e.g. output_invalid, with result.record.rawAnswer

More options, all optional (the SDK sends them to the API as options):

| Option | What it does | | ------------------ | ---------------------------------------------------------------------------- | | inputs | Fill {{name}} placeholders in the message; every placeholder needs a value | | outputSchema | JSON Schema (at most 32 KB) the output must match | | successCriteria | A guideline for a judge, or { id, check, returnEvidence? } yes/no checks | | idempotencyKey | The same key again returns the earlier turn and sends nothing | | recordTrajectory | Keep a screenshot and the action for each step |

inputs, outputSchema or successCriteria make the message structured. A structured message behaves like any other (same session, same computer); it only adds the typed result. It is rejected with ConflictError (code === 'session_busy') while the session is working, because it starts a turn of its own. A plain message sent then joins the running turn instead.

const result = await session.run(
  'Save "Invoice {{invoice_number}}: {{amount}} USD" on the Desktop as {{file_name}}. Return its path.',
  {
    inputs: { invoice_number: 'INV-1001', amount: '42.50', file_name: 'inv-1001.txt' },
    outputSchema: {
      type: 'object',
      properties: { path: { type: 'string' } },
      required: ['path'],
    },
    successCriteria: [
      {
        id: 'saved',
        check: 'The file is saved and contains the invoice line',
        returnEvidence: true,
      },
    ],
    idempotencyKey: 'inv-1001', // a retry returns this turn and sends nothing
    recordTrajectory: true,
  },
)
const record = result.record
console.log(record?.status) // succeeded | failed
console.log(record?.evidence) // [{ id: 'saved', passed: true, score: 0.94, evidence: '...' }]
console.log(record?.usage) // tokens of the agent's model calls in this turn

result.ok is true only when the turn succeeded. On failed, record.reasonCode says why (agent_error, output_invalid, criteria_not_met, ...); the output is never a partial object.

Reading a turn later. Every message gets a turn id, turn.id (plain messages too):

const turn = await session.send('...')
let record = await sai.turns.get(turn) // never waits; `finalizing` while the result is worked out
record = await sai.turns.wait(turn, { timeoutMs: 600_000 }) // until succeeded, failed or needs_input
for (const step of await sai.turns.trajectory(turn)) {
  // needs recordTrajectory: true
  console.log(step.index, step.title, step.code)
  if (step.hasScreenshot)
    await writeFile(`step${step.index}.jpg`, await sai.turns.screenshot(turn, step.index))
}

Each takes a Turn, a TurnRecord or the id string. record.status is running, needs_input (an approval is pending: answer it on sai.sessions.resume(record.sessionId)), finalizing, succeeded or failed.

Drive a computer yourself

Direct control runs Simulang blocks on a cloud computer. While the lease is open, Sai's own tasks on that computer wait, so always close it.

const lease = await sai.control.open({ machine: pc })
try {
  console.log((await lease.shell('hostname')).stdout)

  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+ and a runtime that supports explicit resource management, await using lease = ... closes it for you.

exec() resolves with an ExecResult; a block that threw still resolves, so check r.error (exception, timeout, interrupted). Use var, not let/const, for values the next block needs. lease.screenshot() grabs the whole screen; it needs the computer's screen-capture service, and where that is off it throws BlockError.

Learn code from turns (research preview)

sai.learn turns what the agent did into Simulang code you own, and repairs that code when it breaks. sai.judge checks recorded turns against criteria you choose afterwards. The turns must be sent with recordTrajectory: true.

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
console.log(
  learned.template,
  learned.parameters.map((p) => p.name),
)
console.log(learned.code) // async function main(params = { site: "..." }) { ... }

await using 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) // what main returned

// It broke on a new site: repair it from the run (or from guidance, or a new turn that solved it)
const fixed = await sai.learn.create([], {
  initialCode: learned.code,
  evaluations: [{ inputs: { site: 'https://lobste.rs' }, result: r, passed: false }],
})
console.log(fixed.changes)

const judgement = await sai.judge.create(
  [turn],
  [{ id: 'title', check: 'The agent reported a story title' }],
)
console.log(judgement.verdict, judgement.criteria[0]?.score)

learned.status is failed when the code still has learned.validation.errors after one retry. Pass a judgement as { ..., judgement } in an evaluation to repair code from its evidence.

Run, judge and repair on a machine (policy iteration)

With maxIterations, learn also runs the code on a machine, judges each run, and repairs the code after a failed run, for up to that many versions. It starts from your turns, or, without them, the agent does taskTemplate first. The call answers at once with a 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")' }, // or { task: '...' }
  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) // did the call do its work
console.log(done.testResult) // { status: 'passed', passedRuns: 3, requiredRuns: 3, inputs }
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 run 1 of version 1 returned, and its judgement
await sai.learn.cancel(job) // stops at once and keeps the last complete results

status says whether the call did its work (succeeded, failed when no code was written, or stopped with a stopReason such as cancelled, time_limit or agent_failed); testResult says how the returned code did on inputs. With judgeInitialAgentRun: true, a job whose agent run fails the judge stops before writing code; pass its agentTurnId as turns to start again from that run. The runs do the task for real, so use test inputs or a test account.

Run Sai for your customers

With an Organisation, each customer is a Tenant. Your backend manages Tenants with an Organisation Key, and acts as one with a Tenant token.

const org = new Sai({ apiKey: 'sapi_...', orgId: 'org_...' }) // an Organisation Key; or SAI_API_KEY, SAI_ORG_ID

const tenant = await org.tenants.create('Riverside Clinic', { externalId: 'clinic-4821' })
const code = await org.tenants.createEnrollment(tenant) // hand code.token to your installer
console.log(await org.tenantMachines.list(tenant))

const clinic = await org.tenants.client(tenant) // acts as the Tenant; renews its token
const session = await clinic.sessions.create()
await session.run("Open the calendar and read today's appointments")
  • tenants.list({ status, externalId, limit, cursor }) resolves with one page: page.tenants, page.nextCursor. externalId finds your customer's Tenant without storing tenant.id.
  • tenants.update(tenant, { name, metadata, status: 'disabled' | 'active', cloudMachineLimit, residentialProxy }). cloudMachineLimit is 0 to 1000, and null removes the cap. tenants.get also returns cloudMachineCount and residentialProxy.
  • tenants.delete(tenant) disables it and revokes every enrolled machine. Its cloud machines stay until you delete them.
  • tenants.token(tenant) is the raw exchange: a one-hour TenantToken to pass as new Sai({ apiKey: token.token }).
  • tenantMachines.list(tenant) and tenantMachines.get(tenant, machineId) return both kinds: machine.kind is enrolled or cloud, and machine.state is online / offline for an enrolled machine, or provisioning, ready, starting, hibernated, deleting or failed for a cloud one.
  • tenantMachines.delete(tenant, machine) resolves with 'revoked' for an enrolled machine and 'deleting' for a cloud machine, whose disk goes with it.

Cloud machines

Sai can host a computer for a Tenant instead of the Tenant enrolling its own:

const machine = await org.tenantMachines.create(tenant, {
  os: 'windows', // or 'linux'
  name: 'intake-desk-3',
  requestId: crypto.randomUUID(), // keep it, and send it again if you retry this create
  wait: true, // read the machine until it is ready (timeoutMs: 10 minutes)
})
const onCloud = await clinic.sessions.create({ machine: machine.id })
await onCloud.run("Open the calendar and read today's appointments")

Without wait: true, create resolves at once with state: 'provisioning'. A create can throw SaiError (402 insufficient_credits), SpendCapReachedError (the Tenant's budget, err.body.scope), ConflictError (machine_limit_reached with err.body.limit and err.body.used, or request_id_reused), PermissionDeniedError (tenant_disabled) or ServiceUnavailableError (capacity_unavailable: retry later). A 500 carries err.body.errorId to quote to support.

Errors

Every non-2xx response throws a SaiError subclass with status, code, message and retryAfter: AuthenticationError (401), BadRequestError (400), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), LinkOnlyApprovalError (approvalUrl), GoneError (410), SpendCapReachedError (403, the key reached its monthly limit; resetsAt), RateLimitError (429), ServiceUnavailableError (503), ConnectionError, BlockError.

A task that ran out of credit ends with result.status === 'error' and result.errorCode set to insufficient_credits or free_computer_time_exhausted: stop retrying for today.

Configuration

| Option | Env | Default | | ----------- | ------------- | ------------------------ | | apiKey | SAI_API_KEY | required | | baseUrl | SAI_API_URL | https://api.simular.ai | | timeoutMs | | 60 000 per request | | fetch | | global fetch | | orgId | SAI_ORG_ID | for tenants.token() |

Development

cd typescript
npm install
npm test
npm run typecheck