# Sandbox CRD (agents.x-k8s.io/v1beta1)

> Field-by-field reference for the core Sandbox resource: PodTemplate, VolumeClaimTemplates, Lifecycle, Replicas (0/1), and Service toggle.

- Repository: kubernetes-sigs/agent-sandbox
- GitHub: https://github.com/kubernetes-sigs/agent-sandbox
- Human wiki: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a
- Complete Markdown: https://grok-wiki.com/public/wiki/kubernetes-sigs-agent-sandbox-c3f2597a654a/llms-full.txt

## Source Files

- `api/v1beta1/sandbox_types.go`
- `api/v1beta1/groupversion_info.go`
- `api/v1beta1/zz_generated.deepcopy.go`
- `k8s/crds/agents.x-k8s.io_sandboxes.yaml`
- `docs/api.md`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [api/v1beta1/sandbox_types.go](api/v1beta1/sandbox_types.go)
- [api/v1beta1/groupversion_info.go](api/v1beta1/groupversion_info.go)
- [api/v1beta1/zz_generated.deepcopy.go](api/v1beta1/zz_generated.deepcopy.go)
- [k8s/crds/agents.x-k8s.io_sandboxes.yaml](k8s/crds/agents.x-k8s.io_sandboxes.yaml)
- [docs/api.md](docs/api.md)
- [controllers/sandbox_controller.go](controllers/sandbox_controller.go)
</details>

# Sandbox CRD (agents.x-k8s.io/v1beta1)

The `Sandbox` resource is the core CRD of agent-sandbox. It defines a single, controller-managed Kubernetes Pod (with optional headless `Service` and `PersistentVolumeClaim`s) intended to host an agent workload. The schema lives in package `v1beta1`, GroupVersion `agents.x-k8s.io/v1beta1`, and is registered through a standard kubebuilder `SchemeBuilder`.

This page is a field-by-field reference for the `Sandbox` resource as defined in the Go API types and rendered into the published CRD. It covers the `PodTemplate` and `VolumeClaimTemplates` inputs, the inline `Lifecycle` block, the `Replicas` 0/1 toggle, the tri-state `Service` field, well-known annotations, and the `status` shape. The semantics tied to each field by the `Sandbox` controller are documented where they affect what the field means in practice.

Sources: [api/v1beta1/sandbox_types.go:130-244](), [api/v1beta1/groupversion_info.go:25-36]()

## Group, Version, and Resource Identity

The CRD is registered as a namespaced resource with the short name `sandbox`. It exposes both the `/status` subresource and the `/scale` subresource, which maps to `.spec.replicas`, `.status.replicas`, and `.status.selector` so that `kubectl scale` works against a single Sandbox.

| Property | Value | Source |
| --- | --- | --- |
| API group | `agents.x-k8s.io` | [api/v1beta1/groupversion_info.go:27]() |
| Version | `v1beta1` | [api/v1beta1/groupversion_info.go:27]() |
| Kind / List Kind | `Sandbox` / `SandboxList` | [k8s/crds/agents.x-k8s.io_sandboxes.yaml:10-13]() |
| Plural / Singular | `sandboxes` / `sandbox` | [k8s/crds/agents.x-k8s.io_sandboxes.yaml:13-16]() |
| Short name | `sandbox` | [api/v1beta1/sandbox_types.go:228]() |
| Scope | `Namespaced` | [api/v1beta1/sandbox_types.go:228]() |
| Status subresource | enabled | [k8s/crds/agents.x-k8s.io_sandboxes.yaml:4020-4025]() |
| Scale subresource | `specReplicasPath=.spec.replicas`, `statusReplicasPath=.status.replicas`, `labelSelectorPath=.status.selector` | [k8s/crds/agents.x-k8s.io_sandboxes.yaml:4020-4025]() |

The kubebuilder markers on the top-level Go type drive these CRD attributes directly:

```go
// api/v1beta1/sandbox_types.go
// +genclient
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas,selectorpath=.status.selector
// +kubebuilder:resource:scope=Namespaced,shortName=sandbox
type Sandbox struct { ... }
```

Sources: [api/v1beta1/sandbox_types.go:224-244](), [k8s/crds/agents.x-k8s.io_sandboxes.yaml:1-25]()

## Top-Level Shape

A `Sandbox` follows the standard Kubernetes object layout: `apiVersion`, `kind`, `metadata`, `spec`, and `status`. Only `spec` is `+required`; `status` is `omitempty,omitzero`.

```text
Sandbox
├── metadata (ObjectMeta)
└── spec  (SandboxSpec, required)
    ├── podTemplate            (PodTemplate, required)
    ├── volumeClaimTemplates   ([]PersistentVolumeClaimTemplate, atomic)
    ├── shutdownTime           (metav1.Time, inline Lifecycle)
    ├── shutdownPolicy         (ShutdownPolicy, inline Lifecycle, default=Retain)
    ├── replicas               (*int32, 0..1, default=1)
    └── service                (*bool, tri-state)
└── status (SandboxStatus)
    ├── serviceFQDN, service, podIPs
    ├── replicas, selector
    └── conditions ([]metav1.Condition)
```

The `Lifecycle` struct is embedded inline into `SandboxSpec` via `Lifecycle `json:",inline"``, which is why `shutdownTime` and `shutdownPolicy` appear at the top level of `spec` rather than under a nested `lifecycle` key.

Sources: [api/v1beta1/sandbox_types.go:129-166](), [api/v1beta1/sandbox_types.go:181-222](), [k8s/crds/agents.x-k8s.io_sandboxes.yaml:3833-4017]()

## `spec.podTemplate`

`podTemplate` is the only required field on `SandboxSpec`. It carries the full Pod specification that the controller materializes for the Sandbox, plus a restricted `metadata` block.

```go
// api/v1beta1/sandbox_types.go:109-117
type PodTemplate struct {
    Spec       corev1.PodSpec `json:"spec"`           // required
    ObjectMeta PodMetadata    `json:"metadata"`        // optional, labels/annotations only
}
```

- `podTemplate.spec` is a full upstream `corev1.PodSpec`, surfaced verbatim through the generated CRD OpenAPI schema. Anything that can appear on a Pod (containers, volumes, affinity, security context, runtime class, etc.) can appear here.
- `podTemplate.metadata` is a narrowed `PodMetadata` shape, only `labels` and `annotations` are honored ([api/v1beta1/sandbox_types.go:68-82]()). A `name` is intentionally not surfaced; the underlying Pod is named after the Sandbox.

Two well-known annotations participate in controller bookkeeping for label/annotation propagation from Sandbox to Pod:

| Constant | Annotation key | Purpose |
| --- | --- | --- |
| `SandboxPropagatedLabelsAnnotation` | `agents.x-k8s.io/propagated-labels` | Tracks labels explicitly propagated from `Sandbox` spec to Pod. |
| `SandboxPropagatedAnnotationsAnnotation` | `agents.x-k8s.io/propagated-annotations` | Tracks annotations explicitly propagated from `Sandbox` spec to Pod. |
| `SandboxPodTemplateHashLabel` | `agents.x-k8s.io/sandbox-pod-template-hash` | Hash label set on the Pod for template comparison. |
| `SandboxPodNameAnnotation` | `agents.x-k8s.io/pod-name` | Records the Pod name when the Sandbox adopts one from a warm pool. |
| `SandboxTemplateRefAnnotation` | `agents.x-k8s.io/sandbox-template-ref` | Records the `SandboxTemplate` reference, when used. |

Sources: [api/v1beta1/sandbox_types.go:56-66](), [api/v1beta1/sandbox_types.go:68-117](), [docs/api.md:75-108]()

## `spec.volumeClaimTemplates`

`volumeClaimTemplates` is an optional, atomic list of PVC templates the Sandbox is allowed to reference. Each entry combines a narrowed metadata block with a standard `corev1.PersistentVolumeClaimSpec`.

```go
// api/v1beta1/sandbox_types.go:119-127
type PersistentVolumeClaimTemplate struct {
    EmbeddedObjectMetadata `json:"metadata"`
    Spec corev1.PersistentVolumeClaimSpec `json:"spec"` // required
}
```

Key field characteristics:

- Listed as `+listType=atomic` in `SandboxSpec`, so the entire list is replaced on update rather than merged ([api/v1beta1/sandbox_types.go:138-142](), [k8s/crds/agents.x-k8s.io_sandboxes.yaml:3850-3958]()).
- `EmbeddedObjectMetadata` exposes `name`, `labels`, and `annotations` on the template ([api/v1beta1/sandbox_types.go:84-107]()). The `name` is required for the controller to derive the actual PVC name.
- The API-level docstring requires that every claim has at least one matching access mode with a provisioner volume ([api/v1beta1/sandbox_types.go:138-141]()).

The `Sandbox` controller materializes each template into a real `PersistentVolumeClaim`. The PVC name is `"<template.Name>-<sandbox.Name>"`, the sandbox name hash label is added, and an existing same-named PVC that is unowned is adopted; PVCs owned by other controllers cause reconciliation to fail.

```go
// controllers/sandbox_controller.go:952-1008 (excerpt)
for _, pvcTemplate := range sandbox.Spec.VolumeClaimTemplates {
    pvcName := pvcTemplate.Name + "-" + sandbox.Name
    ...
    // Adopt unowned PVC, refuse to use one owned by a different controller,
    // otherwise create from pvcTemplate.Spec with cloned labels/annotations.
}
```

Sources: [api/v1beta1/sandbox_types.go:84-127](), [api/v1beta1/sandbox_types.go:138-142](), [controllers/sandbox_controller.go:945-1010]()

## `spec.shutdownTime` and `spec.shutdownPolicy` (inline `Lifecycle`)

`Lifecycle` is embedded inline into `SandboxSpec`, exposing two top-level keys on `spec` that govern expiry.

```go
// api/v1beta1/sandbox_types.go:181-192
type Lifecycle struct {
    ShutdownTime   *metav1.Time     `json:"shutdownTime,omitempty"`
    ShutdownPolicy *ShutdownPolicy  `json:"shutdownPolicy,omitempty"`
}
```

| Field | Type | Default | Validation | Effect |
| --- | --- | --- | --- | --- |
| `shutdownTime` | RFC 3339 timestamp | (none) | `format: date-time` | Absolute wall-clock expiry. When `now >= shutdownTime`, the Sandbox is treated as expired. |
| `shutdownPolicy` | `Delete` \| `Retain` | `Retain` | `+kubebuilder:validation:Enum=Delete;Retain` | After child cleanup at expiry, `Delete` removes the `Sandbox` object too; `Retain` keeps it with an `Expired` condition. |

The `ShutdownPolicy` enum is defined as:

```go
// api/v1beta1/sandbox_types.go:168-178
const (
    ShutdownPolicyDelete ShutdownPolicy = "Delete"
    ShutdownPolicyRetain ShutdownPolicy = "Retain"
)
```

Controller semantics:

- `checkSandboxExpiry` returns `expired=true` when `now` is no longer before `spec.ShutdownTime`. If `ShutdownTime` is nil, the Sandbox never expires ([controllers/sandbox_controller.go:1092-1111]()).
- On expiry, `handleSandboxExpiry` deletes the child Pod, owned `Service`, and owned PVCs regardless of `ShutdownPolicy`. The doc on `ShutdownPolicy` confirms this: "Underlying resources (Pods, Services) are always deleted on expiry." ([api/v1beta1/sandbox_types.go:187-188]())
- If `ShutdownPolicy == Delete`, the controller then deletes the `Sandbox` resource itself ([controllers/sandbox_controller.go:1065-1071]()).
- If `ShutdownPolicy == Retain` (default), the controller resets `status` (keeping conditions) and sets a `Ready=False` condition with reason `SandboxExpired` ([controllers/sandbox_controller.go:1074-1087](), [api/v1beta1/sandbox_types.go:53-54]()).

Sources: [api/v1beta1/sandbox_types.go:168-192](), [controllers/sandbox_controller.go:197-216](), [controllers/sandbox_controller.go:1065-1127]()

## `spec.replicas` (0 or 1)

`replicas` is a pointer-to-`int32` constrained to the closed range `[0, 1]`, defaulting to `1`. A Sandbox is intentionally a single-Pod resource; the field exists as an on/off toggle that is compatible with the standard `/scale` subresource.

```go
// api/v1beta1/sandbox_types.go:148-155
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=1
// +kubebuilder:default=1
// +optional
Replicas *int32 `json:"replicas,omitempty"`
```

The controller treats `replicas=0` as a suspension signal rather than a separate "paused" state:

```mermaid
stateDiagram-v2
    [*] --> Running: replicas=1 (default)
    Running --> Suspending: spec.replicas set to 0
    Suspending --> Suspended: Pod terminated
    Suspended --> Running: spec.replicas set to 1
    Running --> Expired: now >= shutdownTime
    Suspended --> Expired: now >= shutdownTime
    Expired --> [*]: shutdownPolicy=Delete
    Expired --> Expired: shutdownPolicy=Retain
```

Concrete behavior driven from `Spec.Replicas`:

- `computeSuspendedCondition` only emits a `Suspended` condition when `*Spec.Replicas == 0`. If the Pod still exists, the condition is `False` with reason `PodNotTerminated`; once the Pod is gone, it becomes `True` with reason `PodTerminated` ([controllers/sandbox_controller.go:289-311](), [api/v1beta1/sandbox_types.go:28-34]()).
- When `replicas=0`, `computeReadyCondition` short-circuits with `Ready=False` and reason `SandboxSuspended`, with message `"Sandbox is suspending"` if the Pod is still terminating, otherwise `"Sandbox is suspended"` ([controllers/sandbox_controller.go:328-337]()).
- During reconciliation, `replicas=0` causes the controller to delete the backing Pod ([controllers/sandbox_controller.go:671-678]()).
- Because `replicas` is the spec path for the `scale` subresource, `kubectl scale sandbox/<name> --replicas=0` (or `1`) is the supported way to toggle this state.

Sources: [api/v1beta1/sandbox_types.go:148-155](), [api/v1beta1/sandbox_types.go:28-44](), [controllers/sandbox_controller.go:188-216](), [controllers/sandbox_controller.go:289-337](), [controllers/sandbox_controller.go:660-690]()

## `spec.service` (tri-state Service toggle)

`service` is a `*bool` rather than a plain `bool`, because the controller distinguishes three states. The `nolint` comments on the field document why this was intentional rather than promoted to an enum:

```go
// api/v1beta1/sandbox_types.go:157-165
// service controls whether the controller should automatically create a
// headless Service for this Sandbox.
// When unset, the controller preserves existing Services for backward
// compatibility but does not create new ones. Set to true to enable or false
// to explicitly disable and remove the Service.
//nolint:kubeapilinter
//nolint:nobools // Enum not used to avoid duplicating the Service API; field is not expected to extend (issue #746).
// +optional
Service *bool `json:"service,omitempty"`
```

| `spec.service` | Behavior in `reconcileService` | Source |
| --- | --- | --- |
| `nil` (unset) | Do not create a new Service. If a Service already exists, leave it untouched (do not adopt unowned ones, do not delete). Used for backward compatibility with older Sandboxes that predated this field. | [controllers/sandbox_controller.go:462-503](), [controllers/sandbox_controller.go:524-538]() |
| `true` | Create a headless Service (`ClusterIP: None`) named after the Sandbox, with a selector keyed by the sandbox name hash. Adopt an unowned same-named Service if its `ClusterIP` is `None` (or empty); refuse to adopt one already owned by another controller. | [controllers/sandbox_controller.go:471-499](), [controllers/sandbox_controller.go:539-563]() |
| `false` | Delete the Service if and only if it is owned by this Sandbox. Services owned by other controllers or unowned Services are left alone. | [controllers/sandbox_controller.go:511-522]() |

The Sandbox-managed Service is always headless: the controller hard-codes `Spec.ClusterIP = "None"` and a `Spec.Selector` of `{ <sandboxLabel>: <nameHash> }` so the Service resolves to the Pod via DNS.

The readiness gate also reflects the tri-state. `svcRequired` is `true` if `*Spec.Service` is `true`, or if `Spec.Service` is `nil` but a Service already exists (legacy preservation):

```go
// controllers/sandbox_controller.go:364-372
svcRequired := false
if sandbox.Spec.Service != nil {
    svcRequired = *sandbox.Spec.Service
} else if svc != nil {
    // Backward compatibility: require service readiness
    svcRequired = true
}
```

When the Service exists, the controller sets `status.service` to its name and `status.serviceFQDN` to a fully qualified DNS name for the Pod inside the headless Service.

Sources: [api/v1beta1/sandbox_types.go:157-165](), [controllers/sandbox_controller.go:460-594](), [controllers/sandbox_controller.go:364-389]()

## `status` Shape

`SandboxStatus` is published on the `/status` subresource. It reports observed state only and is owned by the controller.

```go
// api/v1beta1/sandbox_types.go:194-222
type SandboxStatus struct {
    ServiceFQDN   string             `json:"serviceFQDN,omitempty"`
    Service       string             `json:"service,omitempty"`
    Conditions    []metav1.Condition `json:"conditions,omitempty"`
    Replicas      int32              `json:"replicas,omitempty"`
    LabelSelector string             `json:"selector,omitempty"`
    PodIPs        []string           `json:"podIPs,omitempty"`
}
```

| Status field | JSON key | Meaning |
| --- | --- | --- |
| `ServiceFQDN` | `serviceFQDN` | Fully qualified DNS name valid for default cluster settings. The cluster domain defaults to `cluster.local` but is configurable via the controller flag `--cluster-domain`. |
| `Service` | `service` | Name of the Service the Sandbox is using (when one exists). |
| `Conditions` | `conditions` | Standard `metav1.Condition` array (see below). |
| `Replicas` | `replicas` | Actual replica count (0 or 1), wired into the `scale` subresource. |
| `LabelSelector` | `selector` | String form of the label selector matching the Pod, used by `kubectl scale` and HPAs. |
| `PodIPs` | `podIPs` | IPs of the backing Pod; can be multiple on dual-stack clusters. |

### Status conditions

The controller emits three condition types defined as `ConditionType` constants:

| Condition `type` | Possible `reason` values | When set |
| --- | --- | --- |
| `Ready` | `DependenciesReady`, `DependenciesNotReady`, `SandboxSuspended`, `SandboxExpired`, `ReconcilerError` | Always computed. `True` only when Pod is `Ready` with `PodIPs`, and Service readiness gate (`svcRequired`) is satisfied. |
| `Suspended` | `PodTerminated`, `PodNotTerminated` | Only when `spec.replicas == 0`. `True` once the Pod has been terminated. |
| `Finished` | `PodSucceeded`, `PodFailed` | Set when the backing Pod reaches a terminal phase. |

The reason constants are defined together with the condition types:

```go
// api/v1beta1/sandbox_types.go:27-54
const (
    SandboxConditionSuspended ConditionType = "Suspended"
    SandboxReasonSuspendedPodTerminated    = "PodTerminated"
    SandboxReasonSuspendedPodNotTerminated = "PodNotTerminated"

    SandboxConditionReady ConditionType = "Ready"
    SandboxReasonDependenciesReady       = "DependenciesReady"
    SandboxReasonDependenciesNotReady    = "DependenciesNotReady"
    SandboxReasonSuspended               = "SandboxSuspended"

    SandboxConditionFinished ConditionType = "Finished"
    SandboxReasonPodSucceeded             = "PodSucceeded"
    SandboxReasonPodFailed                = "PodFailed"

    SandboxReasonExpired = "SandboxExpired"
)
```

Sources: [api/v1beta1/sandbox_types.go:22-66](), [api/v1beta1/sandbox_types.go:194-222](), [controllers/sandbox_controller.go:280-417]()

## Minimal Example

The smallest valid `v1beta1` Sandbox just sets `podTemplate.spec`. The remaining fields default sensibly: `replicas=1`, `shutdownPolicy=Retain`, no `shutdownTime`, no Service, no PVCs.

```yaml
apiVersion: agents.x-k8s.io/v1beta1
kind: Sandbox
metadata:
  name: hello-world
spec:
  podTemplate:
    spec:
      containers:
        - name: agent
          image: ghcr.io/example/agent:latest
      restartPolicy: Never
```

A more complete example exercising every top-level `spec` field documented above:

```yaml
apiVersion: agents.x-k8s.io/v1beta1
kind: Sandbox
metadata:
  name: agent-with-storage
spec:
  podTemplate:
    metadata:
      labels:
        app: my-agent
    spec:
      containers:
        - name: agent
          image: ghcr.io/example/agent:latest
          volumeMounts:
            - name: work
              mountPath: /work
  volumeClaimTemplates:
    - metadata:
        name: work
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 10Gi
  shutdownTime: "2026-06-01T00:00:00Z"
  shutdownPolicy: Delete       # default is Retain
  replicas: 1                  # 0 to suspend
  service: true                # create a headless Service
```

Sources: [api/v1beta1/sandbox_types.go:129-166](), [k8s/crds/agents.x-k8s.io_sandboxes.yaml:3833-3960]()

## Summary

The `Sandbox` CRD is a deliberately small surface area on top of a full `corev1.PodSpec`. `podTemplate` is the only required field; `volumeClaimTemplates` provides PVC materialization tied to the Sandbox lifetime; the inline `Lifecycle` fields (`shutdownTime`, `shutdownPolicy`) implement timed expiry with a default-`Retain` cleanup policy; `replicas` is a 0/1 scale toggle that doubles as a suspend switch; and `service` is a tri-state pointer-bool that distinguishes "create a headless Service", "delete the owned Service", and "leave any existing Service alone for backward compatibility". All field defaults and validation rules are expressed as kubebuilder markers on the Go types and surface verbatim in the generated CRD schema and the published `docs/api.md` reference.

Sources: [api/v1beta1/sandbox_types.go:129-222](), [docs/api.md:111-172]()
