Ruby
v0.1.0The 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 before1.0.0.
Install
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
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 taskmachine:can be left out when the account has one computer.- More
session.run(...)calls continue the same conversation.sai.sessions.createstarts a new one with no memory of the old. See Sessions. runsends 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:
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.waitWithout 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:):
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"
endThe 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.
session.run("Export last month's invoices to CSV", autonomous: true)Files, models, stopping
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.abortA 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.
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
endsend 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:
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}"
endLearn 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.
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.
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 resultstemplate: 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.
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)
endexecreturns aSai::ExecResultwithresult,logs,error,screenshot(a binaryString),screenshot_mime_typeandduration_ms. A block that throws still returns: checkr.error.kind(exception,timeout,interrupted).- Use
var, notletorconst, for values the next block needs. lease.shellreturns aSai::ShellResultwithstdout,stderr,exit_codeandtimed_out.lease.screenshotgrabs the whole screen. It needs the computer's screen-capture service; where that is off it raisesSai::BlockError, and a page or app snapshot is the way to get an image.lease.interruptstops a running block from another thread.- Without a block,
sai.control.openreturns the lease: calllease.closein anensure.
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
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 manysai.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 firstHand 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.
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 revokedlive_urlandexpires_at(Unix time in milliseconds) arenilwhere the server does not offer the page. Both URLs carry a token: keep them private.websocket_urlis the same screen as a Guacamole tunnel for a viewer of your own. It takes input too.machines.revoke_live(machine)revokes everylive_urlhanded 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.
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: passtoken.tokenasSai::Client.new(api_key:).org_id:is needed only for the exchange. It defaults toSAI_ORG_ID;tokenandclientalso takeorg_id:.org.tenants.list(status:, external_id:, limit:, cursor:)returns one page:page.tenantsandpage.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)andorg.tenant_machines.revoke(tenant, machine).
A Tenant can also have cloud machines that Sai hosts:
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).osis"windows"or"linux". Pass the samerequest_idwhen you retry a create. Withwait: trueit gets the machine every 5 seconds until itsstateisready. Afailedordeletingmachine raisesSai::Errorwith the machine ine.body. Noreadywithintimeoutseconds raisesTimeout::Error.org.tenant_machines.list(tenant)and.get(tenant, machine_id)return both kinds. Each machine haskind,state,machine_class(cloud only) andcreated_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: nilremoves the limit.residential_proxyhas no effect yet.- A refused create raises the usual
Sai::Errorsubclass. Its details are ine.body:limitandused,scopeandresetsAt, orerrorId.
See Tenants for every field and error.
What your Tenants spent
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}"
endorg.usage.get(granularity:, from:, to:)is what the Organisation is charged, in total and per Tenant, perhour,dayormonth. 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 passingpage.next_cursorascursor:until it isnil: a page can be empty and not be the last. Withsession_ids:(1 to 50) instead of the range, it is those sessions' spend right now, each withsettled(trueonce its latest task has ended and every charge for it has come in), and the page'sas_of;cost_usdandmodel_callsarenilwhen a session can't be read in full right now. See Session spend.- Both take
org_id:, defaulting to the client'sorg_id(SAI_ORG_ID).
Errors
Every non-2xx response raises a Sai::Error subclass carrying status, code, message and retry_after:
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.