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 Job is cleaned up by the executor on completion via JSON patch back to the IR status.
Executor Container (executor/)
One image per CLI provider, built from executor/Dockerfile.{claude,codex,qwen-code} (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.
The
entrypoint.shstill contains legacygemma/bonsai/qwen(llama-cli+ GGUF) branches, but no image is built for them in the current matrix — they are not part of the supported provider set above.
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: on startup, models whose
activeJobIdsreference IRs no longerRunningare reconciled back to idle.
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.