kosa8 docs

Guides / Java / Developer guide

Java developer guide

Each section starts with what the feature is for, in plain words, then goes into exactly what the SDK does: which HTTP call it makes, what comes back, what can go wrong. The quickstart is the five-minute version; the API reference lists every signature.

How this page was tested

This page describes SDK 0.1.2 (com.kosa8:kosa8:0.1.2 on Maven Central; see the quickstart). The capture and mount examples, the only code 0.1.2 changed, were run on 0.1.2 built from the v0.1.2 tag (commit 14e5fc6) with mvn install, because Maven Central was still answering 404 for 0.1.2 while it was being published. Every block of output on this page was printed by a real program run against kosa8 daemon 0.9.36 on an Apple silicon Mac (macOS 26.0.1) on OpenJDK 21.0.12.1. Everything that changed in 0.1.1 (mounts, capture, the stdin and exec-env checks, the Sandbox handle's new methods, concurrent checkpoints, the lock, the redacted Endpoint) was run on 0.1.1, built from its release tag just before it was published. The quickstart was re-run on the 0.1.1 jar from Maven Central. Output from code paths that 0.1.1 did not change was first recorded on 0.1.0 from Maven Central. The agent loop, the shared-checkpoint example and the run/exec/stdin/frames examples were then re-run on 0.1.1, and they behaved the same apart from timings and container ids. Long JSON answers are shortened where marked with …; nothing else was edited. Timings come from a machine that was busy with other sandboxes. Where a statement comes from reading the SDK's source rather than from a run (signed approvals, encrypted snapshots, checkpoint eviction at 10, remote workers over https), the text says so.

What this is, in plain words

kosa8 gives a program a sandbox: a small virtual computer, with its own operating system and its own disk, that runs on your machine. You decide whether it may use the network, and which websites. Code inside it cannot reach your files or your passwords. You can save it at any moment and go back to that moment later, or make copies of it and try different things in each.

The Java SDK is a library that lets a Java program drive all of that. It is a thin layer over the daemon's HTTP API: each method sends one request (a few send several) and gives back the answer. It has no dependencies, so adding it to a project adds one jar and nothing else.

The pieces

ClassIn plain wordsIn detail
Kosa8ClientYour connection to kosa8.Built with Kosa8Client.builder()…build(). Every operation is a method on it, taking the sandbox name or id as the first argument. Final class, immutable after build().
SandboxA shortcut for one sandbox.client.sandbox("name"). The same methods without the first argument. It holds only the client and the name; creating one sends nothing and does not check that the sandbox exists.
Options.*The settings for one call.Small builders: new Options.Create().name("x").egress("deny"). Each setter returns the same object. Fields you do not set are not sent, so the daemon's defaults apply. null wherever an options argument is accepted means "no options".
Results.*What came back.Java records: Exec, Fork, Explore, ExploreBranch, CheckpointSaved, Rewind. Calls with no record of their own return the daemon's JSON as Map<String, Object>.
CheckpointOne saved restore point.Record (id, sandbox, label, auto, created).
Kosa8ExceptionSomething went wrong.Unchecked (extends RuntimeException), with nested subclasses per cause. See Errors and troubleshooting.

JSON in Java. The SDK carries its own small JSON codec (com.kosa8.Json, public). Objects decode to Map<String, Object> (a LinkedHashMap, so key order is the daemon's), arrays to List<Object>, whole numbers to Long, other numbers to Double, and true/false to Boolean. So created.get("boot_ms") is a Long, and nested objects need a cast:

@SuppressWarnings("unchecked")
Map<String, Object> info = (Map<String, Object>) created.get("sandbox");
// or, without the warning:
if (created.get("sandbox") instanceof Map<?, ?> m) System.out.println(m.get("state"));

Connecting

In plain words. Most of the time kosa8 runs on the same machine as your program, and Kosa8Client.builder().build() is all you need. If kosa8 runs on another machine (a "worker"), you give the client its address and a password-like token.

In detail. Java's built-in java.net.http.HttpClient cannot talk over a unix socket, so the SDK writes HTTP/1.1 itself: over a UnixDomainSocketAddress for the local daemon, or a plain or TLS Socket for a worker. Each request opens a new connection and sends Connection: close; there is no pool and nothing to shut down. build() resolves where to connect and checks the settings; it sends nothing.

Where requests go

You setThe client talks to
nothingThe unix socket ~/.kosa8/run/kosa8.sock (Endpoint.defaultSocketPath(), from the user.home system property).
socketPath("/path/kosa8.sock")That socket. Ignored if a host is set here or in KOSA8_HOST.
host(…) or KOSA8_HOSTA worker. host(…) wins over the variable. Forms: https://h[:port][/base], http://h[:port], tcp://h:port (same as http://), unix:///path (a socket), or a bare h:port (taken as http://). Trailing slashes are dropped; a path after the host becomes a prefix on every request.
System.out.println(Kosa8Client.builder().host("https://worker.example.com/k8/").token("t").build().endpoint());
System.out.println(Kosa8Client.builder().host("tcp://127.0.0.1:7070").build().endpoint().target());
Endpoint[https://worker.example.com:443/k8, token=***]
http://127.0.0.1:7070

Endpoint.toString() never prints the token, only token=*** when one is set (0.1.0 printed it). The token itself is still available as endpoint().token().

Settings and environment variables

Builder methodEnvironment variableDefaultWhat it does
socketPath(String)none~/.kosa8/run/kosa8.sockThe local daemon's socket.
host(String)KOSA8_HOSTnone (local socket)A remote worker, forms above.
token(String)KOSA8_TOKENnoneSent as Authorization: Bearer …, only to a network endpoint, never over a unix socket. The variable is read only when the host also came from KOSA8_HOST: if you call host(…), call token(…) too.
insecure(boolean)KOSA8_INSECURE=1falseAllows plain http:// to a host that is not loopback (localhost, ::1, 127.*). Without it, build() refuses, because the token would cross the network unencrypted.
actor(String)none"tool"human, agent or tool, sent as X-Kosa8-Actor and recorded in the audit log. Anything else makes build() throw.
agentToken(String)KOSA8_AGENT_TOKENnoneSent as X-Kosa8-Agent-Token, for agent identity.
headers(Map)nonenoneExtra header lines on every request, written after X-Kosa8-Actor and before the approval, agent-token and authorization headers. They are added, not substituted: repeating a header the SDK sets sends it twice.
timeout(Duration)nonenoneA time limit for every request; see Timeouts.
checkpoints(CheckpointStore)nonethe CLI's ~/.kosa8/checkpoints.jsonWhere checkpoint stacks are kept; see Checkpoints.
getenv(Function)noneSystem::getenvWhere the variables above are read from. For tests.
// The variables can come from anywhere; here, a map.
Map<String, String> env = Map.of("KOSA8_HOST", "worker.internal:9000", "KOSA8_INSECURE", "1", "KOSA8_TOKEN", "sekret");
System.out.println(Kosa8Client.builder().getenv(env::get).build().endpoint());
Endpoint[http://worker.internal:9000, token=***]

A network connection is given 30 seconds to connect (fixed in the SDK); https uses the JVM's default SSLSocketFactory, so the JVM's trust store decides which certificates are accepted. Remote workers were not exercised for this page; the forms above were checked with endpoint() only.

Is the daemon there?

System.out.println("health " + k.health());
health {commit=f6124c0, go=go1.26.9, phase=0-foundations, status=ok, uptime_seconds=4328, version=0.9.36}

version() is health().get("version") as a string.

Sandboxes

In plain words. Creating a sandbox boots a small virtual machine. You choose how many processor cores and how much memory it gets, and what network it may use. It keeps running until you stop or delete it. Stopping keeps its disk; deleting removes everything.

Map<String, Object> c = k.createSandbox(new Options.Create().name("doc-java-guide").cpus(2).memMb(1024)
    .egressAllow(List.of("example.com")).labels(Map.of("team", "docs")));
System.out.println("create " + c);
Sandbox sb = k.sandbox("doc-java-guide");
System.out.println("info " + sb.info());
System.out.println("egress " + sb.egress());
create {boot_ms=10666, sandbox={id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}}}
info {id=doc-java-guide, name=doc-java-guide, created=2026-11-03T01:39:45.161322-05:00, state=running, cpus=2, mem_mb=1024, disk_gb=32, egress=allow:example.com, ip=192.168.127.2, labels={team=docs}}
egress {policy=allow:example.com, stats={passed=3, denied=0, dropped=2, dns_answered=2, dns_refused=0}}

createSandbox blocks until the machine has booted. If you leave out the name, read the one the daemon chose from c.get("sandbox"). labels are your own free-form tags; they are not the classification label (CUI and so on) that kosa8 sandbox create --label sets, which Options.Create has no setter for.

Options.Create

SetterSent asMeaning
name(String)nameThe sandbox's name and id. A name already in use is a ConflictException (409).
cpus(int)cpusVirtual CPUs (the run above shows the daemon's default of 2).
memMb(int)mem_mbMemory in MiB.
diskGb(int)disk_gbDisk size in GB (default seen: 32).
mounts(List<String>)mounts, as {host_path, guest_path, read_only} objectsHost folders shared in, each host:guest, host:guest:ro or host:guest:rw. Both paths must be absolute: the host path as Path.isAbsolute() sees it, the guest path starting with /. Any other shape, or a relative path, throws Kosa8Exception before sending (the absolute-path check is new in 0.1.2). Mounted paths are visible inside containers too (example). Broken in 0.1.0, which sent the strings as they were and got 500 mount has no guest path.
egress(String)egressdeny, any, fakenet or allow:host,host. See Network rules.
egressAllow(List<String>)egress as allow:…Only these hosts. Each is trimmed and joined with commas. Combined with any egress other than deny, the SDK throws a plain Kosa8Exception before sending anything.
capture(String hostFile)capture as {"path": …}Record every frame the sandbox sends or receives, on the host, as one pcapng file at that path (opens in Wireshark); the folder must exist (example). An empty string throws capture needs a host file path to write the capture to. The file holds everything sent in the clear; treat it as secret.
capture(boolean)nothingDeprecated. capture(true) throws Kosa8Exception before sending, because the daemon needs a path (in 0.1.0 it reached the daemon and failed with a 500). The message is quoted on the errors page.
labels(Map<String,String>)labelsYour own tags; they come back in listSandboxes(), which makes clean-up easy (recipe).
broker(Object)brokerThe credential-broker configuration, passed through as JSON. See Keep API keys out.
approval(String)X-Kosa8-Approval headerA signed approval, when a policy gates creation. See Approvals.

The network rule, seen from inside

Results.Exec net = sb.run("alpine:latest", List.of("sh", "-c",
    "wget -q -T 5 -O- http://example.com | head -c 60; echo; wget -q -T 5 -O- http://pypi.org >/dev/null && echo pypi ok || echo pypi blocked"));
System.out.println("net: " + net.stdout());
net: <!doctype html><html lang=en><head><meta charset=utf-8><link
pypi blocked

The allowed host answered; the other did not. The rule is enforced by the host, outside the virtual machine, so nothing inside can change it.

Sharing a folder, and recording traffic

Map<String, Object> c = k.createSandbox(new Options.Create().name("doc-java-012").egress("deny")
    .mounts(List.of(dir + "/mnt:/work:ro")).capture(dir + "/traffic.pcapng"));
System.out.println("create " + c);
sb.pull("alpine:latest");
System.out.println("mount from a container: " + sb.run("alpine:latest",
    List.of("sh", "-c", "cat /work/hello.txt 2>&1; ls / | tr '\n' ' '")).stdout());
create {boot_ms=13255, sandbox={id=doc-java-012, …, mounts=[{host_path=/private/tmp/…/p12/mnt, guest_path=/work, read_only=true}], egress=deny, capture=/private/tmp/…/p12/traffic.pcapng, ip=192.168.127.2}}
mount from a container: hello from the host
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var work
$ file p12/traffic.pcapng
p12/traffic.pcapng: pcapng capture file - version 1.0

The host folder mnt (holding hello.txt) appeared read-only at /work in a fresh container. The capture path became a 2.7 kB pcapng file, owner-only (mode 0600), once the sandbox was gone. Mount strings are checked before sending:

mount "/tmp": want host:guest or host:guest:ro
mount "mnt:/work": both paths must be absolute (use Path.toAbsolutePath() for the host side)
mount "…/p12/mnt:work": both paths must be absolute (use Path.toAbsolutePath() for the host side)

To use a folder relative to your program, build the host side with Path.of("mnt").toAbsolutePath().

Lifecycle

System.out.println("stop: " + sb.stop());
System.out.println("state: " + sb.info().get("state"));
Map<String, Object> up = sb.start();
System.out.println("start: state=" + ((Map<?, ?>) up.get("sandbox")).get("state") + " boot_ms=" + up.get("boot_ms"));
stop: {ok=true, sandbox={id=doc-java-sweep, name=doc-java-sweep, created=2026-11-03T01:51:31.468078-05:00, state=stopped, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, labels={owner=doc-java}}, warning=}
state: stopped
start: state=running boot_ms=743
CallHTTPNotes
listSandboxes()GET /kosa8/v1/sandboxesEvery sandbox, as maps.
getSandbox(idOrName) / sb.info()the same listFiltered in the client: first by id, then by name. null when there is none; it does not throw.
stopSandbox / sb.stop()POST …/downShuts the machine down; the disk is kept. Running processes are lost.
startSandbox / sb.start()POST …/upBoots a stopped sandbox from its disk. On a sandbox that does not exist, kosa8 0.9.36 answers 500, so this is a plain ApiException, not NotFoundException.
deleteSandbox / sb.delete()DELETE …Then removes the sandbox's checkpoint records and their snapshots (below).

What delete does, step by step. It sends DELETE /kosa8/v1/sandboxes/{id} (with ?wipe=1 if you asked for Options.Delete().wipe(true), which overwrites the disks instead of only unlinking them). If that fails, the exception is thrown and nothing else happens. If it succeeds, and you did not ask for keepCheckpoints(true), the SDK removes the sandbox's stack from the checkpoint store and deletes each of those snapshots, ignoring individual failures. The answer is the daemon's map plus checkpoints_dropped, the number of records removed.

Running code

In plain words. Inside a sandbox, programs run in containers, the same kind Docker uses. You can run a command and wait for it, keep a container alive and run several commands in it, feed a program input, or watch output as it happens.

First, an image. A container needs an image, and the image must be inside the sandbox. sb.pull(ref) (pullIntoSandbox) fetches it on the host and copies it in, so it works even with the network denied. k.pull(ref) pulls into the host's shared engine instead, not into any sandbox. sb.images() lists what a sandbox holds:

pull {image=docker.io/library/alpine:latest, ok=true}
images [docker.io/library/alpine:latest]

Running an image that was never pulled does not fail with a 404. The daemon starts the stream and then ends it with an error, so it is a StreamException:

unpulled: com.kosa8.Kosa8Exception$StreamException status=0: image "docker.io/library/python:3.12-alpine" not found in engine: image "docker.io/library/python:3.12-alpine": not found

The four ways to run

MethodUse it whenReturns
run(sandbox, image, args, Options.Run)One command in a fresh container; you want its output when it ends.Results.Exec
startContainer(sandbox, image, args) then exec(sandbox, container, args, Options.Run)Several commands that share files or running processes.a container id, then Results.Exec per command
startInteractive, writeStdin, attachA program that reads its input from stdin.a container id, {ok=true}, then Results.Exec when it exits
execFrames(sandbox, container, args, Consumer<Frame>, Options.Approval)You want the raw stream, frame by frame, including the final one.nothing; your consumer sees every frame

Results.Exec is (int exitCode, byte[] stdoutBytes, byte[] stderrBytes) with stdout() and stderr() decoding as UTF-8, and ok() meaning exitCode == 0. A non-zero exit code is a result, not an exception. The call throws only if the command could not run or the stream broke.

Environment variables and live output

Results.Exec r = sb.run("alpine:latest",
    List.of("sh", "-c", "echo GREETING=$GREETING; echo oops >&2; for i in 1 2 3; do echo tick $i; sleep 0.3; done; exit 0"),
    new Options.Run().env(Map.of("GREETING", "hello"))
        .onStdout(b -> System.out.print("[out] " + new String(b, StandardCharsets.UTF_8)))
        .onStderr(b -> System.out.print("[err] " + new String(b, StandardCharsets.UTF_8)))
        .onStatus(b -> System.out.print("[status] " + new String(b, StandardCharsets.UTF_8))));
System.out.println("run exit=" + r.exitCode() + " ok=" + r.ok()
    + " stdout=" + r.stdout().replace("\n", "\\n") + " stderr=" + r.stderr().replace("\n", "\\n"));
[err] oops
[out] GREETING=hello
tick 1
[out] tick 2
[out] tick 3
run exit=0 ok=true stdout=GREETING=hello\ntick 1\ntick 2\ntick 3\n stderr=oops\n

Three things to notice, all real behaviour:

env takes a Map or a List of KEY=VALUE strings. Only run can use it. exec with a non-empty env throws Kosa8Exception before sending, because the daemon's exec takes no environment (0.1.0 silently dropped it):

exec env: exec does not take env: the container keeps the environment it was started with. Set it at startInteractive, or wrap the command: List.of("env", "K=V", "cmd")

attach ignores it. For exec, put variables in the command (env KEY=V cmd) or set them when starting the container with startInteractive(…, new Options.Interactive().env(…)). onStatus receives frames on the status stream (pull and progress lines); this run produced none.

A container that stays up

String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity"));
Results.Exec r = sb.exec(cid, List.of("sh", "-c", "echo one > /f; cat /f"), null);
System.out.println(sb.containers());

startContainer returns as soon as the container is started, with its id (such as k3-745c8bcf). containers() lists every container with its id, image, status (running, exited) and ip. An exec into a container that does not exist is a StreamException (no such container: nope), for the same reason as an unpulled image.

Feeding stdin

String ic = sb.startInteractive("alpine:latest", List.of("sh", "-c", "tr a-z A-Z"), null);
System.out.println("stdin " + sb.writeStdin(ic, "hello stdin\n".getBytes(StandardCharsets.UTF_8), null));
try {
  sb.writeStdin(ic, "again\n".getBytes(StandardCharsets.UTF_8), null);
} catch (Kosa8Exception e) {
  System.out.println("second stdin: " + e.getMessage());
}
System.out.println("attach " + sb.attach(ic, null).stdout().strip());
stdin {ok=true}
second stdin: stdin of container k3-d6cc9b51 in doc-java-011 was already sent and is closed: the daemon treats one writeStdin body as the whole of stdin. Pass an InputStream to stream input
attach HELLO STDIN

startInteractive starts a container with stdin held open (with Options.Interactive().tty(true) it gets a terminal). writeStdin sends the bytes; when the body ends, the program's stdin is closed, which is why tr finished. One body is the whole of stdin, so a second writeStdin to the same container from the same client throws, as above (0.1.0 answered ok for input the program never saw). The check is kept per client, in memory: a second client, or a new JVM, is not stopped by it, and the daemon still discards such input. To feed input over time, pass one InputStream that produces bytes as you go, such as a PipedInputStream. attach follows the container's output until it exits. writeStdin also takes an InputStream, which it sends with chunked transfer encoding, 16 KiB at a time, until the stream returns end-of-file (the byte-array form was the one run here). Since 0.1.1 the Sandbox handle has startInteractive, writeStdin, attach and execFrames as well as the client.

Raw frames

Underneath, run, exec and attach read a stream of JSON lines (NDJSON). Each line is a Frame: (stream, data, done, ok, error, exit, id), with data already decoded from base64. execFrames hands you each one:

String svc = sb.startContainer("alpine:latest", List.of("sleep", "3600"));
k.execFrames("doc-java-guide", svc, List.of("sh", "-c", "echo a; echo b >&2; exit 4"), f ->
    System.out.println("frame stream=" + f.stream() + " data=" + new String(f.data(), StandardCharsets.UTF_8).replace("\n", "\\n")
        + " done=" + f.done() + " ok=" + f.ok() + " exit=" + f.exit() + " error=" + f.error() + " id=" + f.id()), null);
frame stream=stderr data=b\n done=false ok=false exit=null error=null id=null
frame stream=stdout data=a\n done=false ok=false exit=null error=null id=null
frame stream=null data= done=true ok=true exit=4 error=null id=null

The last frame has done=true. Its ok means the stream ended cleanly, not that the command succeeded: ok=true with exit=4. A final frame with error set is how the daemon reports a command that could not run, and the collecting methods turn it into a StreamException. execFrames does not: it passes the error frame to you and returns normally. It also takes no timeout.

Timeouts

In plain words. A timeout stops your program from waiting forever. It does not stop the command in the sandbox.

try {
  sb.run("alpine:latest", List.of("sleep", "30"), new Options.Run().timeout(Duration.ofSeconds(2)));
} catch (Kosa8Exception.TimeoutException e) {
  System.out.println("timeout: " + e.getMessage());
}
Thread.sleep(1000);
System.out.println("containers after timeout " + sb.containers());
timeout: POST /kosa8/v1/sandboxes/doc-java-guide/run timed out talking to unix:///Users/dhruvit/.kosa8/run/kosa8.sock after 2012ms
containers after timeout [{id=k3-745c8bcf, image=docker.io/library/alpine:latest, status=running, ip=10.88.0.4}]

(The "after 2012ms" was added by the test program.) In detail: a timeout is a watchdog on one shared daemon thread (kosa8-timeout). When it fires it closes the connection, and the blocked read fails with TimeoutException. The sleep 30 container was still running afterwards. Stop such a container yourself, or delete the sandbox. A TimeoutException carries no output: if you need what was printed before the limit, collect it with onStdout/onStderr.

WhereApplies toDefault
Options.Run.timeout(Duration)That one run, exec or attach.the client's
Builder.timeout(Duration)Every request without its own, including createSandbox, snapshot and restores. Booting took 10–16 seconds and one restore 31 seconds on the busy test machine, so set this generously or not at all.none
Options.Explore.timeout(Duration)Each branch's command in explore.120 seconds

A zero Duration means no limit. The limit is measured from when the connection opens, so it covers sending the request too.

Snapshots

In plain words. A snapshot is a photograph of a whole running sandbox: memory, running programs and disk. You can start a new sandbox from it later, as many times as you like, and every copy carries on from exactly that moment.

var snap = sb.snapshot(new Options.Snapshot().id("doc-java-snap-manual"));
var restored = k.restoreSnapshot("doc-java-snap-manual", new Options.Restore().name("doc-java-snap-copy"));
System.out.println("copy /f=" + k.exec("doc-java-snap-copy", cid, List.of("cat", "/f"), null).stdout().strip());
System.out.println("delete snap " + k.deleteSnapshot("doc-java-snap-manual", null));
snapshot {snapshot={id=doc-java-snap-manual, sandbox=doc-java-snap, created=2026-11-03T01:42:22.741039-05:00, …, size_bytes=106958848, state_path=/Users/dhruvit/.kosa8/snapshots/doc-java-snap-manual/state.vzvmsave, …, egress=deny}, snapshot_ms=14794}
restored {restore_ms=31285, sandbox={id=doc-java-snap-copy, name=doc-java-snap-copy, created=2026-11-03T01:42:54.029023-05:00, state=running, parent=doc-java-snap, cpus=2, mem_mb=1024, disk_gb=32, egress=deny, ip=192.168.127.2}}
copy /f=one
delete snap {encrypted=false, ok=true, removed=doc-java-snap-manual}

The copy has the original's container, with the same id, still running and holding the same file. listSnapshots() returns every snapshot on the machine (checkpoints included, since they are snapshots too).

OptionsSetterMeaning
Snapshotid(String)The snapshot's id; the daemon picks one if unset.
encrypt(boolean)Encrypt the snapshot at rest once it is taken, as kosa8 snapshot create --encrypt. An org policy with require_snapshot_encryption does it anyway. (Not run for this page.)
Restorename(String)The new sandbox's name.
label(String)Raise the restored sandbox's classification label above the snapshot's. Lower or sideways is refused. Not a free-form tag.
allowTampered(boolean)Restore a sealed snapshot whose seal does not verify, to examine the tampering. Recorded in the audit log.
SnapshotDeletewipe(boolean)Overwrite the files, not just unlink them (?wipe=1).
force(boolean)?force=1.

Fork and explore

In plain words. Forking makes live copies of a running sandbox, so you can try several things side by side. Explore does that for you: one copy per idea, run them all at once, keep the copy where the idea worked fastest, throw the others away.

var f = sb.fork(2, new Options.Fork().prefix("doc-java-fork"));
System.out.println("fork " + f);
fork Fork[forks=[{id=doc-java-fork1, name=doc-java-fork1, …, state=running, parent=doc-java-snap, …}, {id=doc-java-fork2, name=doc-java-fork2, …, state=running, parent=doc-java-snap, …}], snapshot=doc-java-snap-fork-1793688195516612000, forkMs=3356, count=2]

fork(sandbox, n, Options.Fork) sends POST …/fork with n (at least 1; a smaller value is raised to 1). Forks are named prefix plus a number, as doc-java-fork1, doc-java-fork2 above. Options.Fork().label(…) raises the forks' classification above the parent's (never lower); it is not a free-form tag. The forks are ordinary sandboxes: delete them when done.

var ex = sb.explore(List.of("sleep 2; echo slow", "echo fast", "exit 1"),
    new Options.Explore().pull(false).timeout(Duration.ofSeconds(60)));
System.out.println("explore parent=" + ex.parent() + " winner=" + ex.winner() + " removed=" + ex.removed());
for (var b : ex.branches()) System.out.println("  " + b);
explore parent=doc-java-snap winner=doc-java-snap-explore-881982 removed=2
  ExploreBranch[name=doc-java-snap-explore-881981, variant=sleep 2; echo slow, exit=0, output=slow, error=null, durationMs=3586]
  ExploreBranch[name=doc-java-snap-explore-881982, variant=echo fast, exit=0, output=fast, error=null, durationMs=1603]
  ExploreBranch[name=doc-java-snap-explore-881983, variant=exit 1, exit=1, output=, error=null, durationMs=1603]

What explore does, step by step (it is client-side, the same algorithm as kosa8 explore):

  1. Throws at once if there are fewer than two variants.
  2. Unless pull(false), pulls image (default alpine:latest) into the sandbox.
  3. Forks the sandbox once per variant, with prefix prefix or <sandbox>-explore-<5 digits of the clock>. If fewer forks come back than variants, it throws and leaves what was created for you to find with listSandboxes().
  4. Runs sh -c VARIANT in each fork at the same time, on a thread pool with one thread per variant, each with the branch timeout. A connection-level failure (refused, reset, EOF, …) is retried up to four times with a growing pause, since a just-restored branch can refuse its first connection. A timeout is not retried.
  5. The winner is the fastest branch with exit 0 and no error. keep(name) overrides that; a name that is not one of the branches throws, after the run, with every branch kept.
  6. Unless keepAll(true), deletes every branch except the winner. removed counts the deletions that succeeded. When no branch succeeded, winner is "" and every branch is deleted.

The winner is left running and is yours to delete. The parent sandbox is untouched.

Checkpoints

In plain words. A checkpoint is an "undo" point. Take one before something risky; if it goes wrong, rewind, and the sandbox is back as it was, programs still running. The SDK and the kosa8 command line share the same list, so a checkpoint taken in Java can be rewound from the terminal and the other way round.

sb.checkpoint("taken from Java");
// …then, from the terminal:  kosa8 checkpoint doc-java-shared --label 'taken from the CLI'
for (Checkpoint c : sb.checkpoints())
  System.out.println(c.id() + "  label=\"" + c.label() + "\"  auto=" + c.auto() + "  created=" + c.created());
System.out.println("rewind to: " + sb.rewind("taken from java").checkpoint().id());
$ kosa8 checkpoint doc-java-shared --label 'taken from the CLI'
checkpoint doc-java-shared-ckpt-2 (taken from the CLI) saved in 545ms
  rewind with `kosa8 rewind doc-java-shared`
doc-java-shared-ckpt-1  label="taken from Java"  auto=false  created=2026-11-03T06:52:55.215776Z
doc-java-shared-ckpt-2  label="taken from the CLI"  auto=false  created=2026-11-03T06:52:55.837825Z
rewind to: doc-java-shared-ckpt-1
$ kosa8 checkpoint ls doc-java-shared
CHECKPOINT                WHEN       TAKEN   WHY
*doc-java-shared-ckpt-1   just now   you     taken from Java

* is where `kosa8 rewind doc-java-shared` goes
deleted: {ok=true, checkpoints_dropped=1}

Java saw the CLI's checkpoint, rewound to its own by label (matched without regard to case: "taken from java" found "taken from Java"), and the CLI then saw that the later checkpoint had been dropped.

How checkpoints work

The daemon has snapshots; checkpoints are bookkeeping the client keeps on top of them, exactly as the CLI's own pkg/checkpoint does.

Where the list lives, and the locking caveat

By default the client uses FileCheckpointStore, which reads and writes the CLI's own file, ~/.kosa8/checkpoints.json. Every write goes to checkpoints.json.tmp, is set to mode 0600, and is renamed over the file atomically, so a reader never sees half a file. A file that cannot be parsed is treated as empty rather than breaking your sandbox.

To keep two writers from overwriting each other's changes, each update first takes an in-process lock shared by every FileCheckpointStore on that path in the JVM (so two clients in one program queue up instead of failing), then an exclusive lock on checkpoints.json.lock. It waits at most 10 seconds for both together, then throws CheckpointException. Choose a different limit with new FileCheckpointStore(path, Duration lockTimeout). With another process holding the lock and a 2-second limit:

com.kosa8.Kosa8Exception$CheckpointException after 2021ms: timed out after 2s waiting for /private/tmp/…/ck/checkpoints.json.lock; another kosa8 process is holding it

Here is the caveat about the CLI, precisely:

java update took 8ms         # no other lock held
python holds flock for 6s
java update took 5172ms      # waited for the flock to be released

The window is one file rewrite, a few milliseconds, so in practice it matters only on Linux, when scripts and the CLI checkpoint the same machine at the same moment. If that is your situation, or you want the SDK's checkpoints to be private, use a store of your own:

// In memory: nothing shared with the CLI, gone when the JVM exits.
Kosa8Client k = Kosa8Client.builder().checkpoints(new MemoryCheckpointStore()).build();

// A different file, same format and locking.
Kosa8Client k2 = Kosa8Client.builder()
    .checkpoints(new FileCheckpointStore(Path.of("/srv/agent/checkpoints.json"))).build();

Or implement CheckpointStore yourself: load() returns a copy of all stacks (sandbox name to list, oldest first), and update(fn) must run fn on a mutable copy and save what it leaves, excluding other updates while it does.

Thread safety

In plain words. One client can be shared by many threads, and several clients can live in one program. Calls do not get in each other's way.

ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<String>> out = new ArrayList<>();
for (int i = 1; i <= 4; i++) {
  int n = i;
  out.add(pool.submit(() -> sb.exec(cid, List.of("sh", "-c", "echo job " + n + " squared is $((" + n + "*" + n + "))"), null).stdout().strip()));
}
for (Future<String> f : out) System.out.println(f.get());
job 1 squared is 1
job 2 squared is 4
job 3 squared is 9
job 4 squared is 16

In detail:

PartSafe to share?Why
Kosa8Client, SandboxYesAll fields are final and set in build(). Each call opens its own connection, so calls do not share any stream.
Results.*, Checkpoint, Frame, EndpointYes, read-onlyRecords. Exec exposes its byte[] arrays directly; do not modify them if others read them.
Options.*Build per callMutable builders with no locking.
Checkpoints of the same sandbox from two threads or clientsYesBoth can pick the same next id; the one the daemon refuses with 409 steps to the next (seen: ckpt-1 and ckpt-2). Each still waits its turn for the store's lock.
Two clients with default stores in one JVMYesEach build() without checkpoints(…) makes a new FileCheckpointStore, but stores on one path share one in-process lock, so the second waits (seen: 1027 ms behind a 1-second update, no exception). In 0.1.0 it threw OverlappingFileLockException.
The writeStdin once-only checkPer clientKept in a concurrent set on the client. Another client does not know what this one sent.

Streaming callbacks run on the calling thread. explore starts its own pool, sized to the number of variants, and shuts it down before returning.

Resources and clean-up

Neither Kosa8Client nor Sandbox implements AutoCloseable, so there is no try-with-resources form. The client holds no open connection and needs no closing. What does need cleaning up is what you create in the daemon: sandboxes, forks, explore winners and snapshots. They outlive your program. Use try … finally, as in the quickstart, and sweep by label to catch anything left by a crash.

If you want try-with-resources, a three-line wrapper of your own does it:

record TempSandbox(Sandbox sb) implements AutoCloseable {
  @Override public void close() { sb.delete(); }
}
// try (var t = new TempSandbox(k.sandbox("x"))) { t.sb().run(…); }

Approvals and policy

A company policy can forbid an action, or require a signed approval for it. Every option class extends Options.Call, so any gated call takes .approval(sig); calls whose only option is an approval take Options.Approval.of(sig). The signature is sent as the X-Kosa8-Approval header. A refusal that an approval would lift is an ApprovalRequiredException, whose approval() map is the daemon's explanation (why, who may sign, the command to request one). On the Sandbox handle, methods that take an options object carry the approval in it; start, stop, pull, startContainer, checkpoint and rewind take none, so use the client's overloads for those. (From the source; no policy was installed for this page.)

Recipes

An agent loop with undo

Checkpoint before each step an agent proposes; if the step fails, rewind so the next step starts from a known-good machine. A failed step costs nothing.

import com.kosa8.*;
import java.util.List;
import java.util.Map;

public class AgentLoop {
  public static void main(String[] args) {
    Kosa8Client k = Kosa8Client.builder().actor("agent").build();
    String name = "doc-java-agent";
    k.createSandbox(new Options.Create().name(name).egress("deny").labels(Map.of("owner", "doc-java")));
    Sandbox sb = k.sandbox(name);
    try {
      sb.pull("alpine:latest");
      String cid = sb.startContainer("alpine:latest", List.of("sleep", "infinity"));

      // The steps an agent proposed. One of them is a mistake.
      List<String> steps = List.of(
          "mkdir -p /work && echo 'print(1)' > /work/app.py",
          "rm -rf /work && false",            // destroys the work, then fails
          "echo 'print(2)' >> /work/app.py");
      for (String step : steps) {
        sb.checkpoint("before: " + step);
        Results.Exec r = sb.exec(cid, List.of("sh", "-c", step), null);
        if (r.ok()) {
          System.out.println("ok      " + step);
        } else {
          Results.Rewind rw = sb.rewind(null);   // undo exactly this step
          System.out.println("undone  " + step + "  (exit " + r.exitCode() + ", back to " + rw.checkpoint().id() + ")");
        }
      }
      System.out.print("result:\n" + sb.exec(cid, List.of("cat", "/work/app.py"), null).stdout());
      System.out.println("checkpoints kept: " + sb.checkpoints().size());
    } finally {
      System.out.println("deleted: " + sb.delete());
    }
  }
}
ok      mkdir -p /work && echo 'print(1)' > /work/app.py
undone  rm -rf /work && false  (exit 1, back to doc-java-agent-ckpt-2)
ok      echo 'print(2)' >> /work/app.py
result:
print(1)
print(2)
checkpoints kept: 3
deleted: {ok=true, checkpoints_dropped=3}

The container id stayed valid across the rewind, because the rewind brings back the running container. actor("agent") makes the audit log show these calls as an agent's. (The run that printed this also ran the thread-pool example above before deleting; those lines are left out.)

Sweep up leftovers by label

Label everything your program creates, and delete by label at start-up or in a cron job. This catches sandboxes left behind by a crash or a killed JVM.

/** Delete every sandbox labelled owner=OWNER, with its checkpoints. */
static List<String> sweep(Kosa8Client k, String owner) {
  List<String> gone = new ArrayList<>();
  for (Map<String, Object> s : k.listSandboxes()) {
    if (s.get("labels") instanceof Map<?, ?> l && owner.equals(l.get("owner"))) {
      String id = (String) s.get("id");
      try {
        k.deleteSandbox(id);
        gone.add(id);
      } catch (Kosa8Exception.NotFoundException e) {
        // already gone
      }
    }
  }
  return gone;
}
swept: [doc-java-sweep]

Forks and explore branches carry a parent field in listSandboxes() and are named after a prefix, so a sweep can also match on either of those. (Whether forks copy their parent's labels was not checked for this page.)

The same thing from the command line

JavaCLI
k.createSandbox(new Options.Create().name("x").egress("deny"))kosa8 sandbox create x --egress deny
k.listSandboxes()kosa8 sandbox ls
sb.checkpoint("why")kosa8 checkpoint x --label why
sb.checkpoints()kosa8 checkpoint ls x
sb.rewind(null)kosa8 rewind x
sb.explore(List.of("a", "b"), null)kosa8 explore x --variant a --variant b
k.listSnapshots()kosa8 snapshot ls

Every call made from Java appears in kosa8 audit ls with the actor you set.

Next

Errors and troubleshooting: every exception and what to do about it. API reference: every class and method. SDKs in five languages compares Java with the others.