Open-source execution substrate

Run work in a disposable cell, with full observability.

Process, container, or VM — one interface, one resumable event stream.

Overview

The layer beneath your orchestrator.

Umbra starts an isolated cell, runs your workload inside it, streams every event back over one resumable channel, and tears it down when the run ends. What runs inside is your call — a shell command, a build, a test suite, an agent. Umbra stays out of it.

  • One runtime seam

    Four methods over plain value types — Start, Stop, Status, Close — with five implementations behind them. Moving a cell from a local subprocess to a VM on another machine changes only who calls Start. Your integration code doesn’t move.

  • Observability, not log scraping

    The guest inside the cell reports straight to the receiver, never through the factory. Events are sequence-numbered and idempotently resumable, so the live stream and the stored transcript are the same data — read now or read later, you get the same run.

  • Bring your own orchestration

    Umbra is deliberately not a scheduler, a queue, or a placement engine. Keep Temporal, Argo, your CI runner, or your own worker pool — along with the business logic and the identity model that already live there.

Architecture

Three tiers, and the seam that keeps them apart.

You mint the run token, put it in the cell’s environment, and call Start. The guest reads that token from its own environment and reports directly to the receiver. The factory never mints a token and never terminates the run protocol — which is why you can adopt the factory on its own and leave the rest of Umbra alone.

Yours

Orchestrator

Mints the run token. Any language.

Umbra

Factory

runtime/ + node/

The security boundary

Cell

process · container · VM

Guest

umbra-guest, PID 1

Umbra

Receiver

claim / events / control / complete

Your orchestrator mints a single-use run token and asks the Umbra factory to start a cell — a process, a container, or a virtual machine. The guest boots inside that cell as PID 1, reads the token from its own environment, and talks directly to the receiver over the run protocol: claim, events, control, complete. The receiver gives your orchestrator one resumable event stream, replayable from the beginning.
  • Factory

    Creates and destroys disposable cells.

    Library runtime/ + a backend

    Binary umbra-node

  • Receiver

    Collects and serves everything the run emits.

    Library run/ receiver

    Binary umbra

  • Guest

    Boots inside the cell, runs the workload, streams events out.

    Library run/ client + harness

    Binary umbra-guest

Backends

One Runtime interface. Five implementations.

Pick whichever one your host can support. If the tooling a backend needs is missing, it fails at Start with a specific reason — so you find out at configuration time rather than halfway through a run.

  • fake no isolation
    How it isolates
    The guest as a local subprocess.
    Needs
    Nothing.
    Good for
    Tests, CI, local development.
  • docker container
    How it isolates
    Containers via the Engine API over the unix socket — not the CLI.
    Needs
    Docker Engine.
    Good for
    The familiar container path.
  • linux kernel
    How it isolates
    Namespaces + cgroups v2, no daemon.
    Needs
    Linux, root, cgroups v2, a rootfs.
    Good for
    The fastest start, real kernel isolation.
  • macos virtual machine
    How it isolates
    A Virtualization.framework VM per run.
    Needs
    macOS, a vz-tagged signed build.
    Good for
    A real VM guest on a Mac.
  • qemu virtual machine
    How it isolates
    QEMU + KVM as subprocesses.
    Needs
    qemu-system, virtiofsd.
    Good for
    A real VM guest anywhere.

fake provides no isolation

Despite the sandbox terminology, fake runs the guest as an ordinary local subprocess with your user account and your host filesystem in reach, and Spec.Tmpfs is ignored. Never use it for untrusted workloads, and don’t hand it host secrets — reach for docker, linux, macos, or qemu when you need a security boundary that matches your threat model.

Spec.Resources (memory, CPU, pids) and Spec.Network are safety rails, not contracts: a backend that can’t enforce a cap ignores it rather than refusing the run. Both VM backends pass the guest its configuration over a read-only virtiofs share rather than the kernel command line, which is world-readable through /proc/cmdline — so the same guest binary works on either.

Examples

Two ways in, and the same guest behind both.

Link the Go libraries and terminate the protocol in your own process, or run the reference daemon and speak HTTP from any language. The daemon is a thin wrapper over the same run/ library the Go path links, so the two front doors can’t drift apart.

Go library

// Stand up a receiver: an HTTP surface the guest reports to.
store, bus := local.NewMemStore(), local.NewMemBus()
recv, err := run.NewReceiver(store, bus)

mux := http.NewServeMux()
local.MountReceiver(mux, recv, store) // /claim, /events, /control, /complete
go http.ListenAndServe("127.0.0.1:8080", mux)

// Register the run and mint its single-use token.
token, err := local.NewToken()
store.Create(runID, run.Spec{
    RunID: runID,
    Agent: run.AgentSpec{Name: "exec", Command: "sh", Args: []string{"-c", "make test"}},
}, token)

// Pick a backend and start the cell. The guest finds the receiver
// through these three environment variables and nothing else.
rt := fake.New(fake.WithCommand([]string{"/usr/local/bin/umbra-guest"}))
// ...or docker.New(host), linux.New(cfg), qemu.New(cfg) — same interface.

handle, err := rt.Start(ctx, runtime.Spec{
    Name: runID,
    Env: []string{
        "UMBRA_RUN_URL=http://127.0.0.1:8080",
        "UMBRA_RUN_ID=" + runID,
        "UMBRA_RUN_TOKEN=" + token,
    },
    Resources: runtime.Resources{MemoryMax: 2 << 30, CPUMax: 1.5, PidsMax: 512},
})

The stream replays from the beginning, so a client that connects late still gets the whole transcript. Disconnecting doesn’t leak the cell: a per-run supervisor tears it down on the run’s own done signal, whether or not anyone is still streaming. To put the factory on a different machine, run umbra-node there and point the daemon at it — that’s a topology change, not a code change.

The run protocol

Four routes, all nested under the run id.

The run id in the path is the only thing that names anything, so scoping is structural — not a parameter someone has to remember to validate.

  1. guest → receiver

    claim POST /v1/runs/{id}/claim

    Single-use, enforced with a compare-and-set from pending to running. A second claim is refused, whether it comes from a guest that restarted or from anyone who read the token out of the cell.

  2. guest → receiver

    events POST /v1/runs/{id}/events

    NDJSON, sequence-numbered, idempotently resumable. A guest that reconnects after a network blip picks up from its own counter, and duplicates collapse. Readers tail the same rows, so there is no separate log pipeline and no Logs() on the runtime.

  3. receiver → guest

    control GET /v1/runs/{id}/control

    Cancellation arrives on this SSE stream, and the guest still gets its final events out: the send path detaches from the run’s context, so a cancelled run is still fully observable right up to the end.

  4. guest → receiver

    complete POST /v1/runs/{id}/complete

    Idempotent, and it carries the exit status.

Two security boundaries

The cell is the security boundary for the filesystem and the network. The run token is the security boundary for the API. The guest runs your workload unconfined and can read its own environment — and therefore its own token — so in production that token has to be worthless outside its own run: resolve it to exactly one run, keep the TTL short, store only a hash, expose only run-scoped routes, and allow a single claim.

The bundled run/local store is a development reference, not that production credential store: it keeps plaintext tokens in process memory and has no TTL. Those stronger requirements belong in your own auth and durable-store layer.

Scope

What Umbra is not.

The boundary is deliberate. Everything below is either something you already run, or something that belongs in your platform rather than in the substrate underneath it.

  • Not a scheduler
  • Not a queue
  • Not a placement engine
  • Not an identity model
  • No hosted control plane
  • No dashboard to log into

Umbra never models your domain. Extension routes ride on the same run-scoped mux, and your own overlay travels as the raw claim body — one JSON object, decoded twice, wire-compatible by shared tags. None of your fields land on Umbra’s Spec.

Get started

Thirty seconds, one command.

You need Go 1.26 or newer, and nothing else — no Docker, no root, no VM host. That one command exercises the whole system end to end: the factory, a receiver collecting what the run emits, and a guest process inside the cell, all in one binary over loopback.

terminal
# from a checkout of the repository
go run ./cmd/umbra-run --local --cmd 'echo hello from the sandbox'

hello from the sandbox
run 01K8Z7Q4XJ4M0VQ4Z2N1B9E7TQ: succeeded

# or link it as a library
go get github.com/nytra-io/umbra

Security warning: this quickstart uses the fake backend, an unisolated local subprocess. Run only code you trust.