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.
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:
| Word | In plain words | For developers |
|---|---|---|
| Sandbox | A sealed computer inside yours. | A microVM with its own kernel, disk and private container engine. |
| Image | A packaged set of programs, like "Alpine Linux" or "Python 3.12". | An OCI image. It must be pulled into the sandbox before use. |
| Container | One program running from an image, inside the sandbox. | A container on the sandbox's engine; ids look like k4-c858e2e1. |
| Egress | Which websites the sandbox may talk to. | A host-enforced network policy: deny, any, fakenet, allow:h1,h2. |
| Checkpoint | An "undo point": the whole sandbox saved, so you can go back. | A snapshot named <sandbox>-ckpt-<n> plus a record in ~/.kosa8/checkpoints.json. |
| Rewind | Go back to an undo point. | Delete the sandbox, restore the snapshot under the same name, processes resumed. |
| Fork | Make copies of a running sandbox, to try different things in each. | Snapshot once, restore N times as <prefix>1…N. |
| Explore | Try 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:
- Standard library only.
go.modhas norequirelines. Requests are plain HTTP/1.1 over the daemon's unix socket (or to a remote worker), to the native API under/kosa8/v1(see Native HTTP API). kosa8.Newsends nothing. It resolves the endpoint, checksActor, builds anhttp.Clientand picks a checkpoint store. A wrong socket path is only noticed on the first call.- Answers are maps. Most methods return
kosa8.Info, which ismap[string]any: the daemon's JSON, untouched. Numbers arefloat64, nested objects aremap[string]any. Only the process, fork, explore, checkpoint and rewind results are typed structs. Use the comma-ok form when you read a field:s, _ := created["sandbox"].(map[string]any). - Two ways to address a sandbox. Every method exists on
*Clientwith the sandbox id as its second argument.k.Sandbox(id)returns a*Sandboxhandle with the id filled in. The handle is just{client, ID}; making one sends nothing and does not check that the sandbox exists. It has noExecFrames,StartInteractive,WriteStdin,Attach,RestoreSnapshotorDeleteSnapshot; call those on the client withsb.ID. - Approvals ride along per call. Methods without an options struct take a trailing
call ...kosa8.Call; methods with one have anApprovalfield. Either way it becomes theX-Kosa8-Approvalheader.
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
| Variable | Read by | Effect |
|---|---|---|
KOSA8_HOST | New, when Options.Host is empty | Selects a remote worker. Empty: the local socket. |
KOSA8_TOKEN | New, only when the host came from KOSA8_HOST and Options.Token is empty | Sent as Authorization: Bearer …. Never sent over a unix socket. |
KOSA8_INSECURE | New | 1 allows plain http:// to a host that is not localhost, ::1 or 127.*. |
KOSA8_AGENT_TOKEN | New, when Options.AgentToken is empty | Sent as X-Kosa8-Agent-Token; see Agent identity. |
HOME | DefaultSocketPath, 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 ended | Error | Check with |
|---|---|---|
Deadline passed (yours, or RunOptions.Timeout) | *kosa8.Error, Kind timeout | errors.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.Error | errors.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:
- Deferred clean-up should not use the context that was cancelled, or the delete is cancelled
too. Use
context.Background()(or a fresh short timeout) in the defer, as every program on this page does. Explore'sTimeoutis per branch, default 120 s; the context you pass covers the whole call.
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.
*Clientis safe for concurrent use. It holds anhttp.Client(safe), immutable settings, and a checkpoint store guarded by a mutex. Create one client and share it;Exploreitself runs its branches on goroutines through one client.*Sandboxhandles are just a client and an id; share them freely.- Callbacks (
OnStdout,OnStderr,OnStatus, theExecFramesfunction) run on the goroutine that called the method, one frame at a time, never concurrently with each other for one call. A slow callback slows the stream. - The checkpoint store (
FileCheckpointStore) serialises updates with a mutex in the process and an exclusiveflockacross processes, so the SDK andkosa8 checkpointnever interleave writes. - Two
Checkpointcalls on the same sandbox at once both succeed. The next id (…-ckpt-<n>) is chosen from a read of the store before the snapshot is taken, so both goroutines pick the same id; the daemon refuses the second snapshot with a 409already exists, and from v0.1.1 that call moves on to the next number (up to 10 tries). A 409 for any other reason is returned. This ran:
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:
| Field | Zero value means | Notes |
|---|---|---|
Name | the daemon picks a name | Read 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, DiskGB | the daemon's defaults (here 2 CPUs, 1024 MiB, 32 GB) | Values of 0 or less are not sent. |
Egress | the 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. |
EgressAllow | not used | Sends egress: "allow:h1,h2", each host trimmed. Combined with an Egress other than "" or "deny" it is a KindUsage error, before anything is sent. |
Labels | none | Free-form key/value tags, returned under labels. Not the same as the classification Label on forks and restores (below). |
Mounts | none | "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.) |
Capture | nil: 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.) |
Broker | none | Passed through as JSON, for the credential broker (Keep API keys out). Not tested. |
Approval | none | Sent 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:
- Each
Mountsentry is"HOST:GUEST","HOST:GUEST:ro"or"HOST:GUEST:rw". Anything else (one part, an empty part, another suffix) isKindUsagebefore sending. Both paths must be absolute: from v0.1.2 a relative host path, or a guest path not starting with/, isKindUsagetoo, so resolve the host side withfilepath.Absas above. A colon inside a path cannot be expressed. - An org policy, or the classification label a sandbox has, can forbid mounts; that would be a 403 (not tested here).
Capture.Pathis the pcapng file to write, as withkosa8 sandbox create --pcap FILE. A directory is refused by the daemon with HTTP 500capture: open …/pcap: is a directory(v0.1.1's comment and usage message wrongly said "directory"; v0.1.2 fixes the wording).MaxBytes(per segment) andMaxFiles(segments kept) are sent only when positive; otherwise the daemon's defaults apply (64 MiB and 8 for the CLI).- The file kept growing after the program read it (7056 bytes once the sandbox was deleted), so read a capture after the sandbox is stopped or deleted. It is owner-only (mode 0600) and holds everything sent in the clear; treat it as a secret.
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 want | Use | Behaviour |
|---|---|---|
| Run one command, from a clean image, and get its output | Run | New container each time; waits for exit. Sends RunOptions.Env. |
| Several commands sharing files and processes | StartContainer then Exec | The container lives until the sandbox stops. Exec refuses RunOptions.Env (KindUsage). |
| Feed a program input | StartInteractive, WriteStdin, Attach | One WriteStdin per container: its reader is the whole of stdin. |
| Handle raw output frames yourself | ExecFrames | Your 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
- Checkpoint picks the id
<sandbox>-ckpt-<n>, where n is one more than both the stack size and the highest number already used; takes a snapshot with that id (POST …/snapshot); deletes evicted snapshots; then records the checkpoint. Only the record is written under the lock (see Goroutines). - At most 10 (
MaxCheckpointsPerSandbox) per sandbox. Taking the 11th evicts the oldest, and only after the new snapshot exists;CheckpointResult.Evictedlists the ids removed. - Rewind's
tois a checkpoint id (exact) or a label (case-insensitive, newest match first);""is the latest. It deletes the sandbox, restores the snapshot under the same name, then drops the checkpoints taken after the target (record and snapshot) and returns them inDropped. The current state is lost. The target itself is kept, so you can rewind to it again, as the agent loop below does. - Order matters when it fails. If the delete is refused, nothing has changed and that
error comes back unchanged. If the restore fails after the delete, the sandbox is gone and the
snapshot is intact; the error is
KindCheckpoint, wraps the daemon's error, and its message ends with the exactRestoreSnapshotcall that recovers it. On a Mac, a locked screen is the usual cause; see the errors page. - Names that are empty,
.,.., start with a dot or contain/or\are refused withKindCheckpointbefore anything is sent. - The file is written with mode 0600 to
checkpoints.json.tmpand renamed over the original, under an exclusiveflockoncheckpoints.json.lock, the same lock the CLI takes. A file that is not valid JSON reads as empty rather than failing. On systems withoutflockonly the in-process mutex applies. - Rewind and ListCheckpoints read only the store. From the source: a checkpoint
whose snapshot was deleted some other way (say
kosa8 snapshot rm) still lists, andRewindto it deletes the sandbox before the restore fails. Delete checkpoint snapshots only throughRewindorDeleteSandbox.
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.
| Method | What 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.
- Do the work in a function that returns an error, and call
os.Exitonly inmain.os.Exitandlog.Fatalskip deferred calls. - Register the delete right after the create succeeds. A failed create left nothing behind in our tests.
- Delete forks and the explore winner too; they are separate sandboxes with their own lifetimes.
- If a program died anyway, find leftovers with
kosa8 sandbox ls(orListSandboxes) andkosa8 snapshot ls. Give your sandboxes a common name prefix so they are easy to spot.
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
| Version | What changed |
|---|---|
v0.1.2 | Version 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.1 | Mounts 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.0 | First release. |
Install the latest with go get github.com/kosa8ai/[email protected]. The full list is
in the module's CHANGELOG.md.
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.