Guide

Make your Sandbox kits configurable

  • Intermediate
  • 20 min
  • Published
  • Verified

What you'll learn

  • Declare kit arguments with defaults, required values, and enum or pattern constraints
  • Create templated capability config and generated files
  • Supply argument values with --kit-arg, a handle prefix, an args file, or an sbxenv file
  • Choose between build-phase and create-phase arguments
  • Pin or re-expose a component's arguments in a kit set

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

Your platform team wrote a kit that connects agents to the company’s internal API. It works well, for the payments team, against staging. Then the search team asks for it, and someone else needs it pointed at production for a release-day investigation. The obvious fix is to copy the kit and edit the copy. Two months later there are five forks, each with a slightly different network rule, and nobody knows which one is current.

Kit arguments solve this. You publish one kit that declares the values that may vary, with defaults and constraints, and each sandbox supplies its own answers when it is created. The published kit never changes. Each sandbox gets its own expanded copy.

This guide starts with the short version: how to declare an argument, use it, and supply a value. The rest is a worked example that turns a kit with hardcoded values into one every team can use, and covers the options in more depth.

Kit arguments in brief

An argument is a named value that a kit declares and each sandbox answers when it’s created.

Declare it in the descriptor’s args block:

args:
  environment:
    default: staging
    enum: [staging, production]
    description: Which Acme API environment the agent may call

Use it anywhere in the descriptor with ${{ kit.args.<name> }}, including inside capability config:

capabilities:
  - type: com.docker.sandbox/network-policy@1
    config:
      runtime:
        allow:
          - api.${{ kit.args.environment }}.acme.example.com:443

The ${{ opener isn’t valid shell, so it never collides with $VAR or ${VAR} in a hook command. A reference to an argument the kit doesn’t declare, such as ${{ kit.args.enviroment }}, is a validation error, not an empty string.

Supply a value when you create the sandbox:

$ sbx create docker/sbx-kit-shell:1.0.0 --kit ./acme-api --kit-arg environment=production

Leave it out and the default applies. Values can also come from an args file, an sbxenv file, or a kit set that pins them.

Constrain it with the argument’s other fields:

Field What it does
default The value used when none is supplied. Can’t be combined with required.
required: true Refuses to create the sandbox without a value.
enum The exact set of accepted values. Can’t be combined with pattern.
pattern A regular expression the whole value must match.
env Also sets the value as this environment variable. Otherwise an argument stays private.
buildArg Resolves the value when the kit image is built, and passes it to the Dockerfile.
description Tells whoever supplies the value what it’s for.

The published kit keeps its ${{ kit.args.* }} references. When a sandbox is created, sbx checks the values against the constraints and expands them into an effective descriptor for that sandbox alone. That’s how one kit serves many sandboxes with different values. Argument values are plain text, so never use one for a token or password. Declare a credential instead, as in Connect to services without exposing credentials.

Worked example: one kit for every team

The rest of this guide takes a kit with hardcoded values and makes it configurable. You will:

  • declare a required argument, a defaulted argument with an enum, and a pattern that keeps values safe;
  • template those arguments into a network rule, a generated config file, and an environment variable;
  • create sandboxes with different values and prove each one got its own configuration and policy;
  • see a missing or invalid value refused before the sandbox exists;
  • record the values for a project in an sbxenv file, and see how a kit set pins or re-exposes them.

You need:

The API in this guide, acme.example.com, is fictional. The kit mechanics are the same for any real service.

The starting point: a kit with hardcoded values

Create a directory named acme-api and save this descriptor in it as acme-api/acme-api.yaml:

# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Acme API access
description: Lets the agent call the Acme internal API
version: "1.0.0"

capabilities:
  - type: com.docker.sandbox/network-policy@1
    config:
      runtime:
        allow:
          - api.staging.acme.example.com:443

  - type: com.docker.sandbox/lifecycle@1
    config:
      files:
        - path: /home/agent/.config/acme/config.json
          mode: "0644"
          content: |
            {
              "team": "payments",
              "environment": "staging",
              "baseUrl": "https://api.staging.acme.example.com"
            }

It is a declaration-only mixin: no Dockerfile, just two capability requests. The network rule lets the agent reach the staging API. The lifecycle files entry writes a config file the team’s scripts read before the agent starts.

Three values in this file belong to one team in one situation: the team name, the environment, and, because it is derived from the environment, the API host. Those are the values to turn into arguments.

Declare the arguments

Start by declaring the arguments. In acme-api/acme-api.yaml, add this args block between version and capabilities:

args:
  team:
    required: true
    pattern: "^[a-z][a-z0-9-]*$"
    description: Team slug, as used in the Acme API (for example, payments)
  environment:
    default: staging
    enum: [staging, production]
    description: Which Acme API environment the agent may call
    env: ACME_ENV

Each part of the args block is doing a specific job:

  • team is required: true. There is no sensible default team, so the kit refuses to guess. A sandbox cannot be created without a value.
  • team has a pattern. The value lands inside a JSON string in the config file. Kit expansion carries a value verbatim, so a value containing a " would produce broken JSON. The pattern, matched against the whole value, rules that out. Treat every constraint as a statement about where the value lands.
  • environment has a default and an enum. Most people want staging and should not have to say so. The enum is the exact accepted set, so a typo like prod fails instead of producing a network rule for a host that does not exist.
  • environment has env: ACME_ENV. Arguments are private by default. A value reaches the running container’s environment only if you opt in with env:. team has no env:, so it appears in the config file and nowhere else.

Nothing in the kit uses these arguments yet. The capabilities still have the hardcoded values, which the next step replaces.

Use the arguments in the kit

Now replace each hardcoded value with a reference to an argument. In the capabilities section of acme-api/acme-api.yaml, make these changes:

  • In the network policy, replace api.staging.acme.example.com:443 with api.${{ kit.args.environment }}.acme.example.com:443.
  • In the lifecycle file’s content, replace payments with ${{ kit.args.team }}, and both occurrences of staging with ${{ kit.args.environment }}.

The complete acme-api/acme-api.yaml now looks like this:

# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Acme API access
description: Lets the agent call the Acme internal API
version: "1.0.0"

args:
  team:
    required: true
    pattern: "^[a-z][a-z0-9-]*$"
    description: Team slug, as used in the Acme API (for example, payments)
  environment:
    default: staging
    enum: [staging, production]
    description: Which Acme API environment the agent may call
    env: ACME_ENV

capabilities:
  - type: com.docker.sandbox/network-policy@1
    config:
      runtime:
        allow:
          - api.${{ kit.args.environment }}.acme.example.com:443

  - type: com.docker.sandbox/lifecycle@1
    config:
      files:
        - path: /home/agent/.config/acme/config.json
          mode: "0644"
          content: |
            {
              "team": "${{ kit.args.team }}",
              "environment": "${{ kit.args.environment }}",
              "baseUrl": "https://api.${{ kit.args.environment }}.acme.example.com"
            }

Create a sandbox without a value

Arguments that have no buildArg: are create-phase arguments: sbx resolves them when it creates the sandbox. Use the local directory as a mixin on Docker’s published v3 shell workload, and leave out the required team:

Host machine

sbx create docker/sbx-kit-shell:1.0.0 --name acme-payments --kit ./acme-api
KITS
  ○ ./acme-api — building…
  ✓ ./acme-api — built (3s)
      sbx-kit-src:acme-api-4b9e38e875ce
error: resolve kits: kit "./acme-api": kit arg "team" is required and no value was supplied

This failure is intended. The kit declared that team has no safe default, so nothing was created. Supply the value with --kit-arg:

Host machine

sbx create docker/sbx-kit-shell:1.0.0 --name acme-payments --kit ./acme-api --kit-arg team=payments
KITS
  ○ ./acme-api — building…
  ✓ ./acme-api — built (1s)
      sbx-kit-src:acme-api-6a75564307ed

SANDBOX
  Name:       acme-payments
  Agent:      sbx-kit-shell
  Kits:       docker/sbx-kit-shell:1.0.0
              ./acme-api
  Workspace:  none · no workspace bind mount
  Image:      docker.io/docker/sbx-kit-shell
  CPU:        16
  Memory:     32 GiB

IMAGE
  ✓ image — ready

AGENT
    assembling kit image from 2 kits
    pulling kit image /home/moby/acme-api/acme-api
  ✓ assembled kit image (3 layers)
    setting 1 environment variable
    writing 1 init file

  ✓ Created sandbox acme-payments
      try: sbx run --name acme-payments

You supplied team but not environment, so environment took its default, staging.

Verify what the arguments produced

Look at the generated config file:

Host machine · acme-payments

sbx exec acme-payments -- cat /home/agent/.config/acme/config.json
{
  "team": "payments",
  "environment": "staging",
  "baseUrl": "https://api.staging.acme.example.com"
}
sbx exec acme-payments -- printenv ACME_ENV
staging
sbx exec acme-payments -- sh -c 'printenv ACME_TEAM || echo "ACME_TEAM is not set"'
ACME_TEAM is not set

ACME_ENV is set because environment declares env: ACME_ENV. There is no ACME_TEAM, and no other variable holding the team name. An argument without env: stays out of the environment.

Now check that the argument reached the network policy. The staging API is allowed, and production is not:

Host machine · acme-payments

sbx exec acme-payments -- curl -s https://api.staging.acme.example.com/v1/health
{"status":"ok","environment":"staging"}
sbx exec acme-payments -- curl -s https://api.production.acme.example.com/v1/health
Blocked by network policy: domain api.production.acme.example.com

One kit, one value, and the sandbox can reach exactly one environment.

Finally, look at the descriptor the sandbox recorded for the kit:

Host machine · acme-payments

sbx exec acme-payments -- grep acme.example.com /usr/share/sandbox/kit/acme-api/kit.yaml
          - api.${{ kit.args.environment }}.acme.example.com:443
              "baseUrl": "https://api.${{ kit.args.environment }}.acme.example.com"

The references are still there. The kit itself is never rewritten. sbx derives an effective descriptor for this one sandbox by expanding the values, and that expanded form is what it enforces.

Create a second sandbox with different values

Now create a sandbox for a production investigation. Arguments are recorded when a sandbox is created, so a different configuration means a new sandbox with a new name.

First, try the common typo:

Host machine

sbx create docker/sbx-kit-shell:1.0.0 --name acme-payments-prod --kit ./acme-api --kit-arg team=payments --kit-arg environment=prod
KITS
  ○ ./acme-api — building…
  ✓ ./acme-api — built (1s)
      sbx-kit-src:acme-api-eeac2c9a1aae
error: resolve kits: kit "./acme-api": kit arg "environment": value "prod" is not one of [staging production]

The enum refused it before anything was created. Use the accepted value, and this time prefix the argument with the kit’s handle, acme-api:

Host machine

sbx create docker/sbx-kit-shell:1.0.0 --name acme-payments-prod --kit ./acme-api --kit-arg team=payments --kit-arg acme-api.environment=production
KITS
  ○ ./acme-api — building…
  ✓ ./acme-api — built (1s)
      sbx-kit-src:acme-api-3e2310800e3e

SANDBOX
  Name:       acme-payments-prod
  Agent:      sbx-kit-shell
  Kits:       docker/sbx-kit-shell:1.0.0
              ./acme-api
  Workspace:  none · no workspace bind mount
  Image:      docker.io/docker/sbx-kit-shell
  CPU:        16
  Memory:     32 GiB

IMAGE
  ✓ image — ready

AGENT
    assembling kit image from 2 kits
    pulling kit image /home/moby/acme-api/acme-api
  ✓ assembled kit image (3 layers)
    setting 1 environment variable
    writing 1 init file

  ✓ Created sandbox acme-payments-prod
      try: sbx run --name acme-payments-prod

An unprefixed --kit-arg applies to every selected kit that declares that argument. When you combine several kits, two of them may both declare an argument named environment. The <handle>.<argument>=<value> form targets one kit, and it overrides an unprefixed value. For a local directory, the handle is the directory name. For a published image, it is the last part of the repository name.

Confirm that this sandbox has its own configuration:

Host machine · acme-payments-prod

sbx exec acme-payments-prod -- cat /home/agent/.config/acme/config.json
{
  "team": "payments",
  "environment": "production",
  "baseUrl": "https://api.production.acme.example.com"
}
sbx exec acme-payments-prod -- curl -s https://api.production.acme.example.com/v1/health
{"status":"ok","environment":"production"}

Both sandboxes came from the same kit directory. Neither knows the other exists.

Record the values for a project

Typing --kit-arg on every create works for one-offs. For values a project always uses, record them where the project lives.

An args file. Put one name=value per line, with an optional handle prefix, and pass it with --kit-args-file:

acme-api.team=payments
acme-api.environment=staging
$ sbx create docker/sbx-kit-shell:1.0.0 --name acme-payments \
    --kit ./acme-api --kit-args-file ./acme.args

A --kit-arg on the same command line overrides the file.

An sbxenv file. A sandbox environment file describes the whole environment a project consumes. An object entry under kits takes the kit’s arguments under args:

schemaVersion: "1"
name: acme-payments
agent: sbx-kit-shell
kits:
  - docker/sbx-kit-shell:1.0.0
  - source: ./acme-api
    args:
      team: payments
      environment: staging
workspace: .

agent is the workload’s handle, and the reference itself goes under kits. Commit this file next to the project, and sbx env run creates the same sandbox for everyone on the team. The environment file can also declare its own inputs and forward them to a kit, as in environment: ${{ env.args.channel }}. See Parameterize an environment.

This is the division of labour to aim for: the kit declares what can vary and what values are acceptable, and the environment file answers those questions for one project.

Choose the phase: build or create

Everything so far used create-phase arguments. An argument can instead resolve when the kit image is built, by declaring buildArg:. Tool versions are the usual case, because the version decides what is downloaded into the image. This excerpt is adapted from the GitHub CLI example in the spec:

args:
  version:
    default: "2.98.0"
    pattern: '^[0-9]+\.[0-9]+\.[0-9]+$'
    description: GitHub CLI release to install
    buildArg: GH_VERSION

provides: ["gh@${{ kit.args.version }}"]

The kit’s Dockerfile reads ARG GH_VERSION to download that release. When you build, you pass the value by its kit-argument name, and the frontend validates it against the pattern before the Dockerfile sees it as GH_VERSION:

$ docker buildx build . -f gh.yaml --build-arg version=2.99.0 -t gh-kit:2.99.0

The build expands every reference to a build-phase argument into the published descriptor, so the published kit says gh@2.99.0. That value is then fixed: changing it means building and publishing a new image.

Create phase (no buildArg:) Build phase (buildArg:)
Who answers Whoever creates the sandbox Whoever builds the image
Supplied with --kit-arg, --kit-args-file, sbxenv docker buildx build --build-arg
Published descriptor Keeps the ${{ kit.args.* }} reference Has the value baked in
Typical use Team, environment, model, feature toggles Tool versions, anything in a RUN
Reaches the container via env:, if you opt in The image contents

An argument resolves in exactly one phase, so env: and buildArg: are mutually exclusive. To install a tool at a configurable version, see Add tools and runtimes to a sandbox.

Pin or re-expose arguments in a kit set

A kit set publishes several kits as one reference. When the payments team always uses the shell workload with acme-api, a set saves everyone the command line. The set also decides which of its components’ arguments users may still change.

A set’s components must be published references. This example assumes you pushed acme-api as moby/acme-api:1.0.0. See Publish and share your Docker Sandbox kits.

# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: set
displayName: Acme payments environment
version: "1.0.0"

args:
  environment:
    default: staging
    enum: [staging, production]
    description: Which Acme API environment the agent may call

kits:
  - ref: docker/sbx-kit-shell:1.0.0
  - ref: moby/acme-api:1.0.0
    args:
      team: payments
      environment: ${{ kit.args.environment }}

The two component arguments get different treatment:

  • team: payments is pinned. A literal value answers the argument when the set is published. Users of the set cannot change it.
  • environment is re-exported. A value that is exactly one ${{ kit.args.* }} reference passes the question through to the set’s own environment argument. Users answer it with --kit-arg environment=production when they run the set.

A re-exported argument must promise at least what the component promised: required where it was required, defaulted where it had a default, and its enum restated or narrowed. That is why the set repeats default: staging and the full enum. Without them, a user could pass a value that acme-api would have refused, and nothing would be left to refuse it. A set could narrow the choice to enum: [staging] to publish a staging-only environment.

Understand what happened

One published kit, one effective descriptor per sandbox The published acme-api descriptor keeps its argument references and constraints. When sandbox acme-payments is created with team=payments and the default environment, sbx validates the values and expands them into an effective descriptor that allows api.staging.acme.example.com. When acme-payments-prod is created with environment=production, it gets its own effective descriptor that allows api.production.acme.example.com. The published descriptor is never rewritten. Published kit · acme-api team: required, pattern environment: staging | production allow api.${{ kit.args.environment }}… team=payments validate, expand environment=production validate, expand acme-payments allow api.staging… ACME_ENV=staging config.json: staging acme-payments-prod allow api.production… ACME_ENV=production config.json: production

A configurable kit is a contract with two halves:

  • The kit states the questions and the acceptable answers. The argument names, defaults, and enum or pattern constraints are part of the kit, and they travel with it when it is published and signed. Whoever runs the kit can choose only among values you allowed.
  • Each sandbox supplies its answers once, at creation. sbx checks the values, expands them across the whole descriptor, including capability config, and validates the result strictly. The effective descriptor is what it enforces and what it records, so recreating the sandbox reproduces it exactly.

Some details follow from this model:

  • Expansion is structural. Values are substituted into the parsed document, not into raw text. A value cannot escape the YAML string it lands in and inject new fields. It can still break a format nested inside that string, such as the JSON in the config file, so constrain values with a pattern or enum.
  • A value can widen access. A different environment value opens a different host. The effective descriptor is what sbx judges, so it treats access opened by a value like any other access a kit requests.
  • Not every file is a template. ${{ kit.args.* }} expands in descriptor strings: capability config, hook commands, and lifecycle files content. It does not expand inside files the kit ships, such as an agent-context@1 contentFile. Generate per-sandbox text with a lifecycle file instead.
  • Choosing other values means a new sandbox. A sandbox keeps the arguments it was created with. To use other values, create a sandbox with another name, as you did for production.

Clean up

Remove the two sandboxes:

Host machine

sbx rm acme-payments acme-payments-prod
✓ Removed sandbox acme-payments
✓ Removed sandbox acme-payments-prod

Where to go next

Learning paths

Part of a learning path

This works on its own and is also included in the paths below. Open a path to see the complete route.