Docs/Ruby
SDKs

Ruby

v0.1.0

The Ruby 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 before 1.0.0.

Install

InstallShell
gem install sai-sdk
export SAI_API_KEY="sapi_..."

Or add gem "sai-sdk" to your Gemfile. The require name is sai. Ruby 3.1 or later; no dependencies beyond the standard library. Create the key in the portal (see Quickstart).

Give Sai a task

Give Sai a taskRuby
require "sai"

sai = Sai::Client.new
pc = sai.machines.list.first

session = sai.sessions.create(machine: pc)
result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")

puts result.status # idle, needs_approval or error
puts result.text   # Sai's answer
p result.usage     # tokens and cost of 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. 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 returns at once with a turn. Iterate its events with a block, then read the result:

Watch the eventsRuby
turn = session.send("Find last month's invoices in Downloads and total them")
turn.events do |ev|
  case ev.kind
  when "step" then puts "   #{ev.text}"
  when "approval" then turn.respond(ev.approval, "yes")
  end
end
result = turn.wait
ev.kindWhat it is
stepA progress line: what Sai is doing
messageSai's text
approvalSai is waiting for permission. ev.approval holds it
tool_errorA step failed. Sai usually recovers
errorThe turn failed. ev.code is set when the account ran out (below)
finishedThe turn is complete

Without a block, turn.events returns an Enumerator. 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 return with status == "needs_approval" and the request in result.approval. To answer as it comes, pass a block (or a callable as on_approval:):

Approval handlerRuby
result = session.run("Clean up the temp folder") do |approval|
  if approval.link_only?
    puts "Approve in a browser: #{approval.url}"
    next nil # a person answers it; keep waiting
  end
  next ["yes", approval.questions.map { |q| [q["options"][0]["value"]] }] if approval.type == "choice"

  "yes"
end

The handler returns "yes", "no", "task" (allow the rest of this task), a [response, selections] pair for a choice, or nil to leave the approval to someone else. A link-only approval cannot be answered over the API: respond raises Sai::LinkOnlyApprovalError with approval_url.

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.

Unattended runRuby
session.run("Export last month's invoices to CSV", autonomous: true)

Files, models, stopping

Files, models, stoppingRuby
report = sai.files.upload("report.pdf") # or data: bytes, name: "report.pdf"
session.run("Summarise the attached report", attachments: [report])

allowed = sai.models.list.select(&:allowed).map(&:id)
session.run("...", model: "anthropic/claude-opus-5-5") # sticks to the session

turn = session.send("...")
turn.abort

A message sent while a turn is running steers that turn; turn.queued is then true.

Typed output (advanced)

Pass output_schema: and the turn's record comes back in result.record, with output matching the schema. See Structured output for every option.

Typed outputRuby
result = session.run(
  "Open https://news.ycombinator.com and read the title of the top story.",
  output_schema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] }
)
if result.ok?
  puts result.record.output["title"]
else
  puts result.record.reason_code # e.g. output_invalid
end

send and run also take inputs:, success_criteria: (a string, or a list of Sai::Check), idempotency_key: and record_trajectory:, and thinking_level: ("off", "low", "medium", "high" or "xhigh"). Every turn has an id, turn.id; read it later with sai.turns:

Read a turn laterRuby
turn = session.send("...", record_trajectory: true)
record = sai.turns.wait(turn) # until succeeded, failed or needs_input
sai.turns.trajectory(turn).each do |step|
  puts "#{step.index} #{step.title}"
end

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.

Learn and repair codeRuby
turn = session.send(
  "Open {{newsSite}}, find all {{topic}} news published today, write their headlines into Notepad, one per line, and save the file as {{fileName}}",
  inputs: { "newsSite" => "TechCrunch", "topic" => "AI", "fileName" => "techcrunch_headlines.txt" },
  record_trajectory: true
)
sai.turns.wait(turn)

learned = sai.learn.create([turn]) # waits for the code; longer for a longer task
inputs = { "newsSite" => "TechCrunch", "topic" => "Fintech", "fileName" => "techcrunch_fintech.txt" }
r = sai.control.open(machine: pc) do |lease|
  lease.run(learned, inputs) # inputs over the defaults
end
p r.result

fixed = sai.learn.create(
  initial_code: learned.code,
  evaluations: [Sai::Evaluation.new(inputs: inputs, result: r, passed: false)]
)
judgement = sai.judge.create([turn], [Sai::Check.new(id: "file", check: "The agent saved the headlines to the file")])

learned.status is failed when the code still has learned.validation.errors.

With max_iterations:, 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 task_template:. The call answers at once with a learn job; wait reads it until it ends.

Run, judge and repair on a computerRuby
job = sai.learn.create(
  task_template: "Open {{newsSite}}, find all {{topic}} news published today, write their headlines into Notepad, one per line, and save the file as {{fileName}}",
  inputs: { "newsSite" => "TechCrunch", "topic" => "AI", "fileName" => "techcrunch_headlines.txt" },
  machine: pc,
  criteria: "techcrunch_headlines.txt is saved and holds, one per line, the headlines of the AI articles TechCrunch published today",
  max_iterations: 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: <<~JS },
    for (const t of await machine.browser.listTabs()) await machine.browser.closeTab(t.tabId)
    for (const w of await machine.getWindow("Notepad")) await w.close()
  JS
  continue_with_agent: true  # after a failed run, the agent finishes the task and the repair learns from it
)
done = sai.learn.wait(job)   # can take an hour; timeout: to stop waiting
p done.status, done.stop_reason, done.test_result
puts done.code               # the latest version
done.iterations.each do |it|
  p [it.index, it.changes, it.rollouts.map(&:verdict)]
end

run = sai.learn.rollout(job, 1, 1) # what one run returned and logged, and its judgement
sai.learn.cancel(job)              # stops at once and keeps the last complete results

template: and feedback: are still accepted as the old names of task_template: and guidance:.

Drive a computer yourself

A control lease holds one cloud computer and runs Simulang blocks on it. Open it with a block: the lease is closed when the block ends. While it is open, Sai's own tasks on that computer wait.

Drive a computerRuby
sai.control.open(machine: pc) do |lease|
  puts lease.shell("hostname").stdout

  lease.write_file('C:\data\note.txt', "hello")
  puts lease.read_file('C:\data\note.txt')

  r = lease.exec(<<~JS)
    var page = await machine.browser.newtab('https://example.com')
    var snap = await page.snapshot()
    snap.title
  JS
  p r.result # Example Domain
  ext = r.screenshot_mime_type.split("/")[1] # jpeg or png
  File.binwrite("page.#{ext}", r.screenshot)
end
  • exec returns a Sai::ExecResult with result, logs, error, screenshot (a binary String), screenshot_mime_type and duration_ms. A block that throws still returns: check r.error.kind (exception, timeout, interrupted).
  • Use var, not let or const, for values the next block needs.
  • lease.shell returns a Sai::ShellResult with stdout, stderr, exit_code and timed_out.
  • lease.screenshot grabs the whole screen. It needs the computer's screen-capture service; where that is off it raises Sai::BlockError, and a page or app snapshot is the way to get an image.
  • lease.interrupt stops a running block from another thread.
  • Without a block, sai.control.open returns the lease: call lease.close in an ensure.

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

Other callsRuby
sai.account.get             # plan and spendable credit
sai.models.list             # models, with `allowed` for this plan
sai.machines.live(pc)       # a link to the screen for a person, and a stream for your viewer; see Live view
sai.machines.revoke_live(pc) # every live_url stops working and its screen sessions end; returns how many
Who the key is, and its sessionsRuby
sai.auth.check                  # the user, and the Tenant and Organisation for a Tenant token
sai.sessions.list(machine: pc)  # your sessions on the computer, any channel, most recent first

Hand the screen to a person

When Sai needs a person at the screen (a sign-in, a captcha), machines.live returns a Sai::LiveView with live_url: a page anyone with the link can open without a Simular account. They can use the mouse and keyboard. The page pauses its own input while a Sai turn runs; the pause is not enforced. Send a message once they are done, and revoke the link.

Hand the screen to a personRuby
view = sai.machines.live(pc)
puts "Sign in here, then tell me: #{view.live_url}" # valid until view.expires_at (ms)
# ... once they are done
session.run("I signed in. Carry on.")
sai.machines.revoke_live(pc) # returns the number of links revoked
  • live_url and expires_at (Unix time in milliseconds) are nil where the server does not offer the page. Both URLs carry a token: keep them private.
  • websocket_url is the same screen as a Guacamole tunnel for a viewer of your own. It takes input too.
  • machines.revoke_live(machine) revokes every live_url handed out for the computer and ends the screen sessions they opened, and returns how many. See When Sai needs a person.

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 Tenant through a Tenant token.

TenantsRuby
require "sai"

org = Sai::Client.new(api_key: ENV.fetch("SAI_ORG_KEY"), org_id: "org_3f9a1c0b7e2d4a6f8c10")

tenant = org.tenants.create("Riverside Clinic", external_id: "clinic-4821")
enrollment = org.tenants.create_enrollment(tenant) # hand enrollment.token to your installer
p org.tenant_machines.list(tenant)

clinic = org.tenants.client(tenant) # acts as the Tenant
clinic.sessions.create.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: pass token.token as Sai::Client.new(api_key:).
  • org_id: is needed only for the exchange. It defaults to SAI_ORG_ID; token and client also take org_id:.
  • org.tenants.list(status:, external_id:, limit:, cursor:) returns one page: page.tenants and page.next_cursor. external_id: finds 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.tenant_machines.get(tenant, machine_id) and org.tenant_machines.revoke(tenant, machine).

A Tenant can also have cloud machines that Sai hosts:

Cloud machinesRuby
require "sai"
require "securerandom"

org = Sai::Client.new(api_key: ENV.fetch("SAI_ORG_KEY"), org_id: "org_3f9a1c0b7e2d4a6f8c10")

machine = org.tenant_machines.create(
  "Xk2p9QzR4tLm8vNa3bCd",
  os: "windows",
  name: "Riverside intake",
  request_id: SecureRandom.uuid,
  wait: true
)
clinic = org.tenants.client("Xk2p9QzR4tLm8vNa3bCd")
clinic.sessions.create(machine: machine.id).run("Open Notepad and type hello")
org.tenant_machines.delete("Xk2p9QzR4tLm8vNa3bCd", machine)
  • org.tenant_machines.create(tenant, os:, name:, request_id:, wait: false, timeout: 600). os is "windows" or "linux". Pass the same request_id when you retry a create. With wait: true it gets the machine every 5 seconds until its state is ready. A failed or deleting machine raises Sai::Error with the machine in e.body. No ready within timeout seconds raises Timeout::Error.
  • org.tenant_machines.list(tenant) and .get(tenant, machine_id) return both kinds. Each machine has kind, state, machine_class (cloud only) and created_at.
  • org.tenant_machines.delete(tenant, machine) returns "deleting" for a cloud machine and "revoked" for an enrolled one.
  • org.tenants.update(tenant, cloud_machine_limit:, residential_proxy:). cloud_machine_limit: nil removes the limit. residential_proxy has no effect yet.
  • A refused create raises the usual Sai::Error subclass. Its details are in e.body: limit and used, scope and resetsAt, or errorId.

See Tenants for every field and error.

What your Tenants spent

UsageRuby
require "sai"

org = Sai::Client.new(api_key: ENV.fetch("SAI_ORG_KEY"), org_id: "org_3f9a1c0b7e2d4a6f8c10")
usage = org.usage.get(granularity: "month", from: "2026-10", to: "2026-10")
usage.totals.by_tenant.each_value do |t|
  puts "#{t.name} #{t.cost_cents}"
end

page = org.usage.sessions("Xk2p9QzR4tLm8vNa3bCd", from: "2026-10-01", to: "2026-10-31")
page.sessions.each do |s|
  puts "#{s.session_id} #{s.cost_usd}"
end
  • org.usage.get(granularity:, from:, to:) is what the Organisation is charged, in total and per Tenant, per hour, day or month. See Organisation usage.
  • org.usage.sessions(tenant, from:, to:, limit:, cursor:) is one page of what each of the Tenant's sessions spent on AI calls. Keep passing page.next_cursor as cursor: until it is nil: a page can be empty and not be the last. With session_ids: (1 to 50) instead of the range, it is those sessions' spend right now, each with settled (true once its latest task has ended and every charge for it has come in), and the page's as_of; cost_usd and model_calls are nil when a session can't be read in full right now. See Session spend.
  • Both take org_id:, defaulting to the client's org_id (SAI_ORG_ID).

Errors

Every non-2xx response raises a Sai::Error subclass carrying status, code, message and retry_after:

ExceptionWhen
Sai::AuthenticationError401: the key is missing, unknown or revoked
Sai::BadRequestError400: for example several computers and no machine:
Sai::PermissionDeniedError403: plan or ownership, such as model_not_allowed_for_plan
Sai::ConflictError409: for example machine_busy, or an approval already answered
Sai::LinkOnlyApprovalErrorThe approval must be answered in a browser: e.approval_url
Sai::GoneError410: the control lease ended (e.body["reason"]). Open a new one
Sai::SpendCapReachedError403: the key reached its monthly spend limit. e.resets_at
Sai::RateLimitError429: a rate limit, or the free plan's daily computer time
Sai::ServiceUnavailableError503: the computer did not come online. Honor retry_after
Sai::BlockErrorA control helper's block failed on the computer

Sai::ConnectionError means the API could not be reached. A task that ran out of credit is not an exception. It ends with result.status == "error" and result.error_code set to insufficient_credits or free_computer_time_exhausted. Stop retrying for today. See Billing and limits.

Configuration

KeywordEnvironmentDefault
api_key:SAI_API_KEYrequired
base_url:SAI_API_URLhttps://api.simular.ai
timeout:60 seconds per request
org_id:SAI_ORG_IDfor tenants.token and usage
version_tag:routes requests to a tagged API revision
© 2026 Simular Inc. All rights reserved.