Skip to content

Go SDK

The official Go client: a zero-dependency module (stdlib only), versioned independently of the daemon with sdk/vX.Y.Z tags.

go get github.com/gnana997/crucible/sdk
import (
    crucible "github.com/gnana997/crucible/sdk"
    "github.com/gnana997/crucible/sdk/api"
    "github.com/gnana997/crucible/sdk/wire"
)

Full API docs: pkg.go.dev/github.com/gnana997/crucible/sdk.

Client setup

cr := crucible.New("127.0.0.1:7878") // keyless loopback daemon (dev)

cr = crucible.New("daemon.internal:7878",
    crucible.WithToken(os.Getenv("CRUCIBLE_TOKEN"))) // production: TLS + key

New accepts host:port or a full http(s):// URL. The token is a daemon API key (see authentication); treat the SDK as server-side only; a key grants control of the host's microVMs.

Sandboxes

// Boot from an OCI image (pulled and converted on demand, then cached):
sb, err := cr.CreateSandbox(ctx, api.CreateSandboxRequest{
    Image:     &api.ImageRef{OCI: "python:3.12-alpine"},
    VCPUs:     2,
    MemoryMiB: 512,
})

// Or from a configured rootfs profile / the daemon default:
sb, err = cr.CreateSandbox(ctx, api.CreateSandboxRequest{})

defer cr.DeleteSandbox(ctx, sb.ID)

Lists are pages, a shape that stays stable when a future control-plane adds cursors:

page, err := cr.ListSandboxes(ctx)
for _, s := range page.Items { fmt.Println(s.ID, s.Profile) }

Flat methods and handles

Every REST route has a flat method on Client returning the full wire DTOs. Handles are chaining sugar over them: cheap value structs, no hidden round-trips:

sbx := cr.Sandbox(sb.ID)

res, err := sbx.Exec(ctx, wire.ExecRequest{Cmd: []string{"pytest", "-q"}},
    os.Stdout, os.Stderr)

snap, err := sbx.Snapshot(ctx)      // Snapshot handle
forks, err := snap.Fork(ctx, 8)     // []Sandbox handles

Rule of thumb: handles for chaining, flat methods when you need the response body (Client.Fork returns the full []api.SandboxResponse with network details; snap.Fork returns handles).

Exec: streaming and interactive

One-shot exec streams stdout/stderr to io.Writers as the command runs and returns the exit result:

res, err := sbx.Exec(ctx, wire.ExecRequest{
    Cmd:        []string{"python3", "train.py"},
    Env:        map[string]string{"EPOCHS": "10"},
    TimeoutSec: 600, // SIGKILL + TimedOut=true when exceeded
}, os.Stdout, os.Stderr)

fmt.Println(res.ExitCode, res.DurationMs, res.Usage.PeakMemoryBytes)

res.Usage carries per-exec CPU, memory, and I/O counters; it is nil when the process never started. Failures after the stream commits (VM died mid-run) arrive as ExitCode -1 with res.Error set, never as a broken stream.

Interactive exec is a full-duplex session: live stdin, persistent cwd/env for the life of the process (what crucible shell uses):

res, err := sbx.ExecInteractive(ctx, wire.ExecRequest{Cmd: []string{"/bin/sh"}},
    os.Stdin, os.Stdout, os.Stderr)

Cancel the context to kill the guest command. (Under the hood this is a hijacked connection; the wire doc has both transports.)

Files in and out

// Push a tar stream, extracted beneath /app in the guest (path-escape safe):
result, err := sbx.CopyTo(ctx, "/app", tarReader)

// Read one file back, capped at 1 MiB:
data, err := sbx.ReadFile(ctx, "/app/output.json", 1<<20)

The CLI's crucible cp builds the tar for you; from Go, use archive/tar over your sources.

Snapshot → fork: the agent fan-out

The pattern crucible exists for: pay for setup once, explore N branches from the exact same warm state:

sbx := cr.Sandbox(sb.ID)
_, err := sbx.Exec(ctx, wire.ExecRequest{
    Cmd: []string{"pip", "install", "-r", "requirements.txt"},
}, nil, nil)

snap, err := sbx.Snapshot(ctx)   // capture the warm state
forks, err := snap.Fork(ctx, 8)  // 8 copies, lazy memory, ~100ms each

var wg sync.WaitGroup
for i, f := range forks {
    wg.Add(1)
    go func() {
        defer wg.Done()
        defer f.Delete(ctx)
        f.Exec(ctx, wire.ExecRequest{Cmd: []string{"python3", "attempt.py", strconv.Itoa(i)}}, nil, nil)
    }()
}
wg.Wait()

Every fork wakes with fresh entropy and identity (clone-safety): divergent RNG, unique hostname, its own network address.

Services

Run a long-lived entrypoint under the guest supervisor (Docker-style restart policies):

st, err := sbx.ConfigureService(ctx, wire.ServiceSpec{
    Cmd:     []string{"node", "server.js"},
    Restart: wire.RestartPolicy{Policy: wire.RestartOnFailure, MaxRetries: 3},
})
st, err = sbx.StartService(ctx)
logs, err := sbx.ServiceLogs(ctx, 0, 0) // ring-buffer cursor API

Publish guest ports to the host with api.CreateSandboxRequest.Publish (docker -p semantics).

Errors

Every daemon error is a structured *crucible.Error (Status, Message, reserved Code), and the common statuses also match sentinels:

_, err := cr.GetSandbox(ctx, id)
switch {
case errors.Is(err, crucible.ErrNotFound):     // 404: gone or never existed
case errors.Is(err, crucible.ErrUnauthorized): // 401: bad/missing key
case errors.Is(err, crucible.ErrPolicyDenied): // 403: token's policy forbids it
}

var de *crucible.Error
if errors.As(err, &de) { log.Printf("daemon said %d: %s", de.Status, de.Message) }

Versioning

The module is tagged sdk/vX.Y.Z, independent of daemon releases, and is pre-1.0 (surface may move between minors). The wire contract itself is frozen; see the wire protocol and the API reference.

Was this page helpful?