kosa8 docs

Guides / Go / Developer guide

Go developer guide

The quickstart shows one happy path. This page covers everything else github.com/kosa8ai/kosa8-go v0.1.2 does, how it does it, and where it surprises you. Each section starts in plain words and then goes into detail. Exact signatures are in the API reference; every error is in Errors and troubleshooting.

How this page was tested

Every program on this page was run against a real kosa8 daemon 0.9.36 on an Apple silicon Mac with 16 GB of memory, with github.com/kosa8ai/kosa8-go v0.1.2 fetched from the Go module proxy and built with go1.26.9 darwin/arm64. The programs were first run on v0.1.0; v0.1.1 and v0.1.2 fixed bugs found that way (see Versions). Every program was run again on v0.1.1; the sandbox-free error checks and the mount and capture program were run again on v0.1.2, which changed no behaviour the other programs touch. Where an older version behaves differently, the page says so. Long lines are cut only where marked with …; long lines are cut only where marked with …. Other sandboxes were running on the same machine at the time, so timings are slow and will differ on yours, and several attempts to create a sandbox failed with guest not ready on vsock port 1024 after 25s while memory was short (see the errors page); the retries succeeded. Every sandbox, snapshot and capture file the programs created was deleted. Not tested here: a remote worker (KOSA8_HOST), approvals, an org policy, Broker, Encrypt, Wipe and TTY. Those are described from the source, and say so.

What this is, in plain words

kosa8 runs code inside sandboxes: small, separate virtual computers on your own machine. Code inside one cannot reach your files, passwords or network unless you allow it. A background program called the daemon creates and manages them. The Go SDK is a library your Go program uses to give the daemon instructions: make a sandbox, run this command in it, save it, put it back, throw it away.

A few words appear all over this page:

WordIn plain wordsFor developers
SandboxA sealed computer inside yours.A microVM with its own kernel, disk and private container engine.
ImageA packaged set of programs, like "Alpine Linux" or "Python 3.12".An OCI image. It must be pulled into the sandbox before use.
ContainerOne program running from an image, inside the sandbox.A container on the sandbox's engine; ids look like k4-c858e2e1.
EgressWhich websites the sandbox may talk to.A host-enforced network policy: deny, any, fakenet, allow:h1,h2.
CheckpointAn "undo point": the whole sandbox saved, so you can go back.A snapshot named <sandbox>-ckpt-<n> plus a record in ~/.kosa8/checkpoints.json.
RewindGo back to an undo point.Delete the sandbox, restore the snapshot under the same name, processes resumed.
ForkMake copies of a running sandbox, to try different things in each.Snapshot once, restore N times as <prefix>1…N.
ExploreTry several ideas at once and keep whichever worked fastest.Fork per variant, run sh -c VARIANT in each concurrently, keep the fastest exit-0 branch.

How the SDK is built

In plain words: one object, the client, holds the connection details. You ask it to do things, and it sends each request to the daemon and turns the answer into Go values.

In detail:

Connecting

In plain words: by default the SDK talks to the kosa8 running on this machine, and you do not have to configure anything. You only set options to reach a kosa8 on another machine, or to label who is making the calls.

Where requests go

kosa8.ResolveEndpoint is the function New uses, and it is exported, so you can see the decision without sending anything. This program prints the endpoint for several inputs, using a fake environment:

env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, c := range []struct {
	host string
	env  map[string]string
}{
	{"", nil},
	{"", map[string]string{"KOSA8_HOST": "https://worker.example.com/", "KOSA8_TOKEN": "t0k"}},
	{"https://worker.example.com", map[string]string{"KOSA8_TOKEN": "t0k"}},
	{"tcp://127.0.0.1:7878", nil},
	{"10.0.0.5:7878", nil},
	{"10.0.0.5:7878", map[string]string{"KOSA8_INSECURE": "1"}},
	{"unix:///tmp/k8/kosa8.sock", nil},
	{"ftp://x", nil},
} {
	ep, err := kosa8.ResolveEndpoint(c.host, "", "", false, env(c.env))
	if err != nil {
		fmt.Printf("%-28q -> error: %v\n", c.host, err)
		continue
	}
	fmt.Printf("%-28q -> %s token=%q\n", c.host, ep.Target(), ep.Token)
}
_, err = kosa8.New(kosa8.Options{Actor: "robot"})
fmt.Println("bad actor:", err)
""                           -> unix:///Users/dhruvit/.kosa8/run/kosa8.sock token=""
""                           -> https://worker.example.com token="t0k"
"https://worker.example.com" -> https://worker.example.com token=""
"tcp://127.0.0.1:7878"       -> http://127.0.0.1:7878 token=""
"10.0.0.5:7878"              -> error: refusing to talk to 10.0.0.5:7878 over plain http: the token and every request would cross the network unencrypted. Use https://10.0.0.5:7878 (a worker behind a TLS-terminating tunnel or proxy), or set Insecure / KOSA8_INSECURE=1 if this link is already private and trusted
"10.0.0.5:7878"              -> http://10.0.0.5:7878 token=""
"unix:///tmp/k8/kosa8.sock"  -> unix:///tmp/k8/kosa8.sock token=""
"ftp://x"                    -> error: KOSA8_HOST scheme "ftp" is not supported; use http://, https://, tcp:// or unix://
bad actor: actor must be human, agent or tool, not "robot"

Read the third line carefully: when you set Options.Host in code, KOSA8_TOKEN from the environment is ignored. The env token is only paired with an env host. If you pass Host, pass Token too (Token: os.Getenv("KOSA8_TOKEN") if that is where it lives). Otherwise the worker answers 401 (ErrAuth).

Environment variables

VariableRead byEffect
KOSA8_HOSTNew, when Options.Host is emptySelects a remote worker. Empty: the local socket.
KOSA8_TOKENNew, only when the host came from KOSA8_HOST and Options.Token is emptySent as Authorization: Bearer …. Never sent over a unix socket.
KOSA8_INSECURENew1 allows plain http:// to a host that is not localhost, ::1 or 127.*.
KOSA8_AGENT_TOKENNew, when Options.AgentToken is emptySent as X-Kosa8-Agent-Token; see Agent identity.
HOMEDefaultSocketPath, NewFileCheckpointStore("")The socket is $HOME/.kosa8/run/kosa8.sock; checkpoints live in $HOME/.kosa8/checkpoints.json.

All of them are read once, in New, through Options.Getenv (default os.Getenv). Changing the environment later does not affect an existing client. In tests, pass Getenv to keep the real environment out.

Who is calling: Actor, AgentToken, Headers

Actor is "tool" unless you say "human" or "agent", and is sent on every request as X-Kosa8-Actor. The daemon records it in the audit log, and an org policy can treat actors differently, so a program that runs an AI model's commands should say Actor: "agent". Headers are added to every request after Accept and X-Kosa8-Actor (so they can replace those) and before the approval, agent-token and Authorization headers (which win).

Your own http.Client

Options.HTTPClient replaces the SDK's client entirely. For the local daemon the SDK then sends requests to http://kosa8/…, and your transport must dial the socket itself. This ran:

sock := kosa8.DefaultSocketPath()
hc := &http.Client{Transport: &http.Transport{
	DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
		var d net.Dialer
		return d.DialContext(ctx, "unix", sock)
	},
	MaxIdleConnsPerHost: 16,
	IdleConnTimeout:     30 * time.Second,
}}
k, err := kosa8.New(kosa8.Options{HTTPClient: hc, Actor: "agent", Headers: map[string]string{"X-Request-Source": "nightly-job"}})
if err != nil {
	panic(err)
}
v, err := k.Version(context.Background())
fmt.Println("via custom client:", v, err, k.Endpoint().Target())
via custom client: 0.9.36 <nil> unix:///Users/dhruvit/.kosa8/run/kosa8.sock

Do not set http.Client.Timeout on a client you use for Run, Exec or Attach: it would cut long streams off at that length. Use contexts instead (next section).

Contexts, timeouts and cancellation

In plain words: every call can be given a deadline ("give up after two seconds") or be called off. Giving up stops your program from waiting. It does not stop the program running inside the sandbox.

In detail: every method that talks to the daemon takes a context.Context first, and it is attached to the HTTP request. RunOptions.Timeout, when positive, wraps that context with context.WithTimeout for that one Run, Exec or Attach. What you get back depends on how the context ended:

How it endedErrorCheck with
Deadline passed (yours, or RunOptions.Timeout)*kosa8.Error, Kind timeouterrors.Is(err, kosa8.ErrTimeout). Also errors.Is(err, context.DeadlineExceeded) when it happened before the answer started.
Cancelled (cancel(), Ctrl-C via signal.NotifyContext)the context's own error, not a *kosa8.Errorerrors.Is(err, context.Canceled)

In both cases Run, Exec and Attach return the output collected so far in the ExecResult; for a timeout it is also on the error's Stdout/Stderr. This ran (excerpt from a longer program):

// Timeout.
t0 := time.Now()
res, err = sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "echo started; sleep 30"}, &kosa8.RunOptions{Timeout: 2 * time.Second})
fmt.Printf("timeout after %.1fs: ErrTimeout=%v err=%v partial=%q\n", time.Since(t0).Seconds(), errors.Is(err, kosa8.ErrTimeout), err, res.Stdout())
if e, ok := kosa8.AsError(err); ok {
	fmt.Printf("  Kind=%s Stdout=%q\n", e.Kind, e.Stdout)
}

// Cancel.
cctx, cancel := context.WithCancel(ctx)
go func() { time.Sleep(1500 * time.Millisecond); cancel() }()
res, err = sb.Run(cctx, "alpine:latest", []string{"sh", "-c", "echo started; sleep 30"}, nil)
_, isK := kosa8.AsError(err)
fmt.Printf("cancel: context.Canceled=%v isKosa8Error=%v err=%v partial=%q\n", errors.Is(err, context.Canceled), isK, err, res.Stdout())
timeout after 2.0s: ErrTimeout=true err=POST /kosa8/v1/sandboxes/doc-go-g-run/run timed out mid-stream partial="started\n"
  Kind=timeout Stdout="started\n"
cancel: context.Canceled=true isKosa8Error=false err=context canceled partial="started\n"

Later in the same program, sb.Containers(ctx) listed both of those sleep 30 containers as still running:

containers entry: map[id:k2-be22c42e image:docker.io/library/alpine:latest ip:10.88.0.3 status:running]
containers entry: map[id:k3-10945efa image:docker.io/library/alpine:latest ip:10.88.0.4 status:running]
containers entry: map[id:k4-6556b8f4 image:docker.io/library/alpine:latest ip:10.88.0.5 status:running]

v0.1.2 has no call to stop one container. To be sure runaway work stops, stop or delete the sandbox (StopSandbox, DeleteSandbox), or enforce the limit inside the command (timeout 30 make test). Two more points:

Goroutines

In plain words: one client can be used from many parts of your program at once. The one thing not to do at the same moment is take two checkpoints of the same sandbox.

var wg sync.WaitGroup
for i := 0; i < 2; i++ {
	wg.Add(1)
	go func(i int) {
		defer wg.Done()
		c, err := sb.Checkpoint(ctx, fmt.Sprint("race ", i))
		fmt.Printf("concurrent checkpoint %d: %s %v\n", i, c.Checkpoint.ID, err)
	}(i)
}
wg.Wait()
concurrent checkpoint 1: doc-go-g-tick-ckpt-2 <nil>
concurrent checkpoint 0: doc-go-g-tick-ckpt-3 <nil>
  stack: doc-go-g-tick-ckpt-1 t
  stack: doc-go-g-tick-ckpt-2 race 1
  stack: doc-go-g-tick-ckpt-3 race 0

(The stack: lines come from sb.Checkpoints() right after.) Which goroutine gets which number is a race, so do not rely on the order. In v0.1.0 the loser failed with snapshot already exists. The same stepping covers a checkpoint taken by kosa8 checkpoint at the same moment.

Sandboxes

In plain words: you create a sandbox with a name and some limits (how many processors, how much memory, which websites), use it, and delete it. You can also switch it off and on again; its disk is kept while it is off.

Create

created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{
	Name:        name,
	CPUs:        2,
	MemMB:       1024,
	EgressAllow: []string{"pypi.org", "files.pythonhosted.org"},
	Labels:      map[string]string{"team": "docs", "job": "guide"},
})
if err != nil {
	return err
}
sb := k.Sandbox(name)
defer func() {
	out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{})
	fmt.Println("deleted:", out, err)
}()
s := created["sandbox"].(map[string]any)
fmt.Printf("created %s state=%s cpus=%v mem=%v egress=%v labels=%v boot_ms=%v\n",
	s["name"], s["state"], s["cpus"], s["mem_mb"], s["egress"], s["labels"], created["boot_ms"])
fmt.Println("sandbox keys:", keys(s))

info, _ := sb.Info(ctx)
fmt.Println("Info state:", info["state"])
missing, err := k.GetSandbox(ctx, "no-such-sandbox")
fmt.Println("GetSandbox(missing):", missing == nil, err)
created doc-go-g-run state=running cpus=2 mem=1024 egress=allow:pypi.org,files.pythonhosted.org labels=map[job:guide team:docs] boot_ms=12259
sandbox keys: [cpus created disk_gb egress id ip labels mem_mb name state]
Info state: running
GetSandbox(missing): true <nil>

CreateSandbox returns when the sandbox has booted. Its answer has sandbox (an object) and boot_ms. GetSandbox (and sb.Info) lists every sandbox and matches by id, then by name; a missing sandbox is nil, nil, not an error, so check for nil.

Every CreateOptions field, and what the SDK actually sends:

FieldZero value meansNotes
Namethe daemon picks a nameRead the real one from created["sandbox"]. The daemon accepted "bad name!" in our test; the SDK does not check names here (only the checkpoint functions do).
CPUs, MemMB, DiskGBthe daemon's defaults (here 2 CPUs, 1024 MiB, 32 GB)Values of 0 or less are not sent.
Egressthe daemon's default (the org policy's, if it sets one)deny (no network), any, fakenet, or allow:h1,h2. Anything else is a 400. See Network rules.
EgressAllownot usedSends egress: "allow:h1,h2", each host trimmed. Combined with an Egress other than "" or "deny" it is a KindUsage error, before anything is sent.
LabelsnoneFree-form key/value tags, returned under labels. Not the same as the classification Label on forks and restores (below).
Mountsnone"HOST:GUEST" or "HOST:GUEST:ro" (:rw also accepted), sent as {host_path, guest_path, read_only}. Both paths must be absolute. A malformed or relative entry is KindUsage. See below. (Broken in v0.1.0, fixed in v0.1.1.)
Capturenil: no capture&kosa8.CaptureOptions{Path, MaxBytes, MaxFiles}: record every network frame to a pcapng file on the host. Path is required and is a file, not a directory. See below. (A bool that never worked in v0.1.0.)
BrokernonePassed through as JSON, for the credential broker (Keep API keys out). Not tested.
ApprovalnoneSent as X-Kosa8-Approval.

Images, containers and egress state

p, err := sb.Pull(ctx, "alpine:latest")
fmt.Println("pull:", p, err)
imgs, _ := sb.Images(ctx)
fmt.Println("images:", imgs)
// … later
eg, err := sb.Egress(ctx)
fmt.Println("egress:", eg, err)
pull: map[image:docker.io/library/alpine:latest ok:true] <nil>
images: [docker.io/library/alpine:latest]
egress: map[policy:allow:pypi.org,files.pythonhosted.org stats:map[denied:0 dns_answered:4 dns_refused:0 dropped:1 passed:3]] <nil>

sb.Pull (PullIntoSandbox) fetches the image on the host and copies it into the sandbox, so it works with Egress: "deny". k.Pull is different: it pulls into the host's shared engine and does not make an image usable in any sandbox. sb.Egress shows the enforced rule and its counters, which is the quickest way to see whether a failing download was blocked.

Share a host folder, record the traffic

In plain words: a mount lets the sandbox see one folder of your computer, and read-only means it can look but not change anything. A capture writes down every network message the sandbox sends or receives, to a file you can open in Wireshark. Code inside the sandbox cannot see or stop the capture.

host, _ := filepath.Abs(os.Args[1]) // a folder on the host
pcapDir, _ := filepath.Abs(os.Args[2])
pcap := filepath.Join(pcapDir, "traffic.pcapng") // a file, not a folder

_, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-x", Mounts: []string{"relative-path-without-guest"}})
fmt.Println("bad mount:", errors.Is(err, kosa8.ErrUsage), err)
_, err = k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "doc-go-g-x", Capture: &kosa8.CaptureOptions{}})
fmt.Println("capture without path:", errors.Is(err, kosa8.ErrUsage), err)

const name = "doc-go-g-mount"
created, err := k.CreateSandbox(ctx, kosa8.CreateOptions{
	Name:        name,
	Mounts:      []string{host + ":/work:ro"},
	EgressAllow: []string{"example.com"},
	Capture:     &kosa8.CaptureOptions{Path: pcap, MaxFiles: 2},
})
if err != nil {
	return err
}
sb := k.Sandbox(name)
defer func() {
	out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{})
	fmt.Println("deleted:", out, err)
}()
s := created["sandbox"].(map[string]any)
fmt.Println("mounts:", s["mounts"], "capture:", s["capture"])
if _, err := sb.Pull(ctx, "alpine:latest"); err != nil {
	return err
}
res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c",
	"mount | grep /work; cat /work/input.txt; echo x > /work/new.txt || echo write refused; wget -q -T 5 -O /dev/null http://example.com && echo fetched example.com"}, nil)
if err != nil {
	return err
}
fmt.Printf("exit %d\n%s%s", res.ExitCode, res.Stdout(), res.Stderr())
entries, _ := os.ReadDir(pcapDir)
for _, e := range entries {
	fi, _ := e.Info()
	fmt.Printf("capture file: %s (%d bytes)\n", e.Name(), fi.Size())
}
$ go run . hostdir pcap
bad mount: true mount "relative-path-without-guest": want host:guest or host:guest:ro
capture without path: true Capture.Path is required: the host file the capture is written to
mounts: [map[guest_path:/work host_path:/private/tmp/…/go/hostdir read_only:true]] capture: /private/tmp/…/go/pcap/traffic.pcapng
exit 0
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/containerd/…,index=off,uuid=on)
kosa8m0 on /work type virtiofs (ro,relatime)
hello from the host
write refused
fetched example.com
sh: can't create /work/new.txt: Read-only file system
capture file: traffic.pcapng (3588 bytes)
deleted: map[checkpoints_dropped:0 ok:true] <nil>

The folder appears at /work inside containers that Run starts, shared with virtiofs and read-only, so the write was refused. (The overlay line matched grep /work because of its workdir=.) Rules:

Stop, start, delete

st, err := sb.Stop(ctx)
fmt.Println("stop:", st, err)
info, _ = sb.Info(ctx)
fmt.Println("after stop:", info["state"])
up, err := sb.Start(ctx)
if err == nil {
	fmt.Println("start keys:", keys(up), "state:", up["sandbox"].(map[string]any)["state"])
}
imgs, _ = sb.Images(ctx)
fmt.Println("images after restart:", imgs)
stop: map[ok:true sandbox:map[cpus:2 created:2026-11-03T02:20:25.65452-05:00 disk_gb:32 egress:allow:pypi.org,files.pythonhosted.org id:doc-go-g-run labels:map[job:guide team:docs] mem_mb:1024 name:doc-go-g-run state:stopped] warning:] <nil>
after stop: stopped
start keys: [boot_ms sandbox warning] state: running
images after restart: [docker.io/library/alpine:latest]
deleted: map[checkpoints_dropped:0 ok:true] <nil>

Stopping keeps the disk, so pulled images survive; running containers and memory do not. A stopped sandbox refuses work with a 409 (sandbox is not running). DeleteSandbox removes the sandbox and then, unless KeepCheckpoints, its checkpoint records and their snapshots; checkpoints_dropped says how many. Errors deleting those snapshots are ignored, so a sandbox delete never fails because of checkpoint debris. Wipe overwrites disks instead of unlinking them (sent as ?wipe=1, also to each checkpoint snapshot; not tested here).

Running code

In plain words: there are four ways to run something, depending on whether it should start fresh, keep its files between commands, read input you type, or report every line as it happens.

You wantUseBehaviour
Run one command, from a clean image, and get its outputRunNew container each time; waits for exit. Sends RunOptions.Env.
Several commands sharing files and processesStartContainer then ExecThe container lives until the sandbox stops. Exec refuses RunOptions.Env (KindUsage).
Feed a program inputStartInteractive, WriteStdin, AttachOne WriteStdin per container: its reader is the whole of stdin.
Handle raw output frames yourselfExecFramesYour function sees each frame; return an error to stop.

Two rules apply to all of them. A command that exits non-zero is a result, not an error: err is nil and res.ExitCode is set; res.OK() is ExitCode == 0. And err is set when the command could not run at all: no such image in the sandbox, no such executable, no such container, the daemon unreachable. Those come back as KindStream or an HTTP kind; see the errors page.

Run, with environment and streaming

var chunks int
res, err := sb.Run(ctx, "alpine:latest", []string{"sh", "-c", "for i in 1 2 3; do echo line $i; sleep 0.3; done; echo oops >&2; echo GREETING=$GREETING"},
	&kosa8.RunOptions{
		Env:      []string{"GREETING=hello"},
		OnStdout: func(b []byte) { chunks++; fmt.Printf("  [%s] stdout %q\n", time.Now().Format("15:04:05.0"), b) },
		OnStderr: func(b []byte) { fmt.Printf("  stderr %q\n", b) },
	})
if err != nil {
	return err
}
fmt.Printf("run exit=%d ok=%v chunks=%d stdout=%q stderr=%q\n", res.ExitCode, res.OK(), chunks, res.Stdout(), res.Stderr())
  [02:20:27.8] stdout "line 1\n"
  [02:20:28.1] stdout "line 2\n"
  [02:20:28.4] stdout "line 3\n"
  stderr "oops\n"
  [02:20:28.7] stdout "GREETING=hello\n"
run exit=0 ok=true chunks=4 stdout="line 1\nline 2\nline 3\nGREETING=hello\n" stderr="oops\n"

The timestamps show output arriving as it is produced, 0.3 s apart. Callbacks get each chunk, and the chunks are also collected into the result, so you can stream to a log and still read the whole output afterwards. A chunk is whatever the daemon sent in one frame, not necessarily one line; buffer if you need lines. OnStatus gets progress frames, which are not collected. args is an argv, not a shell line: use []string{"sh", "-c", "…"} for pipes and redirects. A nil args is sent as [] (the image's default command). The image must already be in the sandbox.

A long-lived container and Exec

cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "while :; do sleep 3600; done"})
if err != nil {
	return err
}
fmt.Println("container:", cid)
_, err = sb.Exec(ctx, cid, []string{"sh", "-c", "echo FOO=$FOO"}, &kosa8.RunOptions{Env: []string{"FOO=bar"}})
fmt.Println("exec with Env:", errors.Is(err, kosa8.ErrUsage), err)
r, err := sb.Exec(ctx, cid, []string{"env", "FOO=bar", "sh", "-c", "echo state > /tmp/x; cat /tmp/x; echo HOME=$HOME FOO=$FOO"}, nil)
fmt.Printf("exec: %q %v\n", r.Stdout(), err)
container: k4-6556b8f4
exec with Env: true Exec does not take Env: the container keeps the environment it was started with. Set it at StartInteractive (InteractiveOptions.Env), or wrap the command: []string{"env", "K=V", "cmd"}
exec: "state\nHOME=/root FOO=bar\n" <nil>

The daemon's exec takes no environment, so Exec with RunOptions.Env is a KindUsage error, before anything is sent. (v0.1.0 dropped the variables silently.) Wrap the command with env K=V …, as above, or set variables in a sh -c line, or start the container with StartInteractive, whose InteractiveOptions.Env is sent (StartContainer takes no env). The container runs until it exits or the sandbox stops; a checkpoint saves it, and a rewind brings it back with the same id.

Raw frames

Output travels as NDJSON frames. ExecFrames hands you each one, decoded:

err = k.ExecFrames(ctx, name, cid, []string{"sh", "-c", "echo a; echo b >&2; exit 4"}, func(f kosa8.Frame) error {
	exit := "nil"
	if f.Exit != nil {
		exit = fmt.Sprint(*f.Exit)
	}
	fmt.Printf("  frame stream=%q data=%q done=%v ok=%v exit=%s error=%q\n", f.Stream, f.Data, f.Done, f.OK, exit, f.Error)
	return nil
})
fmt.Println("ExecFrames err:", err)

// Stop early from fn.
stop := errors.New("seen enough")
n := 0
err = k.ExecFrames(ctx, name, cid, []string{"sh", "-c", "for i in 1 2 3 4 5; do echo $i; sleep 0.2; done"}, func(f kosa8.Frame) error {
	n++
	if n == 2 {
		return stop
	}
	return nil
})
fmt.Println("ExecFrames stopped:", errors.Is(err, stop), n)

// An error frame is passed to fn, then returned.
err = k.ExecFrames(ctx, name, "k99-nope", []string{"true"}, func(f kosa8.Frame) error {
	fmt.Printf("  frame done=%v error=%q\n", f.Done, f.Error)
	return nil
})
fmt.Println("ExecFrames on a missing container:", errors.Is(err, kosa8.ErrStream), err)
  frame stream="stderr" data="b\n" done=false ok=false exit=nil error=""
  frame stream="stdout" data="a\n" done=false ok=false exit=nil error=""
  frame stream="" data="" done=true ok=true exit=4 error=""
ExecFrames err: <nil>
ExecFrames stopped: true 2
  frame done=true error="no such container: k99-nope"
ExecFrames on a missing container: true no such container: k99-nope

The last frame has Done, and either OK with Exit, or Error. Note OK is true with exit 4: it means "the command ran", not "it succeeded". stderr arrived before stdout in this run (it was the other way round in an earlier one): the order between the two streams is not guaranteed, only the order within each. A final frame with an Error is passed to your function and then returned as a KindStream error (v0.1.0 returned nil). Your function's error is returned as is, so errors.Is works on it. Stopping early closes the stream; nothing in v0.1.2 tells the container to stop the command.

Standard input: StartInteractive, WriteStdin, Attach

icid, err := k.StartInteractive(ctx, name, "alpine:latest", []string{"sh", "-c", "tr a-z A-Z"}, kosa8.InteractiveOptions{})
if err != nil {
	return err
}
fmt.Println("interactive:", icid)
done := make(chan struct{})
go func() {
	defer close(done)
	ar, err := k.Attach(ctx, name, icid, &kosa8.RunOptions{OnStdout: func(b []byte) { fmt.Printf("  attach got %q\n", b) }})
	fmt.Printf("attach: exit=%d out=%q err=%v\n", ar.ExitCode, ar.Stdout(), err)
}()
time.Sleep(500 * time.Millisecond)
w, err := k.WriteStdin(ctx, name, icid, strings.NewReader("hello from stdin\nsecond line\n"))
fmt.Println("WriteStdin:", w, err)
<-done
_, err = k.WriteStdin(ctx, name, icid, strings.NewReader("more\n"))
fmt.Println("second WriteStdin:", errors.Is(err, kosa8.ErrUsage), err)
interactive: k8-e5df7b9b
  attach got "HELLO FROM STDIN\nSECOND LINE\n"
WriteStdin: map[ok:true] <nil>
attach: exit=0 out="HELLO FROM STDIN\nSECOND LINE\n" err=<nil>
second WriteStdin: true stdin of container k8-e5df7b9b in doc-go-g-run was already sent and is closed: the daemon treats one WriteStdin body as the whole of stdin. Pass an io.Pipe reader to stream input

Start the container, attach on a goroutine (so you see output while you write), then write. WriteStdin streams the reader as the request body (chunked when its length is unknown) and closes the container's stdin when the reader returns EOF, which is what made tr finish. So the reader is the whole of stdin: a second WriteStdin to the same container through the same client is a KindUsage error, sent nothing (v0.1.0 answered ok for input the program never saw). The check is per *Client; another client, or another process, is not stopped. For a conversation with a long-running program, pass an io.Pipe reader and write to the pipe as you go; close the writer to end input (not run for this page). Attach returns when the container exits. InteractiveOptions.TTY asks for a terminal (not tested here), and its Env is sent.

Checkpoints and rewind

In plain words: a checkpoint is an undo point for the whole sandbox, including programs that are in the middle of running. Rewind goes back to one. The list of undo points is shared with the kosa8 command, so you can take a checkpoint in Go and undo it from the terminal, or the other way round.

What a checkpoint saves, shown

A container counts up once a second into /tick. We checkpoint, wait, rewind, wait:

cid, _ := sb.StartContainer(ctx, "alpine:latest", []string{"sh", "-c", "i=0; while :; do i=$((i+1)); echo $i > /tick; sleep 1; done"})
read := func(l string) {
	r, err := sb.Exec(ctx, cid, []string{"sh", "-c", "cat /tick; date +%T"}, nil)
	fmt.Printf("%-18s %q %v\n", l, strings.ReplaceAll(r.Stdout(), "\n", " "), err)
}
read("start")
time.Sleep(3 * time.Second)
read("+3s")
cp, err := sb.Checkpoint(ctx, "t")
fmt.Println("checkpoint", cp.Checkpoint.ID, err)
read("after checkpoint")
time.Sleep(3 * time.Second)
read("+3s")
_, err = sb.Rewind(ctx, "")
fmt.Println("rewind", err)
read("after rewind")
time.Sleep(3 * time.Second)
read("+3s")
start              "1 07:21:08 " <nil>
+3s                "4 07:21:11 " <nil>
checkpoint doc-go-g-tick-ckpt-1 <nil>
after checkpoint   "4 07:21:11 " <nil>
+3s                "7 07:21:14 " <nil>
rewind <nil>
after rewind       "4 07:21:15 " <nil>
+3s                "7 07:21:18 " <nil>

After the rewind the counter is back at 4, the value it had at the checkpoint, and keeps counting: the process was frozen and resumed, not restarted. The clock shows the real time, not the checkpoint's. The same container id works before and after.

Shared with the CLI

sh("echo v0 > /app.txt")
c1, err := sb.Checkpoint(ctx, "clean")
if err != nil {
	return err
}
fmt.Printf("checkpoint %s label=%q created=%s snapshot_ms=%v evicted=%v tick=%s\n", …)
fmt.Println("snapshot answer keys:", keysOf(c1.Snapshot))
sh("echo v1 > /app.txt")
c2, _ := sb.Checkpoint(ctx, "step 1")
fmt.Println("checkpoint", c2.Checkpoint.ID)

// The CLI sees the SDK's checkpoints, and the other way round.
cli("kosa8", "checkpoint", "ls", name)
cli("kosa8", "checkpoint", name, "--label", "taken by the CLI")
list, _ := sb.Checkpoints()
for _, c := range list {
	fmt.Printf("  sdk sees %-22s %-18q %s\n", c.ID, c.Label, c.Created.Format("15:04:05"))
}

sh("echo broken > /app.txt")
rw, err := sb.Rewind(ctx, "CLEAN") // labels match without regard to case
if err != nil {
	return err
}
fmt.Printf("rewound to %s in %vms, dropped %v\n", rw.Checkpoint.ID, rw.RestoreMS, rw.Dropped)
fmt.Println("after rewind: app.txt =", sh("cat /app.txt"))
_, err = sb.Rewind(ctx, "step 1")
fmt.Println("rewind to a dropped one:", err)
checkpoint doc-go-g-ckpt-ckpt-1 label="clean" created=07:24:21 snapshot_ms=570 evicted=[] tick=1
snapshot answer keys: [snapshot snapshot_ms]
checkpoint doc-go-g-ckpt-ckpt-2
$ kosa8 checkpoint ls doc-go-g-ckpt
CHECKPOINT              WHEN       TAKEN   WHY
 doc-go-g-ckpt-ckpt-1   just now   you     clean
*doc-go-g-ckpt-ckpt-2   just now   you     step 1

* is where `kosa8 rewind doc-go-g-ckpt` goes
$ kosa8 checkpoint doc-go-g-ckpt --label taken by the CLI
checkpoint doc-go-g-ckpt-ckpt-3 (taken by the CLI) saved in 577ms
  rewind with `kosa8 rewind doc-go-g-ckpt`
  sdk sees doc-go-g-ckpt-ckpt-1   "clean"            07:24:21
  sdk sees doc-go-g-ckpt-ckpt-2   "step 1"           07:24:21
  sdk sees doc-go-g-ckpt-ckpt-3   "taken by the CLI" 07:24:22
rewound to doc-go-g-ckpt-ckpt-1 in 459ms, dropped [doc-go-g-ckpt-ckpt-2 doc-go-g-ckpt-ckpt-3]
after rewind: app.txt = v0
rewind to a dropped one: no checkpoint "step 1" for sandbox "doc-go-g-ckpt"

The store is the file ~/.kosa8/checkpoints.json. This is the sandbox's entry as the program read it back, before the rewind:

{
  "doc-go-g-ckpt": [
    {
      "id": "doc-go-g-ckpt-ckpt-1",
      "sandbox": "doc-go-g-ckpt",
      "label": "clean",
      "created": "2026-11-03T07:24:21.074969Z"
    },
    {
      "id": "doc-go-g-ckpt-ckpt-2",
      "sandbox": "doc-go-g-ckpt",
      "label": "step 1",
      "created": "2026-11-03T07:24:21.755216Z"
    },
    {
      "id": "doc-go-g-ckpt-ckpt-3",
      "sandbox": "doc-go-g-ckpt",
      "label": "taken by the CLI",
      "created": "2026-11-03T07:24:22.367086Z"
    }
  ]
}

The rules, precisely

A private store

Options.Checkpoints takes any CheckpointStore (Load and Update). &kosa8.MemoryCheckpointStore{} keeps stacks in memory, shared with nothing; kosa8.NewFileCheckpointStore(path) uses another file. Useful in tests, or for a service that must not see the user's own checkpoints:

mem := &kosa8.MemoryCheckpointStore{}
k2, _ := kosa8.New(kosa8.Options{Checkpoints: mem})
l2, _ := k2.ListCheckpoints(name)
fmt.Println("memory store sees:", len(l2))
memory store sees: 0

With a private store, kosa8 rewind cannot see your checkpoints, and DeleteSandbox on another client will not clean them up. Errors from your own store come back as you returned them, not as *kosa8.Error.

Snapshots

In plain words: a snapshot is the raw saved copy that a checkpoint is built on. Use snapshots directly when you want a saved state that is not tied to one sandbox's undo list, for example a "ready to use" sandbox you start new ones from.

MethodWhat it does
Snapshot(ctx, sandbox, SnapshotOptions{ID, Encrypt})Saves memory, processes and disks. Without ID the daemon picks one. The sandbox must be running (a stopped one is a 500 sandbox is not running). An id already in use is a 409.
ListSnapshots(ctx)Every snapshot on the daemon, checkpoints included.
RestoreSnapshot(ctx, id, RestoreOptions{Name, Label, AllowTampered})Boots a new sandbox from it, processes resumed; the answer has sandbox and restore_ms. The snapshot stays.
DeleteSnapshot(ctx, id, SnapshotDeleteOptions{Wipe, Force})Removes it. ?wipe=1, ?force=1 when set.

A snapshot you take with Snapshot is not a checkpoint: it is not in the store, Rewind will not find it, and DeleteSandbox does not delete it. Clean it up yourself.

Label on forks and restores is a security level, not a description

ForkOptions.Label and RestoreOptions.Label are classification levels defined by the org's label policy (here UNCLASSIFIED and CUI; see kosa8 label ls). A free-text value is refused with a 403:

refused by label policy: label "experiment": level "EXPERIMENT" is not defined (defined: UNCLASSIFIED, CUI); an org policy defines levels under "labels" (a fork keeps its parent's label or raises it; see `kosa8 label ls`)

To tag a sandbox with a description, use CreateOptions.Labels, or the checkpoint label, which is free text.

Fork and explore

In plain words: forking makes several identical copies of a running sandbox in a second or two, so you can try a different idea in each without the ideas getting in each other's way. Explore does the whole "try them all, keep the best" job in one call.

Fork N ways, by hand

// Before this: a container cid in the parent writes the time to /now every second,
// and the program printed "parent: started … now …" from it.
fr, err := sb.Fork(ctx, 3, kosa8.ForkOptions{Prefix: name + "-b"})
if err != nil {
	return err
}
var ids []string
for _, f := range fr.Forks {
	ids = append(ids, f["id"].(string))
}
defer func() {
	for _, id := range ids {
		_, err := k.DeleteSandbox(context.Background(), id, kosa8.DeleteOptions{})
		fmt.Println("deleted fork", id, err)
	}
}()
fmt.Printf("forked %d in %dms: %v\n", fr.Count, fr.ForkMS, ids)
fmt.Printf("fork entry: %v\nsnapshot: %v\n", fr.Forks[0], fr.Snapshot)

// Try a different approach in each, concurrently.
approaches := []string{"echo A > /choice", "echo B > /choice; exit 1", "echo C > /choice"}
type out struct {
	id, text string
	code     int
	err      error
}
results := make([]out, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
	wg.Add(1)
	go func(i int, id string) {
		defer wg.Done()
		res, err := k.Exec(ctx, id, cid, []string{"sh", "-c", approaches[i] + "; echo $(cat /choice) started $(cat /started) now $(cat /now)"}, nil)
		results[i] = out{id, strings.TrimSpace(res.Stdout()), res.ExitCode, err}
	}(i, id)
}
wg.Wait()
for _, o := range results {
	fmt.Printf("  %s exit=%d %q err=%v\n", o.id, o.code, o.text, o.err)
}
r, _ = sb.Exec(ctx, cid, []string{"sh", "-c", "cat /choice 2>&1; echo now $(cat /now)"}, nil)
fmt.Print("parent untouched: ", r.Stdout())
parent: started 1793690768 now 1793690771
forked 3 in 3587ms: [doc-go-g-fork-b1 doc-go-g-fork-b2 doc-go-g-fork-b3]
fork entry: map[cpus:2 created:2026-11-03T02:26:13.671777-05:00 disk_gb:32 egress:deny id:doc-go-g-fork-b1 ip:192.168.127.2 mem_mb:1024 name:doc-go-g-fork-b1 parent:doc-go-g-fork state:running]
snapshot: doc-go-g-fork-fork-1793690771654953000
  doc-go-g-fork-b1 exit=0 "A started 1793690768 now 1793690774" err=<nil>
  doc-go-g-fork-b2 exit=1 "" err=<nil>
  doc-go-g-fork-b3 exit=0 "C started 1793690768 now 1793690774" err=<nil>
parent untouched: cat: can't open '/choice': No such file or directory
now 1793690773

Here cid is a container started in the parent before the fork; the same id works in every fork, because each fork is a copy of the running parent. Forks are named <prefix>1…N (default prefix <sandbox>-fork), keep the parent's egress rule, and record it as parent. Changes in one fork do not reach the others or the parent. n below 1 is sent as 1. Forks are ordinary sandboxes: delete each one yourself, as the deferred loop does. The fork's snapshot (fr.Snapshot) did not appear in ListSnapshots afterwards.

Explore: best of N in one call

t0 := time.Now()
ex, err := sb.Explore(ctx, []string{"sleep 2; echo slow", "echo fast", "echo broken; exit 3"}, kosa8.ExploreOptions{NoPull: true, Timeout: 60 * time.Second})
if err != nil {
	return err
}
fmt.Printf("explore in %.1fs: winner=%s removed=%d\n", time.Since(t0).Seconds(), ex.Winner, ex.Removed)
for _, b := range ex.Branches {
	fmt.Printf("  %-26s exit=%d %5dms %-22q output=%q error=%q\n", b.Name, b.Exit, b.DurationMS, b.Variant, b.Output, b.Error)
}
if ex.Winner != "" {
	ids = append(ids, ex.Winner) // clean it up with the forks
}
explore in 87.5s: winner=doc-go-g-fork-explore-907752 removed=2
  doc-go-g-fork-explore-907751 exit=0  2279ms "sleep 2; echo slow"   output="slow" error=""
  doc-go-g-fork-explore-907752 exit=0   250ms "echo fast"            output="fast" error=""
  doc-go-g-fork-explore-907753 exit=3   256ms "echo broken; exit 3"  output="broken" error=""
deleted fork doc-go-g-fork-explore-907752 <nil>

What Explore does, in order:

Pull

PullIntoSandbox(Image), default alpine:latest, unless NoPull.

Fork

One fork per variant, prefix <sandbox>-explore-<unix seconds mod 100000> unless Prefix is set. The variants above took under 3 s each, so most of the 87.5 s went on forking and deleting, on a busy machine.

Run

sh -c VARIANT with Run in each branch, all at once on goroutines, each with Timeout (default 120 s). Connection-level failures (refused, reset, EOF, …) are retried up to four times, 0.5, 1 and 1.5 s apart, because a freshly restored branch can refuse its first connection. Output is stdout then stderr, trailing newlines trimmed. A branch that could not run gets Error, not a failed call.

Pick and clean up

The winner is the branch with no error, exit 0 and the smallest DurationMS, or Keep if you named one. Every other branch is deleted unless KeepAll; Removed counts the deletes that succeeded. If no branch succeeds, Winner is "" and every branch is deleted.

The parent is untouched, and the winner is a separate sandbox that you own and must delete. Explore needs at least two variants (KindUsage otherwise). If Keep names no branch, the call returns the result with every branch kept and a KindUsage error. Each variant runs in a fresh container from Image, so files written inside the parent's own containers are not visible to it; when the variants need that state, fork by hand and Exec in the forked container, as in the previous section.

Recipes

An agent loop with undo

In plain words: before each step an AI agent wants to take, save an undo point. If the step breaks something, go back. The agent can then try something else, and nothing it did wrong survives.

package main

import (
	"context"
	"fmt"
	"os"
	"strings"
	"time"

	kosa8 "github.com/kosa8ai/kosa8-go"
)

// proposals stands in for an AI model: each step is a shell command it wants to run.
var proposals = []string{
	"mkdir -p /app && echo 'mode=ok' > /app/config",
	"echo 'retries=3' >> /app/config",
	"rm -rf /app",                       // a bad idea
	"echo 'timeout=30' >> /app/config",
}

// check is your own test of whether the sandbox is still in a good state.
const check = "grep -q mode=ok /app/config"

func main() {
	if err := run(context.Background()); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

func run(ctx context.Context) error {
	k, err := kosa8.New(kosa8.Options{Actor: "agent"})
	if err != nil {
		return err
	}
	const name = "doc-go-g-agent"
	if _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: name, Egress: "deny"}); err != nil {
		return err
	}
	sb := k.Sandbox(name)
	defer func() {
		out, err := sb.Delete(context.Background(), kosa8.DeleteOptions{})
		fmt.Println("cleanup:", out, err)
	}()
	if _, err := sb.Pull(ctx, "alpine:latest"); err != nil {
		return err
	}
	cid, err := sb.StartContainer(ctx, "alpine:latest", []string{"sleep", "infinity"})
	if err != nil {
		return err
	}
	for i, cmd := range proposals {
		cp, err := sb.Checkpoint(ctx, fmt.Sprintf("before step %d", i+1))
		if err != nil {
			return err
		}
		res, err := sb.Exec(ctx, cid, []string{"sh", "-c", cmd + " && " + check}, &kosa8.RunOptions{Timeout: time.Minute})
		if err != nil {
			return err // could not run at all: stop, do not guess
		}
		if res.OK() {
			fmt.Printf("step %d ok:     %s\n", i+1, cmd)
			continue
		}
		rw, err := sb.Rewind(ctx, cp.Checkpoint.ID)
		if err != nil {
			return err
		}
		fmt.Printf("step %d undone: %s (exit %d; rewound to %s in %vms)\n", i+1, cmd, res.ExitCode, rw.Checkpoint.ID, rw.RestoreMS)
	}
	res, err := sb.Exec(ctx, cid, []string{"cat", "/app/config"}, nil)
	if err != nil {
		return err
	}
	fmt.Print("final /app/config:\n", res.Stdout())
	list, _ := sb.Checkpoints()
	var ids []string
	for _, c := range list {
		ids = append(ids, strings.TrimPrefix(c.ID, name+"-"))
	}
	fmt.Println("checkpoints kept:", ids)
	return nil
}
$ go run .
step 1 ok:     mkdir -p /app && echo 'mode=ok' > /app/config
step 2 ok:     echo 'retries=3' >> /app/config
step 3 undone: rm -rf /app (exit 2; rewound to doc-go-g-agent-ckpt-3 in 480ms)
step 4 ok:     echo 'timeout=30' >> /app/config
final /app/config:
mode=ok
retries=3
timeout=30
checkpoints kept: [ckpt-1 ckpt-2 ckpt-3 ckpt-4]
cleanup: map[checkpoints_dropped:4 ok:true] <nil>

Points worth copying: rewind to the id you just took (cp.Checkpoint.ID), not "", so a checkpoint taken elsewhere in between cannot change the target; treat err != nil from Exec as "stop", because it means the command did not run, not that it failed; keep the stack bounded (it is: 10 per sandbox, oldest evicted); and give the model the output (res.Stdout(), res.Stderr()) of a step that was undone, so it learns why. In a real agent, also set an egress rule that allows only what the agent needs; see Securing an agent.

Clean up no matter what

Sandboxes keep running, and using memory and disk, until they are deleted. A program that crashes or is interrupted before deleting leaves them behind. The pattern that held up in every program on this page:

func main() {
	// Ctrl-C cancels ctx instead of killing the process, so defers run.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := run(ctx); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1) // after run returned: its defers have already run
	}
}

func run(ctx context.Context) error {
	k, err := kosa8.New(kosa8.Options{})
	if err != nil {
		return err
	}
	if _, err := k.CreateSandbox(ctx, kosa8.CreateOptions{Name: "job-1", Egress: "deny"}); err != nil {
		return err // nothing to clean up: the create failed
	}
	defer func() {
		// Not ctx: it may be the reason we are leaving.
		dctx, cancel := context.WithTimeout(context.Background(), time.Minute)
		defer cancel()
		if _, err := k.DeleteSandbox(dctx, "job-1", kosa8.DeleteOptions{}); err != nil && !errors.Is(err, kosa8.ErrNotFound) {
			fmt.Fprintln(os.Stderr, "cleanup:", err)
		}
	}()
	// … work …
	return nil
}

We tested it with the work replaced by a Run of sleep 120, and sent the program SIGINT (what Ctrl-C sends) 12 seconds after the sandbox appeared. The Run returned context.Canceled, the deferred delete ran, and kosa8 sandbox ls showed nothing left:

created; working
run: context canceled
error: context canceled

The program then exited with status 1, from os.Exit(1) in main.

Retry a create on a busy machine

Booting needs free memory. On our busy test machine, creates sometimes failed with HTTP 500 guest not ready on vsock port 1024 after 25s and worked on a later try. If your program creates sandboxes under load, retry a few times with a pause, and only on that kind of error:

func createWithRetry(ctx context.Context, k *kosa8.Client, o kosa8.CreateOptions) (kosa8.Info, error) {
	var err error
	for attempt := 0; attempt < 3; attempt++ {
		var info kosa8.Info
		if info, err = k.CreateSandbox(ctx, o); err == nil {
			return info, nil
		}
		e, ok := kosa8.AsError(err)
		if !ok || e.Status != 500 || !strings.Contains(e.DaemonMessage, "guest not ready") {
			return nil, err // a real refusal: do not retry
		}
		select {
		case <-time.After(30 * time.Second):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return nil, err
}

This function compiles against v0.1.2; the retry was done by a shell loop around the test programs rather than by this exact code.

Approvals and policy

An org policy can refuse an action (HTTP 403, ErrPolicy) or require a signed approval for it (ErrApprovalRequired, with the daemon's refusal in e.Approval). Once someone has approved the request, pass the approval id with the call: sb.Run(ctx, img, args, &kosa8.RunOptions{Approval: id}), sb.Stop(ctx, kosa8.Call{Approval: id}). The CLI describes an approval as spent by the one call it covers. The SDK's compound methods (Checkpoint, Rewind, Explore, DeleteSandbox) send the same approval on every request they make, for example the delete and the restore of a rewind; if the policy guards more than one of those steps, one approval may not be enough. No policy was installed on the test machine, so this section is from the source; see Company rules and handling policy errors.

Versions

VersionWhat changed
v0.1.2Version reports "0.1.2" (v0.1.1 still said "0.1.0"). CaptureOptions.Path is documented as the host file, and its usage error says so. Mounts with a relative host path or a guest path not starting with / are KindUsage. The Exec Env error points at InteractiveOptions.Env.
v0.1.1Mounts work. Capture became *CaptureOptions. Exec with Env and a second WriteStdin are KindUsage. ExecFrames returns a final error frame as KindStream. Concurrent checkpoints step to the next id. A failed Rewind says the sandbox was deleted. New ErrUsage.
v0.1.0First release.

Install the latest with go get github.com/kosa8ai/[email protected]. The full list is in the module's CHANGELOG.md.

Next

Go errors and troubleshooting: every error, when it happens and what to do. Go API reference: every export with its signature. SDKs in five languages: the same calls from other languages.