Osmia is a Kubernetes-native AI coding agent harness that orchestrates autonomous developer agents (Claude Code, OpenAI Codex) to perform maintenance and development tasks on codebases at scale. It differentiates from tools like OpenClaw by being enterprise-grade, security-first, and built on Kubernetes primitives for isolation, observability, and scaling.
This plan covers the transformation of Unitary's internal Osmia into an open-source project (Apache 2.0), including technical architecture, plugin system design, implementation phases, and community bootstrapping.
| Decision | Choice | Rationale |
|---|---|---|
| OSS strategy | Fork & generalise | New OSS repo; Unitary deploys as downstream consumer with Shortcut/Slack plugins |
| LLM support | Agnostic from day one | Abstract execution engine interface; ship with Claude Code + Codex |
| Licence | Apache 2.0 | Enterprise-friendly, patent grant, CNCF-aligned |
| Agent teams | Experiment with in-process mode | Try Claude Code's experimental agent teams inside K8s Jobs |
| Project name | Osmia | No major conflicts; direct, descriptive |
| MVP scope | Core + GitHub Issues + Slack | Lowest barrier to entry for OSS contributors |
| Plan scope | Technical + community | Cover architecture, implementation, and OSS bootstrapping |
| Language | Go controller + polyglot plugins | K8s operators are Go's home ground; plugins via gRPC for language-agnostic extensibility |
+---------------------------+
| Ticketing Backends |
| (GitHub Issues, Jira, etc) |
+-------------+-------------+
|
v
+-------------------+ +------------------------------+ +---------------------+
| Notification |<-->| Osmia Controller |<-->| Secrets Backend |
| Channels | | (controller-runtime operator)| | (K8s, Vault, AWS SM) |
| (Slack, Discord, | | | +---------------------+
| Teams, etc) | | - Polls ticketing backend |
+-------------------+ | - Manages TaskRun CRDs | +---------------------+
| - Creates K8s Jobs |<-->| Code Review Backend |
+-------------------+ | - Monitors completion | | (CodeRabbit, native) |
| Human Approval |<-->| - Tracks costs | +---------------------+
| Backend | +-------------+----------------+
| (Slack, PagerDuty)| |
+-------------------+ | creates per ticket
v
+------------------------------+
| K8s Job (per ticket) |
| |
| +------------------------+ |
| | Execution Engine | |
| | (Claude Code / Codex) | |
| +------------------------+ |
| | Guard Rails (hooks) | |
| +------------------------+ |
| | MCP Server (stdio) | |
| | - notify, ask_human | |
| | - wait_for_pipeline | |
| +------------------------+ |
| | Git + SCM CLI | |
| | (git, glab, gh) | |
| +------------------------+ |
| | Result: /workspace/ | |
| | result.json | |
| +------------------------+ |
+------------------------------+
Every ticket-to-job lifecycle is tracked via a TaskRun CRD (or in-memory state object for simpler deployments). This provides idempotency, auditability, and crash recovery.
+----------+
| Queued |
+----+-----+
| controller creates K8s Job
v
+----------+
+----->| Running |
| +----+-----+
| |
| +-----+------+-------+
| | | |
| v v v
+---+------+ +-------+--+ +-+----------+
|NeedsHuman| |Succeeded | | Failed |
+---+------+ +----------+ +-----+------+
| |
| human responds | if retryable
| v
+-----------+ +-----+------+
| | Retrying |
| +------------+
v
(resumes Running)
Idempotency: Each TaskRun has a unique key derived from {ticket_id}-{ticket_revision}. The controller checks for existing TaskRuns before creating jobs, preventing duplicate work on restart or network partition.
Fields:
class TaskRun(BaseModel):
id: str # Unique identifier
idempotency_key: str # {ticket_id}-{revision}
ticket_id: str
engine: str # "claude-code" | "codex"
state: TaskRunState # Queued | Running | NeedsHuman | Succeeded | Failed | Retrying | TimedOut
job_name: str | None # K8s Job name once created
created_at: datetime
updated_at: datetime
result: TaskResult | None # Populated on completion
human_question: str | None # Set when state is NeedsHuman
retry_count: int = 0
max_retries: int = 1
heartbeat_at: datetime | None # Last heartbeat from job
heartbeat_ttl_seconds: int = 300 # Mark stale if no heartbeat
# Progress signals (enriched heartbeat, used by progress watchdog - see 3.7)
tokens_consumed: int = 0
files_changed: int = 0
tool_calls_total: int = 0
last_tool_name: str | None = None
consecutive_identical_tools: int = 0The initial assumption was Python for everything — it's what the Unitary team knows best, and the existing internal Osmia is Python. However, the controller is a Kubernetes operator, and that changes the calculus significantly.
What the controller actually does:
- Runs a K8s operator reconciliation loop (poll ticketing, reconcile TaskRuns, create/monitor Jobs)
- Loads and communicates with plugins
- Exposes Prometheus metrics
- Makes HTTP calls to ticketing/notification APIs
- Reads heartbeat data from ConfigMaps/CRD status
- Manages CRDs and RBAC
What it does not do:
- CPU-intensive computation
- Handle high-throughput request serving (it's a single-replica operator)
- Anything latency-sensitive (the agents themselves take minutes to hours)
Why Go for the controller:
- client-go is the reference K8s client. Every other client (Python, Rust, Java) is a wrapper or port. CRD controllers, informers, leader election, watch semantics — all first-class in Go.
- controller-runtime and kubebuilder are the standard for building K8s operators. They are mature, battle-tested, and what every K8s contributor already knows. By contrast, kopf (Python) is a solid framework but a distant second in community adoption; it does support multi-replica deployments via its Peering mechanism, but leader election is not as seamless as controller-runtime's Lease-based approach and the Python K8s ecosystem is generally less mature.
- Static binary, tiny image, low memory. A Go controller compiles to a single binary with no runtime dependencies. The container image can be built FROM scratch or distroless, typically under 30 MB. This matters for an OSS project where people run it alongside production workloads.
- The OSS K8s community writes Go. Contributors who want to work on a K8s operator expect Go. Choosing Python would be a friction point for the exact audience we're targeting.
- Leader election for HA. controller-runtime has built-in leader election via Lease objects. kopf supports HA via Peering, but controller-runtime's approach is more mature, more widely deployed, and integrates naturally with the K8s ecosystem.
Why not Python for the controller:
- kopf works, but it's swimming against the current in the K8s ecosystem.
- The Python kubernetes client is auto-generated from OpenAPI specs and is often behind the Go client in feature support.
- kopf supports HA via Peering, but it's an add-on rather than a built-in primitive, and the community experience around it is thin compared to controller-runtime's Lease-based leader election.
Why not Rust:
- Overkill for this workload. The controller is I/O-bound, not CPU-bound. Rust's strengths (memory safety without GC, zero-cost abstractions) don't buy much here.
- kube-rs exists but the ecosystem is small compared to client-go.
- It would significantly shrink the contributor pool.
Why polyglot plugins (not Go-only):
- Forcing every plugin author to write Go would be a barrier to adoption. Someone building a Jira ticketing plugin shouldn't need to learn Go.
- Go's built-in
pluginpackage is essentially abandoned. The alternative is hashicorp/go-plugin, which communicates over gRPC — heavier than Python entry points but far more robust and language-agnostic. - A gRPC plugin interface means plugins can be written in Python, Go, TypeScript, or any language with gRPC support. A Python plugin SDK (a thin wrapper around the gRPC stubs) keeps the plugin development experience easy for Python developers.
- First-party plugins that ship with the controller (GitHub Issues, Slack, K8s Secrets) are written in Go and compiled into the binary for zero-config deployments. Third-party plugins run as separate processes communicating over gRPC.
The architecture:
Controller (Go binary, single static image)
├── Built-in plugins (compiled in)
│ ├── GitHub Issues ticketing
│ ├── Slack notifications
│ ├── K8s Secrets backend
│ └── GitHub/GitLab SCM
├── gRPC plugin host (hashicorp/go-plugin)
│ ├── External plugin: Jira (Python, Go, or any gRPC language)
│ ├── External plugin: Teams (TypeScript)
│ └── External plugin: Vault secrets (Go)
└── Plugin SDKs (generated from protobuf)
├── osmia-plugin-sdk-python # pip install osmia-plugin-sdk
├── osmia-plugin-sdk-go # go get github.com/osmia/plugin-sdk-go
└── osmia-plugin-sdk-ts # npm install @osmia/plugin-sdk
Impact on Unitary:
- Unitary's
osmia-plugin-shortcut(Phase 6) can be written in Python using the Python SDK. No need for the Unitary team to learn Go for plugin development. - The controller itself will be built by Claude, which is equally capable in Go and Python.
The core innovation for OSS is a plugin system that makes every external integration swappable. First-party plugins are compiled into the controller binary. Third-party plugins run as separate processes communicating over gRPC (via hashicorp/go-plugin), allowing plugin authors to use any language with gRPC support.
osmia/
cmd/osmia/ # Main entrypoint
internal/
controller/ # Reconciliation loop (controller-runtime)
jobbuilder/ # ExecutionSpec -> K8s Job translation
taskrun/ # TaskRun state machine + idempotency
watchdog/ # Progress watchdog loop
config/ # Configuration loading
metrics/ # Prometheus metrics
pkg/
engine/ # ExecutionEngine interface + built-in engines
claudecode/ # Claude Code engine (compiled in)
codex/ # OpenAI Codex engine (compiled in)
plugin/ # gRPC plugin host (hashicorp/go-plugin)
ticketing/ # TicketingBackend interface + built-in
github/ # GitHub Issues (compiled in)
notifications/ # NotificationChannel interface + built-in
slack/ # Slack (compiled in)
approval/ # HumanApprovalBackend interface + built-in
slack/ # Slack approval (compiled in)
secrets/ # SecretsBackend interface + built-in
k8s/ # Kubernetes Secrets (compiled in)
review/ # ReviewBackend interface
coderabbit/ # CodeRabbit (compiled in)
scm/ # SCMBackend interface
github/ # GitHub (gh CLI, compiled in)
gitlab/ # GitLab (glab CLI, compiled in)
proto/ # Protobuf definitions for plugin interfaces
ticketing.proto
notifications.proto
approval.proto
secrets.proto
review.proto
scm.proto
Built-in plugins are compiled into the controller binary and require no external process. They are registered in Go code at build time and always available.
External plugins run as separate processes communicating over gRPC (hashicorp/go-plugin). They are configured in osmia-config.yaml:
# osmia-config.yaml
plugins:
ticketing:
jira:
command: "/opt/osmia/plugins/osmia-plugin-jira" # Go binary
interface_version: 1
shortcut:
command: "python -m osmia_plugin_shortcut" # Python via SDK
interface_version: 1
notifications:
teams:
command: "node /opt/osmia/plugins/osmia-plugin-teams/index.js" # TypeScript
interface_version: 1The controller spawns each external plugin as a subprocess, establishes a gRPC connection, and performs a health check + interface version handshake at startup. If a plugin fails to respond or declares an incompatible version, the controller logs a clear error and refuses to start.
Plugin deployment — getting binaries onto the controller pod:
External plugin binaries must be available on the controller pod's filesystem. There are three supported approaches, in order of preference:
-
Custom controller image (recommended for production): Build a Dockerfile that starts
FROM ghcr.io/osmia/controller:latestand copies plugin binaries into/opt/osmia/plugins/. This produces a single self-contained image with all plugins baked in.FROM ghcr.io/osmia/controller:latest COPY --from=ghcr.io/myorg/osmia-plugin-jira:latest /plugin /opt/osmia/plugins/osmia-plugin-jira COPY --from=ghcr.io/myorg/osmia-plugin-pagerduty:latest /plugin /opt/osmia/plugins/osmia-plugin-pagerduty
-
Init containers (recommended for Helm-based deployments): Each external plugin ships as a container image containing its binary. Init containers copy the binary into a shared
emptyDirvolume before the controller starts.initContainers: - name: plugin-jira image: ghcr.io/myorg/osmia-plugin-jira:v1.2.0 command: ["cp", "/plugin", "/plugins/osmia-plugin-jira"] volumeMounts: - name: plugins mountPath: /plugins containers: - name: controller image: ghcr.io/osmia/controller:latest volumeMounts: - name: plugins mountPath: /opt/osmia/plugins volumes: - name: plugins emptyDir: {}
-
Python plugins via pip (for Python SDK plugins): If a plugin is a Python package, the controller can invoke it as
python -m plugin_nameprovided the Python runtime and package are available. A sidecar or init container can install the package. This is the simplest approach for Python plugin authors but adds a Python runtime dependency to the controller pod.
The Helm chart includes a plugins values section for configuring init containers declaratively:
# values.yaml
plugins:
- name: jira
image: ghcr.io/myorg/osmia-plugin-jira:v1.2.0
binaryPath: /plugin
- name: pagerduty
image: ghcr.io/myorg/osmia-plugin-pagerduty:v0.3.1
binaryPath: /pluginPlugin crash handling and restart:
External plugins run as gRPC subprocesses managed by hashicorp/go-plugin. If a plugin process crashes:
- Detection: The controller detects the crash via the gRPC connection dropping. hashicorp/go-plugin surfaces this as an error on the next RPC call.
- Restart: The controller attempts to restart the plugin subprocess up to
max_plugin_restartstimes (default: 3) with exponential backoff (1s, 5s, 30s). On restart, the version handshake is re-performed. - Degraded mode: If restarts are exhausted, the controller enters degraded mode for that plugin category. For non-critical plugins (notifications, review), the controller continues operating and logs warnings. For critical plugins (ticketing, secrets), the controller stops processing new tickets and raises an alert.
- Metrics: Plugin health is exposed via Prometheus metrics (
osmia_plugin_restarts_total,osmia_plugin_healthygauge).
# osmia-config.yaml
plugin_health:
max_plugin_restarts: 3
restart_backoff: [1, 5, 30] # seconds
critical_plugins: ["ticketing", "secrets", "scm"]Plugin SDKs are generated from the protobuf definitions in proto/ and published as language-specific packages. Each SDK includes:
- Generated gRPC client/server stubs
- A base class (Python) or interface (Go) with boilerplate handled
- A CLI entrypoint for running the plugin as a gRPC subprocess
- A testing harness for local development (mock controller that exercises the plugin interface)
# Python plugin author
pip install osmia-plugin-sdk
osmia-plugin scaffold --interface ticketing --name my-jira-plugin
# Creates a working skeleton with tests, Dockerfile, and CI config
# Implement the TicketingBackend gRPC service, then run:
osmia-plugin serve --port 0 # Port allocated by hashicorp/go-plugin
# Go plugin author
go get github.com/osmia/plugin-sdk-go
# Implement the TicketingBackend interface, compile, done.
# Test locally without a controller
osmia-plugin test --interface ticketing --binary ./my-pluginEvery plugin interface has an interface_version field in its protobuf service definition. The controller and plugin perform a version handshake during the gRPC health check at startup.
// proto/ticketing.proto
service TicketingBackend {
rpc Handshake(HandshakeRequest) returns (HandshakeResponse);
rpc PollReadyTickets(PollRequest) returns (PollResponse);
rpc MarkInProgress(MarkInProgressRequest) returns (Empty);
// ...
}
message HandshakeResponse {
int32 interface_version = 1; // Bumped on breaking changes
string plugin_name = 2;
string plugin_version = 3;
}Compatibility contract:
- Patch version changes (same
interface_version): backwards-compatible additions only (new optional fields in protobuf messages) - Major version bumps: new
interface_versionvalue; old plugins stop loading with a descriptive error message pointing to a migration guide - The migration guide for each interface version bump is published in the documentation
The controller loads plugins at startup from configuration:
# osmia-config.yaml (mounted as ConfigMap)
ticketing:
backend: github # or "jira", "shortcut", "linear"
config:
repo: "org/repo"
labels: ["osmia"]
notifications:
channels:
- backend: slack
config:
channel_id: "C12345"
secrets:
backend: k8s # or "vault", "aws-sm", "1password"
engines:
default: claude-code # or "codex"
claude-code:
image: ghcr.io/osmia/engine-claude-code:latest
codex:
image: ghcr.io/osmia/engine-codex:latest
review:
backend: coderabbit # or "native", "none"
scm:
backend: github # or "gitlab"All interfaces are defined in protobuf (source of truth) and implemented as Go interfaces in the controller. Plugin SDKs for Python, Go, and TypeScript are generated from these protobufs. The examples below show the Go interfaces for the controller; plugin authors interact with the generated gRPC stubs in their language of choice.
The execution engine is the most critical abstraction — it wraps the AI coding tool that runs inside each K8s Job. The interface is deliberately decoupled from Kubernetes: engines return an ExecutionSpec (a pure data struct), and the core JobBuilder translates that into a V1Job. This enables testing without a cluster and opens the door to non-K8s runtimes (Docker, Podman) in future.
// pkg/engine/engine.go
// TaskResult is the structured result written by the engine to /workspace/result.json.
type TaskResult struct {
Success bool `json:"success"`
MergeRequestURL string `json:"merge_request_url,omitempty"`
BranchName string `json:"branch_name,omitempty"`
Summary string `json:"summary"`
TokenUsage *TokenUsage `json:"token_usage,omitempty"`
CostEstimateUSD float64 `json:"cost_estimate_usd,omitempty"`
ExitCode int `json:"exit_code"` // 0=success, 1=agent failure, 2=guard rail blocked
}
// ExecutionSpec is an engine-agnostic description of what to run.
// The core JobBuilder translates this into a K8s Job (or Docker run, etc).
type ExecutionSpec struct {
Image string `json:"image"`
Command []string `json:"command"`
Env map[string]string `json:"env"`
SecretEnv map[string]string `json:"secret_env"`
ResourceRequests Resources `json:"resource_requests"`
ResourceLimits Resources `json:"resource_limits"`
Volumes []VolumeMount `json:"volumes"`
ActiveDeadlineSeconds int `json:"active_deadline_seconds"`
}
// ExecutionEngine wraps an AI coding tool (Claude Code, Codex, etc).
type ExecutionEngine interface {
// BuildExecutionSpec returns a runtime-agnostic spec; the core JobBuilder
// handles translation to K8s Jobs, Docker containers, etc.
BuildExecutionSpec(task Task, config EngineConfig) (*ExecutionSpec, error)
// BuildPrompt constructs the task prompt for this engine.
BuildPrompt(task Task) (string, error)
// Name returns a unique engine identifier (e.g. "claude-code", "codex").
Name() string
// InterfaceVersion returns the version this engine implements.
InterfaceVersion() int
}The TaskResult is not parsed from pod logs. Instead, engines must write a structured JSON file to /workspace/result.json on exit. The controller reads this from the completed pod's filesystem. Exit codes carry semantic meaning:
0— success (result.json contains the outcome)1— agent failure (result.json may contain partial information)2— guard rail blocked the operation
Defined in proto/ticketing.proto and implemented as a Go interface for built-in plugins:
// pkg/plugin/ticketing/ticketing.go
type Ticket struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
TicketType string `json:"ticket_type"`
Labels []string `json:"labels"`
RepoURL string `json:"repo_url,omitempty"`
ExternalURL string `json:"external_url"`
Raw map[string]any `json:"raw"` // Original ticket data
}
type TicketingBackend interface {
PollReadyTickets(ctx context.Context) ([]Ticket, error)
MarkInProgress(ctx context.Context, ticketID string) error
MarkComplete(ctx context.Context, ticketID string, result TaskResult) error
MarkFailed(ctx context.Context, ticketID string, reason string) error
AddComment(ctx context.Context, ticketID string, comment string) error
}Notifications and human approvals are deliberately separated. NotificationChannel is fire-and-forget; HumanApprovalBackend handles event-driven interactions. This allows a Discord bot to send notifications while a Slack bot handles approvals, for example.
// pkg/plugin/notifications/notifications.go
type NotificationChannel interface {
Notify(ctx context.Context, message string, ticket Ticket) error
NotifyStart(ctx context.Context, ticket Ticket) error
NotifyComplete(ctx context.Context, ticket Ticket, result TaskResult) error
}
// pkg/plugin/approval/approval.go
// HumanApprovalBackend is event-driven, not blocking. When a question is asked,
// the TaskRun transitions to NeedsHuman state. When the human responds,
// a webhook/callback resumes the flow.
type HumanApprovalBackend interface {
RequestApproval(ctx context.Context, question string, ticket Ticket, taskRunID string, options []string) error
CancelPending(ctx context.Context, taskRunID string) error
}// pkg/plugin/secrets/secrets.go
type SecretsBackend interface {
GetSecret(ctx context.Context, key string) (string, error)
GetSecrets(ctx context.Context, keys []string) (map[string]string, error)
BuildEnvVars(secretRefs map[string]string) ([]corev1.EnvVar, error)
}Enterprises need configurable safety boundaries. Osmia provides guard rails at three levels, plus an optional quality gate and progress watchdog for defence in depth:
Applied before a job is created. Configured in osmia-config.yaml:
guardrails:
# Maximum cost per job (USD)
max_cost_per_job: 50.00
# Maximum concurrent jobs
max_concurrent_jobs: 5
# Maximum job duration
max_job_duration_minutes: 120
# Allowed repositories (glob patterns)
allowed_repos:
- "org/frontend-*"
- "org/backend-*"
# Blocked file patterns (never modify these)
blocked_file_patterns:
- "*.env"
- "**/secrets/**"
- "**/credentials/**"
# Require human approval before creating MR
require_human_approval_before_mr: false
# Allowed task types
allowed_task_types:
- "dependency_upgrade"
- "test_fix"
- "bug_fix"
- "documentation"Applied inside the execution container via Claude Code hooks. These are injected as a settings.json file mounted into the container:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/opt/osmia/hooks/block-dangerous-commands.sh"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/opt/osmia/hooks/block-sensitive-files.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/opt/osmia/hooks/on-complete.sh"
}
]
}
]
}
}Users can provide a guardrails.md file that is appended to every prompt sent to the execution engine. This allows natural-language policy enforcement:
# Guard Rails
## Never Do
- Never modify CI/CD pipeline configuration files
- Never change database migration files
- Never alter authentication or authorisation logic
- Never commit secrets, API keys, or credentials
## Always Do
- Always run the full test suite before creating an MR
- Always add tests for new functionality
- Always follow the existing code style in the repositoryDifferent task types carry different risk profiles. The guard rails system supports permission profiles that tailor file access, allowed commands, and cost limits per task type:
guardrails:
task_profiles:
dependency_upgrade:
allowed_file_patterns:
- "pyproject.toml"
- "uv.lock"
- "requirements*.txt"
- "Dockerfile"
- ".github/workflows/*"
max_cost_per_job: 30.00
max_job_duration_minutes: 60
bug_fix:
blocked_file_patterns:
- "*.env"
- "**/migrations/**"
- "**/auth/**"
max_cost_per_job: 50.00
max_job_duration_minutes: 120
documentation:
allowed_file_patterns:
- "*.md"
- "docs/**"
- "README*"
blocked_commands:
- "git push"
- "pip install"
max_cost_per_job: 10.00
max_job_duration_minutes: 30The controller selects the profile based on ticket labels or type field from the ticketing backend. If no profile matches, the global guard rails apply.
Inspired by CI/CD quality gates and Gas Town's "Mayor" and "Witness" roles, Osmia supports an optional quality gate that reviews the work of execution agents before it is finalised. The quality gate runs as a separate, lightweight K8s Job after the main agent completes — a pass/fail check between agent output and MR creation.
Architecture:
K8s Job (main agent) K8s Job (quality gate, optional)
+----------------------+ +---------------------------+
| Execution Engine | ---> | Quality Gate Agent |
| (writes code, MR) | | (reviews output) |
| result.json | | |
+----------------------+ | Checks: |
| - Code quality |
| - Security scan |
| - Guard rail compliance |
| - Test coverage |
| gate-result.json |
+---------------------------+
Two quality gate modes:
-
Post-completion review (default): The gate runs after the main agent finishes, receives the diff and
result.json, and produces agate-result.jsonwith a pass/fail verdict and comments. If it fails, the controller can retry the main agent with the gate's feedback appended to the prompt. -
Security gate: A specialised variant focused exclusively on security concerns:
- Scans the diff for committed secrets (using patterns like
trufflehogorgitleaks) - Checks for common vulnerability patterns (SQL injection, XSS, command injection)
- Validates that guard rails were respected (no blocked files modified)
- Verifies that no new dependencies with known CVEs were introduced
- Scans the diff for committed secrets (using patterns like
quality_gate:
enabled: false # Opt-in
mode: "post-completion" # or "security-only"
engine: claude-code # Can use a cheaper/faster model
max_cost_per_review: 5.00
security_checks:
scan_for_secrets: true
check_owasp_patterns: true
verify_guardrail_compliance: true
check_dependency_cves: true
on_failure: "retry_with_feedback" # or "block_mr", "notify_human"The quality gate is deliberately lightweight — it does not have write access to the repository. It only reads the diff, runs checks, and produces a verdict. This separation of concerns ensures the gate cannot introduce its own issues.
Future consideration: When agent teams matures, the quality gate could run as a teammate rather than a separate job, enabling real-time oversight during execution rather than only post-completion review.
Every agent execution produces a full transcript (prompt, tool calls, results, final output) that is persisted as an append-only log alongside result.json. This is invaluable for debugging, auditing, cost analysis, and improving prompt engineering.
/workspace/
result.json # Structured outcome
transcript.jsonl # Append-only event log (one JSON object per line)
cost-breakdown.json # Token counts and cost estimates per turn
The transcript format follows a simple event schema:
class TranscriptEvent(BaseModel):
timestamp: datetime
event_type: str # "prompt" | "tool_call" | "tool_result" | "response" | "error"
content: dict # Event-specific payload
token_count: int | None # Tokens consumed by this eventThe controller copies these artefacts from completed pods and attaches them to the TaskRun record. For long-term storage, they can be forwarded to an object store (S3, GCS) or log aggregation system.
The quality gate (section 3.5) reviews agent output after completion. The progress watchdog addresses a different problem: detecting agents that are stalled, looping, or otherwise unproductive during execution, before they exhaust their timeout or cost budget.
Failure modes the progress watchdog detects:
- Looping: Agent repeatedly calls the same tool with the same arguments on the same files, never converging on a solution (e.g., editing a file, running tests, reverting, editing the same file again).
- Thrashing: High token consumption with no meaningful progress — cost climbing but no new file changes, no commits, no MR created.
- Stalled process: Agent process is alive (heartbeat continues) but has stopped making tool calls — stuck waiting on something internal.
- Unanswered human question: Agent entered
NeedsHumanstate but no human responded within a configurable window. The job is idle, holding resources. - Telemetry failure: The heartbeat hook itself has crashed or was never initialised — the progress watchdog must distinguish this from a genuine stall.
Architecture:
The progress watchdog is a controller-side loop, not a separate job. It runs on a configurable interval (default: 60 seconds) and inspects progress signals for each active TaskRun.
Controller
+------------------------------------------+
| Progress Watchdog Timer (controller loop)|
| |
| For each active TaskRun: |
| 1. Read heartbeat from K8s resource |
| 2. Compare progress signals to |
| previous checkpoint |
| 3. Evaluate rules for current state |
| (Running vs NeedsHuman) |
| 4. Require 2+ consecutive ticks |
| before any terminate action |
| 5. CAS update TaskRun status |
+------------------------------------------+
Per-state rule activation:
Not all anomaly rules apply to all TaskRun states. The progress watchdog uses a state-based activation table:
| Rule | Running |
NeedsHuman |
|---|---|---|
| Loop detection | Active | Inactive |
| Thrashing detection | Active | Inactive |
| Stall detection | Active | Inactive |
| Cost velocity | Active | Inactive |
| Unanswered human timeout | Inactive | Active |
| Telemetry failure | Active | Inactive |
When the progress watchdog terminates a NeedsHuman job, it must also call HumanApprovalBackend.cancel_pending() to clean up the pending question in Slack/Teams, so the human does not see a reply prompt for a job that no longer exists.
Enriched heartbeat — push-based telemetry:
The existing heartbeat mechanism (section 1.2) is extended with progress signals. Inside the agent container, the PostToolUse hook emits heartbeat data after every tool call. The data is pushed to a Kubernetes resource (ConfigMap or TaskRun status subresource keyed by TaskRun UID), which the controller watches directly. This avoids the need for kubectl exec into agent pods, which would require high-privilege pods/exec RBAC and create a significant blast radius if the controller is compromised.
The hook writes to a local file (/workspace/heartbeat.json) using atomic write-then-rename to prevent partial reads, then a lightweight sidecar or init-container-configured CronJob pushes updates to the K8s API.
class Heartbeat(BaseModel):
seq: int # Monotonically increasing sequence number
run_id: str # TaskRun UID (detect stale/misdirected data)
timestamp: datetime
tokens_consumed: int
files_changed: int # Number of unique files modified
tool_calls_total: int
last_tool_name: str | None
last_tool_args_hash: str | None # Hash of arguments (for loop detection)
consecutive_identical_calls: int # Same (tool, args_hash) in succession
last_meaningful_change_at: datetime | None # Last file write/edit
cost_estimate_usd: floatThe controller rejects any heartbeat where seq is not greater than the last seen value and where run_id does not match the expected TaskRun.
For Codex (no hooks), a wrapper script around the Codex process tails its stdout and extracts progress indicators, writing the same heartbeat format.
Anomaly rules:
progress_watchdog:
enabled: true
check_interval_seconds: 60
# Require anomaly on 2+ consecutive ticks before any terminate action
min_consecutive_ticks: 2
# Grace period at the start of a job for research/exploration
research_grace_period_minutes: 5
rules:
# Agent has called the same (tool, args_hash) N times consecutively
# without files_changed increasing — distinguishes genuine loops from
# legitimate TDD cycles (edit-test-edit-test is normal)
loop_detection:
consecutive_identical_call_threshold: 10
require_no_file_progress: true
action: "terminate_with_feedback"
# Tokens consumed without any file changes (relaxed during grace period)
thrashing_detection:
tokens_without_progress_threshold: 80000
action: "warn" # Warn first, then terminate on next tick
escalation_action: "terminate_with_feedback"
# No tool calls for N seconds despite heartbeat seq advancing
stall_detection:
idle_seconds_threshold: 300
action: "terminate"
# NeedsHuman with no response for N minutes
unanswered_human_timeout_minutes: 30
unanswered_human_action: "terminate_and_notify"
# Cost velocity: spending more than $X in the last Y minutes
cost_velocity:
max_usd_per_10_minutes: 15.00
action: "warn"
# Heartbeat seq stopped advancing but hook was previously active
telemetry_failure:
stale_ticks_threshold: 3 # 3 ticks (~3 minutes) without seq change
action: "warn" # Don't kill a healthy agent due to hook failureActions:
| Action | Behaviour |
|---|---|
terminate |
Kill the K8s Job immediately. TaskRun transitions to Failed with a diagnostic reason. |
terminate_with_feedback |
Kill the job. Append a structured diagnosis to the prompt and retry (if retries remain). |
terminate_and_notify |
Kill the job and notify the human via NotificationChannel. For NeedsHuman, also calls cancel_pending(). |
warn |
Log a warning, send a notification, and set a progress_watchdog_warning condition on the TaskRun. If the anomaly persists on the next tick, escalate to escalation_action. |
Concurrency and state transitions:
The progress watchdog timer and the main controller reconciliation loop can race on TaskRun state updates. To prevent conflicts, all progress watchdog state writes use optimistic concurrency (compare-and-swap on resourceVersion for CRD-backed TaskRuns, or a progress_watchdog_terminating condition flag for in-memory state). If the CAS fails, the progress watchdog skips the update on that tick — the reconciliation loop's write takes precedence.
Retry with feedback:
When the progress watchdog terminates an agent, the retry prompt is rendered from a structured WatchdogReason object using a fixed template — raw tool arguments and file paths from the agent's session are never interpolated directly into the retry prompt, preventing prompt injection from adversarial content in file names or tool output.
class WatchdogReason(BaseModel):
reason_code: str # "loop" | "thrashing" | "stall" | "cost_velocity"
tool_name: str | None
call_count: int | None
tokens_consumed: int
cost_estimate_usd: float
message: str # Human-readable, rendered from templateRelationship to other mechanisms:
- Hard timeout (
activeDeadlineSeconds): A blunt safety net. The progress watchdog intervenes earlier and more intelligently — it can detect a loop at minute 5 rather than waiting for the 120-minute timeout. - Cost ceiling (
max_cost_per_job): Prevents runaway spend but doesn't diagnose why. The progress watchdog provides diagnostic context. - Quality gate (section 3.5): Reviews quality after completion. The progress watchdog prevents wasted execution during the run.
- Heartbeat TTL: Detects a dead process. The progress watchdog detects a live but unproductive process.
Implementation notes:
- Push-based telemetry is preferred. The
PostToolUsehook writes to/workspace/heartbeat.json(atomic rename:.tmp→.json), and a lightweight sidecar pushes to a ConfigMap or TaskRun status subresource. This eliminates the need forpods/execRBAC on the controller's ServiceAccount. If push-based telemetry is not feasible,kubectl execcan be used as a fallback — but it must be constrained to the agent namespace with label selectors, and all exec invocations must be audit-logged. - Cooldown/hysteresis prevents false positives. All
terminate*actions require the anomaly to be present on at leastmin_consecutive_ticks(default: 2) consecutive progress watchdog ticks. A single slow tool call cannot trigger termination. - Research grace period. During the first
research_grace_period_minutesof a job, the thrashing detector uses a relaxed threshold. Agents legitimately spend tokens reading documentation and exploring the codebase before making changes. - Progress watchdog rules are intentionally conservative by default — a false positive that kills a productive agent is worse than a false negative that wastes some tokens. Operators should tune thresholds based on their workload patterns.
Claude Code supports MCP (Model Context Protocol) servers via stdio and HTTP (streamable) transports. Project-scoped servers are configured in .mcp.json at the repository root (committed to source control), while user-scoped servers go in ~/.claude.json. Enterprise-managed servers can be deployed via /etc/claude-code/managed-mcp.json (Linux) or /Library/Application Support/ClaudeCode/managed-mcp.json (macOS). Environment variable expansion (${VAR} and ${VAR:-default}) is supported in all fields.
For Osmia, MCP servers serve two purposes:
-
Built-in MCP server: Osmia ships a notification/interaction MCP server that runs inside each job pod, providing
notify_human,ask_human, andwait_for_pipelinetools to the agent. -
User-configured MCP servers: Operators can bundle additional MCP servers into the container image or mount them, giving agents access to domain-specific tools (databases, internal APIs, monitoring systems).
# osmia-config.yaml
engines:
claude-code:
mcp_servers:
# Built-in (always present)
osmia:
command: "/opt/osmia/mcp/server"
args: ["--mode", "stdio"]
# User-configured (optional)
database:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-postgres"]
env:
DATABASE_URL: "${DATABASE_URL}"
sentry:
command: "npx"
args: ["-y", "@sentry/mcp-server"]
env:
SENTRY_AUTH_TOKEN: "${SENTRY_AUTH_TOKEN}"The engine translates this into a .mcp.json file at the workspace root:
{
"mcpServers": {
"osmia": {
"type": "stdio",
"command": "/opt/osmia/mcp/server",
"args": ["--mode", "stdio"]
},
"database": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env": { "DATABASE_URL": "${DATABASE_URL}" }
},
"remote-tool": {
"type": "http",
"url": "${REMOTE_MCP_URL:-https://mcp.internal.company.com/mcp}",
"headers": { "Authorization": "Bearer ${MCP_AUTH_TOKEN}" }
}
}
}Project-scoped servers in .mcp.json normally require approval before use. In headless mode with --dangerously-skip-permissions, this is bypassed — the expected pattern for K8s jobs.
Security consideration: MCP servers run inside the agent container and can make network calls, access files, and execute commands. They are subject to the same NetworkPolicy and securityContext as the agent. Operators should audit MCP server packages before adding them - the allowedMcpServers guard rail can restrict which servers are permitted:
guardrails:
allowed_mcp_servers:
- "osmia" # Always allowed
- "database"
- "sentry"
# Any MCP server not in this list will be stripped from settings.jsonClaude Code has a structured plugin system. A plugin is a directory containing a .claude-plugin/plugin.json manifest alongside component directories for skills, agents, hooks, MCP servers, and settings. Plugins are namespaced — a skill called review in a plugin named my-plugin becomes /my-plugin:review.
There are three marketplace sources:
- Official Anthropic marketplace (
claude-plugins-official): Pre-loaded, includes integrations for GitHub, GitLab, Slack, Sentry, etc. - Custom team marketplaces: Any Git repository or URL containing a
.claude-plugin/marketplace.json. Configured viaextraKnownMarketplacesin settings. - Community marketplace (
claudecodemarketplace.com): Unofficial, aggregates third-party plugins.
For Osmia containers, plugins can be loaded in three ways:
-
--plugin-dirflag (recommended for K8s): Point at plugin directories baked into the image. No formal installation needed.claude --plugin-dir /opt/osmia/plugins/security-tools -p "your prompt" -
Mounted from a ConfigMap/volume: Plugin directories mounted at runtime, allowing per-deployment customisation without image rebuilds.
-
Pre-enabled via settings.json: The
enabledPluginsfield in.claude/settings.jsonspecifies which marketplace plugins are active.
# osmia-config.yaml
engines:
claude-code:
plugins:
# Loaded via --plugin-dir (pre-baked in image)
- path: "/opt/osmia/plugins/security-tools"
# Enabled from team marketplace
- name: "coderabbit-integration@unitary-tools"
# Team marketplace configuration
marketplaces:
unitary-tools:
source: "gitlab"
repo: "unitaryai/internal-claude-plugins"Security consideration: Plugins can define hooks, MCP servers, and custom tools. They run with the same permissions as Claude Code itself. Enterprise managed settings support strictKnownMarketplaces to restrict which marketplaces are allowed, allowManagedHooksOnly to block plugin-defined hooks, and allowedMcpServers/deniedMcpServers to control which MCP servers (including those bundled with plugins) can actually load.
Claude Code supports two equivalent mechanisms for custom slash commands:
- Legacy:
.claude/commands/<name>.mdfiles (still supported) - Recommended:
.claude/skills/<name>/SKILL.mdfiles (supports frontmatter for invocation control,context: forkfor subagent execution, and supporting files)
Both create /command-name slash commands. Skills also support dynamic context injection via the !command`` syntax, which executes shell commands before the skill content is sent to Claude.
For task-type-specific behaviour, Osmia bundles skills into the container and copies them into the workspace:
/opt/osmia/skills/
dependency-upgrade/
SKILL.md # "Upgrade the specified dependency..."
test-fix/
SKILL.md # "Analyse the failing test and fix..."
bug-fix/
SKILL.md # "Investigate the bug described in..."
Example SKILL.md with frontmatter:
---
name: osmia-dependency-upgrade
description: Upgrade a specified dependency following project conventions
allowed-tools: Bash(uv *), Bash(git *), Read, Edit, Write
---
Upgrade the dependency specified in $ARGUMENTS.
Current branch: !`git branch --show-current`
Current Python version: !`python --version`
1. Check current pinned version in pyproject.toml
2. Run `uv add <package>@latest` to upgrade
3. Run `uv sync` to update the lock file
4. Run the test suite to verify compatibility
5. If tests fail, investigate and fix breaking changesThese are copied into .claude/skills/ in the workspace before the agent starts, and the prompt instructs the agent to use the appropriate skill for its task type.
Claude Code also supports custom subagents defined in .claude/agents/<name>.md. These run in forked context and can be useful for the quality gate pattern (see section 3.5).
- Codex: Uses
AGENTS.mdfor repository context. MCP servers and plugins are not supported. Task-specific instructions must be injected directly into the prompt. - Aider: Uses
.aider.conf.ymlfor configuration. Supports custom commands but not MCP. Repository conventions go in a.aider/conventions.mdfile.
The ExecutionEngine interface abstracts these differences - each engine's build_execution_spec method handles the engine-specific configuration.
| Engine | Method | K8s Implementation |
|---|---|---|
| Claude Code | ANTHROPIC_API_KEY env var |
K8s Secret mounted as env var |
| Claude Code | apiKeyHelper script |
Script in container + Vault/AWS SM integration |
| Claude Code | setup-token (Teams/Max) |
Pre-generated token stored as K8s Secret, set via CLAUDE_CODE_OAUTH_TOKEN |
| Claude Code | Amazon Bedrock | IRSA (IAM Roles for Service Accounts) + CLAUDE_CODE_USE_BEDROCK=1 |
| Claude Code | Google Vertex AI | Workload Identity Federation + CLAUDE_CODE_USE_VERTEX=1 |
| OpenAI Codex | OPENAI_API_KEY env var |
K8s Secret mounted as env var |
Users on Anthropic Teams, Enterprise, or Max plans have included usage allowances and want to use those rather than paying per-token via API keys. However, Claude Code's OAuth flow requires a browser-based login (claude /login), which is fundamentally incompatible with headless K8s environments.
Current state (February 2026):
- Browser-based OAuth is the only supported flow for Teams/Enterprise/Max
- A device-code flow (RFC 8628) has been formally requested (GitHub issue #22992) but is not yet implemented
- Copying
~/.claude/.credentials.jsonfrom a logged-in machine has a known bug where the refresh token is not used, causing 401 errors after token expiry (GitHub issue #21765) claude setup-tokencan generate a long-lived OAuth token (1-year lifetime, formatsk-ant-oat01-...) but requires an initial browser-based authentication on a capable machineANTHROPIC_API_KEYvia env var works reliably in headless mode but uses API billing, not plan allowances- The
apiKeyHelperTTL cache has a known bug (GitHub issue #11639) where the helper is called more frequently than configured
Osmia's approach:
- Default: API key - Works reliably today. Recommend
apiKeyHelperfor rotation. - setup-token (workaround): For Teams/Max plans, an admin can run
claude setup-tokenon a desktop machine to generate a long-lived token. This token is stored as a K8s Secret and set viaCLAUDE_CODE_OAUTH_TOKENenv var. Note: thesk-ant-oat01-format is non-standard and not accepted by all tools (GitHub issue #18340). - Track upstream: Monitor the device-code flow RFC 8628 request. When implemented, add native support.
- Cloud provider pass-through: If the organisation uses Bedrock (
CLAUDE_CODE_USE_BEDROCK=1) or Vertex AI (CLAUDE_CODE_USE_VERTEX=1), those authentication methods (IRSA, Workload Identity Federation) work natively in K8s and avoid the OAuth problem entirely. This is the most robust enterprise-headless pattern. - Credential injection (experimental): For organisations willing to accept the refresh token bug risk, the controller could accept a pre-authenticated credentials file via a K8s Secret. This should be clearly documented as unsupported and fragile.
engines:
claude-code:
auth:
method: api_key # "api_key" | "setup_token" | "bedrock" | "vertex" | "credentials_file"
# For api_key:
api_key_secret: "osmia-anthropic-key"
# For bedrock:
bedrock_region: "us-east-1"
# For credentials_file (experimental, fragile):
credentials_secret: "osmia-claude-credentials"For enterprise deployments with secret rotation requirements:
#!/bin/bash
# /opt/osmia/get-api-key.sh
# Called by Claude Code every 5 minutes (configurable via CLAUDE_CODE_API_KEY_HELPER_TTL_MS)
# Option 1: AWS Secrets Manager
aws secretsmanager get-secret-value \
--secret-id osmia/anthropic-api-key \
--query SecretString --output text
# Option 2: HashiCorp Vault
vault kv get -field=api_key secret/osmia/anthropic
# Option 3: 1Password CLI
op read "op://Infrastructure/Anthropic/api-key"The SecretsBackend interface allows different secrets providers:
k8s(default): Standard Kubernetes Secrets. Simplest setup.vault: HashiCorp Vault via the Vault Agent sidecar or CSI driver.aws-sm: AWS Secrets Manager via IRSA.1password: 1Password Connect server or CLI.external-secrets: Kubernetes External Secrets Operator (supports multiple backends).
The primary engine. Runs Claude Code CLI in headless mode inside a K8s Job pod.
Container contents:
- Node.js runtime (for Claude Code CLI)
- Claude Code CLI (
@anthropic-ai/claude-code) - Git, SCM CLI (gh/glab)
- Python + uv (for target repos)
- Local stdio MCP server (for notifications/human interaction)
- Guard rail hooks
CLAUDE.mdorguardrails.md(mounted from ConfigMap)
Invocation:
claude -p "$(cat /config/task-prompt.md)" \
--output-format json \
--max-turns 50 \
--dangerously-skip-permissions \
--allowedTools "Bash,Read,Write,Edit,Glob,Grep,Task"Agent Teams Experimentation:
Claude Code's agent teams feature (experimental) can be tested in-process mode inside K8s Jobs. The approach:
- Set
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1in the container environment - Use
--teammate-mode in-process(no tmux required) - Define a team configuration that splits work (e.g., one agent writes code, another writes tests)
- Monitor via
TeammateIdleandTaskCompletedhooks
This is experimental and should be behind a feature flag:
engines:
claude-code:
agent_teams:
enabled: false # Opt-in experimental feature
mode: "in-process"
max_teammates: 3Runs OpenAI's Codex CLI in a K8s Job pod.
Container contents:
- Node.js runtime
- Codex CLI
- Git, SCM CLI
- Python + uv
AGENTS.md(Codex equivalent of CLAUDE.md)
Invocation:
codex --quiet \
--approval-mode full-auto \
--full-stdout \
"$(cat /config/task-prompt.md)"Key differences from Claude Code:
- Uses
AGENTS.mdinstead ofCLAUDE.mdfor repository context - Different tool/permission model
- No hooks system (guard rails must be implemented differently - likely via wrapping commands)
- No MCP support (notification/interaction must use a different mechanism)
Where an engine lacks a feature (e.g., Codex has no hooks), the adapter provides it:
type CodexEngine struct { /* ... */ }
func (e *CodexEngine) BuildExecutionSpec(task Task, config EngineConfig) (*ExecutionSpec, error) {
// Wrap commands in a script that enforces guard rails
// since Codex has no PreToolUse hooks
// ...
}
func (e *CodexEngine) BuildPrompt(task Task) (string, error) {
// Include guard rails directly in the prompt text
// since Codex has no hooks to enforce them
prompt, _ := e.basePrompt(task)
prompt += "\n\n" + e.loadGuardrailsAsText()
return prompt, nil
}Osmia jobs are ephemeral, CPU-bound workloads. Karpenter provisions nodes on-demand when jobs are created.
Recommended NodePool:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: osmia
spec:
disruption:
consolidateAfter: 2m
consolidationPolicy: WhenEmpty
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: node.kubernetes.io/instance-type
operator: In
values:
- c6i.xlarge # 4 vCPU, 8 GiB
- c6i.2xlarge # 8 vCPU, 16 GiB
- c7i.xlarge
- c7i.2xlarge
- m6i.xlarge # 4 vCPU, 16 GiB (if memory needed)
taints:
- effect: NoSchedule
key: osmia.io/agent
value: "true"
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: osmia
limits:
cpu: "64" # Max 64 vCPU across all osmia nodes
memory: "128Gi"Key design decisions:
- Spot instances for cost savings (jobs are stateless and retryable)
- Taints to isolate agent workloads from production services
WhenEmptyconsolidation to avoid disrupting running jobs- Instance type diversity across c6i/c7i/m6i for spot availability
- CPU limits to cap maximum spend
The controller supports high-availability deployments via controller-runtime's built-in leader election (Lease objects). Multiple replicas can run simultaneously; only the leader processes tickets and creates jobs. Failover is automatic. (Note: kopf also supports HA via its Peering mechanism, but controller-runtime's Lease-based approach is more widely deployed and better understood by K8s operators.)
guardrails:
max_concurrent_jobs: 10 # Adjustable per deploymentFor higher scale deployments, consider:
- KEDA to scale based on ticket queue depth
- Sharded controllers with ticket partitioning by namespace or label (future enhancement)
Prometheus metrics (exposed by the controller):
| Metric | Type | Description |
|---|---|---|
osmia_jobs_total |
Counter | Total jobs created, by status |
osmia_jobs_active |
Gauge | Currently running jobs |
osmia_job_duration_seconds |
Histogram | Job execution duration |
osmia_tokens_total |
Counter | Total tokens consumed, by engine |
osmia_cost_usd_total |
Counter | Estimated cost in USD |
osmia_tickets_processed_total |
Counter | Tickets processed, by status |
osmia_human_interactions_total |
Counter | Human questions asked |
Structured logging via Go's slog (standard library, structured, JSON output):
slog.Info("job created",
"ticket_id", ticket.ID,
"engine", engineName,
"repo", ticket.RepoURL,
)Grafana dashboard shipped as a JSON model in the Helm chart.
osmia/
.github/
workflows/
ci.yaml # Lint, test, build (golangci-lint, go test)
release.yaml # Semantic versioning + container publish
ISSUE_TEMPLATE/
bug_report.md
feature_request.md
plugin_request.md
PULL_REQUEST_TEMPLATE.md
charts/
osmia/ # Helm chart
Chart.yaml
values.yaml
templates/
deployment.yaml # Controller
rbac.yaml
configmap.yaml
secret.yaml # Optional, for simple setups
servicemonitor.yaml # Prometheus ServiceMonitor
cmd/
osmia/
main.go # Controller entrypoint
docs/
getting-started.md
architecture.md
plugins/
writing-a-plugin.md # Polyglot guide (Go, Python, TypeScript)
ticketing.md
notifications.md
secrets.md
engines.md
guardrails.md
scaling.md
security.md
docker/
controller/
Dockerfile # Multi-stage Go build → distroless
engine-claude-code/
Dockerfile
entrypoint.sh
hooks/ # Guard rail hook scripts
mcp/ # MCP server for notifications
engine-codex/
Dockerfile
entrypoint.sh
examples/
github-slack/ # Minimal: GitHub Issues + Slack
values.yaml
guardrails.md
gitlab-teams/ # GitLab + Microsoft Teams
values.yaml
enterprise/ # Full enterprise setup with Vault
values.yaml
plugins/
example-jira-python/ # Example third-party plugin in Python
example-teams-ts/ # Example third-party plugin in TypeScript
internal/
controller/
controller.go # controller-runtime reconciler
controller_test.go
jobbuilder/
builder.go # ExecutionSpec -> V1Job translation
builder_test.go
taskrun/
taskrun.go # TaskRun state machine + idempotency
taskrun_test.go
watchdog/
watchdog.go # Progress watchdog loop
watchdog_test.go
config/
config.go # Configuration loading
metrics/
metrics.go # Prometheus metrics
costtracker/
tracker.go # Token/cost tracking
promptbuilder/
builder.go # Prompt construction
pkg/
engine/
engine.go # ExecutionEngine interface + ExecutionSpec
claudecode/
engine.go # Claude Code engine
hooks.go # Hook generation
codex/
engine.go # OpenAI Codex engine
aider/
engine.go # Aider engine
plugin/
host.go # gRPC plugin host (hashicorp/go-plugin)
ticketing/
ticketing.go # TicketingBackend interface
github/
github.go # GitHub Issues (built-in)
notifications/
notifications.go # NotificationChannel interface
slack/
slack.go # Slack (built-in)
approval/
approval.go # HumanApprovalBackend interface
slack/
slack.go # Slack approval (built-in)
secrets/
secrets.go # SecretsBackend interface
k8s/
k8s.go # K8s Secrets (built-in)
review/
review.go # ReviewBackend interface
coderabbit/
coderabbit.go # CodeRabbit (built-in)
scm/
scm.go # SCMBackend interface
github/
github.go # GitHub (built-in)
gitlab/
gitlab.go # GitLab (built-in)
proto/ # Protobuf definitions (source of truth)
ticketing.proto
notifications.proto
approval.proto
secrets.proto
review.proto
scm.proto
engine.proto
sdk/ # Generated plugin SDKs
python/ # osmia-plugin-sdk (PyPI)
go/ # github.com/osmia/plugin-sdk-go
typescript/ # @osmia/plugin-sdk (npm)
tests/
e2e/ # End-to-end tests (kind cluster)
integration/
github_ticketing_test.go
slack_notifications_test.go
go.mod
go.sum
Makefile # build, test, lint, proto-gen, sdk-gen
LICENCE
README.md
CHANGELOG.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
SECURITY.md
Goal: Extract the generic framework from the existing Unitary codebase. Define all plugin interfaces with versioning. Establish security baseline from day one. Get the controller running with no concrete plugins (dry-run mode).
Work items:
- Create the new OSS repository on GitHub
- Define all abstract base classes with
interface_versionfields:ExecutionEngine+ExecutionSpec(decoupled from K8s)TicketingBackendNotificationChannel+HumanApprovalBackend(separated concerns)SecretsBackendReviewBackendSCMBackend
- Implement the
TaskRunstate machine with idempotency keys - Implement the
JobBuilderthat translatesExecutionSpec-> K8sV1Job - Implement the gRPC plugin host (hashicorp/go-plugin with version handshake)
- Define protobuf service definitions for all plugin interfaces
- Generate Python and Go plugin SDKs from protobufs
- Extract and generalise the controller (remove Shortcut-specific code)
- Extract and generalise the job manager (structured result via
/workspace/result.json) - Extract and generalise the cost tracker
- Implement configuration loading from
osmia-config.yaml - Implement Prometheus metrics endpoint from the start
- Set up CI pipeline (golangci-lint, go test, buf lint for protobufs)
- Set up container builds (GitHub Actions) with:
- cosign image signing (keyless via OIDC)
- syft SBOM generation (CycloneDX)
- SLSA Level 2 provenance attestations
- Set up structured logging with slog (JSON output)
- Write unit tests for all core components
- Write SECURITY.md with vulnerability disclosure process
Goal: First working end-to-end flow. A GitHub Issue triggers a Claude Code job that creates a PR and notifies via Slack.
Work items:
- Implement
ClaudeCodeEngine(BuildExecutionSpec, BuildPrompt) - Build the Claude Code container image (Dockerfile, entrypoint, MCP server)
- Implement structured result output (
/workspace/result.jsonwith semantic exit codes) - Implement guard rails system (hooks generation, blocked commands, blocked files)
- Implement built-in
GitHubTicketingBackend(poll issues by label, update state, add comments) - Implement built-in
SlackNotificationChannel(fire-and-forget notifications) - Implement built-in
SlackApprovalBackend(event-driven human-in-the-loop via webhook callback) - Implement built-in
K8sSecretsBackend - Implement built-in
GitHubSCMBackend(PR creation via gh CLI) - Implement
guardrails.mdinjection into prompts - Write integration tests with mocked GitHub/Slack APIs
- Create example third-party plugin in Python (example-jira-python) using the SDK
- Implement
osmia-plugin scaffoldCLI (generates working plugin skeleton with tests, Dockerfile, and CI config) - Implement
osmia-plugin testCLI (local testing harness that exercises the plugin interface without a running controller) - Document plugin deployment patterns (custom image, init containers, Python pip)
- Implement plugin crash detection and restart with exponential backoff
- Create the Helm chart (basic, with multi-tenancy support and plugin init container support)
- Create Grafana dashboard JSON
- Write getting-started documentation
- End-to-end testing against a test repository
Goal: Second and third execution engines plus second SCM backend. Proves the abstraction layer works with meaningfully different engines.
Work items:
- Implement
CodexEnginein Go (different prompt format, no hooks, different result parsing) - Implement
AiderEnginein Go (popular OSS agent, broadens community appeal) - Build the Codex and Aider container images
- Implement guard rails for hookless engines (prompt-based + command wrapping)
- Implement
GitLabSCMBackend(MR creation via glab CLI) - Implement
CodeRabbitReviewBackend - Add engine selection logic (per-ticket or per-config)
- Write tests for all engines
- Update documentation
Goal: Experimental multi-agent support and production scaling.
Work items:
- Experiment with Claude Code agent teams in-process mode inside K8s Jobs
- Implement team configuration (roles, task splitting)
- Implement
TeammateIdleandTaskCompletedhook handlers - Implement Karpenter NodePool examples and documentation
- Add KEDA scaling examples (scale on ticket queue depth)
- Implement cost budgets and alerts
- Implement multi-tenancy namespace isolation
- Write scaling documentation
Goal: OSS launch readiness.
Work items:
- Write comprehensive README with architecture diagrams
- Write CONTRIBUTING.md with plugin development guide
- Create issue templates (bug, feature, plugin request)
- Create example configurations (github-slack, gitlab-teams, enterprise)
- Write plugin development tutorial with versioning guide
- Set up GitHub Discussions
- Create a documentation site (MkDocs Material)
- Record a demo video / GIF for README
- Write launch blog post
- Submit to relevant aggregators (Hacker News, Reddit r/kubernetes, r/devops)
Goal: Migrate Unitary's internal deployment to use the OSS framework with Shortcut/Slack plugins.
Work items:
- Create
osmia-plugin-shortcutPython package using the plugin SDK - Migrate existing
shortcut_watcher.pylogic into the gRPC plugin - Migrate existing
coderabbit_watcher.pyintoReviewBackendplugin - Create Unitary-specific Helm values
- Validate against Customer1 test environment
- Cut over from internal codebase to OSS + plugins
- Archive the internal codebase
| Threat | Mitigation |
|---|---|
| Prompt injection via ticket description | Guard rails hooks block dangerous commands; guardrails.md sets boundaries; PreToolUse hooks validate all tool calls |
| Credential leakage | Secrets mounted as env vars (not files); guard rails block commits of .env/credentials; short-lived tokens via apiKeyHelper; prefer workload identity (IRSA/WIF) over static keys in production |
| Container escape | Non-root user; read-only root filesystem; no host mounts; securityContext with runAsNonRoot, readOnlyRootFilesystem, dropped capabilities |
| Lateral movement | NetworkPolicy restricts egress to explicit FQDN/CIDR allowlists; dedicated namespace; RBAC scoped to namespace |
| Supply chain (malicious plugins) | Plugins run as controller extensions with full K8s API access - document this risk prominently; future: plugin capability manifests declaring required permissions |
| Supply chain (container images) | All release images signed with cosign; SBOMs generated with syft; SLSA Level 2 provenance via GitHub Actions |
| Excessive resource consumption | Job activeDeadlineSeconds; cost budgets; max_concurrent_jobs limit; Karpenter CPU limits |
| Unauthorised repository access | allowed_repos guard rail; SCM tokens scoped to specific repos/groups |
| Cross-tenant data leakage | Namespace-per-tenant model (see section 10.5) |
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefaultThe default NetworkPolicy restricts agent pods to HTTPS and SSH egress only. However, to: [] permits any destination IP on those ports, which is effectively minimal restriction.
For production deployments, we strongly recommend using a CNI that supports FQDN-based egress policies (Cilium, Calico Enterprise) or routing through an egress proxy with an explicit allowlist:
# Basic policy (ships by default) - limits ports but not destinations
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: osmia-agent-egress
spec:
podSelector:
matchLabels:
app: osmia-agent
policyTypes: ["Egress"]
egress:
- ports:
- port: 443
protocol: TCP
- port: 22
protocol: TCP # Git SSH# Enterprise policy example (Cilium FQDN-based) - limits destinations
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: osmia-agent-egress-strict
spec:
endpointSelector:
matchLabels:
app: osmia-agent
egress:
- toFQDNs:
- matchName: "api.anthropic.com"
- matchName: "api.openai.com"
- matchName: "github.com"
- matchName: "gitlab.com"
- matchName: "slack.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
- toFQDNs:
- matchName: "github.com"
- matchName: "gitlab.com"
toPorts:
- ports:
- port: "22"
protocol: TCPAll release images are signed and accompanied by provenance attestations. This is a Phase 1 requirement, not a later addition.
- Image signing: cosign keyless signing via GitHub Actions OIDC
- SBOM generation: syft generates CycloneDX SBOMs for each image
- Provenance: SLSA Level 2 via GitHub Actions artifact attestations
- Verification: documented in getting-started guide
# Users can verify image signatures
cosign verify ghcr.io/osmia/controller:v1.0.0
cosign verify-attestation ghcr.io/osmia/engine-claude-code:v1.0.0For shared clusters serving multiple teams or organisations, Osmia supports namespace-per-tenant isolation:
# Helm values for tenant isolation
tenancy:
mode: "namespace-per-tenant" # or "shared" (default)
tenants:
- name: "team-alpha"
namespace: "osmia-alpha"
ticketing:
backend: github
config:
repo: "alpha-org/repos"
secrets:
secret_name: "osmia-alpha-secrets"
- name: "team-beta"
namespace: "osmia-beta"
ticketing:
backend: jira
config:
project: "BETA"
secrets:
secret_name: "osmia-beta-secrets"Each tenant gets:
- Dedicated namespace with its own RBAC, NetworkPolicies, and ResourceQuotas
- Separate K8s Secrets (no cross-tenant secret access)
- Independent job limits and cost budgets
- Isolated Karpenter NodePool (optional, for compute isolation)
| Aspect | OpenClaw | Osmia |
|---|---|---|
| Architecture | Local-first, single machine | Kubernetes-native, distributed |
| Isolation | Shared process, local files | Container per job, namespace isolation |
| Credential storage | Local files (Markdown/JSON) | K8s Secrets / Vault / AWS SM |
| Scaling | Single machine | Karpenter auto-scaling |
| Security model | Local process execution; plugin store without verification | Container isolation; signed images; RBAC; network policies |
| Enterprise readiness | Designed for individuals | Designed for organisations (RBAC, audit, multi-tenancy) |
| Plugin ecosystem | Centralised skill hub | Entry-point based, no central registry (by design - avoids supply chain risk) |
| Aspect | Gas Town | Osmia |
|---|---|---|
| Multi-agent | 20-30 agents, complex role system | Single agent per job (teams experimental) |
| State management | Git-backed "Beads" | K8s Jobs + ConfigMaps |
| Merge conflicts | "Refinery" merge queue | One job per ticket (no conflicts by design) |
| Engine support | Claude Code only | Claude Code + Codex (extensible) |
| Ticketing | Git issues | Pluggable (GitHub, Jira, etc) |
| Aspect | Codex (cloud) | Osmia |
|---|---|---|
| Infrastructure | OpenAI-managed | Self-hosted K8s |
| Data residency | OpenAI's cloud | Your cluster, your data |
| Engine lock-in | OpenAI only | Multi-engine |
| Customisation | AGENTS.md | Full plugin system, guard rails, hooks |
| Cost model | Per-task pricing | BYO compute + API keys |
| Network access | OpenAI sandbox | Your VPC, your network policies |
- GitHub (not GitLab) for maximum OSS visibility and contributor access
- Apache 2.0 licence
- GitHub Actions for CI/CD
- GitHub Container Registry (ghcr.io) for images
- GitHub Discussions for community Q&A
- GitHub Projects for roadmap tracking
Use MkDocs Material (MIT-licensed, widely used in K8s ecosystem):
- Getting Started guide (5-minute quickstart with kind + GitHub Issues)
- Architecture overview with diagrams
- Plugin development guide
- Configuration reference
- Security guide
- FAQ
Key sections:
- Development environment setup (Go toolchain, kind, skaffold, buf for protobufs)
- Plugin development tutorial in Python (implement a simple ticketing backend using the SDK)
- Controller code style (golangci-lint, gofumpt, British English in comments and docs)
- PR process (conventional commits, required reviews)
- Issue triage labels
- Soft launch: Publish repo, share in a few Slack/Discord communities for early feedback
- Blog post: Technical deep-dive on the architecture and security model
- Hacker News: "Show HN: Osmia - Kubernetes-native harness for AI coding agents"
- Reddit: r/kubernetes, r/devops, r/MachineLearning
- X/Twitter: Thread explaining the problem and approach, with demo GIF
- Conference talks: KubeCon, PyCon UK (submit CFPs)
Ships with core (maintained by Osmia team):
- GitHub Issues ticketing
- Slack notifications
- K8s Secrets
- Claude Code engine
- Codex engine
- GitHub SCM
- GitLab SCM
- CodeRabbit review
Community plugins (examples to seed):
- Jira ticketing
- Linear ticketing
- Monday.com ticketing
- Microsoft Teams notifications
- Discord notifications
- Telegram notifications
- HashiCorp Vault secrets
- AWS Secrets Manager secrets
- 1Password secrets
- Container registry: Should we publish to ghcr.io, Docker Hub, or both?
- Helm chart distribution: OCI registry (ghcr.io) or a dedicated Helm repo?
- Multi-repo tickets: How should we handle tickets that span multiple repositories? (Gas Town's approach of parallel agents on the same repo is one option; another is sequential jobs.)
- Cost attribution: Should the controller integrate with cloud billing APIs (AWS Cost Explorer, etc) for actual cost tracking beyond token estimates?
- Plugin capability manifests: Should we require plugins to declare their permission requirements (e.g. "needs K8s API access", "needs egress to api.jira.com") so operators can audit what a plugin does before enabling it?
- Managed offering: Is there a future where Unitary offers Osmia as a managed SaaS? This would influence licence choice (Apache 2.0 allows competitors to do this too).
- CRD vs in-memory state: Should
TaskRunbe a proper Kubernetes CRD (survives controller restarts, visible viakubectl) or an in-memory structure backed by ConfigMaps/annotations (simpler to implement, no CRD installation required)?
| Dependency | Purpose | Version |
|---|---|---|
| Go | Controller runtime | >= 1.23 |
| controller-runtime | K8s operator framework | >= 0.19 |
| client-go | K8s API | >= 0.31 |
| hashicorp/go-plugin | gRPC plugin host | >= 1.6 |
| protobuf / grpc-go | Plugin interface definitions | >= 1.35 / >= 1.68 |
| prometheus/client_golang | Metrics | >= 1.20 |
| slog (stdlib) | Structured logging | (stdlib) |
| Claude Code CLI | Execution engine | Latest |
| Codex CLI | Execution engine | Latest |
| Helm | Chart packaging | >= 3.14 |
| Karpenter | Node auto-scaling | >= 1.0 |
Plugin SDK dependencies (for plugin authors):
| SDK | Language | Package |
|---|---|---|
| osmia-plugin-sdk | Python | pip install osmia-plugin-sdk |
| plugin-sdk-go | Go | go get github.com/osmia/plugin-sdk-go |
| @osmia/plugin-sdk | TypeScript | npm install @osmia/plugin-sdk |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Claude Code agent teams doesn't work in K8s Jobs | Medium | Low | Feature-flagged; single-agent mode is the reliable default |
| Codex/Aider CLI interface changes break our adapter | Medium | Medium | Pin versions; integration tests; abstract enough to adapt |
| Duplicate jobs on controller restart | High | High | TaskRun idempotency keys; check-before-create pattern |
| Low OSS adoption | Medium | Low | Focus on quality docs and quickstart; Unitary uses it regardless |
| Security vulnerability discovered post-launch | Low | High | SECURITY.md with disclosure process; signed images; SBOM; dependency audits |
| gRPC plugin complexity deters plugin authors | Medium | Medium | Ship Python/Go/TS SDKs with examples; provide plugin scaffolding CLI tool |
| Unitary team unfamiliar with Go | Medium | Medium | Claude builds the controller; Unitary plugins stay in Python via SDK; team should invest in Go familiarity for controller debugging, code review, and incident response; consider pairing sessions and a Go style guide early |
| Plugin interface breaks existing plugins | Medium | Medium | Explicit interface_version; migration guides; semver the interfaces |
| Third-party plugins with excessive permissions | Medium | High | Document plugin trust model; future: capability manifests |
| Human-in-the-loop timeouts block resources | Medium | Medium | Event-driven approval (NeedsHuman state); job can be paused/resumed |
When building, use a team. Use Sonnet 4.6 for implementation tasks and Opus 4.6 for planning.