This file provides guidance and conventions for Claude Code when working with this repository.
Agent Sandbox is a Kubernetes Operator that manages AI Agent sandbox Pod lifecycles. The core design uses a pre-warmed Pod pool combined with in-place image upgrades, avoiding the overhead of frequent Pod creation and deletion.
- Go module:
github.com/scitix/agent-sandbox - Go version: 1.25
- CRD Group:
agents.navix.sh
Cluster topology: A worker cluster runs the AgentBox core (Operator + ExtProc + Sandbox Pods); an optional master cluster runs /dashboard (see dashboard/CLAUDE.md).
Each cmd/*/main.go is a thin stub that calls Run() from its sibling
cmd/*/app package — closed-source forks should import these packages rather
than re-implement the bootstrap.
| Binary | Entry | Bootstrap package | Responsibilities |
|---|---|---|---|
cmd/sandbox |
Operator / API Server | cmd/sandbox/app |
REST API (:8080) + Controller Manager, optional E2B-compatible API (:8090) + Prometheus metrics (:8082) |
cmd/envoyextproc |
Data-plane ExtProc | cmd/envoyextproc/app |
Envoy ExternalProcessor gRPC (:9002) + internal control-plane gRPC (:9003) |
cmd/wsproxy |
WS reverse-proxy sidecar | cmd/wsproxy/app |
Listens on :9003, routes /ws/clusters/{cid}/sandboxes/{id}/terminal to the AgentBox API WebSocket endpoint; optional sync manager on :9004 |
# Code generation
make manifests # Regenerate CRD YAML + RBAC (required after modifying api/v1alpha1/)
make generate # Regenerate DeepCopy methods
make gen-all-api # openapi.yaml → Go + TS (dashboard) + Python SDK (all in one)
make sync-crds-to-helm # Sync CRDs + manager ClusterRole into Helm charts (run after make manifests)
# Build
make build # Build all binaries (VERSION injected via -ldflags automatically)
make build-controller # sandbox linux/amd64
make build-extproc # envoyextproc linux/amd64
make build-wsproxy # wsproxy binary
make lint-fix # Run linter with auto-fix
# Version
make sync-version # Sync VERSION file to openapi.yaml / pyproject.toml / __init__.py
# Testing
make test # Unit tests (envtest, no cluster needed); packages run concurrently
make test-e2e # E2E tests (requires a real cluster)
go test -tags=e2e ./test/e2e/ -v -ginkgo.v --ginkgo.focus="xxx" # Run a single E2E test
make test/make buildautomatically runmanifests → generate → gen-all-api → fmt → vetfirst.
Before completion: always run
make lint-fixand confirm 0 issues. The pre-push CI gate runs the same linter and will reject commits that contain lint errors.
- Changed a default port in code → update the corresponding Service definition in Helm charts
- Changed
api/v1alpha1/CRD types →make manifests generatethenmake sync-crds-to-helm - Changed
pkg/openapi/native/openapi.yaml→make gen-all-api(syncs Go + TS + Python SDK) - Added/modified an API response field → follow the full "API Field Addition SOP" below
- Releasing a new version → see the "Version Management" section below
The native API surface uses the generated pkg/apiserver/gen types directly as the single
externally-visible model. The service layer reads CRDs, projects them straight into
gen.* shapes, and the handler returns those untouched. There is no separate "domain"
mirror of wire fields, so the chain is shorter than it used to be.
Using Sandbox as an example, the complete change path for adding a field:
openapi.yaml ← 1. Define field schema
↓ make gen-all-api
gen/agentbox.gen.go ← 2. Auto-generated (do not edit manually)
controllers/.../sandbox_pod.go ← 3. Populate field in SandboxBaseFromPod / CaptureSandboxStopRecord
service/sandbox_service.go ← 4. Ensure the field survives Create/Get/List/Delete paths
e2bcompat/domain/convert.go ← 5. (Optional) map into the E2B compatibility shape
Step-by-step checklist (skip step 5 only when E2B compatibility isn't needed):
| # | File | Action | Verify |
|---|---|---|---|
| 1 | pkg/openapi/native/openapi.yaml |
Add field definition to schema | make gen-all-api succeeds |
| 2 | pkg/apiserver/gen/agentbox.gen.go |
Auto-generated; confirm new field exists | grep new field name |
| 3 | pkg/controllers/sandboxpool/sandbox_pod.go |
Set the field on the gen.Sandbox returned by SandboxBaseFromPod() / CaptureSandboxStopRecord() |
unit test |
| 4 | pkg/apiserver/service/sandbox_service.go |
Make sure live (Create/Get/List) and historical (store) paths populate the field | unit test + API test |
| 5 | pkg/e2bcompat/domain/convert.go |
If E2B needed, read from the gen.Sandbox argument and map into the E2B shape |
E2B API test |
SandboxPool / SandboxTemplate fields follow the same pattern: edit
poolToGen()/templateFromCRD()inpkg/apiserver/service/(they are the only place the projection happens; the handler simply forwards the gen value).
| Type kind | Location | Examples |
|---|---|---|
| Wire shapes (HTTP request/response) | pkg/apiserver/gen/ |
gen.Sandbox, gen.SandboxPool, gen.Quota, gen.APIKeyItem, gen.PoolTemplateOverrides (the caller-supplied subset) |
| Service input wrappers, return shapes | pkg/apiserver/service/ |
CreateSandboxInput, CreateSandboxPoolInput, UpdateSandboxPoolInput, SandboxListFilter, ExecTokenInfo, APIKeyItem/KeyMetadata/CreateAPIKeyInput/APIKeyResult, PoolTemplateOverrides (annotation-storage shape with ImagePullSecretName) |
| Annotation/state bookkeeping shared with controllers | pkg/controllers/sandboxpool/poststarthooks/ |
Action, ExecAction, HTTPPostAction |
| Internal cross-package primitives | pkg/apiserver/domain/ |
AppError + error detail types, AuthInfo |
pkg/apiserver/domain/is now intentionally tiny (≈200 LOC acrossauth.goanderrors.go). Wire mirrors that used to live there have all been collapsed ontogen.*; internal inputs/results moved topkg/apiserver/service/. Anything tempting to add back intodomain/should first look like agen.*type or move into the service package as a parsed input.
All components share a single version number. The single source of truth is the VERSION file in the repo root.
VERSION ← sole version source (semver, e.g. 0.2.0)
│
├─ Makefile (LDFLAGS) → Go binaries get pkg/version.Version injected via -ldflags
├─ make sync-version → openapi.yaml info.version
├─ make sync-version → sdk/python/abx/pyproject.toml version
└─ make sync-version → sdk/python/abx/agentbox_sdk/__init__.py __version__
- SDK sends
X-AgentBox-Client-Versionheader on every request automatically - Server middleware checks this header for write operations (POST/PUT/PATCH/DELETE):
- Version <
MinClientVersion(defined inpkg/apiserver/router/middleware/version.go) → returns426 Upgrade Required - Dashboard/JWT users are exempt (requests without
AGENTBOX-API-KEYheader are skipped)
- Version <
- Grace period: when
MinClientVersion = "0.0.0"all clients are allowed
# 1. Update version number
echo "0.2.0" > VERSION
# 2. Sync to all component version declarations
make sync-version
# 3. Raise the minimum compatible version
# pkg/apiserver/router/middleware/version.go → MinClientVersion = "0.2.0"
# 4. Regenerate API code + build
make gen-all-api
make build
# 5. After deployment, old clients (< 0.2.0) will receive 426 Upgrade Required on writes| File | Role |
|---|---|
VERSION |
Sole version source |
pkg/version/version.go |
Go version variable (injected via -ldflags) |
pkg/apiserver/router/middleware/version.go |
Server-side version check middleware + MinClientVersion constant |
sdk/python/abx/agentbox_sdk/_http.py |
Python SDK sends X-AgentBox-Client-Version header |
- Unit tests: Ginkgo v2 + Gomega (BDD style) +
envtest; some packages use standardtesting - E2E tests:
test/e2e/, requires a Kind cluster - Build tags:
unit/e2e; coverage output written tocover.out - Write tests alongside new functionality whenever possible (unit or integration)
Comments describe the terminal state of the code, not the transition that produced it. Future readers do not have the rollout context — they only see the file as it is now.
Never put in comments:
- Stage/phase markers from a rollout plan: no "S1/S2/S3", "step 4 of the redesign", "added in phase 2", "removed in S5". Those make sense only to the author during execution; six months later they are pure noise.
- References to closed-source paths: this repo is open-source. Do not link to
docs/proposals/*.md(those live in a separate private repo), internal Linear/Jira tickets, internal Slack channels, or any path the public reader cannot resolve. - Self-referential "this is new" language: no "newly added", "recent refactor", "previously this did X". Either describe what the code does now, or — if the why matters — describe the constraint, not the history.
- PR / commit / ticket numbers: those rot. The git log already has them.
Acceptable when load-bearing:
- The reason a non-obvious choice was made (hidden constraint, race condition, surprising K8s API behaviour, regression that motivated the workaround). Write the constraint, not the date.
- Pointers to durable public references: K8s docs, RFCs, upstream library docs.
// Deprecated:markers with a short reason and a pointer to the replacement. Only add this when the call sites are migrated in the same change — Go's staticcheck linter flags every remaining caller, so a premature deprecation will cascade. While call sites still exist, use a plain "Legacy:" prose note instead.
Examples
// BAD (transient context):
// In S4 of the Pool-Centric Autoscaling redesign we moved the decision
// here. See docs/proposals/20260527-pool-centric-autoscaling.md §5.
// GOOD (terminal description):
// The Pool reconciler owns the autoscaling decision; it writes the
// resulting Member.Spec.Replicas onto the owning Env and lets the Env
// reconciler's drift loop propagate the change to the live Pool.// BAD:
// SCHEDULED FOR REMOVAL in S6.
// GOOD (using Go's standard deprecation convention):
// Deprecated: superseded by PoolScheduler.Snapshot(); retained until
// remaining writers are migrated off.When you find yourself reaching for an "S2"/"recent"/"new"/"old" phrasing while writing a comment, stop and rewrite it as "this code does X because Y." If you cannot explain it without the history, the history probably does not belong in the comment either — leave the comment off entirely.
| File | Generated by |
|---|---|
api/v1alpha1/zz_generated.deepcopy.go |
make generate |
config/crd/bases/*.yaml |
make manifests |
config/rbac/role.yaml |
make manifests |
installer/helm/agent-sandbox-worker/templates/crds/*.yaml |
make sync-crds-to-helm (via hack/scripts/generate-helm.py) |
installer/helm/agent-sandbox-hub/templates/crds/*.yaml |
make sync-crds-to-helm (SandboxTemplate only) |
installer/helm/agent-sandbox-worker/templates/rbac-manager-role.yaml |
make sync-crds-to-helm (converts config/rbac/role.yaml) |
docs/openapi/swagger.{json,yaml} |
make openapi |
pkg/apiserver/gen/agentbox.gen.go |
make gen-all-api |
pkg/proto/sandbox/internal/v1/*.pb.go |
make gen-internal-proto (chained into gen-all-api) |
pkg/e2bcompat/gen/e2b.gen.go |
Generated from E2B OpenAPI spec |
dashboard/lib/api/schema.d.ts |
make gen-all-api or cd dashboard && pnpm run gen:types |
sdk/python/abx/agentbox_sdk/_generated/ |
make gen-all-api or cd sdk/python/abx && make gen |
| Module | Docs | Notes |
|---|---|---|
| E2B compatibility layer | pkg/e2bcompat/ |
E2B SDK-compatible server (:8090) |
| Prometheus metrics | pkg/metrics/README.md |
Metric definitions, addition workflow, scrape config |
| ExtProc routing | pkg/envoy/extproc/README.md |
Envoy ExternalProcessor + routing policy |
| Native REST API | pkg/apiserver/README.md |
OpenAPI-first generation flow, field mapping |
| Plugin system | pkg/framework/ |
Plugin (lifecycle hooks) + Provider (data source abstraction) interfaces |
| HTTP logging | pkg/utils/httplog.go |
RequestID middleware + AppError structured logging |
| Version management | CLAUDE.md #version-management |
VERSION file + sync-version + client version negotiation |
In-place upgrade Pod state machine (agentbox.navix.sh/sandbox-phase label):
Idle → Starting → Running → Stopping → Idle
↘ Failed
CRDs:
SandboxPool(namespace-scoped, short:sbp) — pre-warmed Pod pool withReplicas, optional autoscaling, inline or referenced templateSandboxTemplate(cluster-scoped, short:sbt) — reusable Pod template withidleImage,runtimesSandboxEnv(namespace-scoped, short:sbe) — fans a Template out to one or more memberSandboxPools. Each member is anEnvClusterMemberwith three buckets:metadata+spec— frozen post-PreCreatePoolsnapshot of the materialised Pool (ObjectMeta sans server fields + fullSandboxPoolSpec). Server-internal; not exposed via REST. Reconciler stamps verbatim onto the live Pool without re-running plugin admission, so plugin side-effects (Reservation submit, scheduling labels, NodeAffinity, …) survive Pool recreate / Env re-apply.config— user-declared intent (sizing, scaling-group, routing priorities, user-supplied labels/annotations). Only bucket exposed via REST. Plugins do NOT mutate this — it stays equal to the caller's input.- Template upgrades do NOT auto-propagate into
Member.Spec. The (Phase 2)RefreshMemberAPI is the explicit way to re-align with a newer Template revision; without it, Reconciler holds the frozen snapshot.
Plugin contract (pkg/framework/plugins/): every hook returns (updated bool, err *domain.AppError). Callers MUST snapshot the input before invoking, then apply the mutation only when updated && !equality.Semantic.DeepEqual(before, after). See pkg/controllers/sandboxpool/sandboxpool_controller.go:267-281 for the canonical pattern. PreCreatePool is the single side-effect ingress for new Pool admission — invoked once at API time inside AddMember, never re-run by the Reconciler.
API Key: agbx_ prefix + 32 random bytes; SHA-256 hash stored in K8s Secret; in-memory cache TTL 1min (pkg/utils/apikey/)