Docs/Go
SDKs

Go

v0.2.0

The Go 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.2.0. The API may still change before 1.0.0.

Install

InstallShell
go get github.com/simular-ai/sai-sdk/go
export SAI_API_KEY="sapi_..."

Go 1.23 or later. Standard library only. The package is sai. Every call that goes over the network takes a context.Context first, and options come in a struct you can pass as nil. Create the key in the portal (see Quickstart).

Give Sai a task

Give Sai a taskGo
import (
	"context"
	"fmt"
	"log"

	sai "github.com/simular-ai/sai-sdk/go"
)

ctx := context.Background()
client, err := sai.New(sai.Options{}) // SAI_API_KEY, and SAI_API_URL if set
if err != nil {
	log.Fatal(err)
}
machines, err := client.Machines.List(ctx)
if err != nil {
	log.Fatal(err)
}
pc := machines[0]

session, err := client.Sessions.Create(ctx, &sai.SessionOptions{Machine: pc.ID})
if err != nil {
	log.Fatal(err)
}
result, err := session.Run(ctx, "Open Notepad, type hello, save it to the desktop as hello.txt", nil)
if err != nil {
	log.Fatal(err)
}

fmt.Println(result.Status) // idle, needs_approval or error
fmt.Println(result.Text)   // Sai's answer
fmt.Println(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. client.Sessions.Create(ctx, nil) 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. Range over its events, then read the result:

Watch the eventsGo
turn, err := session.Send(ctx, "Find last month's invoices in Downloads and total them", nil)
if err != nil {
	log.Fatal(err)
}
for ev, err := range turn.Events(ctx, nil) {
	if err != nil {
		log.Fatal(err)
	}
	switch ev.Kind {
	case sai.EventStep:
		fmt.Println("  ", ev.Text)
	case sai.EventApproval:
		if _, err := turn.Respond(ctx, ev.Approval, "yes", nil); err != nil {
			log.Fatal(err)
		}
	}
}
result, err := turn.Wait(ctx, nil)
if err != nil {
	log.Fatal(err)
}
fmt.Println(result.Text)
ev.KindWhat it is
sai.EventStepA progress line: what Sai is doing
sai.EventMessageSai's text
sai.EventApprovalSai is waiting for permission. ev.Approval holds it
sai.EventToolErrorA step failed. Sai usually recovers
sai.EventErrorThe turn failed. ev.Code is set when the account ran out (below)
sai.EventFinishedThe turn is complete

turn.Events(ctx, &sai.EventsOptions{Raw: true}) also yields reasoning, tool-call and framing frames as sai.EventOther, with the wire event in ev.Raw. A failed poll is yielded as the error and ends the loop. 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 OnApproval:

Approval handlerGo
result, err := session.Run(ctx, "Clean up the temp folder", &sai.RunOptions{
	OnApproval: func(a *sai.Approval) *sai.ApprovalAnswer {
		if a.IsLinkOnly {
			fmt.Println("Approve in a browser:", a.URL)
			return nil // a person answers it; keep waiting
		}
		if a.Type == "choice" {
			var selections [][]string
			for _, q := range a.Questions {
				first := q["options"].([]any)[0].(map[string]any)
				selections = append(selections, []string{first["value"].(string)})
			}
			return &sai.ApprovalAnswer{Response: "yes", Selections: selections}
		}
		return &sai.ApprovalAnswer{Response: "yes"}
	},
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(result.Status)

The handler returns an answer with Response "yes", "no" or "task" (allow the rest of this task) and, for a choice, Selections, or nil to leave the approval to someone else. A link-only approval cannot be answered over the API: Respond() returns a *sai.LinkOnlyApprovalError 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.

Unattended runGo
if _, err := session.Run(ctx, "Export last month's invoices to CSV", &sai.RunOptions{Autonomous: true}); err != nil {
	log.Fatal(err)
}

Files, models, stopping

Files, models, stoppingGo
import "os"

f, err := os.Open("report.pdf")
if err != nil {
	log.Fatal(err)
}
report, err := client.Files.Upload(ctx, "report.pdf", f) // any io.Reader, with the file's name
f.Close()
if err != nil {
	log.Fatal(err)
}
if _, err := session.Run(ctx, "Summarise the attached report", &sai.RunOptions{Attachments: []*sai.Attachment{report}}); err != nil {
	log.Fatal(err)
}

models, err := client.Models.List(ctx)
if err != nil {
	log.Fatal(err)
}
var allowed []string
for _, m := range models {
	if m.Allowed {
		allowed = append(allowed, m.ID)
	}
}
fmt.Println(allowed)
if _, err := session.Run(ctx, "...", &sai.RunOptions{Model: "anthropic/claude-opus-5-5"}); err != nil { // sticks to the session
	log.Fatal(err)
}

turn, err := session.Send(ctx, "...", nil)
if err != nil {
	log.Fatal(err)
}
if _, err := turn.Abort(ctx); err != nil {
	log.Fatal(err)
}

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. See Structured output for every option.

Typed outputGo
result, err := session.Run(ctx, "Open https://news.ycombinator.com and read the title of the top story.", &sai.RunOptions{
	OutputSchema: map[string]any{
		"type":       "object",
		"properties": map[string]any{"title": map[string]any{"type": "string"}},
		"required":   []string{"title"},
	},
})
if err != nil {
	log.Fatal(err)
}
if result.OK {
	fmt.Println(result.Record.Output.(map[string]any)["title"])
} else if result.Record != nil {
	fmt.Println(result.Record.ReasonCode) // e.g. output_invalid
}

SendOptions and RunOptions also take Inputs, SuccessCriteria (sai.CriteriaText, or sai.Checks), IdempotencyKey and RecordTrajectory, and ThinkingLevel (sai.ThinkingOff, sai.ThinkingLow, sai.ThinkingMedium, sai.ThinkingHigh or sai.ThinkingXHigh). Every turn has an id, turn.ID; read it later with client.Turns:

Read a turn laterGo
turn, err := session.Send(ctx, "...", &sai.SendOptions{RecordTrajectory: true})
if err != nil {
	log.Fatal(err)
}
record, err := client.Turns.Wait(ctx, turn.ID) // until succeeded, failed or needs_input
if err != nil {
	log.Fatal(err)
}
fmt.Println(record.Status)
steps, err := client.Turns.Trajectory(ctx, turn.ID)
if err != nil {
	log.Fatal(err)
}
for _, step := range steps {
	fmt.Println(step.Index, step.Title)
}

Learn code from turns (research preview)

client.Learn turns recorded turns into Simulang code you run with a control lease, and repairs it. client.Judge checks recorded turns against criteria you choose afterwards. See Learned code.

Learn and repair codeGo
turn, err := session.Send(ctx,
	"Open {{newsSite}}, find all {{topic}} news published today, write their headlines into Notepad, one per line, and save the file as {{fileName}}",
	&sai.SendOptions{
		Inputs:           map[string]any{"newsSite": "TechCrunch", "topic": "AI", "fileName": "techcrunch_headlines.txt"},
		RecordTrajectory: true,
	},
)
if err != nil {
	log.Fatal(err)
}
if _, err := client.Turns.Wait(ctx, turn.ID); err != nil {
	log.Fatal(err)
}

learned, err := client.Learn.Create(ctx, []string{turn.ID}, nil) // waits for the code; longer for a longer task
if err != nil {
	log.Fatal(err)
}
lease, err := client.Control.Open(ctx, pc.ID)
if err != nil {
	log.Fatal(err)
}
inputs := map[string]any{"newsSite": "TechCrunch", "topic": "Fintech", "fileName": "techcrunch_fintech.txt"}
r, err := lease.Run(ctx, learned, inputs, nil) // inputs over the defaults
if err != nil {
	log.Fatal(err)
}
fmt.Println(r.Result)
if err := lease.Close(ctx); err != nil {
	log.Fatal(err)
}

fixed, err := client.Learn.Create(ctx, nil, &sai.LearnOptions{
	InitialCode: learned.Code,
	Evaluations: []sai.Evaluation{{Inputs: inputs, Result: r, Passed: sai.Ptr(false)}},
})
if err != nil {
	log.Fatal(err)
}
fmt.Println(fixed.Changes)
judgement, err := client.Judge.Create(ctx,
	[]string{turn.ID},
	sai.Checks{{ID: "file", Check: "The agent saved the headlines to the file"}},
	nil,
)
if err != nil {
	log.Fatal(err)
}
fmt.Println(judgement.Verdict)

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 returns at once with a learn job; Wait reads it until it ends.

Run, judge and repair on a computerGo
job, err := client.Learn.Create(ctx, nil, &sai.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[string]any{"newsSite": "TechCrunch", "topic": "AI", "fileName": "techcrunch_headlines.txt"},
	Machine:       pc.ID,
	Criteria:      sai.CriteriaText("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: &sai.ResetAction{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
})
if err != nil {
	log.Fatal(err)
}
done, err := client.Learn.Wait(ctx, job.ID, nil) // can take an hour; a context deadline stops waiting
if err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s %+v\n", done.Status, done.StopReason, done.TestResult)
fmt.Println(done.Code) // the latest version
for _, it := range done.Iterations {
	var verdicts []string
	for _, r := range it.Rollouts {
		verdicts = append(verdicts, r.Verdict)
	}
	fmt.Println(it.Index, it.Changes, verdicts)
}

run, err := client.Learn.Rollout(ctx, job.ID, 1, 1) // what one run returned and logged, and its judgement
if err != nil {
	log.Fatal(err)
}
fmt.Println(run.Run)
if err := client.Learn.Cancel(ctx, job.ID); err != nil { // stops at once and keeps the last complete results
	log.Fatal(err)
}

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.

Drive a computerGo
import (
	"os"
	"strings"
)

lease, err := client.Control.Open(ctx, pc.ID)
if err != nil {
	log.Fatal(err)
}
defer lease.Close(ctx)

out, err := lease.Shell(ctx, "hostname", nil)
if err != nil {
	log.Fatal(err)
}
fmt.Println(out.Stdout)

if err := lease.WriteFile(ctx, `C:\data\note.txt`, "hello"); err != nil {
	log.Fatal(err)
}
text, err := lease.ReadFile(ctx, `C:\data\note.txt`)
if err != nil {
	log.Fatal(err)
}
fmt.Println(text)

r, err := lease.Exec(ctx, `
	var page = await machine.browser.newtab('https://example.com')
	var snap = await page.snapshot()
	snap.title
`, nil)
if err != nil {
	log.Fatal(err)
}
fmt.Println(r.Result) // Example Domain

ext := strings.TrimPrefix(r.ScreenshotMimeType, "image/") // jpeg or png
if err := os.WriteFile("page."+ext, r.Screenshot, 0o644); err != nil {
	log.Fatal(err)
}
  • defer lease.Close(ctx) releases the computer when the function returns. Close is safe to call twice.
  • Exec() returns an *ExecResult with Result, Logs, Error, Screenshot, ScreenshotMimeType and DurationMs. A block that throws still returns a result: check r.Error.Kind (exception, timeout, interrupted).
  • Use var, not let or const, for values the next block needs.
  • lease.Screenshot(ctx) grabs the whole screen. It needs the computer's screen-capture service; where that is off it returns a *sai.BlockError, and a page or app snapshot is the way to get an image.
  • lease.Interrupt(ctx) stops a running block while Exec() 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

Other callsGo
account, err := client.Account.Get(ctx) // plan and spendable credit
if err != nil {
	log.Fatal(err)
}
models, err := client.Models.List(ctx) // models, with Allowed for this plan
if err != nil {
	log.Fatal(err)
}
view, err := client.Machines.Live(ctx, pc.ID) // a link to the screen for a person, and a stream for your viewer; see Live view
if err != nil {
	log.Fatal(err)
}
revoked, err := client.Machines.RevokeLive(ctx, pc.ID) // every LiveURL stops working and its screen sessions end; returns how many
if err != nil {
	log.Fatal(err)
}
fmt.Println(account.PlanTitle, len(models), view.LiveURL, revoked)
Who the key is, and its sessionsGo
me, err := client.Auth.Check(ctx) // the user, and the Tenant and Organisation for a Tenant token
if err != nil {
	log.Fatal(err)
}
sessions, err := client.Sessions.List(ctx, &sai.SessionOptions{Machine: pc.ID}) // your sessions on the computer, any channel, most recent first
if err != nil {
	log.Fatal(err)
}
fmt.Println(me.UserID, len(sessions))

Hand 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.

Hand the screen to a personGo
view, err := client.Machines.Live(ctx, pc.ID)
if err != nil {
	log.Fatal(err)
}
fmt.Println("Sign in here, then tell me:", view.LiveURL) // valid until view.ExpiresAt (ms)
// ... once they are done
if _, err := session.Run(ctx, "I signed in. Carry on.", nil); err != nil {
	log.Fatal(err)
}
if _, err := client.Machines.RevokeLive(ctx, pc.ID); err != nil { // returns the number of links revoked
	log.Fatal(err)
}
  • LiveURL is empty and ExpiresAt (Unix time in milliseconds) is nil where 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(ctx, machineID) revokes every LiveURL handed 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.

TenantsGo
import (
	"context"
	"fmt"
	"log"
	"os"

	sai "github.com/simular-ai/sai-sdk/go"
)

ctx := context.Background()
org, err := sai.New(sai.Options{APIKey: os.Getenv("SAI_ORG_KEY"), OrgID: "org_3f9a1c0b7e2d4a6f8c10"})
if err != nil {
	log.Fatal(err)
}

tenant, err := org.Tenants.Create(ctx, "Riverside Clinic", &sai.TenantCreateOptions{ExternalID: "clinic-4821"})
if err != nil {
	log.Fatal(err)
}
enrollment, err := org.Tenants.CreateEnrollment(ctx, tenant.ID) // hand enrollment.Token to your installer
if err != nil {
	log.Fatal(err)
}
machines, err := org.TenantMachines.List(ctx, tenant.ID)
if err != nil {
	log.Fatal(err)
}
fmt.Println(enrollment.ExpiresAt, len(machines))

clinic, err := org.Tenants.Client(ctx, tenant.ID, nil) // acts as the Tenant
if err != nil {
	log.Fatal(err)
}
session, err := clinic.Sessions.Create(ctx, nil)
if err != nil {
	log.Fatal(err)
}
if _, err := session.Run(ctx, "Open the calendar and read today's appointments", nil); err != nil {
	log.Fatal(err)
}
  • org.Tenants.Client(ctx, tenantID, nil) exchanges the Organisation Key for a one-hour Tenant token, and exchanges again when it has less than five minutes left. org.Tenants.Token(ctx, tenantID, nil) is the exchange alone: pass token.Token as sai.Options{APIKey: ...}.
  • OrgID is needed only for the exchange. It defaults to SAI_ORG_ID; Token() and Client() also take &sai.TenantTokenOptions{OrgID: ...}.
  • org.Tenants.List(ctx, &sai.TenantListOptions{Status, ExternalID, Limit, Cursor}) returns one page: page.Tenants and page.NextCursor. ExternalID finds your customer's Tenant without storing its id.
  • org.Tenants.Update(ctx, tenantID, &sai.TenantUpdateOptions{Name, Metadata, Status}). Status: "disabled" stops the Tenant, "active" brings it back. org.Tenants.Delete(ctx, tenantID) disables it and revokes every machine.
  • org.TenantMachines.Get(ctx, tenantID, machineID) and org.TenantMachines.Revoke(ctx, tenantID, machineID).

A Tenant can also have cloud machines that Sai hosts:

Cloud machinesGo
import (
	"context"
	"fmt"
	"log"
	"os"

	sai "github.com/simular-ai/sai-sdk/go"
)

ctx := context.Background()
org, err := sai.New(sai.Options{APIKey: os.Getenv("SAI_ORG_KEY"), OrgID: "org_3f9a1c0b7e2d4a6f8c10"})
if err != nil {
	log.Fatal(err)
}

machine, err := org.TenantMachines.Create(ctx, "Xk2p9QzR4tLm8vNa3bCd", &sai.TenantMachineCreateOptions{
	OS:        "windows",
	Name:      "Riverside intake",
	RequestID: "riverside-intake-1", // send the same id again if you retry this create
	Wait:      true,
})
if err != nil {
	log.Fatal(err)
}
clinic, err := org.Tenants.Client(ctx, "Xk2p9QzR4tLm8vNa3bCd", nil)
if err != nil {
	log.Fatal(err)
}
session, err := clinic.Sessions.Create(ctx, &sai.SessionOptions{Machine: machine.ID})
if err != nil {
	log.Fatal(err)
}
if _, err := session.Run(ctx, "Open Notepad and type hello", nil); err != nil {
	log.Fatal(err)
}
state, err := org.TenantMachines.Delete(ctx, "Xk2p9QzR4tLm8vNa3bCd", machine.ID)
if err != nil {
	log.Fatal(err)
}
fmt.Println(state) // deleting
  • org.TenantMachines.Create(ctx, tenantID, &sai.TenantMachineCreateOptions{OS, Name, RequestID, Wait, Timeout}). OS is "windows" or "linux". Pass the same RequestID 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 returns a *sai.SaiError with the machine in Body. No ready within Timeout (default 10 minutes) returns an error that wraps context.DeadlineExceeded.
  • org.TenantMachines.List(ctx, tenantID) and .Get(ctx, tenantID, machineID) return both kinds. Each machine has Kind, State, MachineClass (cloud only) and CreatedAt.
  • org.TenantMachines.Delete(ctx, tenantID, machineID) returns "deleting" for a cloud machine and "revoked" for an enrolled one.
  • org.Tenants.Update(ctx, tenantID, &sai.TenantUpdateOptions{CloudMachineLimit: sai.Ptr(5), ResidentialProxy: sai.Ptr(true)}). ClearCloudMachineLimit: true removes the limit. ResidentialProxy has no effect yet.
  • A refused create returns the usual error type. Its details are in Body: limit and used, scope and resetsAt, or errorId.

See Tenants for every field and error.

What your Tenants spent

UsageGo
import (
	"context"
	"fmt"
	"log"
	"os"

	sai "github.com/simular-ai/sai-sdk/go"
)

ctx := context.Background()
org, err := sai.New(sai.Options{APIKey: os.Getenv("SAI_ORG_KEY"), OrgID: "org_3f9a1c0b7e2d4a6f8c10"})
if err != nil {
	log.Fatal(err)
}
usage, err := org.Usage.Get(ctx, &sai.UsageOptions{Granularity: "month", From: "2026-10", To: "2026-10"})
if err != nil {
	log.Fatal(err)
}
for _, t := range usage.Totals.ByTenant {
	fmt.Println(t.Name, t.CostCents)
}

page, err := org.Usage.Sessions(ctx, "Xk2p9QzR4tLm8vNa3bCd", &sai.UsageSessionsOptions{From: "2026-10-01", To: "2026-10-31"})
if err != nil {
	log.Fatal(err)
}
for _, s := range page.Sessions {
	if s.CostUSD != nil {
		fmt.Println(s.SessionID, *s.CostUSD)
	}
}
  • org.Usage.Get(ctx, &sai.UsageOptions{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(ctx, tenantID, &sai.UsageSessionsOptions{From, To, Limit, Cursor}) is one page of what each of the Tenant's sessions spent on AI calls. Keep passing page.NextCursor as Cursor until it is empty: a page can be empty and not be the last. With SessionIDs (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 AsOf; CostUSD and ModelCalls are nil when a session can't be read in full right now, and Settled is nil on a list page. See Session spend.
  • Both take OrgID, defaulting to the client's OrgID (SAI_ORG_ID).

Errors

Every non-2xx response returns an error of one of these types. Each embeds sai.SaiError, with Status, Code, Message, Body and RetryAfter, and errors.As finds it, or its parent type, in any of them:

ErrorWhen
*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.ApprovalURL()
*sai.GoneError410: the control lease ended (e.Body["reason"]). Open a new one
*sai.SpendCapReachedError403: the key reached its monthly spend limit. e.ResetsAt()
*sai.RateLimitError429: a rate limit, or the free plan's daily computer time
*sai.ServiceUnavailableError503: the computer did not come online. Honor RetryAfter
*sai.ConnectionErrorThe API could not be reached
*sai.BlockErrorA control helper's block failed on the computer
Handle an errorGo
import "errors"

_, err := client.Machines.Get(ctx, "m_missing")
var notFound *sai.NotFoundError
if errors.As(err, &notFound) {
	fmt.Println(notFound.Status, notFound.Message) // 404
}
var saiErr *sai.SaiError
if errors.As(err, &saiErr) {
	fmt.Println(saiErr.Code, saiErr.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

sai.OptionsEnvironmentDefault
APIKeySAI_API_KEYrequired
BaseURLSAI_API_URLhttps://api.simular.ai
Timeout60 seconds per request
HTTPClienthttp.DefaultClient
OrgIDSAI_ORG_IDfor Tenants.Token() and Usage
© 2026 Simular Inc. All rights reserved.