Java
v0.1.0The Java SDK wraps the API on these pages, method for method like the Python SDK and the TypeScript 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
<dependency>
<groupId>ai.simular</groupId>
<artifactId>sai-sdk</artifactId>
<version>0.1.0</version>
</dependency>implementation("ai.simular:sai-sdk:0.1.0")export SAI_API_KEY="sapi_..."Java 17 or later. It uses the JDK's java.net.http.HttpClient; its one runtime dependency is Jackson (jackson-databind). Every call blocks until it has its answer. The examples below are the body of a main method, with their imports above. Create the key in the portal (see Quickstart).
Give Sai a task
import ai.simular.sai.Machine;
import ai.simular.sai.Sai;
import ai.simular.sai.Session;
import ai.simular.sai.TurnResult;
Sai sai = new Sai();
Machine pc = sai.machines().list().get(0);
Session session = sai.sessions().create(pc);
TurnResult result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt");
System.out.println(result.status()); // idle, needs_approval or error
System.out.println(result.text()); // Sai's answer
System.out.println(result.usage()); // tokens and cost of this task- The machine can be left out when the account has one computer:
sai.sessions().create(). - 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, then read the result:
import ai.simular.sai.Event;
import ai.simular.sai.EventKind;
import ai.simular.sai.Turn;
Turn turn = session.send("Find last month's invoices in Downloads and total them");
for (Event ev : turn.events()) {
if (ev.kind() == EventKind.STEP) System.out.println(" " + ev.text());
else if (ev.kind() == EventKind.APPROVAL) turn.respond(ev.approval(), "yes");
}
TurnResult done = turn.waitFor();EventKind.value() is the kind's name in every SDK (step, tool_error, ...). turn.events(new EventsOptions().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 waitFor() return with status NEEDS_APPROVAL and the request in result.approval(). To answer as it comes, pass onApproval:
import ai.simular.sai.ApprovalAnswer;
import ai.simular.sai.WaitOptions;
import java.util.List;
import java.util.Map;
TurnResult cleaned = session.run("Clean up the temp folder", new WaitOptions().onApproval(approval -> {
if (approval.isLinkOnly()) {
System.out.println("Approve in a browser: " + approval.url());
return null; // a person answers it; keep waiting
}
if (approval.type().equals("choice")) {
List<List<String>> selections = approval.questions().stream()
.map(q -> List.of((String) ((Map<?, ?>) ((List<?>) q.get("options")).get(0)).get("value")))
.toList();
return ApprovalAnswer.of("yes", selections);
}
return ApprovalAnswer.of("yes");
}));The handler returns ApprovalAnswer.of("yes"), "no", "task" (allow the rest of this task), ApprovalAnswer.of(response, selections) for a choice, or null to leave the approval to someone else. A link-only approval cannot be answered over the API: respond() throws LinkOnlyApprovalException 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.
import ai.simular.sai.SendOptions;
session.run("Export last month's invoices to CSV", new SendOptions().autonomous(true));Files, models, stopping
import ai.simular.sai.Attachment;
import ai.simular.sai.Model;
import ai.simular.sai.SendOptions;
import ai.simular.sai.Turn;
import java.nio.file.Path;
import java.util.List;
Attachment report = sai.files().upload(Path.of("report.pdf")); // or upload(bytes, "report.pdf")
session.run("Summarise the attached report", new SendOptions().attachments(List.of(report)));
List<String> allowed = sai.models().list().stream().filter(Model::allowed).map(Model::id).toList();
session.run("...", new SendOptions().model("anthropic/claude-opus-5-5")); // sticks to the session
Turn 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 outputSchema and the turn's record comes back in result.record(), with output() matching the schema. See Structured output for every option.
import ai.simular.sai.SendOptions;
import java.util.List;
import java.util.Map;
TurnResult typed = session.run(
"Open https://news.ycombinator.com and read the title of the top story.",
new SendOptions().outputSchema(Map.of(
"type", "object",
"properties", Map.of("title", Map.of("type", "string")),
"required", List.of("title"))));
if (typed.ok()) System.out.println(((Map<?, ?>) typed.record().output()).get("title"));
else System.out.println(typed.record().reasonCode()); // e.g. output_invalidSendOptions also takes inputs, successCriteria (a string, or a list of Checks), idempotencyKey and recordTrajectory, and thinkingLevel ("off", "low", "medium", "high" or "xhigh"). Every turn has an id, turn.id(); read it later with sai.turns():
import ai.simular.sai.SendOptions;
import ai.simular.sai.TrajectoryStep;
import ai.simular.sai.Turn;
import ai.simular.sai.TurnRecord;
Turn turn = session.send("...", new SendOptions().recordTrajectory(true));
TurnRecord record = sai.turns().waitFor(turn); // until succeeded, failed or needs_input
for (TrajectoryStep step : sai.turns().trajectory(turn)) {
System.out.println(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.
import ai.simular.sai.Check;
import ai.simular.sai.ControlLease;
import ai.simular.sai.Evaluation;
import ai.simular.sai.ExecResult;
import ai.simular.sai.Judgement;
import ai.simular.sai.LearnOptions;
import ai.simular.sai.LearnResult;
import ai.simular.sai.SendOptions;
import ai.simular.sai.Turn;
import java.util.List;
import java.util.Map;
Turn 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}}",
new SendOptions()
.inputs(Map.of("newsSite", "TechCrunch", "topic", "AI", "fileName", "techcrunch_headlines.txt"))
.recordTrajectory(true));
sai.turns().waitFor(turn);
LearnResult learned = sai.learn().create(List.of(turn)); // waits for the code; longer for a longer task
ControlLease lease = sai.control().open(pc);
ExecResult r = lease.run(learned, Map.of("newsSite", "TechCrunch", "topic", "Fintech", "fileName", "techcrunch_fintech.txt")); // inputs over the defaults
System.out.println(r.result());
lease.close();
LearnResult fixed = sai.learn().create(List.of(), new LearnOptions()
.initialCode(learned.code())
.evaluations(List.of(
new Evaluation(Map.of("newsSite", "TechCrunch", "topic", "Fintech", "fileName", "techcrunch_fintech.txt"))
.result(r)
.passed(false))));
Judgement judgement = sai.judge().create(
List.of(turn),
List.of(new Check("file", "The agent saved the headlines to the file")));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; waitFor reads it until it ends.
import ai.simular.sai.LearnIteration;
import ai.simular.sai.LearnOptions;
import ai.simular.sai.LearnResult;
import ai.simular.sai.LearnRollout;
import ai.simular.sai.LearnRun;
import java.util.List;
import java.util.Map;
LearnResult job = sai.learn().create(List.of(), new LearnOptions()
.taskTemplate("Open {{newsSite}}, find all {{topic}} news published today, write their headlines into Notepad, one per line, and save the file as {{fileName}}")
.inputs(Map.of("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")
.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(Map.of("code", """
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()"""))
.continueWithAgent(true)); // after a failed run, the agent finishes the task and the repair learns from it
LearnResult done = sai.learn().waitFor(job); // can take an hour; pass a timeout to stop waiting
System.out.println(done.status() + " " + done.stopReason() + " " + done.testResult());
System.out.println(done.code()); // the latest version
for (LearnIteration it : done.iterations()) {
System.out.println(it.index() + " " + it.changes() + " " + it.rollouts().stream().map(LearnRollout::verdict).toList());
}
LearnRun 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 (deprecated) 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: ControlLease is AutoCloseable.
import ai.simular.sai.ControlLease;
import ai.simular.sai.ExecResult;
import java.nio.file.Files;
import java.nio.file.Path;
try (ControlLease lease = sai.control().open(pc)) {
System.out.println(lease.shell("hostname").stdout());
lease.writeFile("C:\\data\\note.txt", "hello");
System.out.println(lease.readFile("C:\\data\\note.txt"));
ExecResult r = lease.exec("""
var page = await machine.browser.newtab('https://example.com')
var snap = await page.snapshot()
snap.title
""");
System.out.println(r.result()); // Example Domain
Files.write(Path.of("page." + r.screenshotMimeType().split("/")[1]), r.screenshot()); // jpeg or png
}exec()returns anExecResultwithresult(),logs(),error(),screenshot(),screenshotMimeType()anddurationMs(). A block that throws still returns: 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 throwsBlockException, and a page or app snapshot is the way to get an image.lease.interrupt()stops a running block whileexec()is waiting on another thread.
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().revokeLive(pc); // every liveUrl 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(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 LiveView whose liveUrl() is 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.
import ai.simular.sai.LiveView;
LiveView view = sai.machines().live(pc);
System.out.println("Sign in here, then tell me: " + view.liveUrl()); // valid until view.expiresAt() (ms)
// ... once they are done
session.run("I signed in. Carry on.");
sai.machines().revokeLive(pc); // returns the number of links revokedliveUrl()andexpiresAt()(Unix time in milliseconds) arenullwhere the server does not offer the page.websocketUrl()is the same screen as a Guacamole tunnel for a viewer of your own. It takes input too.machines().revokeLive(machine)revokes everyliveUrlhanded out for the computer, ends the screen sessions they opened, and returns the number revoked. 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.
import ai.simular.sai.CreateTenantOptions;
import ai.simular.sai.Enrollment;
import ai.simular.sai.Sai;
import ai.simular.sai.Session;
import ai.simular.sai.Tenant;
Sai org = Sai.builder().apiKey(System.getenv("SAI_ORG_KEY")).orgId("org_3f9a1c0b7e2d4a6f8c10").build();
Tenant tenant = org.tenants().create("Riverside Clinic", new CreateTenantOptions().externalId("clinic-4821"));
Enrollment enrollment = org.tenants().createEnrollment(tenant); // hand enrollment.token() to your installer
System.out.println(org.tenantMachines().list(tenant));
Sai clinic = org.tenants().client(tenant); // acts as the Tenant
Session visit = clinic.sessions().create();
visit.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.token()asSai.builder().apiKey(...).orgIdis needed only for the exchange. It defaults toSAI_ORG_ID;token()andclient()also take it as a second argument.org.tenants().list(new ListTenantsOptions().status(...).externalId(...).limit(...).cursor(...))returns one page:page.tenants()andpage.nextCursor().externalIdfinds your customer's Tenant without storing its id.org.tenants().update(tenant, new UpdateTenantOptions().name(...).metadata(...).status(...)). Statusdisabledstops the Tenant,activebrings 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 ai.simular.sai.CreateMachineOptions;
import ai.simular.sai.Sai;
import ai.simular.sai.Session;
import ai.simular.sai.TenantMachine;
import java.util.UUID;
Sai org = Sai.builder().apiKey(System.getenv("SAI_ORG_KEY")).orgId("org_3f9a1c0b7e2d4a6f8c10").build();
TenantMachine machine = org.tenantMachines().create("Xk2p9QzR4tLm8vNa3bCd", "windows", new CreateMachineOptions()
.name("Riverside intake")
.requestId(UUID.randomUUID().toString())
.waitForReady(true));
Sai clinic = org.tenants().client("Xk2p9QzR4tLm8vNa3bCd");
Session onCloud = clinic.sessions().create(machine.id());
onCloud.run("Open Notepad and type hello");
org.tenantMachines().delete("Xk2p9QzR4tLm8vNa3bCd", machine);org.tenantMachines().create(tenant, os, new CreateMachineOptions().name(...).requestId(...).waitForReady(...).timeout(...)).osis"windows"or"linux". Pass the samerequestIdwhen you retry a create. WithwaitForReady(true)it gets the machine every 5 seconds until itsstate()isready. Afailedordeletingmachine throwsSaiExceptionwith the machine ine.body(). Noreadywithin the timeout (default 10 minutes) throwsSaiTimeoutException.org.tenantMachines().list(tenant)and.get(tenant, machineId)return both kinds. Each machine haskind(),state(),machineClass()(cloud only) andcreatedAt().org.tenantMachines().delete(tenant, machine)returns"deleting"for a cloud machine and"revoked"for an enrolled one.org.tenants().update(tenant, new UpdateTenantOptions().cloudMachineLimit(...).residentialProxy(...)).cloudMachineLimit(null)removes the limit.residentialProxyhas no effect yet.- A refused create throws the usual
SaiExceptionsubclass. Its details are ine.body():limitandused,scopeandresetsAt, orerrorId.
See Tenants for every field and error.
What your Tenants spent
import ai.simular.sai.OrgUsage;
import ai.simular.sai.Sai;
import ai.simular.sai.SessionSpend;
import ai.simular.sai.SessionSpendPage;
import ai.simular.sai.TenantUsage;
import ai.simular.sai.UsageOptions;
Sai org = Sai.builder().apiKey(System.getenv("SAI_ORG_KEY")).orgId("org_3f9a1c0b7e2d4a6f8c10").build();
OrgUsage usage = org.usage().get(new UsageOptions().granularity("month").from("2026-10").to("2026-10"));
for (TenantUsage t : usage.totals().byTenant().values()) System.out.println(t.name() + " " + t.costCents());
SessionSpendPage page = org.usage().sessions("Xk2p9QzR4tLm8vNa3bCd", "2026-10-01", "2026-10-31");
for (SessionSpend s : page.sessions()) System.out.println(s.sessionId() + " " + s.costUsd());org.usage().get(new UsageOptions().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, new SessionSpendOptions().limit(...).cursor(...))is one page of what each of the Tenant's sessions spent on AI calls. Keep passingpage.nextCursor()ascursoruntil it is null: a page can be empty and not be the last. Withorg.usage().sessions(tenant, new SessionSpendOptions().sessionIds(...))(1 to 50 ids) instead of the range, it is those sessions' spend right now, each withsettled()(true once its latest task has ended and every charge for it has come in), and the page'sasOf();costUsd()andmodelCalls()are null when a session can't be read in full right now. See Session spend.- Both take an
orgIdoption, defaulting to the client's (SAI_ORG_ID).
Errors
Every non-2xx response throws a SaiException subclass, unchecked, carrying status(), code(), getMessage(), body() and retryAfter():
A wait that runs past its timeout throws SaiTimeoutException, which is not an API error.
A task that ran out of credit is not an error. It ends with status ERROR and result.errorCode() set to insufficient_credits or free_computer_time_exhausted. Stop retrying for today. See Billing and limits.
Configuration
new Sai() reads the environment. Sai.builder() sets each option in code: