Compatibility Layers Should Be Thin, Not Fake
How Kalahari maps common ComputeSDK, E2B-style, and Daytona-style process and filesystem workflows onto one native sandbox lifecycle.
Compatibility is useful when it lowers the cost of trying a new runtime. It is dangerous when it pretends the new runtime has the exact same machinery as the old one.
Kalahari’s compatibility modules are built around that distinction. Kalahari exposes a native API through KalahariClient and KalahariSandbox, then adds adapters for ComputeSDK, E2B-style, and Daytona-style workflows. Those adapters cover the common surface area teams tend to depend on first: sandbox lifecycle, command execution, long-running process handles, PTY-style sessions, filesystem reads and writes, and a few provider-specific helper names.
They are not complete clones of those providers. Some APIs remain unsupported. The point is to make common migrations incremental without creating fake lifecycle state that will surprise users later.
The rule is simple:
translate API shape; do not invent sandbox behavior.
One Native Lifecycle
The native API is the center of Kalahari’s TypeScript surface:
import { KalahariClient } from '@amlalabs/kalahari';
const client = new KalahariClient({ image: 'node:22-alpine' });
const sandbox = await client.createSandbox();
try {
await sandbox.writeFile('/tmp/message.txt', 'hello\n');
const result = await sandbox.run('cat', {
args: ['/tmp/message.txt'],
});
console.log(result.stdout);
} finally {
await sandbox.destroy();
}The adapters sit on top of that same sandbox object. They rename methods, translate option shapes, and return provider-flavored results where that makes sense. They do not create a second VM, a second process table, or a second filesystem model.
That matters for reconnect-style APIs. E2B’s Sandbox.connect(...) is asynchronous in Kalahari and must be awaited:
import { Sandbox } from '@amlalabs/kalahari/e2b';
const first = await Sandbox.create('base', { image: 'node:22-alpine' });
const second = await Sandbox.connect(first.sandboxId);
try {
const handle = await first.pty.create({ cmd: 'sleep 60' });
const sameProcess = await second.commands.connect(handle.pid);
await sameProcess.kill();
} finally {
await first.destroy();
}Today that reconnect is process-local. Kalahari can reconnect wrappers to a sandbox that is still known inside the same Node.js process. It does not yet recover a native VM handle from another process and pretend nothing changed.
ComputeSDK follows the same rule. If sandboxId points at an in-process sandbox, the adapter returns it. If it points outside the current process, Kalahari fails explicitly instead of fabricating a remote handle.
Process Handles Are Shared State
Process handles are where compatibility layers often get leaky.
Different sandbox providers expose long-running work differently. One API has run(..., { background: true }). Another has PTY sessions. Another has kill(pid), list(), callbacks, or stdin helpers. If each wrapper keeps its own PID counter and process map, two JavaScript objects connected to the same sandbox can disagree about what is running.
Kalahari avoids that by keeping process records in a shared registry keyed by the native sandbox identity. The E2B-style command and PTY APIs call into the same registry used by KalahariSandbox.startProcess() and KalahariSandbox.startShell().
That gives the wrappers the same lifecycle semantics:
- repeated
wait()calls observe the same collected result list()drops a handle after the process exitskill(pid)returnsfalseonce a process is gonesendStdin()andresize()fail after the handle has completed- destroying a sandbox cancels in-flight waits and clears the registry
Callbacks are part of that lifecycle, not a separate feature bolted onto one adapter. Kalahari starts collecting PTY output when the process starts, so callbacks can fire even if the caller never waits:
const handle = await sandbox.startShell('cat', {
onStdout(chunk) {
process.stdout.write(chunk);
},
});
await handle.sendStdin('streamed\n');
await handle.kill();If a callback throws, the process handle fails and Kalahari cleans it up. That is intentional. A callback is the user’s requested observation path; silently continuing after it breaks would hide a failure and risk leaving work running without supervision.
Command Strings Need a Meaning
Compatibility is not just method names. It also includes command semantics.
Kalahari’s native API keeps direct execution and shell execution separate:
await sandbox.run('node', {
args: ['-e', "console.log('direct argv')"],
});
await sandbox.runShell(`printf 'shell expansion works: %s\n' "$HOME"`);The adapters translate provider expectations into those native calls. ComputeSDK runCommand() is shell-style because ComputeSDK callers usually pass a command string. E2B-style and Daytona-style command methods use direct argv execution when args is provided, and shell execution when the caller gives only a command string. E2B-style pty.create({ cmd }) also treats cmd as a shell command string.
That distinction is small but important. Kalahari should not make the native API fuzzy by guessing whether "printf hello" is an executable path or a shell command. The compatibility wrapper knows the upstream convention and performs that translation at the edge.
Filesystem APIs Map to Real Files
The filesystem adapters follow the same pattern.
The native API exposes operations such as readFile, writeFile, readFileBytes, writeFileBytes, mkdir, listFiles, statFile, exists, remove, and rename. Under the hood, those calls run Kalahari’s filesystem helpers inside the VM.
The E2B-style adapter maps sandbox.files.read(), write(), list(), makeDir(), remove(), and rename() onto those native operations. It handles text and byte data, then returns E2B-shaped entry metadata.
The Daytona-style adapter maps common fs methods onto the same primitives. It also covers host upload and download helpers, streams, simple literal search and replace, glob-like search, and file permission mode changes. When owner or group changes are requested, Kalahari rejects them because the current primitive only supports chmod-style mode changes.
This is enough for normal agent workflows: stage files, run code, inspect outputs, move artifacts back to the host. It is not a claim that Kalahari implements every storage feature of every provider.
Unsupported Means Unsupported
Some provider APIs require infrastructure Kalahari does not expose yet. The adapters still define those method names when they are part of the expected surface, but they fail with explicit errors.
Examples include:
- E2B-style
files.watchDir(), which rejects withE2B files.watchDir is not supported by Kalahari yet. - E2B-style
uploadUrl()anddownloadUrl(), which throwE2B uploadUrl is not supported by local Kalahari sandboxes yet.andE2B downloadUrl is not supported by local Kalahari sandboxes yet. - ComputeSDK
getUrl(), which throwskalahari does not expose guest ports through ComputeSDK getUrl yet. - Daytona SSH and LSP helpers, experimental snapshot and fork helpers, runtime network changes through
updateNetworkSettings(), and owner/group permission changes, which reject through Kalahari’s unsupported-feature helper with messages likeDaytona createSshAccess is not supported by Kalahari yet.
The important behavior is the failure mode. An unsupported method should fail at the call site with a precise reason. It should not return a plausible-looking value that only works in a narrow demo.
That gives the adapter boundary three states:
- implemented directly on native Kalahari behavior
- translated into native Kalahari behavior
- rejected as unsupported
There should not be a fourth state where the adapter quietly fakes provider behavior it cannot actually provide.
Why There Is No Docker Facade
The same principle explains why Kalahari does not currently expose a Docker-style compatibility layer.
Docker compatibility is not just run a command in an image. It implies a daemon model, image and container identity, exec sessions, log streams, networks, bind mounts, volumes, labels, and a large set of lifecycle rules tied to the Docker engine.
Kalahari can run VM-backed sandboxes from OCI images and expose process and filesystem APIs. That does not make it a Docker daemon. A Docker-shaped facade would either be very incomplete or force Kalahari to maintain fake daemon state unrelated to the underlying runtime.
If Kalahari adds another compatibility module, it should be another thin translation layer over KalahariSandbox, not a parallel backend.
Tests Should Exercise the Runtime
Thin adapters are only trustworthy if tests run through the real runtime boundary.
The Kalahari tests assert that the real runtime binding is available before exercising the API. They run real commands, read and write real files, check zygote copy-on-write behavior, verify network policy enforcement, and exercise the ComputeSDK, E2B-style, and Daytona-style adapters. The conformance tests compare core Kalahari and E2B-style process behavior for repeated waits, callback delivery without wait(), callback failures, startup cleanup, and sandbox destroy while a wait is in flight.
Those tests matter because adapter-only fakes tend to hide exactly the bugs compatibility layers introduce: shell-vs-argv mismatches, process cleanup races, output delivery timing, and handles that look valid after the VM has already moved on.
Compatibility Without a Second Runtime
Kalahari’s compatibility modules are migration tools. They make the common path familiar:
@amlalabs/kalahari
KalahariClient / KalahariSandbox / KalahariZygote
@amlalabs/kalahari/computesdk
ComputeSDK provider adapter
@amlalabs/kalahari/e2b
E2B-style Sandbox adapter
@amlalabs/kalahari/daytona
Daytona-style adapterThe adapters are intentionally less ambitious than full provider emulators. They cover the core process and filesystem workflows that map cleanly to Kalahari today, expose unsupported gaps plainly, and leave the native API as the source of truth.
That is the compatibility line worth holding: reduce migration cost without making behavior unknowable.