.NET
v0.1.0The .NET 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
dotnet add package Simular.Sai
export SAI_API_KEY="sapi_...".NET 8 or later. No dependencies beyond the .NET libraries. Every call that goes to the API is async, ends in Async and takes an optional CancellationToken. The examples are top-level statements in a console app (dotnet new console). Create the key in the portal (see Quickstart).
Give Sai a task
using Simular.Sai;
var sai = new SaiClient();
var pc = (await sai.Machines.ListAsync())[0];
var session = await sai.Sessions.CreateAsync(machine: pc);
var result = await session.RunAsync("Open Notepad, type hello, save it to the desktop as hello.txt");
Console.WriteLine(result.Status); // idle, needs_approval or error
Console.WriteLine(result.Text); // Sai's answer
Console.WriteLine(result.Usage); // tokens and cost of this taskmachinecan be left out when the account has one computer.- More
session.RunAsync(...)calls continue the same conversation.sai.Sessions.CreateAsync()starts a new one with no memory of the old. See Sessions. RunAsync()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
SendAsync() returns at once with a turn. Iterate its events, then read the result:
var turn = await session.SendAsync("Find last month's invoices in Downloads and total them");
await foreach (var ev in turn.EventsAsync())
{
if (ev.Kind == EventKind.Step) Console.WriteLine($" {ev.Text}");
else if (ev.Kind == EventKind.Approval) await turn.RespondAsync(ev.Approval!, "yes");
}
var answer = await turn.WaitAsync();EventKind has a constant for each. turn.EventsAsync(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, RunAsync() and WaitAsync() return with Status == "needs_approval" and the request in result.Approval. To answer as it comes, pass OnApproval:
var cleaned = await session.RunAsync("Clean up the temp folder", new RunOptions
{
OnApproval = approval =>
{
if (approval.IsLinkOnly)
{
Console.WriteLine($"Approve in a browser: {approval.Url}");
return null; // a person answers it; keep waiting
}
if (approval.Type == "choice")
{
var selections = approval.Questions
.Select(q => new[] { q.GetProperty("options")[0].GetProperty("value").GetString()! })
.ToArray();
return new ApprovalAnswer("yes", selections);
}
return "yes";
},
});The handler returns "yes", "no", "task" (allow the rest of this task), an ApprovalAnswer with selections for a choice, or null to leave the approval to someone else. OnApprovalAsync takes a handler that awaits. A link-only approval cannot be answered over the API: RespondAsync() throws LinkOnlyApprovalException with ApprovalUrl.
For an unattended run with nobody to answer, set 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.RunAsync("Export last month's invoices to CSV", new RunOptions { Autonomous = true });Files, models, stopping
var report = await sai.Files.UploadAsync("report.pdf"); // or UploadAsync(bytes, "report.pdf")
await session.RunAsync("Summarise the attached report", new RunOptions { Attachments = [report] });
var allowed = (await sai.Models.ListAsync()).Where(m => m.Allowed).Select(m => m.Id).ToList();
await session.RunAsync("...", new RunOptions { Model = "anthropic/claude-opus-5-5" }); // sticks to the session
var turn = await session.SendAsync("...");
await turn.AbortAsync();A message sent while a turn is running steers that turn; turn.Queued is then true.
Typed output (advanced)
Set OutputSchema and the turn's record comes back in result.Record, with Output matching the schema. The schema is a JsonNode, or any object System.Text.Json serializes, such as an anonymous object. See Structured output for every option.
var typed = await session.RunAsync(
"Open https://news.ycombinator.com and read the title of the top story.",
new RunOptions
{
OutputSchema = new
{
type = "object",
properties = new { title = new { type = "string" } },
required = new[] { "title" },
},
});
if (typed.Ok) Console.WriteLine(typed.Record?.Output?.GetProperty("title").GetString());
else Console.WriteLine(typed.Record?.ReasonCode); // e.g. output_invalidSendOptions and RunOptions also take Inputs, SuccessCriteria (a string, or an array 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:
var turn = await session.SendAsync("...", new SendOptions { RecordTrajectory = true });
var record = await sai.Turns.WaitAsync(turn); // until succeeded, failed or needs_input
foreach (var step in await sai.Turns.TrajectoryAsync(turn))
{
Console.WriteLine($"{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.
var turn = await session.SendAsync(
"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 = new Dictionary<string, object> { ["newsSite"] = "TechCrunch", ["topic"] = "AI", ["fileName"] = "techcrunch_headlines.txt" },
RecordTrajectory = true,
});
await sai.Turns.WaitAsync(turn);
var learned = await sai.Learn.CreateAsync([turn]); // waits for the code; longer for a longer task
var fintech = new Dictionary<string, object> { ["newsSite"] = "TechCrunch", ["topic"] = "Fintech", ["fileName"] = "techcrunch_fintech.txt" };
var lease = await sai.Control.OpenAsync(pc);
var r = await lease.RunAsync(learned, fintech); // inputs over the defaults
Console.WriteLine(r.Result);
await lease.CloseAsync();
var repaired = await sai.Learn.CreateAsync([], new LearnOptions
{
InitialCode = learned.Code,
Evaluations = [new Evaluation(fintech, r, Passed: false)],
});
var judgement = await sai.Judge.CreateAsync(
[turn],
new[] { 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, CreateAsync 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 returns at once with a learn job; WaitAsync reads it until it ends.
var job = await sai.Learn.CreateAsync([], 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 = new Dictionary<string, object> { ["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 = Reset.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
});
var done = await sai.Learn.WaitAsync(job); // can take an hour; pass timeout: to stop waiting
Console.WriteLine($"{done.Status} {done.StopReason} {done.TestResult}");
Console.WriteLine(done.Code); // the latest version
foreach (var it in done.Iterations)
{
Console.WriteLine($"{it.Index} {it.Changes} {string.Join(", ", it.Rollouts.Select(x => x.Verdict))}");
}
var run = await sai.Learn.RolloutAsync(job, 1, 1); // what one run returned and logged, and its judgement
await sai.Learn.CancelAsync(job); // stops at once and keeps the last complete resultsDrive 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.
await using (var lease = await sai.Control.OpenAsync(pc))
{
Console.WriteLine((await lease.ShellAsync("hostname")).Stdout);
await lease.WriteFileAsync(@"C:\data\note.txt", "hello");
Console.WriteLine(await lease.ReadFileAsync(@"C:\data\note.txt"));
var r = await lease.ExecAsync("""
var page = await machine.browser.newtab('https://example.com')
var snap = await page.snapshot()
snap.title
""");
Console.WriteLine(r.Result); // Example Domain
await File.WriteAllBytesAsync($"page.{r.ScreenshotMimeType!.Split('/')[1]}", r.Screenshot!); // jpeg or png
}ControlLeaseisIAsyncDisposable:await usingcloses it at the end of the block, asCloseAsync()does.ExecAsync()returns anExecResultwithResult(aJsonElement),Logs,Error,Screenshot,ScreenshotMimeTypeandDurationMs. A block that throws still returns: checkr.Error.Kind(exception,timeout,interrupted).- Use
var, notletorconst, for values the next block needs. lease.ScreenshotAsync()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.InterruptAsync()stops a running block whileExecAsync()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.GetAsync(); // plan and spendable credit
await sai.Models.ListAsync(); // models, with Allowed for this plan
await sai.Machines.LiveAsync(pc); // a link to the screen for a person, and a stream for your viewer; see Live view
await sai.Machines.RevokeLiveAsync(pc); // every LiveUrl stops working and its screen sessions end; returns how manyawait sai.Auth.CheckAsync(); // the user, and the Tenant and Organisation for a Tenant token
await sai.Sessions.ListAsync(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.LiveAsync 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.
var view = await sai.Machines.LiveAsync(pc);
Console.WriteLine($"Sign in here, then tell me: {view.LiveUrl}"); // valid until view.ExpiresAt (ms)
// ... once they are done
await session.RunAsync("I signed in. Carry on.");
await sai.Machines.RevokeLiveAsync(pc); // returns the number of links revokedLiveUrlandExpiresAt(Unix time in milliseconds) arenullwhere the server does not offer the page.WebsocketUrlis the same screen as a Guacamole tunnel for a viewer of your own. It takes input too.Machines.RevokeLiveAsync(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.
using Simular.Sai;
var org = new SaiClient(new SaiClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("SAI_ORG_KEY"),
OrgId = "org_3f9a1c0b7e2d4a6f8c10",
});
var tenant = await org.Tenants.CreateAsync("Riverside Clinic", externalId: "clinic-4821");
var enrollment = await org.Tenants.CreateEnrollmentAsync(tenant); // hand enrollment.Token to your installer
Console.WriteLine(string.Join(", ", await org.TenantMachines.ListAsync(tenant)));
var clinic = await org.Tenants.ClientAsync(tenant); // acts as the Tenant
var visit = await clinic.Sessions.CreateAsync();
await visit.RunAsync("Open the calendar and read today's appointments");org.Tenants.ClientAsync(tenant)exchanges the Organisation Key for a one-hour Tenant token, and exchanges again when it has less than five minutes left.org.Tenants.TokenAsync(tenant)is the exchange alone: passtoken.Tokenasnew SaiClient(new SaiClientOptions { ApiKey = ... }).OrgIdis needed only for the exchange. It defaults toSAI_ORG_ID;TokenAsync()andClientAsync()also takeorgId:.org.Tenants.ListAsync(status:, externalId:, limit:, cursor:)returns one page:page.Tenantsandpage.NextCursor.externalIdfinds your customer's Tenant without storing its id.org.Tenants.UpdateAsync(tenant, new TenantUpdate { Name, Metadata, Status }).Status = "disabled"stops the Tenant,"active"brings it back.org.Tenants.DeleteAsync(tenant)disables it and revokes every machine.org.TenantMachines.GetAsync(tenant, machineId)andorg.TenantMachines.RevokeAsync(tenant, machine).
A Tenant can also have cloud machines that Sai hosts:
using Simular.Sai;
var org = new SaiClient(new SaiClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("SAI_ORG_KEY"),
OrgId = "org_3f9a1c0b7e2d4a6f8c10",
});
var machine = await org.TenantMachines.CreateAsync(
"Xk2p9QzR4tLm8vNa3bCd",
os: "windows",
name: "Riverside intake",
requestId: Guid.NewGuid().ToString(),
wait: true);
var clinic = await org.Tenants.ClientAsync("Xk2p9QzR4tLm8vNa3bCd");
var visit = await clinic.Sessions.CreateAsync(machine: machine.Id);
await visit.RunAsync("Open Notepad and type hello");
await org.TenantMachines.DeleteAsync("Xk2p9QzR4tLm8vNa3bCd", machine);org.TenantMachines.CreateAsync(tenant, os:, name:, requestId:, wait:, timeout:).osis"windows"or"linux". Pass the samerequestIdwhen you retry a create. Withwait: trueit gets the machine every 5 seconds until itsStateisready. Afailedordeletingmachine throwsSaiExceptionwith the machine ine.Body. NoreadywithintimeoutthrowsTimeoutException.org.TenantMachines.ListAsync(tenant)and.GetAsync(tenant, machineId)return both kinds. Each machine hasKind,State,MachineClass(cloud only) andCreatedAt.org.TenantMachines.DeleteAsync(tenant, machine)returns"deleting"for a cloud machine and"revoked"for an enrolled one.org.Tenants.UpdateAsync(tenant, new TenantUpdate { CloudMachineLimit, ResidentialProxy }).CloudMachineLimit = nullremoves the limit; leaving it out changes nothing.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
using Simular.Sai;
var org = new SaiClient(new SaiClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("SAI_ORG_KEY"),
OrgId = "org_3f9a1c0b7e2d4a6f8c10",
});
var usage = await org.Usage.GetAsync(granularity: "month", from: "2026-10", to: "2026-10");
foreach (var t in usage.Totals.ByTenant.Values) Console.WriteLine($"{t.Name} {t.CostCents}");
var page = await org.Usage.SessionsAsync("Xk2p9QzR4tLm8vNa3bCd", from: "2026-10-01", to: "2026-10-31");
foreach (var s in page.Sessions) Console.WriteLine($"{s.SessionId} {s.CostUsd}");org.Usage.GetAsync(granularity:, from:, to:)is what the Organisation is charged, in total and per Tenant, perhour,dayormonth. See Organisation usage.org.Usage.SessionsAsync(tenant, from:, to:, limit:, cursor:)is one page of what each of the Tenant's sessions spent on AI calls. Keep passingpage.NextCursorascursoruntil it is null: a page can be empty and not be the last. WithsessionIds:(1 to 50) 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;CostUsdandModelCallsare null when a session can't be read in full right now. See Session spend.- Both take
orgId:, defaulting to the client'sOrgId(SAI_ORG_ID).
Errors
Every non-2xx response throws a SaiException subclass carrying Status, Code, Message, Body 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.
Configuration
new SaiClient(new SaiClientOptions { ... }):