The Kit v3 descriptor specification, its BuildKit frontend, and the conformance suites that judge both a published Kit and a runtime that claims to support one.
A Dockerfile answers everything about the software itself: how it is built, what gets packaged, and how it starts — entrypoint, command, user, environment. That answer was worth a decade of tooling, because the same bits build, pull, and run the same way on every machine that has ever heard of an OCI image.
Then we started shipping agents, and an agent is not a static application workload. A container isolates an application: a thing that runs, does its job, and touches only what it was handed. An agent is an actor. It decides what to do next and then does it — to your filesystem, your network, your databases, your cloud account — with authority you granted on purpose.
That authority is not a flaw to be closed off; it is the point. An agent that cannot install a dependency, reach an API, or hold a credential cannot do the job. But every grant trades away a piece of the isolation you were counting on, which is how the capabilities that make an agent useful end up dissolving the walls around it.
So the boundary has to move out: from the container to the containment — the machine, the filesystem, the network, the credentials, everything the agent can reach or change. A sandbox supplies the part of that a model cannot argue with: its own kernel, a boundary that is enforced rather than agreed to. But an empty sandbox is not an environment. Something still has to say which harness runs, which tools and MCP servers it gets, which skills and instructions shape it, and exactly what it is allowed to touch.
None of that is a Dockerfile's question. It specifies the inside of the
image completely and the outside not at all, so the other half has lived
in docker run flags, a Compose file, a CI config, an onboarding doc, and
whatever the person who set it up still remembers — outside the artifact,
unversioned, and unreviewable. A Kit brings it in: the content and the
authority it asks for, in one image, under one digest.
Which makes authority diffable. When the next version of an agent asks for another credential or another network destination, that is not a software update — it is a change in authority. It shows up in the diff, it can stop for approval, and it travels with the thing it describes wherever that thing runs. Which is also why this is a specification and not a product feature: a Kit that stops being useful because you ran it somewhere else is not a trust boundary, it is a lock-in.
Dockerfiles made software reproducible. Kits make authority reproducible.
Important
This specification is experimental. It is published to be used and argued with, and it will keep moving as implementers find gaps.
Moving is meant to stay additive. Capability types carry their own
version for exactly this reason: a contract that has to change ships as
@2 alongside the @1 it joins, both stay published, and a descriptor
names the one it was written against — so Kits that resolve today are
expected to keep resolving. A descriptor-wide schemaVersion bump is
the last resort for what that lever cannot express.
A final version is targeted for Q4 2026, after community feedback. That feedback is the point — if a Kit you want to write cannot be expressed, or a runtime duty is stated in a way you cannot implement or check, please open an issue.
A Kit packages a piece of a working environment — a tool, an agent, a service — so that a runtime can install it, grant it what it needs, and combine it with other Kits without knowing anything about it in advance.
There is no Kit media type, no artifact type, no sidecar file. A Kit is one
OCI image where the manifest annotation
vnd.docker.sandbox.kit.descriptor carries the Kit's declarations and
the layers carry its content.
Everything follows from that. A Kit pulls with docker pull, gets
inspected with regctl, and can be FROMed like any other image. A
registry that has never heard of Kits stores one correctly, and an engine
that ignores the annotation still runs it as an ordinary image. The
declarations travel with the content, in the same artifact, under the
same digest: there is no second place to look and nothing to keep in sync.
| Kind | Its layers are | Per composition |
|---|---|---|
workload |
A root filesystem. The image config supplies entrypoint, cmd, env, user, workdir. | Exactly one |
mixin |
An overlay that lands on the workload's filesystem. May carry no content at all and only declare. | Zero or more |
A workload is the thing that runs. Mixins add to it — a CLI, a credential binding, a network rule, a piece of context for an agent.
A few principles decided most of the design. They are worth reading before the details, because nearly every rule in the specification is one of these applied to a specific case.
Ride the ecosystem, don't extend it. A Kit introduces no new media type, no artifact type, and no sidecar file, so every registry, scanner, signer, and mirror already handles one correctly. The cost of a new artifact format is not writing it — it is the decade of tooling that does not know about it.
One artifact, one digest. Declarations live in the manifest of the image they describe, so a Kit cannot be half-updated: pinning the digest pins the policy, the content, and the metadata together. A separate file describing an image is a second source of truth, and second sources drift.
Declare only what images cannot already express. The descriptor carries no name, no image reference, no entrypoint or env. Identity is the reference you consume the Kit by; the runtime contract is the image config. Restating either would create two answers to one question, and one of them would be stale.
One model for every ask. Resource grants and engine-executed behaviors
are the same kind of request — a typed, versioned entry in capabilities.
A host reviews one list to decide what a Kit may do, and a Kit has one way
to ask, so support is a question with a single answer rather than a
patchwork of unrelated fields.
Fail closed, and fail early. Unknown descriptor fields are errors rather than ignored, an unsatisfied requirement stops resolution, and a required capability the host cannot grant refuses the launch. A permission silently dropped is indistinguishable from one never requested, and the failure would surface as behavior instead of an error.
Composition is a function, not a sequence. A resolved Kit set is ordered by its dependency graph rather than by the order arguments were typed, so the same set always composes to the same image. That is what makes a composition lockable, reproducible, and worth caching.
The grammar declares; runtimes behave. The descriptor states what is wanted and never how a host provides it, which is why the capability pages — not the grammar — are normative for runtime behavior. A host that cannot implement a capability refuses it honestly instead of approximating it.
Let types evolve on their own clock. The @1 in a capability type
versions that type's config schema, so a capability can change shape without
a descriptor grammar bump and hosts can support types the grammar has never
heard of.
For a worked tour — a real Kit, its capabilities, and how a set composes — see docs/kit-intro.md.
-
docs/spec/SPEC-v3.md— the normative specification: the descriptor grammar, the OCI layout, and one page per well-known capability type detailing the runtime behavior a supporting runtime implements. -
spec/— descriptor types, strict decoding, validation, arg expansion, and capability/version parsing. Import asgithub.com/docker/sandbox-kit-spec/v3/spec; this package is the single source of truth for the grammar, and the Docker Sandboxes runtime imports it to read published Kits. -
schema/kit.schema.json— the descriptor grammar as a JSON Schema, for editor validation and completion. Point the yaml-language-server at it with a modeline on the descriptor's second line (the# syntax=line must stay first):# syntax=docker/sandbox-kit:3 # yaml-language-server: $schema=https://raw.githubusercontent.com/docker/sandbox-kit-spec/main/schema/kit.schema.json
The Go validator remains the validator of record;
spec/schema_test.gopins the schema's constants (need types, enums, field regexes) to the spec package so the two cannot drift silently. -
schema/capabilities/— one schema file per well-known capability type, named by the type (com.docker.sandbox/volume@1.schema.json): the@versionin a need type names an addressable config schema, and these are those documents — each type owns its config shape, andkit.schema.jsoncomposes them by reference. Config-less types (privileged@1,kit-registry@1) reject every config value. A host-specific type's author publishes the equivalent under their own namespace. -
cmd/frontend/— the BuildKit gateway frontend dispatched by the descriptor's first line,# syntax=docker/sandbox-kit:3. -
skills/— tool-agnostic agent skills for authoring a v3 Kit and migrating a v2 one; skills/README.md says how to wire them into an agent.
A guided tour from a published Kit to the local edit-and-run loop. Running
Kits takes the sbx CLI (public install via
Docker Docs /
sbx-releases). The current stable
sbx release supports Kits v3 in both local and cloud mode (sbx run …
and sbx --cloud …). Cloud sandboxes use the same Kit references; they do
not mount a host workspace.
Published Kits live as ordinary images on registries. Browse Verified
Publisher Sandbox Kits on
Docker Hub.
The docker org publishes v3 Kits (this specification); the sbx org
still carries the older v2 line — do not mix the two. Or author your
own from this checkout.
1. Install sbx.
# macOS
brew trust docker/tap
brew install docker/tap/sbx
# Windows
winget install -h Docker.sbx
# Ubuntu (sbx only; or use SBX=1 with get.docker.com for Engine + sbx)
curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh
sudo apt install docker-sbx
sudo usermod -aG kvm $USER
# re-login (or: newgrp kvm) so /dev/kvm is usable before sbx run
# Or download platform artifacts from
# https://github.com/docker/sbx-releases/releases
sbx loginThere is nothing to install for the frontend itself:
docker/sandbox-kit:3 is
on Docker Hub, and BuildKit pulls it when it reads the # syntax= line.
2. Run a published Kit. No build, no push — compose a workload with a
mixin from the docker org:
sbx run docker/sbx-kit-shell:1.0.0 --kit docker/sbx-kit-claude-mixin:2.1.281 .
# same references in cloud (no local workspace path):
# sbx --cloud run docker/sbx-kit-shell:1.0.0 --kit docker/sbx-kit-claude-mixin:2.1.281shell is a minimal workload; claude-mixin overlays Claude Code onto it.
More v3 Kits from the docker org are on
Docker Hub
(filter to that org — sbx/* there is still v2).
3. Push the hello Kit and run it. From here on you will build Kits from this checkout. The sandbox runtime resolves Kit images from registries, so an image that only exists in Docker Desktop's local store cannot run — push to a namespace you own (Docker Hub works), or use Kit directories (step 6), which need no registry at all.
Set your registry namespace once and log in:
export KIT_REGISTRY=docker.io/<your-hub-username>
docker loginhello is the smallest workload Kit: a full agent environment with a
startup hook, guidance, and a network policy. task kit:push builds
examples/hello with the Kit frontend and pushes it as an ordinary image:
task kit:push KIT=hello TAG=1.0.0 REGISTRY=$KIT_REGISTRY
sbx run $KIT_REGISTRY/sbx-kit-hello:1.0.0 .Inside the sandbox, the Kit is self-describing: cat /usr/share/sandbox/kit/hello/kit.yaml shows the published descriptor,
kit.dockerfile the recipe that produced the content, and
cat /var/log/sbx-kit-startup.log shows the startup hook's run.
(task kit:build KIT=hello builds without pushing — useful for iterating
on a descriptor until it validates, but the result can't run in sbx
until it is pushed or consumed as a directory.)
4. Add the tool Kit — composition. tool is a content-bearing mixin
that requires hello: the resolver validates the set is coherent, orders
provider before requirer, and the assembler merges the layers into one
image (cached by the lock — the second run reuses it).
task kit:push KIT=tool TAG=1.0.0 REGISTRY=$KIT_REGISTRY
sbx run $KIT_REGISTRY/sbx-kit-hello:1.0.0 --kit $KIT_REGISTRY/sbx-kit-tool:1.0.0 .cat /time.txt inside the sandbox shows tool's startup hook ran on
hello's filesystem.
5. Add the gh Kit — binary content. gh is a mixin whose overlay
carries the GitHub CLI as a pinned Nix closure, plus a phased network
policy and a proxy-managed credential. Composing it drops a real binary
into the workload's filesystem:
task kit:push KIT=gh TAG=2.72.0 REGISTRY=$KIT_REGISTRY
sbx run $KIT_REGISTRY/sbx-kit-hello:1.0.0 --kit $KIT_REGISTRY/sbx-kit-gh:2.72.0 .Inside: gh --version works (/usr/local/bin/gh resolves into the
overlay's /nix/store), and with a github secret bound on the host
(sbx secret set github), gh api user authenticates through the
proxy — the container only ever sees a sentinel token.
6. The local loop — no registry, no push. Point sbx run at the Kit
directories and the runtime builds them on demand, keyed by source hash,
and loads the results straight into the sandbox runtime:
cd examples
sbx run ./hello --kit ./gh .Edit hello/hello.yaml (say, add an allow entry) and re-run: only hello
rebuilds; unchanged Kits reuse the cache. SBX_KIT_BUILDER=sandbox moves
these builds into a dedicated builder sandbox (sbx kit builder status
shows it) instead of the host engine. Created with
--kit-arg buildkitExpose=true, that sandbox also publishes BuildKit so
the host's own docker buildx can attach to it:
# After the builder sandbox is up, read the published buildkit port from
# `sbx kit builder status`, then:
docker buildx create --name sbx-remote --driver remote \
tcp://127.0.0.1:<host-port> --use
cd claude && docker buildx build --builder sbx-remote . -f claude.yaml \
--output type=cacheonlyThat endpoint builds as root and takes no credential, and port@1 only
says a host binding should be loopback — so it stays off unless asked
for, and is worth turning on only where you know the runtime binds
loopback.
The in-sandbox BuildKit port is 3330 and the engine-store
volume defaults to 20 GiB (--kit-arg volumeSize=…);
--kit-arg buildkitPort=… moves the port. Raising size does not grow
an already-formatted volume —
recreate the builder sandbox once after changing it. When a Kit is ready
to share, step 3's task kit:push is the whole publishing story.
docker buildx build . -f claude.yaml -t docker.io/me/claude-kit:2.1.0A workload Kit's companion must build on a base that provides the runtime's
platform floor — bash, the agent user (uid 1000), git, a CA store — which
the hardened dhi.io/sbx-templates:* images carry; the workloads under
examples/ build on them, except devin and wordpress, whose recipes
say why. A Kit built on a bare distro image builds fine
but fails at agent launch.
A Kit's content recipe lives in one of three places: a companion
<stem>.dockerfile next to the descriptor, an inline build: block in
the descriptor carrying literal Dockerfile text (see examples/motd for
the single-file form), or a kits: list naming other Kits (see
examples/team — kind: set, below). They are mutually exclusive; a
kind: mixin Kit with none of them is declaration-only.
The frontend finds the companion claude.dockerfile by naming convention,
builds it through dockerfile.v0 (honoring the companion's own # syntax=
directive if it names a foreign frontend), validates the descriptor against
the resulting image, stages guidance content into the image, and attaches the
published descriptor as a manifest annotation. A kind: mixin descriptor
with no companion produces a declaration-only image whose single layer carries the published descriptor.
Build-phase args are passed by their Kit-arg name and validated before the
Dockerfile sees them under the declared buildArg name:
docker buildx build . -f gh.yaml --build-arg version=2.99.0 -t gh-kit:2.99.0A composition worth sharing does not have to stay a command line. A
kind: set descriptor names other Kits in kits:, and the frontend
resolves them, checks the set is coherent, and merges their layers and
declarations into one ordinary Kit:
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: set
displayName: Team environment
version: "1.0.0"
kits:
- ref: docker.io/me/sbx-kit-shell:1.0.0
- ref: docker.io/me/sbx-kit-gh:2.72.0# The registry the set is pushed to and the one it resolves its Kits
# from are separate answers: the first names where this artifact goes,
# the second is baked into the references it merges.
task kit:push KIT=team TAG=1.0.0 REGISTRY=$KIT_REGISTRY \
BUILD_ARGS="--build-arg registry=$KIT_REGISTRY"
sbx run $KIT_REGISTRY/sbx-kit-team:1.0.0 .The result is a Kit like any other — nothing in the runtime knows it was a
set — and kind: set never reaches consumers: publishing derives
workload or mixin from the Kits it lists. The kits: list survives in
the published descriptor, pinned by digest, so the artifact records what it
was built from; inside the sandbox, ls /usr/share/sandbox/kit/ enumerates
each of their staged sources. They must be published references: the set
has to be resolvable from its manifest alone, not from the directory it was
written in. See §3.4 for the
grammar and §9.5 for what the
merge does with each declaration.
Multi-platform builds produce an image index with the descriptor annotation on every platform manifest:
docker buildx build . -f claude.yaml --platform linux/amd64,linux/arm64 --push \
-t docker.io/me/claude-kit:2.1.0# syntax=docker/sandbox-kit:3 resolves to
docker/sandbox-kit on
Docker Hub, which BuildKit pulls and caches on the first build that names
it.
Every release also publishes its exact version — docker/sandbox-kit:3.0.0-m.3
— which is what a build names when it must resolve the same frontend
every time. The floating 3 moves only when a stable 3.X.Y is
released, never for a milestone, so until v3 has one the two tags can
name different builds.
Building it yourself is for working on the frontend, not for using it. An image under that tag in the local store is what a local build resolves, without a registry pull, which is what makes an unreleased change testable:
docker build -t docker/sandbox-kit:3 .While iterating, task frontend:dev builds under a fresh tag and prints
the # syntax= line to paste, because BuildKit caches frontend
resolution per reference and a reused tag can keep dispatching the
previous binary. task frontend:push publishes, guarded.
Two suites judge conformance, and docs/spec/conformance.md
specifies what each one means. From this checkout, task runs the TCK via
go run. Released kit-tck binaries are attached to each
GitHub Release
(linux/darwin/windows, amd64/arm64).
task tck:kit REF=docker.io/me/sbx-kit-gh:1.0.0 # is this artifact a conforming Kit?
task tck:runtime ADAPTER=./my-adapter # does this runtime behave as the pages require?The Kit checks also run inside the frontend during docker buildx build,
so a Kit built here cannot be published malformed. Running them against a
published artifact catches what only the exporter and the registry can do
to it — and judges Kits this frontend did not build.
A registry on loopback is reached over plain HTTP without asking, so a
throwaway registry:2 works as a target while iterating; kit-tck validate --plain-http <ref> says so explicitly for a TLS-less registry anywhere
else. An artifact that never reached a registry is judged in place from
the --output type=oci directory:
task tck:kit:layout DIR=/tmp/out TAG=sha256:...A runtime is tested through an adapter: an executable implementing seven verbs. Any language will do.
Both suites report the same way: every finding names the statement it
judged and links to where the specification says it, and the run ends in
a tally of what was checked. Nothing is printed for a check that passed —
--verbose lists those too — and --format json is the same run as data,
each finding carrying its spec link, for a pipeline that annotates rather
than reads. Color follows the terminal and NO_COLOR; --color settles
it either way.
kit-tck validate docker.io/me/sbx-kit-gh:1.0.0 --verbose
kit-tck validate docker.io/me/sbx-kit-gh:1.0.0 --format jsonkit-tck inspect reads a Kit without judging it: the descriptor from its
manifest annotation, and the content recipe it staged at
/usr/share/sandbox/kit/<stem>/kit.dockerfile — whichever way that recipe
was authored. It takes the same --layout and --plain-http as validate.
kit-tck inspect docker.io/me/sbx-kit-gh:1.0.0 # both, as YAML and Dockerfile
kit-tck inspect docker.io/me/sbx-kit-gh:1.0.0 --dockerfile > gh.dockerfile
kit-tck inspect docker.io/me/sbx-kit-gh:1.0.0 --format jsontask validate # gofmt + go vet
task test # all Go testsSee CONTRIBUTING.md — in particular the note on changing the grammar, which spans the Go types, the JSON Schema, the normative spec, and the examples together.
Licensed under the Apache License, Version 2.0.
