Quickstart
Install the SDK, register an environment, and run your first job.
This walks through running one container end to end: a namespace-scoped API key, a client, a registered image, and a job.
1. Get a namespace and a key
Namespaces are the tenancy boundary and are admin-created. Ask a platform admin (or use the console at containers.xeonr.io if you hold the admin scope) to create one and issue you a machine key:
await cp.raw.namespace.createNamespace({
namespace: "my-app",
displayName: "My App",
groups: [{ oidcGroup: "my-team", role: Role.OPERATOR }],
quotaCpuMillis: 4000,
quotaMemoryMb: 8192,
quotaMaxPods: 20,
});
const { plaintext } = await cp.raw.namespace.issueApiKey({
namespace: "my-app",
name: "ci",
role: Role.OPERATOR,
});
// plaintext is shown exactly once — store it now.Creating the namespace also provisions its dedicated Kubernetes runner namespace (cp-rn-my-app) with its NetworkPolicies, quota and gVisor runtime class. See Authentication.
2. Get a client
TypeScript — install the SDK. It carries the transport, auth, the namespace, error mapping and stream handling, and ships ESM and CommonJS:
npm add @xeonr/containers @bufbuild/protobuf @connectrpc/connectimport { createClient } from "@xeonr/containers";
const cp = createClient({
endpoint: "https://containers.xeonr.io",
apiKey: process.env.CP_API_KEY,
namespace: "my-app",
});The key goes in an Authorization: Bearer header. It is pinned server-side to one namespace, so it cannot name another however the code is called.
Go, or TypeScript without the SDK — the protocol lives on the schema registry as module proto.prod.wtf/container-platform/core, package containers.api.v1.
go get proto.prod.wtf/gen/go/container-platform/core/protocolbuffers/go
go get proto.prod.wtf/gen/go/container-platform/core/connectrpc/go# buf.gen.yaml — TypeScript codegen, if you are not using the SDK
version: v2
inputs:
- module: proto.prod.wtf/container-platform/core
plugins:
- remote: buf.build/bufbuild/es:v2.2.3
out: src/gen
opt:
- target=ts
- import_extension=.js3. Wire up auth by hand (Go)
package main
import (
"context"
"net/http"
"os"
"connectrpc.com/connect"
"proto.prod.wtf/gen/go/container-platform/core/connectrpc/go/containers/api/v1/apiv1connect"
)
type bearer struct{ key string }
func (b bearer) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
req.Header().Set("Authorization", "Bearer "+b.key)
return next(ctx, req)
}
}
func (b bearer) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {
conn := next(ctx, spec)
conn.RequestHeader().Set("Authorization", "Bearer "+b.key)
return conn
}
}
func (b bearer) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return next
}
func main() {
opts := connect.WithInterceptors(bearer{key: os.Getenv("CP_API_KEY")})
jobs := apiv1connect.NewJobServiceClient(http.DefaultClient, "https://containers.xeonr.io", opts)
_ = jobs
}Build the transport over HTTP/2, not HTTP/1.1. Client- and bidi-streaming RPCs — WriteFile, ImportZip, Exec, AttachConsole — deadlock over HTTP/1.1 with no error on either side; the call simply hangs. Over an http:// in-cluster URL that means h2c, which is what the platform serves. The SDK does this for you and offers no way to get it wrong.
4. Register an environment
An environment is the image a workload runs. Register one that already exists — a tag is resolved to a digest at registration and the digest is what runs:
const environment = await cp.environments.register(
"docker.io/library/python:3.12-slim",
{ label: "python-3.12" },
);For an image your CI rebuilds, use cp.environments.ensure(ref) instead — it registers a digest the first time it sees it and reuses the id afterwards, so a new build becomes a new environment with nobody remembering to register anything.
To build one from a spec instead, see Environments.
5. Run a job
RunJob is a server stream: an accepted event carrying the job id, then state changes, log frames and a terminal result. The SDK drains it for you and hands back the outcome:
const run = await cp.jobs.run({
environment,
argv: ["python", "-c", "print('hello from a sandbox')"],
label: "hello",
resources: { cpu: "500m", memory: "256Mi" },
timeout: "60s",
}, {
onLog: (chunk) => process.stdout.write(chunk.text),
});
console.log(run.jobId, "exit", run.exitCode, run.terminationName);A non-zero exit is a value, not an exception — the RPC succeeded and told you the exit code. run.check() throws instead, if that suits the caller better. Use cp.jobs.stream(...) if you want the raw event sequence.
The job runs under gVisor as uid 1000 with a read-only root filesystem, no capabilities, and no network at all — the default egress mode is MODE_NONE. To let it reach something, attach a policy: see Egress.