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 callsStart. 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
-
Factory
Creates and destroys disposable cells.
-
Receiver
Collects and serves everything the run emits.
-
Guest
Boots inside the cell, runs the workload, streams events out.
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},
})
CLI
# 1. A node: hosts the factory, serves it on a unix socket.
umbra-node serve \
--runtime fake --guest-bin "$(command -v umbra-guest)" \
--listen unix:///tmp/umbra-node.sock
# 2. The daemon: receiver + submit API, dialing that node.
umbra serve --node unix:///tmp/umbra-node.sock --listen 127.0.0.1:8788
# 3. Submit a run and stream its transcript.
umbra submit --daemon http://127.0.0.1:8788 --cmd 'cat /etc/os-release'
# A node behind NAT dials out instead — only the daemon needs a
# routable address, and it already has one: every guest reports to it.
# Both ends read the shared token from the environment. It never goes
# in argv, which every other process on the host can read.
export UMBRA_NODE_TOKEN="$(head -c 32 /dev/urandom | base64)"
umbra serve --listen 127.0.0.1:8788
umbra-node connect --daemon-url http://127.0.0.1:8788 \
--runtime fake --guest-bin "$(command -v umbra-guest)"
HTTP
# Two calls you can make from Python, TypeScript, Rust — anything.
curl -sX POST http://127.0.0.1:8788/v1/submit -d '{"cmd":"echo hi"}'
# → {"id":"01K8Z7Q4XJ4M0VQ4Z2N1B9E7TQ"}
curl -sN http://127.0.0.1:8788/v1/runs/01K8Z7Q4XJ4M0VQ4Z2N1B9E7TQ/stream
# → SSE: every event from the start, then live, then a terminal `done` frame
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.
-
guest → receiver
claim
POST /v1/runs/{id}/claimSingle-use, enforced with a compare-and-set from
pendingtorunning. A second claim is refused, whether it comes from a guest that restarted or from anyone who read the token out of the cell. -
guest → receiver
events
POST /v1/runs/{id}/eventsNDJSON, 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. -
receiver → guest
control
GET /v1/runs/{id}/controlCancellation 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.
-
guest → receiver
complete
POST /v1/runs/{id}/completeIdempotent, 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.
# 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.