TypeScript SDK
@xeonr/containers — the client library, instead of the raw protocol.
The protocol is nine services and about seventy RPCs, with six streaming shapes, oneof-wrapped events and bytes everywhere. Four consumers wrote the same ~500 lines of glue over it before this existed, and two of them wrote the same bug.
@xeonr/containers is that glue, once.
npm add @xeonr/containers @bufbuild/protobuf @connectrpc/connectimport { createClient, egress } from "@xeonr/containers";
const cp = createClient({
endpoint: "https://containers.xeonr.io",
apiKey: process.env.CP_API_KEY, // namespace-scoped key (cpk_…)
namespace: "my-app",
});
const environment = await cp.environments.ensure(
"registry.containers.xeonr.io/my-app/runner@sha256:…",
);
const run = await cp.jobs.run({
environment,
argv: ["python3", "-c", "print('hello from a sandbox')"],
resources: { cpu: "500m", memory: "256Mi" },
timeout: "10m",
egress: egress.presets("pypi"),
});
if (!run.ok) console.error(run.terminationName, run.exitCode, run.logs);It ships ESM and CommonJS, so a CommonJS project imports it the same way and needs no tsconfig change.
What it does that the raw client doesn't
Gets the transport right. Always HTTP/2 in Node — over an http:// in-cluster URL that means h2c, which is what the platform serves. There is no option for this, because every other value is wrong: client- and bidi-streaming over HTTP/1.1 deadlock with no error on either side, so a WriteFile or an Exec simply hangs.
Splits the two kinds of failure. Transport and control-plane failures throw (NotFoundError, PermissionDeniedError, QuotaExceededError, …). A workload that exits non-zero is a value — run.ok === false — because the RPC succeeded and told you the exit code. run.check() escalates it if you'd rather have the exception.
Cancellation that actually cancels. The api runs a job under context.WithoutCancel, deliberately — a closed browser tab shouldn't kill a pipeline step. The consequence is that dropping a RunJob stream leaves the pod running. Pass an AbortSignal and the SDK sends CancelJob before letting go; { onAbort: "detach" } opts back out.
environments.ensure(). RegisterStaticEnvironment always creates a new environment, by design. Resolving an image digest to an id therefore needs a look-before-register, a cache, and single-flighting — without the last one, two dispatches racing after a deploy each mint one. That is this call.
Human units in, protocol units out. "10m", "500m", "256Mi", and unparseable input throws rather than silently becoming 0 — which the platform reads as no limit.
Streams drained once. run() returns a typed outcome and takes onLog / onState / onAccepted. Log text is decoded across chunk boundaries so multi-byte characters survive, and a stream that closes without its terminal event is a named error carrying the job id, not a silent success.
The surface
cp.jobs | run · stream · get · list · cancel · tailLogs |
cp.environments | ensure · register · build · get · list · pin · delete |
cp.workspaces | files, snapshots, zip import/export, presigned uploads |
cp.sandboxes | exec · files · ports · egress grants · sync · lifecycle · heartbeat · run log |
sandbox.processes | persistent console sessions |
sandbox.fs / cp.jobs.fs(id) | the container's own filesystem — not the workspace |
cp.apps | versions, promotion, rollback, activation, merged replica logs |
Sandboxes
const sandbox = await cp.sandboxes.create({ environment, workspace: ws.id });
// Silence, not wall-clock, is what says a process is stuck. Partial output is kept.
const out = await sandbox.exec(["npm", "ci"], { idleTimeout: "2m" });
// The grant closes with the scope — no id to remember, no window left open.
{
await using grant = await sandbox.grantEgress(egress.presets("npm"), { reason: "install" });
await sandbox.shell("npm ci");
}
// A dev server that outlives the call, checked for an instant crash before it returns.
const dev = await sandbox.processes.start("dev", "npm run dev", { cwd: "/workspace" });
if (dev.stateName === "EXITED") console.error(dev.startupOutput);
console.log((await sandbox.expose(5173, { auth: "public" })).url);Reads from sandbox.files hit the durable store. A sandbox's /workspace is a materialised copy synced back on a ~10s tick: a file that has never reached the store is flushed on demand, but a file changed inside the sandbox reads stale until the tick. Pass { fresh: true } to reconcile first — it costs a whole-tree walk and hash, which is why it's opt-in.
sandbox.files and sandbox.fs are different things with similar names. .files is the durable workspace that outlives the pod; .fs is the container itself — the whole rootfs, gone when the pod is, and read-only outside its mounts unless the environment says otherwise. There is no { fresh: true } on .fs, because it talks to the live container and has nothing to reconcile.
Recipes
@xeonr/containers/recipes is the opinionated layer — separately imported, built only out of the core.
import { ensureSandbox, createAgentTools } from "@xeonr/containers/recipes";
// "a sandbox for this key, usable right now" — creates, resumes, or recreates
// against the RETAINED workspace, and re-exposes the ports on every branch.
const { sandbox, previewUrls, status } = await ensureSandbox(cp, scopeId, {
store, // two methods: get(key) / set(key, value)
environment,
workspace: true,
ports: [5173],
});
// The file/command/process tools an agent needs, as provider-agnostic descriptors.
const tools = createAgentTools(cp, { scope: scopeId, store, environment });A sandbox can be suspended, recreated or lost. From where a model sits, none of that happens — every tool ensures first, and a failure comes back as text it can act on rather than a throw that ends the turn.
Testing
import { fakePlatform } from "@xeonr/containers/testing";
const { client, execScript } = fakePlatform();
execScript.stdout = "ok";Not a stub library — an in-memory api the real client speaks the real protocol to, so framing, streaming and error mapping are exercised on the way through.
Nothing is hidden
cp.raw.<service> is the generated client for every RPC, modelled or not, and @xeonr/containers/proto is the schema it was generated from — the same revision the package version pins. Anything the SDK doesn't model is one property away, and it throws the same error taxonomy as the modelled surface.
Versions
The package version pins one schema revision, so cp.raw is always that revision's client. Releases are cut by bumping version in package.json on main; CI publishes and tags what it published.