Extensible Agent Runtime Registry

March 4, 2026 · View on GitHub

Status: Approved Branch: worktree-gemini-runner Date: 2026-03-03


1. Problem Statement

Runner type configuration is scattered across 5 layers with hardcoded logic in each:

LayerCurrent LocationWhat's hardcoded
Registry dataagent-registry-configmap.yamlDisplay names, models
State dirsrunner_types.go L24-27runnerStateDirs map
Container imageconfig.go L86-90AMBIENT_CODE_RUNNER_IMAGE env var
Bridge factorymain.py L7-20if/elif chain
API key listsettings-section.tsx L44-46RUNNER_API_KEYS const

Adding a new runner requires touching all 5 layers.

2. Goals

  1. Single source of truth: One registry ConfigMap drives all layers
  2. Registry-driven pod specs: Operator reads container image, resources, state dir from registry
  3. Zero-code runner addition: New runners via ConfigMap + container image only
  4. Registry management: Admin UI to view/manage registered runtimes

3. Non-Goals

  • 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

4. Architecture

4.1 Conceptual Model

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.

4.2 Registry Schema

// agent-registry-configmap.yaml → runtimes.json
[
  {
    // ─── Identity ───
    "id": "claude-agent-sdk",           // unique key, used in CRD spec.runnerType
    "displayName": "Claude Code",
    "description": "Anthropic Claude with full coding capabilities",
    "framework": "claude-agent-sdk",    // maps to RUNNER_TYPE env / bridge key

    // ─── Container Spec ───
    "container": {
      "image": "quay.io/ambient_code/ambient_runner:latest",
      "port": 8001,
      "env": {                          // injected into runner container
        "RUNNER_TYPE": "claude-agent-sdk",
        "RUNNER_STATE_DIR": ".claude"
      },
      "resources": {
        "requests": { "cpu": "500m", "memory": "512Mi" },
        "limits":   { "cpu": "2",    "memory": "4Gi" }
      }
    },

    // ─── Sandbox Config ───
    "sandbox": {
      "stateDir": ".claude",
      "stateSyncImage": "quay.io/ambient_code/state_sync:latest",
      "persistence": "s3",             // "s3" | "none"
      "workspaceSize": "10Gi",
      "terminationGracePeriod": 60,
      "seed": {
        "cloneRepos": true,             // init container clones repos
        "hydrateState": true            // init container restores from S3
      }
    },

    // ─── Auth ───
    "auth": {
      "requiredSecretKeys": ["ANTHROPIC_API_KEY"],
      "secretKeyLogic": "any",          // "any" = at least one, "all" = every key
      "vertexSupported": true
    },

    // ─── Models ───
    "defaultModel": "claude-sonnet-4-5",
    "models": [
      { "value": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5" },
      { "value": "claude-opus-4-6",   "label": "Claude Opus 4.6" },
      { "value": "claude-opus-4-5",   "label": "Claude Opus 4.5" },
      { "value": "claude-haiku-4-5",  "label": "Claude Haiku 4.5" }
    ],

    // ─── Feature Gate ───
    "featureGate": ""                   // empty = always enabled
    // NOTE: No capabilities here — capabilities come from the runner's
    // /capabilities endpoint at runtime. That is the source of truth.
  },

  {
    "id": "gemini-cli",
    "displayName": "Gemini CLI",
    "description": "Google Gemini coding agent with built-in file, bash, and search tools",
    "framework": "gemini-cli",
    "container": {
      "image": "quay.io/ambient_code/ambient_runner:latest",
      "port": 8001,
      "env": {
        "RUNNER_TYPE": "gemini-cli",
        "RUNNER_STATE_DIR": ".gemini"
      }
    },
    "sandbox": {
      "stateDir": ".gemini",
      "stateSyncImage": "quay.io/ambient_code/state_sync:latest",
      "persistence": "s3",
      "workspaceSize": "10Gi",
      "terminationGracePeriod": 60,
      "seed": { "cloneRepos": true, "hydrateState": true }
    },
    "auth": {
      "requiredSecretKeys": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
      "secretKeyLogic": "any",
      "vertexSupported": true
    },
    "defaultModel": "gemini-2.5-flash",
    "models": [
      { "value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash" },
      { "value": "gemini-2.5-pro",   "label": "Gemini 2.5 Pro" }
    ],
    "featureGate": "runner.gemini-cli.enabled"
  }
]

4.3 Request Flow

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

5. Data Model

5.1 Go Types (Backend + Operator)

// 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"`
}

5.2 TypeScript Types (Frontend)

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.
}

5.3 Python Bridge Registry

# 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"),
}

6. Operator Changes

6.1 Registry-Driven Pod Spec Builder

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.
}

6.2 What Changes from Current Code

Current (hardcoded)New (from registry)
AMBIENT_CODE_RUNNER_IMAGE env varruntime.Container.Image
Hardcoded resource requests/limitsruntime.Container.Resources
Hardcoded port 8001runtime.Container.Port
getRunnerInternalEnvVars() mapruntime.Container.Env
STATE_SYNC_IMAGE env varruntime.Sandbox.StateSyncImage
runnerStateDirs Go mapruntime.Sandbox.StateDir
Hardcoded "10Gi" workspaceruntime.Sandbox.WorkspaceSize
Hardcoded 60s grace periodruntime.Sandbox.TerminationGracePeriod

6.3 Conditional Pod Components

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
}

6.4 Backend Routing

func getRunnerEndpoint(projectName, sessionName string, runtime *AgentRuntimeSpec) string {
    return fmt.Sprintf("http://session-%s.%s.svc.cluster.local:%d/",
        sessionName, projectName, runtime.Container.Port)
}

6.5 Remove Hardcoded Logic

Delete:

  • runnerStateDirs map in runner_types.go
  • getRunnerInternalEnvVars() — replaced by runtime.Container.Env
  • Direct reference to AMBIENT_CODE_RUNNER_IMAGE — replaced by runtime.Container.Image

7. Runner (Python) Changes

7.1 Registry-Driven Bridge Factory

# 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")

8. Frontend Changes

8.1 Registry-Driven Settings

The settings page already derives API keys from useRunnerTypes() (done in prior work).

8.2 Admin Runtimes Page (New)

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.

8.3 Create Session Dialog

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.


9. Implementation Tasks

Phase 1: Registry Schema & Operator (Foundation)

#TaskFilesEffort
1.1Extend ConfigMap schema with full AgentRuntimeSpecagent-registry-configmap.yamlS
1.2Add Go types for AgentRuntimeSpec in backend + operatorbackend/handlers/runner_types.go, operator/internal/handlers/S
1.3Operator reads container spec from registry (image, port, resources, env)operator/internal/handlers/sessions.go, operator/internal/config/config.goM
1.4Operator conditionally builds pod components from sandbox configoperator/internal/handlers/sessions.goM
1.5Remove hardcoded runnerStateDirs, AMBIENT_CODE_RUNNER_IMAGE, getRunnerInternalEnvVars()backend/handlers/runner_types.go, operator/internal/config/config.goS
1.6Backend routing reads port from registrybackend/websocket/agui_proxy.goS

Phase 2: Python Bridge Registry

#TaskFilesEffort
2.1Replace if/elif factory with BRIDGE_REGISTRY dictmain.pyS

Phase 3: Frontend & Admin

#TaskFilesEffort
3.1Extend RunnerType interface with full schema (auth, featureGate)services/api/runner-types.tsS
3.2Admin runtimes page (read-only list + details)app/(main)/admin/runtimes/ (new)M
3.3Feature gate toggle via existing Unleash admin APIadmin/runtimes/S

Phase 4: Tests

#TaskFilesEffort
4.1Bridge registry teststests/test_bridge_registry.pyS
4.2Operator conditional pod component testsoperator testsM
4.3Backend routing testsbackend testsS

10. Migration & Backward Compatibility

ConfigMap Migration

The enhanced ConfigMap is a superset of the current schema. Existing fields preserved. New fields have defaults:

New FieldDefault if Missing
frameworkSame as id
containerBuilt from AMBIENT_CODE_RUNNER_IMAGE env var
sandboxBuilt from existing hardcoded values
auth.secretKeyLogic"any"
featureGate"" (enabled)

Operator Fallbacks

// 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")
}

11. Security Considerations

ConcernMitigation
Credential isolationPod-per-session. Each session gets its own pod with its own env vars.
Cross-session leakageImpossible — separate pods, separate volumes, separate processes.
Registry tamperingConfigMap protected by K8s RBAC. Only cluster admins can modify.
Container image trustImages from trusted registries (enforced via admission policy).

12. Resolved Decisions

#QuestionDecisionRationale
D1Execution modeAll sandbox (no shared mode)Shared adds complexity without clear benefit. Lightweight sandbox (no init, no sidecar, no workspace) gives fast startup with full isolation.
D2Admin UI scopeView + feature gate toggleGitOps for config changes. Prevents UI-driven drift.
D3CRD promotionNever — ConfigMap permanentSimpler to operate. Validation in Go code. 60s cache TTL sufficient.
D4Capabilities source of truthRunner's /capabilities endpoint, NOT the registryRegistry stores infrastructure config (image, resources, sandbox). Capabilities are runtime-reported by the bridge. Frontend already uses useCapabilities() hook. No duplication.
D5Direct-to-LLMDeferred — not in this implementationAdd later as a ConfigMap entry + bridge package. Registry is designed to support it without code changes.

13. Success Criteria

  • 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 /capabilities endpoint (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