Architecture
Internals of the ai-agents execution engine. The engine uses Kubernetes CRDs as the sole state store — no Redis, no BullMQ, no database.
For the top-level system view (context, building blocks, deployment, RAD conformance) see the Software Architecture Document. This page is the engine-internals deep-dive.
Deployable Processes
One TypeScript codebase (npm workspace) produces two server entrypoints, a Next.js frontend, and a set of executor images:
| Process | Entrypoint | Dockerfile | Image | Role |
|---|---|---|---|---|
| API server | src/index.ts | Dockerfile | apps.ai-agents | Express API + operator controller + webhooks + WebSocket terminal |
| Inference Proxy | src/proxy.ts | Dockerfile.proxy | apps.ai-agents/proxy | OpenAI-compatible inference proxy (OIDC/bearer) — the single auth boundary in front of all model backends |
| Frontend | — (app/) | app/Dockerfile | apps.ai-agents/app | Next.js dashboard; proxies /api/* to the API server |
The operator controller is conditional on the API process via ENABLE_OPERATOR (default true). Webhook pods run the same API image in MODE=webhook: when a WebhookSource CR is created, the API server auto-provisions a Deployment + Service + Ingress for it (src/k8s/webhook-sources.ts), each cleaned up via a finalizer on CR deletion. There are no separate processor/webhook images or charts — everything ships from the single charts/ai-agents-main chart.
GitHub event ──► Webhook pod ──► InferenceRequest CR
POST /api/jobs/submit ─────────► │
POST /api/events/ingest ───────► ▼
┌──────────────────────────────────────────┐
│ API server (operator reconcile loop) │
│ watches IR CRs, creates k8s Jobs │
└──────────────────────────────────────────┘
│
▼
ephemeral k8s Job
(executor container)
│ inference + MCP tool callbacks
▼
Inference Proxy ──► Anthropic / OpenAI / GX10 vLLM / Ollama
CRDs (group: labrats.work, version: v1alpha1)
| Kind | Purpose |
|---|---|
InferenceRequest | One job record. Phases: Pending → Running → Succeeded / Failed / Cancelled. |
AiModel | One per logical model. Holds providers[] (inference endpoints) and tracks activeJobs / activeJobIds[] against maxConcurrency for concurrency control. Auto-populated by model discovery (spec 0010). |
AiAgent | Named agent role. Holds default model/effort/provider, assigned instructions, event triggers, tool/MCP/skill refs, and chain configuration. |
AiConfig | Singleton named default. Holds global model/effort, discovery providers[], executor image and resource overrides. |
AiInstruction | Reusable prompt fragment. Scope global (applied to all) or local (assigned per-agent). |
AiTool | Reusable OpenAI-style function tool definition, attached per agent. |
AiMcpServer | Reusable MCP server definition (http/stdio). Global catalog; applied only where an agent opts in via spec.mcpServers — no auto-attach. (spec 0011) |
AiSkill | Reusable Agent Skill (SKILL.md + files in a ConfigMap). Global catalog; installed only where an agent opts in via spec.skills. (spec 0012) |
AiExecutor | Declarative executor image + tag/env/provider-config for a provider. |
WebhookSource | Configures a webhook receiver. Auto-provisions Deployment + Service + Ingress per source (cleaned up via a finalizer). |
Prompts are not stored inline in IR specs (CRD size limit ≈1 MiB). Each IR holds a promptRef pointing to a per-job ConfigMap ({irName}-prompt).
InferenceRequest (full shape, src/k8s/inference-request-types.ts)
spec: {
source: string // "github", "manual", "dashboard", "github-actions", ...
type: string // "implement-issue", "code-review", "document-extraction", ...
agentRole?: string // matches AiAgent.metadata.name or trigger
priority?: number // default 2; lower = higher priority
promptRef: { configMapName, key } // ConfigMap holding the prompt
workspaceSetup?: {
gitRepo, gitRef, gitDepth, githubToken
}
workspaceFiles?: [{ name, data }] // base64-encoded
workspaceUploads?: [{ id, name }] // RWX uploads PVC references
config?: { model, effort, fast, provider, timeoutMs, maxTurns, executorImage, reactEnabled }
callbackUrl?: string
pipeline?: { type: "pdf-sections" }
chain?: {
chainId, chainDepth, parentRequestName, chainPath[]
nextAgentTrigger, nextAgentCondition, nextFailurePolicy
}
retryPolicy?: { maxAttempts, backoffMs }
metadata?: Record<string, unknown>
errors?: [{ context, message, stack, timestamp }]
}
status: {
phase: "Pending"|"Running"|"Succeeded"|"Failed"|"Cancelled"
attempts, accountName, k8sJobName, executorPodName
startedAt, completedAt
result: { exitCode, timedOut, budgetExceeded, budgetReason, usage{...}, provider, model, effort, fast }
failedReason, logs[], conditions[]
}
Operator (src/operator/)
Controller (controller.ts)
- Informer-driven plus a 5-second poll fallback (
DEFAULT_POLL_INTERVAL_MS = 5_000). - Default concurrency is 5; override with
WORKER_CONCURRENCY. - Tracks in-flight reconciles in an internal
Setof IR names (processing). - Pending IRs are sorted by
priorityascending, then bycreationTimestamp. - Running IRs are reconciled before Pending IRs so status polling stays fresh.
- New events (informer
add/update) are debounced viasetImmediateinto the next reconcile pass. - A slow retention GC sweep (
gc.ts, default every 10 min viaIR_GC_INTERVAL_MINUTES) hard-deletes terminal IRs past their per-phase TTL —IR_TTL_SUCCEEDED(24h),IR_TTL_FAILED(168h),IR_TTL_CANCELLED(24h);0disables a phase. (spec 0016)
Reconciler (reconciler.ts)
| Phase in | Action | Phase out |
|---|---|---|
Pending | acquire an AiModel slot → write prompt ConfigMap → create k8s Job → record k8sJobName | Running |
Running | poll Job status → stream pod logs → on terminal status, parse usage and run callbacks | Succeeded / Failed |
Failed (cancel/retry) | Retry endpoint resets to Pending; cancel sets Cancelled and deletes Job | — |
On terminal status the reconciler also calls maybeChainNextAgent() to optionally enqueue a child IR.
Prompt Builder (prompt-builder.ts)
Resolution at job start:
config.model : IR.spec.config.model
→ AiAgent.spec.defaultModel
→ AiConfig.spec.providers[provider].model
→ AiConfig.spec.model
→ CLI default (no flag passed)
config.effort : IR.spec.config.effort
→ AiAgent.spec.defaultEffort
→ AiConfig.spec.providers[provider].effort
→ AiConfig.spec.effort
→ "medium"
config.provider : IR.spec.config.provider
→ AiAgent.spec.provider
→ "claude"
Instruction injection (instructionWatcher.resolveForAgent()):
- Collect all
globalAiInstructions sorted bypriorityascending. - Append
localinstructions listed in the agent'sspec.instructions[]. - Prepend each as an
<instruction name="…" scope="…">…</instruction>XML block to the prompt.
Pipeline jobs (currently only pdf-sections) skip instruction injection.
Chain (chain.ts)
After job completion, maybeChainNextAgent():
- Returns immediately if
spec.chain.nextAgentTriggeris unset. - Loop guard — refuses if
nextAgentTriggeralready appears inchainPath. - Depth guard —
MAX_CHAIN_DEPTH = 10. - Condition — evaluates
nextAgentCondition(always|on-success|on-failure) againstexitCode === 0. - Failure policy —
stop|skip|notify(only blocks chaining when policy says so). - Captures up to 4,000 chars of parent stdout, wraps it in
<chain-context>…</chain-context>, and prepends it to the parent prompt to form the child prompt. - Creates a child IR with
chainDepth + 1, extendedchainPath, and the next agent's ownnextconfig (resolved from itseventTriggers).
See Agent Chains for configuration details.
Recursive sub-agent delegation (spec 0013). Separate from event chains, the bundled MCP server's delegate_subtask tool spawns a focused sub-agent — a child IR with its own tools and a small turn budget — capped at depth 3 with a cycle guard via the same spec.chain lineage. To stay deadlock-free the parent parks while waiting: POST /api/jobs/:id/park releases its AiModel slot and unpark re-acquires before it resumes, so leaf sub-agents free slots and the tree drains bottom-up (status.parked tracks it).
Job Executor (src/k8s/job-executor.ts)
Each IR transition to Running creates one ephemeral k8s batch/v1 Job.
| Resource | Created per IR | Purpose |
|---|---|---|
ConfigMap {jobName}-prompt | yes | Holds prompt text at key prompt.txt, mounted at /prompt. |
Secret {jobName}-git | conditional | Holds repo-url (clone URL with token), mounted at /git-config. |
Job {jobName} | yes | Runs the executor container. |
Mounts on the executor pod:
| Path | Source |
|---|---|
/credentials | The AiModel's optional credentialsRef Secret (key CREDENTIALS_SECRET_KEY, default token). Mounted only when set — used by the claude executor; qwen-code/codex authenticate from the caller's apiKeyRef. |
/prompt | Prompt ConfigMap, key prompt.txt. |
/git-config | Optional git clone URL Secret. |
/uploads | Read-only mount of the shared RWX uploads PVC, when workspaceUploads is set. |
/models | Optional models-cache PVC mount for local inference model downloads (when modelUrl is configured). |
Defaults:
- Image: per-provider executor image (default
ghcr.io/labrats-work/apps.ai-agents/executor-claude:<app-version>, or theEXECUTOR_IMAGEenv). Resolved per provider fromAiExecutorCRs /AiConfig.spec.executors, and overridable per-job via the IR'sspec.config.executorImage. - Resources: requests
cpu: 100m, memory: 512Mi; limitscpu: 2, memory: 4Gi(uniform default for all providers). Requests are intentionally a low floor — executors are I/O-bound on remote inference; the limits carry burst headroom (#1261). Override per provider via theAiExecutorCR'sspec.resources. activeDeadlineSeconds = ceil(timeoutMs / 1000) + 60(defaulttimeoutMs= 3 300 000 ms / 55 min).- Labels:
app.kubernetes.io/managed-by=ai-agents,app.kubernetes.io/component=executor,ai-agents.labrats.work/job-id=<truncated jobId>. - ServiceAccount: from
EXECUTOR_SERVICE_ACCOUNTenv. Must allow PATCHing the credentials Secret (used by the executor entrypoint to persist OAuth refresh on exit).
The operator polls the Job on each reconcile pass and, on a terminal Job condition, patches the IR status (phase, usage) and deletes the Job. The executor never writes IR status — it only PATCHes its own credentials Secret on exit.
Executor Container (executor/)
One image per CLI provider, built from executor/Dockerfile.{claude,codex,qwen-code,gemini} (Node Alpine base, CLIs pre-installed). All run as user executor (uid 1001). The qwen-code image bundles Qwen Code (a Gemini-CLI fork) pointed at an OpenAI-compatible endpoint — the in-cluster Inference Proxy, which fronts the GX10 vLLM backend.
vllm and ollama are not executor images: they are HTTP inference backends. When an AiModel has a spec.providers[].url, the reconciler routes inference via HttpInferenceClient (src/k8s/http-inference-client.ts) to that URL instead of creating a Job (falling back to a Job if the endpoint is unhealthy). mock is a test provider.
geminiis a fully supported, built provider (spec 0027) — it runs from its ownentrypoint-gemini.sh/Dockerfile.gemini. The legacygemma/bonsai/qwen(llama-cli+ GGUF) branches were removed fromentrypoint.sh(no image ever built them); the live providers areclaude/codex/qwen-code/mock(+gemini).
Entrypoint flow (executor/entrypoint.sh):
- Raise file descriptor limit (claude-code spawns many fsnotify watchers).
- Credentials:
PROVIDER=claude→ copy/credentials/tokeninto$HOME/.claude/.credentials.json(mode 0600). On EXIT, PATCH the Secret to persist refreshed tokens.PROVIDER=codex→ read/credentials/tokenintoCODEX_API_KEY.PROVIDER=qwen-code→ render~/.qwen/settings.jsonfrom the template, pointing atOPENAI_BASE_URL(the proxy); the caller's key is presented as the API key.
- Git clone: read repo URL from
/git-config/repo-url(orGIT_REPO_URLenv); shallow clone with depth fromGIT_DEPTH(default 1). - Workspace uploads: if
WORKSPACE_UPLOADSenv is set (JSON[{id,name}]), copy/uploads/{id}/{name}into the workspace. - MCP servers & skills: translate the
MCP_SERVERSenv into~/.claude.json/~/.qwen/settings.json, and install any mounted/skills/<name>into the CLI's skills dir for auto-discovery. - Prompt: read from
/prompt/prompt.txt(orPROMPT_TEXTenv). - CLI invocation (wrapped in
timeout ${TIMEOUT_SEC}s):claude—claude --print --verbose --dangerously-skip-permissions --output-format stream-json --no-session-persistence [--model M] [--effort E] [--fast] [--max-turns N]codex—codex exec --json --full-auto --skip-git-repo-check [--model M] [--config model_reasoning_effort=E]qwen-code— the Qwen Code CLI against the configured OpenAI-compatible endpoint.mock— log metadata, exit 0 (used in tests).
- Exit code is propagated;
124means timeout.
Model Slots & Credentials (src/k8s/models.ts)
There is no AiAccount pool — it was removed in spec 0003; credential and concurrency tracking moved onto AiModel. A watcher-driven informer keeps a local cache of AiModel CRs.
- Status states (
AiModelCR.status.state):idle,busy,unavailable,error.spec.paused: truekeeps a model out of rotation. - Concurrency:
spec.maxConcurrencycaps simultaneous jobs per model (0 = unlimited);status.activeJobs/status.activeJobIds[]track in-flight IRs. - Acquisition (
acquire()) uses an optimistic JSON Patch with atestop onstatus.activeJobsto avoid races; on conflict the next candidate is tried. - Selection filters out
paused/error/unavailablemodels and those at max concurrency, optionally filters by model name, then sorts by load ratio ascending, then least-recently-used. A provider URL is chosen fromspec.providers[](skippingunavailableones). - Crash recovery: a periodic in-operation slot audit (
auditSlots, every 60s, also once at startup) reclaims only slots whoseactiveJobIdsreference IRs that are missing or terminal. Startup deliberately does not zero busy models — in-flight executor Jobs survive an operator restart (#1525).
Credentials
- Caller identity: every IR carries
spec.apiKeyRef(required) → executorAI_AGENTS_API_KEYfor the MCP callback. Sourced from the request's Bearer token (submit/events/ingest),WebhookSource.spec.apiKeyRef(webhook events),AiAgent.apiKeyRef(triggers), or inherited (chains). No long-lived executor service credential. (spec 0007) - Backend token: optional
AiModel.spec.credentialsRef→/credentials/token, consumed only by theclaudeexecutor.qwen-code/codexuse the caller's key, so discovered models need none. - GitHub: short-lived App installation tokens minted per webhook event; webhooks HMAC-verified with the App
webhookSecret. No PAT.
Webhook Ingest
GitHub webhooks land at POST /api/webhooks/github on the webhook pod (or the API pod for the unified deployment).
src/sources/github/index.ts flow:
- Look up the
WebhookSourceCR byspec.type === "github". - Read
webhookSecret,appId,privateKey,installationIdfrom the credentials Secret referenced byspec.credentialsRef. - Verify HMAC-SHA-256 (
x-hub-signature-256header vs raw body). - Normalize
x-github-event+payload.actioninto a canonical event name. - Public-repo filter: if
payload.repository.private === false, deny unlessallowPublicRepos: trueorrepository.full_nameis inallowedPublicRepos. Returns200withmatched: 0on deny (not 4xx). - Match agents via
agentWatcher.findByEvent(source, event)plus mention parsing (@ai-{role},@agents-assemble). - Create one
InferenceRequestper matched agent viairClient.create(...).
Workspace and Uploads
The shared RWX uploads PVC is mounted read-write by the API pod and read-only by executor Jobs. Files referenced via workspaceUploads are copied from /uploads/{id}/{name} into the workspace before the CLI runs. The PVC name comes from UPLOADS_PVC_NAME.
Per-job ConfigMaps and git Secrets are deleted after Job termination via owner references and TTL.
Metrics and Logs
GET /metricsexposes Prometheus metrics (registryfromsrc/metrics.ts). The dashboard timeseries page reads fromPROMETHEUS_URL.GET /api/jobs/:id/logsis a Server-Sent Events stream that:- Streams executor pod logs live (
follow: true) when anexecutorPodNameis recorded. - Falls back to tailing the persisted log file in the uploads PVC (used by pipeline jobs).
- Closes with
{done:true,status:…}once the IR reaches a terminal phase.
- Streams executor pod logs live (
Config Defaults Resolution
Job submission spec.config
│
▼ (per-field fallback)
AiAgent.spec.defaultModel / defaultEffort / provider
│
▼
AiConfig.spec.providers[provider].model / effort
│
▼
AiConfig.spec.model / effort
│
▼
Hard defaults: provider="claude", effort="medium"
AiConfig.spec.executorImage and executorResources apply uniformly to all jobs unless an IR explicitly overrides them.