Status: Approved
Branch: worktree-gemini-runner
Date: 2026-03-03
Runner type configuration is scattered across 5 layers with hardcoded logic in each:
| Layer | Current Location | What's hardcoded |
|---|---|---|
| Registry data | agent-registry-configmap.yaml |
Display names, models |
| State dirs | runner_types.go L24-27 |
runnerStateDirs map |
| Container image | config.go L86-90 |
AMBIENT_CODE_RUNNER_IMAGE env var |
| Bridge factory | main.py L7-20 |
if/elif chain |
| API key list | settings-section.tsx L44-46 |
RUNNER_API_KEYS const |
Adding a new runner requires touching all 5 layers.
- Single source of truth: One registry ConfigMap drives all layers
- Registry-driven pod specs: Operator reads container image, resources, state dir from registry
- Zero-code runner addition: New runners via ConfigMap + container image only
- Registry management: Admin UI to view/manage registered runtimes
- Direct-to-LLM runner (future — add as a ConfigMap entry + bridge package when ready)
- Shared execution mode (all runners use sandbox — isolated pod per session)
- CRD-based registry (ConfigMap is permanent)
- Warm pool / pre-provisioned pods
- Auto-scaling
All runners follow the same execution model: one pod per session (sandbox). The registry controls what goes into the pod.
┌──────────────────────────┐
│ AgentRuntime Registry │
│ (ConfigMap) │
└────────────┬─────────────┘
│
┌────────────────┴────────────────┐
│ │
Claude SDK Gemini CLI
(full sandbox) (full sandbox)
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ Init: │ │ Init: │
│ hydrate │ │ hydrate │
│ Main: │ │ Main: │
│ runner │ │ runner │
│ Sidecar: │ │ Sidecar: │
│ sync │ │ sync │
└──────────┘ └──────────┘
Future runners added via ConfigMap + container image.
Lightweight runners can skip init/sidecar via sandbox.seed config.
All runners follow the same flow. The registry controls what the pod looks like.
CreateSession POST
→ Backend: resolve runtime from registry
→ Backend: validate auth.requiredSecretKeys
→ Backend: create AgenticSession CRD with runnerType
→ Operator: read runtime from registry
→ Operator: create Pod from runtime.container spec
- If seed.cloneRepos: add init-hydrate container
- If seed.hydrateState: init container restores from S3
- If persistence != "none": add state-sync sidecar
- Otherwise: skip init + sidecar (lightweight pod)
→ Operator: create Service session-{name}
→ Backend: proxy to http://session-{name}.{ns}:{port}/
Run POST
→ Backend: getRunnerEndpoint() → per-session Service
→ Runner: bridge.run() → AG-UI events
→ Frontend: SSE stream
// AgentRuntimeSpec — parsed from registry ConfigMap JSON
type AgentRuntimeSpec struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Framework string `json:"framework"`
Container ContainerSpec `json:"container"`
Sandbox SandboxSpec `json:"sandbox"`
Auth AuthSpec `json:"auth"`
DefaultModel string `json:"defaultModel"`
Models []ModelEntry `json:"models"`
FeatureGate string `json:"featureGate"`
// NOTE: No capabilities — runtime-reported by runner's /capabilities endpoint.
}
type ContainerSpec struct {
Image string `json:"image"`
Port int `json:"port"`
Env map[string]string `json:"env"`
Resources *ResourcesSpec `json:"resources,omitempty"`
}
type SandboxSpec struct {
StateDir string `json:"stateDir,omitempty"`
StateSyncImage string `json:"stateSyncImage,omitempty"`
Persistence string `json:"persistence"` // "s3" | "none"
WorkspaceSize string `json:"workspaceSize,omitempty"`
TerminationGracePeriod int `json:"terminationGracePeriod,omitempty"`
Seed struct {
CloneRepos bool `json:"cloneRepos"`
HydrateState bool `json:"hydrateState"`
} `json:"seed"`
}
type AuthSpec struct {
RequiredSecretKeys []string `json:"requiredSecretKeys"`
SecretKeyLogic string `json:"secretKeyLogic"` // "any" | "all"
VertexSupported bool `json:"vertexSupported"`
}interface AgentRuntime {
id: string;
displayName: string;
description: string;
framework: string;
defaultModel: string;
models: RunnerModel[];
auth: {
requiredSecretKeys: string[];
secretKeyLogic: "any" | "all";
vertexSupported: boolean;
};
featureGate: string;
// Capabilities come from runner's /capabilities endpoint at runtime.
}# Bridge registry — maps framework ID to (module_path, class_name)
# Lazy imports avoid pulling in unused dependencies
BRIDGE_REGISTRY: dict[str, tuple[str, str]] = {
"claude-agent-sdk": ("ambient_runner.bridges.claude", "ClaudeBridge"),
"gemini-cli": ("ambient_runner.bridges.gemini_cli", "GeminiCLIBridge"),
"langgraph": ("ambient_runner.bridges.langgraph", "LangGraphBridge"),
}Replace hardcoded values with registry lookups:
func (h *Handler) createPodForSession(session *v1alpha1.AgenticSession) error {
runtime, err := h.registryCache.GetRuntime(session.Spec.RunnerType)
if err != nil {
return fmt.Errorf("unknown runner type %q: %w", session.Spec.RunnerType, err)
}
pod := h.buildPodSpec(session, runtime)
// ... create pod, service, etc.
}| Current (hardcoded) | New (from registry) |
|---|---|
AMBIENT_CODE_RUNNER_IMAGE env var |
runtime.Container.Image |
| Hardcoded resource requests/limits | runtime.Container.Resources |
| Hardcoded port 8001 | runtime.Container.Port |
getRunnerInternalEnvVars() map |
runtime.Container.Env |
STATE_SYNC_IMAGE env var |
runtime.Sandbox.StateSyncImage |
runnerStateDirs Go map |
runtime.Sandbox.StateDir |
| Hardcoded "10Gi" workspace | runtime.Sandbox.WorkspaceSize |
| Hardcoded 60s grace period | runtime.Sandbox.TerminationGracePeriod |
The operator uses sandbox.seed and sandbox.persistence to decide what to include:
func (h *Handler) buildPodSpec(session *v1alpha1.AgenticSession, runtime *AgentRuntimeSpec) *corev1.PodSpec {
spec := &corev1.PodSpec{
Containers: []corev1.Container{h.buildRunnerContainer(session, runtime)},
}
// Only add workspace volume if persistence is configured or repos are seeded
if runtime.Sandbox.Persistence != "none" || runtime.Sandbox.Seed.CloneRepos {
spec.Volumes = append(spec.Volumes, h.buildWorkspaceVolume(runtime))
}
// Only add init container if seeding is needed
if runtime.Sandbox.Seed.CloneRepos || runtime.Sandbox.Seed.HydrateState {
spec.InitContainers = []corev1.Container{h.buildInitContainer(session, runtime)}
}
// Only add state-sync sidecar if persistence is enabled
if runtime.Sandbox.Persistence != "none" {
spec.Containers = append(spec.Containers, h.buildStateSyncContainer(session, runtime))
}
return spec
}func getRunnerEndpoint(projectName, sessionName string, runtime *AgentRuntimeSpec) string {
return fmt.Sprintf("http://session-%s.%s.svc.cluster.local:%d/",
sessionName, projectName, runtime.Container.Port)
}Delete:
runnerStateDirsmap inrunner_types.gogetRunnerInternalEnvVars()— replaced byruntime.Container.Env- Direct reference to
AMBIENT_CODE_RUNNER_IMAGE— replaced byruntime.Container.Image
# main.py
import os
import importlib
import logging
logger = logging.getLogger(__name__)
RUNNER_TYPE = os.getenv("RUNNER_TYPE", "claude-agent-sdk").strip().lower()
BRIDGE_REGISTRY: dict[str, tuple[str, str]] = {
"claude-agent-sdk": ("ambient_runner.bridges.claude", "ClaudeBridge"),
"gemini-cli": ("ambient_runner.bridges.gemini_cli", "GeminiCLIBridge"),
"langgraph": ("ambient_runner.bridges.langgraph", "LangGraphBridge"),
}
def _load_bridge():
if RUNNER_TYPE not in BRIDGE_REGISTRY:
raise ValueError(
f"Unknown RUNNER_TYPE={RUNNER_TYPE!r}. "
f"Available: {sorted(BRIDGE_REGISTRY)}"
)
module_path, class_name = BRIDGE_REGISTRY[RUNNER_TYPE]
module = importlib.import_module(module_path)
bridge_cls = getattr(module, class_name)
logger.info(f"Loading bridge: {class_name} from {module_path}")
return bridge_cls()
app = create_ambient_app(_load_bridge(), title="Ambient Runner AG-UI Server")The settings page already derives API keys from useRunnerTypes() (done in prior work).
Simple read-only management view at /admin/runtimes:
┌──────────────────────────────────────────────────┐
│ Agent Runtimes │
├───────────────┬────────┬─────────────────────────┤
│ Runtime │ Models │ Status │
├───────────────┼────────┼─────────────────────────┤
│ Claude Code │ 4 │ ● Enabled │
│ Gemini CLI │ 2 │ ○ Gated (toggle) │
└───────────────┴────────┴─────────────────────────┘
Clicking a runtime shows its full configuration (read-only from ConfigMap). Feature gate toggle uses the existing Unleash admin API — the ConfigMap's featureGate field holds the flag name, and isRunnerEnabled() checks Unleash.
Already handles multiple runners. Capability-driven UI works via the useCapabilities() hook which polls the runner's /capabilities endpoint after session creation. No registry involvement needed.
| # | Task | Files | Effort |
|---|---|---|---|
| 1.1 | Extend ConfigMap schema with full AgentRuntimeSpec | agent-registry-configmap.yaml |
S |
| 1.2 | Add Go types for AgentRuntimeSpec in backend + operator | backend/handlers/runner_types.go, operator/internal/handlers/ |
S |
| 1.3 | Operator reads container spec from registry (image, port, resources, env) | operator/internal/handlers/sessions.go, operator/internal/config/config.go |
M |
| 1.4 | Operator conditionally builds pod components from sandbox config | operator/internal/handlers/sessions.go |
M |
| 1.5 | Remove hardcoded runnerStateDirs, AMBIENT_CODE_RUNNER_IMAGE, getRunnerInternalEnvVars() |
backend/handlers/runner_types.go, operator/internal/config/config.go |
S |
| 1.6 | Backend routing reads port from registry | backend/websocket/agui_proxy.go |
S |
| # | Task | Files | Effort |
|---|---|---|---|
| 2.1 | Replace if/elif factory with BRIDGE_REGISTRY dict |
main.py |
S |
| # | Task | Files | Effort |
|---|---|---|---|
| 3.1 | Extend RunnerType interface with full schema (auth, featureGate) | services/api/runner-types.ts |
S |
| 3.2 | Admin runtimes page (read-only list + details) | app/(main)/admin/runtimes/ (new) |
M |
| 3.3 | Feature gate toggle via existing Unleash admin API | admin/runtimes/ |
S |
| # | Task | Files | Effort |
|---|---|---|---|
| 4.1 | Bridge registry tests | tests/test_bridge_registry.py |
S |
| 4.2 | Operator conditional pod component tests | operator tests |
M |
| 4.3 | Backend routing tests | backend tests |
S |
The enhanced ConfigMap is a superset of the current schema. Existing fields preserved. New fields have defaults:
| New Field | Default if Missing |
|---|---|
framework |
Same as id |
container |
Built from AMBIENT_CODE_RUNNER_IMAGE env var |
sandbox |
Built from existing hardcoded values |
auth.secretKeyLogic |
"any" |
featureGate |
"" (enabled) |
// If container.image is empty, fall back to env var (backward compat)
image := runtime.Container.Image
if image == "" {
image = os.Getenv("AMBIENT_CODE_RUNNER_IMAGE")
}| Concern | Mitigation |
|---|---|
| Credential isolation | Pod-per-session. Each session gets its own pod with its own env vars. |
| Cross-session leakage | Impossible — separate pods, separate volumes, separate processes. |
| Registry tampering | ConfigMap protected by K8s RBAC. Only cluster admins can modify. |
| Container image trust | Images from trusted registries (enforced via admission policy). |
| # | Question | Decision | Rationale |
|---|---|---|---|
| D1 | Execution mode | All sandbox (no shared mode) | Shared adds complexity without clear benefit. Lightweight sandbox (no init, no sidecar, no workspace) gives fast startup with full isolation. |
| D2 | Admin UI scope | View + feature gate toggle | GitOps for config changes. Prevents UI-driven drift. |
| D3 | CRD promotion | Never — ConfigMap permanent | Simpler to operate. Validation in Go code. 60s cache TTL sufficient. |
| D4 | Capabilities source of truth | Runner's /capabilities endpoint, NOT the registry |
Registry stores infrastructure config (image, resources, sandbox). Capabilities are runtime-reported by the bridge. Frontend already uses useCapabilities() hook. No duplication. |
| D5 | Direct-to-LLM | Deferred — not in this implementation | Add later as a ConfigMap entry + bridge package. Registry is designed to support it without code changes. |
- New runners can be added by updating ConfigMap + building a container image (no Go/Python/TS code changes)
- Operator builds pod spec from registry (image, resources, conditional init/sidecar)
- Frontend derives all runner-specific UI from runner's
/capabilitiesendpoint (no hardcoded runner checks) - Existing Claude SDK and Gemini CLI runners work identically after migration
- Admin page shows registered runtimes with status and feature gates