- Status: Approved (2026-05-12)
- Owner: stubbi (jannes@aqora.io)
- Repo:
stubbi/hermes-operator(private at start) - Target ship: v1.0.0: full feature parity with openclaw-operator v0.32 + hermes-specific bits, no v0.x grind
- Inspired by: openclaw-rocks/openclaw-operator, iterated through v0.5 → v0.32 with substantial production feedback
- Manages: nousresearch/hermes-agent: Python-based self-improving multi-platform AI agent
Hermes-agent is a Python 3.11+ AI agent that fronts multiple messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal), self-improves through a built-in learning loop, persists session memory via FTS5, models users via Honcho dialectic profiles, and runs scheduled automations via a native cron scheduler. Upstream ships as a uv-installable Python package plus CLI; there is no canonical container image yet.
The goal is a Kubernetes operator that deploys and manages hermes-agent instances with production-grade security, observability, and lifecycle management: out of the gate at v1.0, not after the v0.x grind openclaw-operator went through.
- G1: Full feature parity with openclaw-operator v0.32 adapted to hermes's Python/uv runtime and multi-platform gateway shape.
- G2: v1 API stable from day one. No
v1alpha1spoke. Public versioning, deprecation, and conversion policies published with v1.0. - G3: Lessons baked in. Openclaw issues #437, #446, #433, #471, #479, #458, #469, and the rest of the v0.x bug log informed concrete guardrails (Reconcile Guard CI, SSA for SelfConfig from day one, explicit k8s defaults in builders, zombie-process reaper, namespace-scoped RBAC option, etc.).
- G4: GitOps coexistence. SSA-based SelfConfig means FluxCD/Argo can manage the instance while the agent self-mutates allowed fields without flap.
- G5: One declarative migration path from openclaw.
spec.migration.fromOpenClawuses hermes-agent's built-in importer to drain a sibling OpenClawInstance or its S3 backup. - G6: Distribution from day one: Helm chart, OLM bundle (OperatorHub submission), plain manifests, signed multi-arch container images with SBOMs.
- NG1: Multi-cluster federation. Deferred; single-cluster control loop only.
- NG2: Modal / Daytona "hibernation" integration. K8s-native scale-to-zero (
spec.suspended) is the equivalent; we don't reach into external serverless platforms. - NG3: Generic "AgentInstance" operator. Hermes is the only runtime in v1. No premature abstraction.
- NG4: kubectl plugin (krew) for v1.0. Nice to have, not blocking; mirrors
kubectl-openclawlater. - NG5: Public OpenClaw → Hermes data conversion guarantees beyond what hermes-agent's importer itself provides.
| Repo | stubbi/hermes-operator (private) |
| Go module | github.com/paperclipinc/hermes-operator |
| Go version | 1.24 |
| Framework | kubebuilder v4 / controller-runtime |
| Kubernetes | ≥ 1.28 (CI matrix: 1.28, 1.29, 1.30, 1.31, 1.32) |
| License | Apache-2.0 (operator). Independent of hermes-agent's MIT. |
| Operator image | ghcr.io/paperclipinc/hermes-operator (multi-arch amd64+arm64, Cosign-signed, SBOM attested) |
| Agent image | ghcr.io/paperclipinc/hermes-agent (operator's responsibility to build/publish from the upstream Python package; default spec.image.repository) |
| Conventional commits | feat:, fix:, docs:, ci:, chore:, refactor:, test: (release-please uses feat:/fix: for changelog) |
| CRD | Scope | API | Short | Purpose |
|---|---|---|---|---|
HermesInstance |
Namespaced | hermes.agent/v1 |
hi |
The agent: spec for a single hermes-agent deployment. |
HermesSelfConfig |
Namespaced | hermes.agent/v1 |
hsc |
Agent-initiated mutations, validated against the parent instance's selfConfigure policy, applied via SSA. |
HermesClusterDefaults |
Cluster (singleton; name must be cluster) |
hermes.agent/v1 |
hcd |
Cluster-wide defaults and policy applied by the defaulting webhook. |
- Categories:
hermes,agents.kubectl get agentslists all three. - Storage version:
v1only. No spoke. Conversion-webhook scaffolding is in place from day one so futurev2can land without re-plumbing. - Status conventions: every CR uses
meta.SetStatusConditionfor conditions plusobservedGenerationand subsystem readiness flags (StorageReady, ConfigReady, SecretsReady, GatewayReady, ProfileStoreReady on HermesInstance). Condition catalogue published indocs/conditions.md.
Top-level shape, each a typed sub-spec:
apiVersion: hermes.agent/v1
kind: HermesInstance
metadata:
name: my-hermes
spec:
image: # repository, tag, digest, imagePullPolicy, imagePullSecrets
config: # YAML config for ~/.hermes/config.yaml (raw / configMapRef / mergeMode)
workspace: # initial files & directories seeded into ~/.hermes
resources: # cpu / memory requests + limits
security: # podSecurityContext, container security, RBAC, CA bundle, SA annotations (IRSA/WI)
storage: # PVC: enabled, size, storageClassName, accessModes, existingClaim
networking: # Service, Ingress, NetworkPolicy (deny-all default)
observability: # metrics, ServiceMonitor, logging
availability: # PDB, HPA, topologySpreadConstraints
probes: # liveness, readiness, startup overrides
backup: # S3 target, schedule, onDelete, preUpdate, history limits
restoreFrom: # snapshot key to restore into a new instance
runtime: # Python version, uv, ffmpeg, ripgrep, extra apt/pip
gateways: # platform bindings: telegram, discord, slack, whatsapp, signal
profileStore: # Honcho companion (enabled, image, persistence, secret)
ollama: # optional local LLM sidecar
webTerminal: # optional kubectl-attach style terminal sidecar
tailscale: # optional Tailscale Serve / Funnel
autoUpdate: # opt-in OCI registry polling, rollback on failed probes
selfConfigure: # allowlist policy for HermesSelfConfig mutations
migration: # fromOpenClaw: declarative one-shot migration
scheduling: # nodeSelector, tolerations, affinity, priorityClassName
initContainers: # arbitrary additional init containers
sidecars: # arbitrary additional sidecars
extraVolumes:
extraVolumeMounts:
envFrom: # secretRef / configMapRef list for env injection
env: # explicit env vars
suspended: # scale-to-zero| Area | Hermes change | Why |
|---|---|---|
runtime |
Replaces openclaw's runtimeDeps. Defaults: python: "3.11", uv: latest, ffmpeg.enabled: true, ripgrep.enabled: true. Init container runs uv sync against a lockfile bundled in the agent image. |
Hermes is Python-native; pnpm/Node init containers don't apply. FFmpeg + ripgrep are hard dependencies of hermes-agent. |
gateways |
First-class section with telegram, discord, slack, whatsapp, signal. Each takes one or more secretRefs for tokens, exposes enabled, and contributes generated config + Service/Ingress allowances. |
Hermes is a multi-platform gateway; tokens live in many secrets and must be auditable/rotatable independently. |
profileStore |
New section for Honcho companion (Deployment + Service + PVC, optional). | Honcho is hermes-shaped, no openclaw analogue. |
selfConfigure.allowedActions |
[skills, config, envVars, workspaceFiles, profiles]: adds profiles (Honcho snapshot persistence). |
Hermes self-improves natively; SelfConfig is the audited cluster-side surface. |
migration.fromOpenClaw |
Optional one-shot migration via hermes-agent's built-in importer. Source: in-cluster OpenClawInstanceRef or S3 backup snapshot. |
Hermes-agent already ships the importer; surfacing it declaratively turns the OpenClaw → Hermes transition into a single resource change. |
No chromium sub-spec |
Removed. If browser automation is needed, use generic sidecars. |
Not first-class for hermes-agent. |
No BOOTSTRAP.md injection toggle |
Removed. | Openclaw-specific. |
No OPENCLAW_DISABLE_BONJOUR equivalent |
Removed. | Hermes has no mDNS pairing. |
config.format |
yaml only. |
~/.hermes/config.yaml is canonical. |
- StatefulSet (single replica by default; HPA opt-in via
availability.hpa). Preserves identity through restarts. - Default-deny
NetworkPolicybaseline + explicit allow rules derived fromgatewaysandnetworking.egress. - PodDisruptionBudget always created when
replicas > 1. - Read-only root filesystem by default; writable
emptyDirs for/tmp,~/.configwritable subPath on the PVC (openclaw lesson #458). - Prometheus metrics + ServiceMonitor with
metrics.secureconsistency between operator flag and ServiceMonitor scheme (lesson #435/#440). - Validating webhook for spec sanity + provider/secret cross-warnings.
- Finalizer for backup-on-delete, set via
r.Patch(JSON patch), neverr.Update(lesson #437). - Owner refs on every managed resource;
controllerutil.CreateOrUpdateexclusively (Reconcile Guard CI enforced).
Agent-driven, audited mutation API. The agent creates one of these to persist a learned skill, env var, config patch, workspace file, or Honcho profile snapshot. The operator validates against the parent instance's selfConfigure policy, then applies via SSA.
apiVersion: hermes.agent/v1
kind: HermesSelfConfig
metadata:
name: install-finance-skill
spec:
instanceRef: my-hermes # required, same namespace
addSkills:
- source: "git+https://github.com/foo/finance-skill@v1.2.0"
patchConfig: # JSON-merge-patch into ~/.hermes/config.yaml
schedules:
morning-brief: "0 8 * * *"
addEnvVars:
- name: FINANCE_TZ
value: Europe/Berlin
addWorkspaceFiles:
- path: "notes/finance.md"
content: "..."
addProfileSnapshot:
profileID: "user-42"
data: "..."
status:
conditions:
- type: Applied | Denied | Pending
appliedAt: <ts>
denyReason: "" # populated when deniedReconciler: validate → SSA-patch the HermesInstance (allowed fields only) and the workspace ConfigMap (for addWorkspaceFiles) → set status. SSA field manager = hermes.agent/selfconfig, so GitOps controllers writing to other fields on the same instance keep working without flap.
Cluster-scoped singleton (name must be cluster; webhook rejects any other name). Provides cluster-wide defaults applied by the defaulting webhook when an instance leaves a field nil. Examples:
apiVersion: hermes.agent/v1
kind: HermesClusterDefaults
metadata:
name: cluster
spec:
image:
repository: ghcr.io/paperclipinc/hermes-agent
tag: "1.4.2"
registry:
pullSecretName: ghcr-pull
storage:
storageClassName: gp3
size: 10Gi
security:
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123:role/hermes
observability:
serviceMonitor:
enabled: true
networking:
networkPolicy:
enabled: trueClusterDefaults only fill nil fields; an explicit value on the instance always wins. This is the inverse of "policy overrides": for hard enforcement, the validating webhook is the right mechanism, not defaults.
api/v1/ hub types + webhook impls + groupversion_info.go
hermesinstance_types.go
hermesselfconfig_types.go
hermesclusterdefaults_types.go
webhook_hermesinstance.go
webhook_hermesselfconfig.go
webhook_hermesclusterdefaults.go
zz_generated.deepcopy.go
internal/controller/ orchestration only: no resource construction here
hermesinstance_controller.go
hermesselfconfig_controller.go
hermesclusterdefaults_controller.go
backup.go, restore.go, autoupdate.go, s3.go, metrics.go
*_test.go (envtest suite)
internal/resources/ pure builder funcs, one file per resource
statefulset.go service.go configmap.go secret.go networkpolicy.go pdb.go
hpa.go ingress.go rbac.go servicemonitor.go prometheusrule.go pvc.go
honcho.go gateways.go runtime_init.go selfconfig_apply.go common.go
resources_test.go resources_bench_test.go
internal/webhook/ validator + defaulter implementations
config/crd/bases/ generated CRD YAML (committed)
charts/hermes-operator/ Helm chart (CRDs templated, RBAC auto-synced)
bundle/ OLM bundle for OperatorHub
test/e2e/ kind-cluster tests
test/conformance/ negative + idempotency + upgrade-matrix + GitOps tests
cmd/manager/ entrypoint
docs/ api-reference.md, conditions.md, api-versioning.md, deprecations.md, examples/, images/
hack/ boilerplate, kustomize bins, env helpers
Hard separation: controllers orchestrate the CreateOrUpdate dance; all resource construction lives in pure Build<Resource>(*HermesInstance, *HermesClusterDefaults) *<Resource> functions in internal/resources/. Builders are unit-tested without envtest in <2s.
- Only
controllerutil.CreateOrUpdatefor managed resources. Barer.Update()is grep-banned. Exception:// reconcile-guard:allowwith justification comment. - Server-Side Apply (SSA) for the
HermesSelfConfigreconciler from day one (lesson #433/#439). Field manager =hermes.agent/selfconfig. - Set every server-side k8s default explicitly in builders:
RevisionHistoryLimit,ProgressDeadlineSeconds,RestartPolicy,DNSPolicy,SchedulerName,TerminationGracePeriodSeconds,TerminationMessagePath,TerminationMessagePolicy,ImagePullPolicyon every container,SuccessThresholdon every probe,DefaultModeon volume sources,SessionAffinity: Noneon Service. Skipping these is what produced openclaw's generation-thrash bugs. - Preserve third-party annotations and labels on update (lesson #446/#447). Merge function whitelists operator-owned keys (
hermes.agent/*, well-known kubebuilder/k8s labels) for overwrite; everything else is preserved. - Finalizer add/remove uses
r.Patch()with a JSON patch, neverr.Update()(lesson #437: finalizer add bumped generation and replaced the pod on first reconcile). - Preserve server-assigned fields on update:
Service.ClusterIP/ClusterIPs,PVC.VolumeName, etc. PVCs are never updated, only created (immutable per k8s). - Status updates are separate transactions from spec/metadata (
r.Status().Update). - Owner references on every managed resource so deletion cascades.
- Reconcile result discipline:
RequeueAfter: 5mfor drift detection;Requeue: trueonly when an immediate retry is meaningful; on error return(ctrl.Result{}, err)and let exponential backoff handle it.
Defaulter: populates nil fields from HermesClusterDefaults (singleton). ClusterDefaults fills nil only: explicit values on the instance always win.
Validator (instance):
- Required fields (
image.repositorywhen no clusterDefault, valid storage size, exactly-one ofconfig.raw/config.configMapRef). - Gateway/secret cross-checks: warn (not deny) if
gateways.telegram.enabledand no resolvable token secret exists. - Unknown top-level config keys → warning (lesson from openclaw v0.10 provider-aware warnings).
- Immutable fields after creation:
storage.persistence.storageClassName,storage.persistence.accessModes,metadata.name. - Forbid
selfConfigure.enabled: truewithselfConfigure.protectedKeysempty: must be explicit allowlist.
Validator (selfconfig): denies any request that touches a protectedKeys path; logs deny with reason as a k8s Event so it surfaces in kubectl describe.
Validator (clusterdefaults): name must be cluster; rejects everything else.
- Zombie-process reaper (lesson #471, still OPEN on openclaw): agent container ships with tini as PID 1;
shareProcessNamespace: falseby default. - Multi-namespace + namespace-scoped RBAC opt-in in Helm chart (lesson #469/#470). Default = cluster-scoped; opt-in
watchNamespaces: [...]with scoped Role/RoleBinding. - ClusterRole aggregation labels on user-facing roles so they fold into Kubernetes
admin/edit/viewautomatically (lesson #479/#480).kubectl auth can-i create hermesinstances --as=janereturns the right answer with no extra config. - Init containers mount the full data volume for hostPath PVCs (lesson #450).
- Read-only root FS with explicit writable subPaths for
~/.config,/tmp(lesson #458).
spec:
autoUpdate:
enabled: true
source:
registry: ghcr.io/paperclipinc/hermes-agent
channel: "1.x" # semver range; default = same major as current
pollInterval: 1h
rollback:
enabled: true
probeFailureThreshold: 3 # roll back after N readiness probe failures post-rollout
backupBeforeUpdate: true # default true; records snapshotID in statusController: every pollInterval, list tags via OCI registry, pick the highest tag in the channel, compare to current. If newer: take a pre-update backup → patch spec.image.tag via a status-tracked annotation (not user spec) → watch readiness for 5 minutes → on failure restore previous tag and record the failed version in status to suppress retry.
spec:
backup:
s3:
bucket: hermes-backups
endpoint: s3.amazonaws.com # any S3-compatible (R2, MinIO, etc.)
region: us-east-1
pathPrefix: prod/
credentialsSecretRef:
name: hermes-s3-creds
schedule: "0 3 * * *" # cron, optional
onDelete: true # back up before allowing finalizer to release
preUpdate: true # back up before autoUpdate rollout
historyLimit: 30
failedHistoryLimit: 3
restoreFrom: "prod/my-hermes/2026-05-10T03-00.tar.zst"- Finalizer
hermes.agent/backup-on-deleteholds CR deletion until the backup Job finishes. - Backups run as one-shot Jobs using a
restic/rclone-style image that snapshots the PVC. restoreFromtriggers an init-container restore on a fresh PVC; oncestatus.restoredFrom == spec.restoreFrom, the field is locked (immutable thereafter: preventing accidental re-restore on restart).- Snapshot manifest:
tar.zstof the PVC root +meta.json(instance UID, hermes-agent version, k8s version, timestamp).
spec:
migration:
fromOpenClaw:
source:
# Option A: in-cluster sibling OpenClawInstance
openclawInstanceRef:
name: my-openclaw
namespace: agents
# Option B: from a backup snapshot
backupRef:
s3:
bucket: openclaw-backups
key: prod/my-openclaw/2026-05-11.tar.zst
credentialsSecretRef:
name: oc-s3-creds
mode: copy # copy | move; move marks source as terminatedOn first reconcile, if migration.fromOpenClaw is set and status.migrationCompleted == false: run a migration init container that mounts the source PVC (or downloads the snapshot), invokes hermes-agent migrate from-openclaw, writes results onto the hermes PVC, sets status. The field becomes immutable after success.
| Channel | What | Notes |
|---|---|---|
| Helm chart | charts/hermes-operator/ |
CRDs templated under templates/crds/ so helm upgrade propagates schema changes. make sync-chart-crds enforced by CI. RBAC auto-derived from kubebuilder markers via a CI check. Values: watchNamespaces, createRBAC, logLevel, metrics.secure, webhook.certManager.enabled. |
| OLM bundle | bundle/ |
OperatorHub submission via the community-operators repo. Bundled CSV, descriptors per CRD, OpenAPI schemas, alm-examples. Auto-submission workflow on minor releases. |
| Plain manifests | config/ (kustomize) |
kustomize build config/default produces a single YAML for kubectl apply users. |
| Container images | ghcr.io/paperclipinc/hermes-operator, ghcr.io/paperclipinc/hermes-agent |
Multi-arch (amd64+arm64), Cosign-signed (keyless OIDC), SBOM attested, uploaded to release assets. |
- Conventional commits (
feat:/fix:) on main →release-pleaseopens a release PR. - Merging the release PR bumps
CHANGELOG.md,.release-please-manifest.json,Chart.yaml,appVersion, then tagsvX.Y.Z(via PAT so downstream workflows fire). - Tag triggers GoReleaser: binaries + multi-arch images (draft release) → Cosign sign → SBOM generate+attest → SBOM uploaded → release published.
This is where v1 earns its name: substantially stronger than openclaw's v0.x baseline.
- Unit tests (
internal/resources/*_test.go): every builder is pure, no envtest, runs <2s. - envtest integration tests (
internal/controller/*_test.go): reconcile loops against a fake apiserver; happy-path create/update/delete per CRD. - E2E tests (
test/e2e/): kind cluster, real reconciliation, asserts on real resources. Required on every PR. - Conformance suite (
test/conformance/): new for v1, ships day one:- Negative tests: every webhook-rejection path asserts the rejection.
- Idempotency: 10 reconciles in a row must not change
metadata.generationorresourceVersionafter the first. This is the test that would have caught openclaw's #437 before it shipped. - Upgrade-path matrix: install v1.0.0 → create resources → upgrade operator to vCurrent → assert resources unchanged. Runs for every minor release.
- GitOps coexistence: apply an instance via FluxCD with SSA, have a fake agent create a SelfConfig touching different fields, assert no flap and both reconcilers converge.
- Failure injection: kill the manager mid-reconcile, assert eventual consistency.
- Performance benchmarks (
*_bench_test.goin both resources and controller). Tracked over time; CI fails on >20% regression. - Security scans: gosec + Trivy CRITICAL/HIGH on every PR.
- Reconcile Guard: grep-banned patterns:
r.Update()on managed resources, missing SSA on selfconfig path, finalizer-onlyr.Updateon the CR. - Helm RBAC Sync: diff kubebuilder RBAC markers vs Helm ClusterRole; fail on drift.
CI matrix: k8s 1.28, 1.29, 1.30, 1.31, 1.32. Drop the oldest as Kubernetes EOLs it.
The thing that makes this "v1, not v0.1." Each commitment is documented in a dedicated file shipped with v1.0.
hermes.agent/v1will not have breaking changes for the lifetime of v1.x.- New optional fields are non-breaking (
omitempty+ sane defaults). - Field removal requires
hermes.agent/v2+ a conversion webhook + ≥6 months overlap. - Status field semantics are stable; new conditions are additive.
A field is deprecated by:
// Deprecated:godoc ++kubebuilder:validation:Descriptionwarning.- Webhook warning on use.
- Entry in
CHANGELOG.mdanddeprecations.mdwith target removal version (≥2 minors out, ≥6 months).
Set up at v1.0 with v1 as both hub and storage, no spokes yet. When v2 lands, the conversion plumbing is in place: no retrofit.
Every condition documented with reason codes. Status consumers (dashboards, kubectl plugins) can rely on the catalogue across all v1.x.
Declared in README.md. Drop happens on minor releases only, never patch.
- Operator chart version: semver of the chart itself.
- Operator appVersion: semver of the operator image (chart
appVersion). - Hermes-agent image version:
spec.image.tagorHermesClusterDefaults.spec.image.tag. Decoupled from operator version; operator advertises supported agent versions inREADME.md.
- Multi-cluster federation.
- Scale-from-zero on incoming webhook event.
kubectl-hermesplugin (krew).- Grafana dashboard library.
- AI provider health monitoring and cost recommendations.
Nothing on this list is breaking-API for v1.x.
None as of approval (2026-05-12). Append here if anything surfaces during implementation planning.