Overview
Hermes Agent is a self-hosted AI agent from Nous Research. You bring the model — an API key for Anthropic, OpenAI, or any OpenAI-compatible endpoint — and the agent brings the memory, tools, and interfaces around it. This template deploys the supervised gateway as a single stateful replica with an OpenAI-compatible API for your clients, a web dashboard, a pre-registered Control Plane MCP server, and optional browser automation, external webhooks, and a personal WhatsApp bridge, with all agent state on a persistent volume. Every credential — your LLM API key, the gateway’s bearer token, the dashboard password, and the optional webhook signing secret — lives in one dictionary secret you create before installing. No credential passes through Helm or lands in the release.What Gets Created
Prerequisites
Two things must exist before you install: an LLM API key from your provider, and a dictionary secret that holds it together with the other credentials. A custom domain is needed only for one optional webhook setup.Get an LLM API key
model.provider: custom).Anthropic keys must be workspace-scoped — create the key inside a workspace in the Anthropic console. A default or identity-linked key fails every request with HTTP 400: anthropic-workspace-id is required.Create the dictionary secret
secret.keys mapping, so an existing secret works once you point secret.keys at its key names. The gateway API key must be at least 16 characters. Secrets are org-level, so there is no --gvc flag:secret.name to this name.Optional: a custom domain for webhooks
publicAccess.expose: webhooks) needs no domain.Installation
Install the template into an existing GVC, naming the dictionary secret you created:UI
CLI
Terraform
Pulumi
Configuration
Image
Model
model.baseUrl has no effect with provider: anthropic — the Anthropic client ignores it, so the chart refuses to render that combination. To route Anthropic models through a proxy, use provider: custom.Secret
secret.name is the dictionary secret from Prerequisites; the policy grants the workload reveal on exactly that one secret. Each secret.keys.* entry names the key inside your secret that holds a value — override them to match an existing secret rather than renaming its keys. The chart references every value as cpln://secret/SECRET_NAME.KEY, so no credential appears in the workload spec or the Helm release.
Dashboard
publicAccess.expose: dashboard, the chart sets the dashboard’s public URL to the canonical endpoint automatically, which is what makes MCP OAuth callbacks work. Set dashboard.publicUrl only when you front the workload with your own domain. publicAccess.expose: dashboard requires dashboard.enabled: true; the chart refuses to render otherwise.
Browser Automation
web_extract). Set browser.enabled: true and the chart adds a pinned headless-Chromium container (chromedp/headless-shell) to the workload and seeds browser.cdp_url so the agent attaches to it over loopback CDP and drives real page navigations. The browser profile is ephemeral, the sidecar has no published port and no probe, and it is a real ongoing CPU and memory cost — which is why it is off by default. Keep its maxCpu within 4× its minCpu.
Control Plane MCP
cplnMcp.enabled: false to skip registering it. See Connecting MCP Servers That Need OAuth.
Webhook Listener
8644 and reads the shared signing secret from secret.keys.webhookSecret, so add that key to your secret before enabling it. See Webhooks under Connecting for how to expose the listener and send a signed event.
registry.npmjs.org) and stores the session there, so it survives restarts and redeploys. Pairing needs your phone — see Messaging Platforms. The WhatsApp Cloud (business) API is a separate integration this template does not wire.
Resources
maxCpu within 4× minCpu — the platform rejects a wider ratio on a stateful workload. The browser sidecar has its own browser.resources block under the same rule. Values files from template version 1.0.0 still carry resources.cpu / resources.memory; see Upgrading From 1.0.0.
Storage
/opt/data and holds everything the agent remembers: the SQLite memory database, sessions, learned skills, agent config, MCP OAuth tokens, messaging-platform tokens, and the WhatsApp session. maxCapacity must be at least capacity when autoscaling is enabled.
Access
publicAccess.expose picks which surface that is; the other surfaces stay reachable only inside Control Plane. It has no effect until publicAccess.enabled is true.
publicAccess.expose: webhooks publishes only the HMAC-signed webhook listener while the API stays on the internal endpoint.
Connecting
200 means the API is up. A chat completion against the public API (expose: api), or from another workload against the internal address, looks like this — export API_SERVER_KEY from cpln secret reveal SECRET_NAME -o yaml first:
cpln port-forward is for quick checks like the one above. The supported way to use the dashboard in a browser is the public canonical endpoint (publicAccess.enabled: true with the default expose: dashboard); with public access off, the dashboard is reachable only from inside Control Plane.First Run
The dashboard needs no setup wizard — the admin login isdashboard.username plus the password in your secret. The first-run work is signing in and, if you keep cplnMcp.enabled: true, authorizing the Control Plane MCP server.
Install with public access on
publicAccess.enabled: true, keeping the default publicAccess.expose: dashboard, and wait for the workload to report ready:Open the canonical endpoint and sign in
status.canonicalEndpoint from cpln workload get RELEASE_NAME-hermes-agent --gvc GVC_NAME -o yaml, open it in a browser, and sign in with dashboard.username (default admin) and the dashboard password from your secret.Authenticate the Control Plane MCP server
Confirm the server is authenticated
hermes mcp test cpln:Webhooks
Setwebhooks.enabled: true to start the listener on port 8644; the chart also enables the hermes webhook subscribe CLI inside the container. Each subscription carries its own HMAC signing secret: hermes webhook subscribe generates one and prints it at creation, or pass --secret "$WEBHOOK_SECRET" to reuse the shared value from secret.keys.webhookSecret, which the container sees as $WEBHOOK_SECRET. Callers sign each event as the HMAC-SHA256 of the body in the X-Webhook-Signature header; the gateway also accepts the timestamped X-Webhook-Signature-V2 form for replay protection.
There are two ways to make the listener reachable from outside, both HTTPS:
Webhooks on a Custom Domain
This is the only way to have the dashboard (or API) public on the canonical endpoint and accept external HTTPS webhooks at the same time. The domain is a prerequisite you own: a domain is an org-level resource, and only you can add its DNS records.Enable the listener
webhooks.enabled: true and a webhook-secret key in your prerequisite secret. The dashboard stays on the canonical endpoint (default publicAccess.expose: dashboard); the listener runs internally on port 8644.Create the domain resource
domain.yaml, substituting your subdomain, GVC, and release, then apply it:Add the two DNS records
status.dnsConfig:Wait for the domain to reach ready
cpln domain get webhooks.example.com -o yaml until the status reads ready. It passes through pendingDnsConfig and pendingCertificate while DNS resolves and the certificate is issued.Register a subscription
hermes webhook subscribe NAME --events "*". Copy the subscription’s own secret, or pass --secret "$WEBHOOK_SECRET" to reuse the shared one.Send a signed event
https://webhooks.example.com/webhooks/NAME with the body’s HMAC-SHA256 in X-Webhook-Signature:200 or 202; a wrong one returns 401.GET / on the webhook domain returns 404 by design — the domain routes to the webhook listener, which serves only /webhooks/NAME. A 404 at the root means the domain is wired correctly.Messaging Platforms
Hermes supports chat-platform gateways such as Telegram, Discord, and Slack. They are configured after install with Hermes’s own interactive setup, not through this template’s values, and the configuration is stored on the data volume:- Telegram works with a bot token from
@BotFather; nothing else is needed. - Slack requires an app manifest. Slack caps an app at 25 slash commands and Hermes’s generated manifest emits around 50 — trim it to 25 or fewer before creating the app, or Slack rejects the manifest. Slack’s user allowlist also fails closed when empty — an empty allowlist silently rejects everyone, so set the allowed users explicitly.
- WhatsApp (personal account) requires
whatsapp.enabled: true. Pair after install: open the dashboard’s WhatsApp setup and scan the QR with WhatsApp on your phone (Settings → Linked devices → Link a device), or get a terminal QR withcpln workload exec RELEASE_NAME-hermes-agent --gvc GVC_NAME --container hermes -- hermes whatsapp. Hermes then runs as that WhatsApp account; when someone messages the linked number, Hermes replies with a pairing code that you approve on the dashboard’s Pairing page (or pre-authorize people with an allowlist during setup).
Connecting MCP Servers That Need OAuth
Control Plane’s own MCP server (mcp.cpln.io) is pre-registered when cplnMcp.enabled is true, so it appears on the dashboard’s MCP page and you go straight to Authenticate. For any other OAuth MCP server, add it on the MCP page with Authentication: OAuth first. Then:
- With
publicAccess.expose: dashboard(the default): click Authenticate — your browser goes to the provider, you sign in, and it redirects back to the dashboard. The chart sets the dashboard’s public URL to the canonical endpoint automatically, which is what makes the callback work. Tokens persist on the volume across restarts and redeploys. - With a custom domain in front of the dashboard, set
dashboard.publicUrlto that domain’s base URL so callbacks and asset URLs use it instead of the canonical endpoint. - With
expose: apithe dashboard has no public URL for OAuth callbacks, so use the one-time CLI flow instead: open a shell withcpln workload connect RELEASE_NAME-hermes-agent --gvc GVC_NAME --container hermes, runhermes mcp login NAME, open the printed URL in your browser, and when it lands on a127.0.0.1:27890/callbackconnection error (expected), paste that full URL back into the shell.
hermes mcp test NAME) is the reliable way to tell whether a server is authenticated.Operations
Backing Up
There is no scheduled-backup feature. The volume set takes no scheduled snapshots; the only automatic snapshot is the final one taken when the volume set is deleted, kept for 7 days. Everything the agent remembers lives on that volume — the memory database, sessions, skills, config, MCP tokens, platform tokens, and the WhatsApp session — so take a manual volume set snapshot before a risky change:Restoring a Backup
Restoring the agent’s state from a volume set snapshot has not been verified on this template. Treat snapshots as a safety net for a risky change rather than a tested recovery path, and keep anything you cannot lose outside the agent.Rotating a Secret Value
Acpln:// secret reference is resolved when a replica starts and never re-read while it runs, so changing a value in your secret does nothing to the running workload — the old credential keeps working with no error. Update the secret, then force a redeployment:
Update the secret
secret.yaml and apply it:If you rotated the LLM API key, clear its on-volume copy
/opt/data/.env on the volume, where messaging-platform and webhook-triggered turns read it — but only when no entry exists there yet. After rotating that key, remove the old line so the next start writes the new value (use OPENAI_API_KEY for openai and custom providers):Force a redeployment
Upgrading From 1.0.0
Template version 1.1.0 renamed the two ceiling keys inresources, because a block that exposes both a reservation and a limit names the limit explicitly:
Upgrading From 1.2.1 or Earlier
Template version 1.3.0 changed the defaultpublicAccess.expose from api to dashboard. Versions 1.0.0 and 1.1.0 had no expose knob at all — public access always meant the gateway API.
Also new in 1.3.0: expose: dashboard requires dashboard.enabled: true (the render fails otherwise), and secret.keys.webhookSecret was added — it is read only when webhooks.enabled is true, so an existing secret needs no new key unless you turn webhooks on.
Upgrading From 1.3.0 or Earlier
Template version 1.4.0 pre-registers the Control Plane MCP server by default (cplnMcp.enabled: true). After upgrading, the server appears on the dashboard’s MCP page ready to authenticate; it stays inert until someone clicks Authenticate, and nothing reaches your org before that. Set cplnMcp.enabled: false to opt out. Version 1.4.1 also moved to image v2026.9.21 and fixed messaging-platform and webhook-triggered turns, which on a default 1.4.0 install replied that they could not connect to the model.
Scaling and Availability
This template runs exactly one replica by design: the agent’s memory is a single-writer SQLite database, and upstream forbids two gateways sharing one data directory. Do not scale the workload out. State persists on the volume set, so a restart or upgrade loses only in-flight work and causes a brief outage. The firsthelm upgrade after an install re-applies resources even when no value changed, which restarts the workload; later no-op upgrades do not. Grow storage with volumeset.autoscaling rather than replicas.
Troubleshooting
The workload never starts after install
The workload never starts after install
cpln helm install succeeded, but the workload has no running replica and cpln logs returns nothing.Cause: The secret named in secret.name does not exist. The deployment is paused waiting for it.Fix: Confirm with cpln workload get-deployments RELEASE_NAME-hermes-agent --gvc GVC_NAME -o yaml — status.versions[].message reads The secret SECRET_NAME no longer exists. Workload updates are paused until the secret is added or the reference to the secret removed. Create the secret as shown in Prerequisites; the deployment recovers on its own, or run cpln workload force-redeployment RELEASE_NAME-hermes-agent --gvc GVC_NAME to skip the wait.The gateway starts but the workload never becomes ready
The gateway starts but the workload never becomes ready
/health never passes and the workload never reports ready.Cause: The gateway API key in your secret is shorter than 16 characters. Hermes refuses it, so the API server never serves.Fix: Generate a longer key with openssl rand -hex 32, update the secret, and force a redeployment — see Rotating a Secret Value.Every request fails with anthropic-workspace-id is required
Every request fails with anthropic-workspace-id is required
provider: anthropic, every model call fails with HTTP 400: anthropic-workspace-id is required.Cause: The Anthropic key is a default or identity-linked key, not a workspace-scoped one.Fix: Create a new key inside a workspace in the Anthropic console, put it in your secret, and force a redeployment.Requests fail with Unsupported parameter reasoning.effort
Requests fail with Unsupported parameter reasoning.effort
400: Unsupported parameter: 'reasoning.effort'.Cause: The configured model does not support reasoning, and Hermes sends a reasoning effort with every request.Fix: Set model.reasoningEffort: none and upgrade the release.A model call returns HTTP 200 but no answer
A model call returns HTTP 200 but no answer
200, but the body carries "finish_reason": "error" and "hermes": {"failed": true} instead of a completion.Cause: The call to your LLM provider failed — a bad key, a rejected model ID, or a provider-side error. Hermes reports provider failures inside the body, not in the HTTP status.Fix: Read the error in the body, or the agent log inside the container: cpln workload exec RELEASE_NAME-hermes-agent --gvc GVC_NAME --container hermes -- tail -n 50 /opt/data/logs/agent.log. Make your clients inspect the body rather than the status code.Telegram, Slack, WhatsApp or webhook turns reply that they cannot connect to the model
Telegram, Slack, WhatsApp or webhook turns reply that they cannot connect to the model
/opt/data/.env on the volume rather than from the environment. On template version 1.4.0 and earlier that file was never written, so every channel failed on a default install. On 1.4.1 the same symptom appears after rotating the LLM API key, because the file keeps the old value.Fix: Upgrade to 1.4.1. After a key rotation, clear the stale line and force a redeployment as described in Rotating a Secret Value.MCP Authenticate fails with cleartext http redirect_uris are only allowed for loopback hosts
MCP Authenticate fails with cleartext http redirect_uris are only allowed for loopback hosts
registration failed … cleartext http redirect_uris are only allowed for loopback hosts.Cause: The dashboard does not know its public https:// URL, so it registers a cleartext callback that the provider refuses. The chart sets the URL automatically only under publicAccess.expose: dashboard.Fix: Fronting the dashboard with your own domain, set dashboard.publicUrl to that domain’s base URL. Under expose: api, use the CLI login flow in Connecting MCP Servers That Need OAuth.Browser tool calls return page text but never drive a real browser
Browser tool calls return page text but never drive a real browser
browser.enabled is false. The Hermes image ships no browser, so browser tools fall back to web_extract.Fix: Set browser.enabled: true and upgrade the release. A real navigation logs a resolved CDP endpoint in /opt/data/logs/agent.log.The webhook URL in the dashboard says localhost
The webhook URL in the dashboard says localhost
hermes webhook CLI show every subscription as http://localhost:8644/webhooks/NAME, even with public access or a custom domain in place.Cause: Upstream Hermes displays the listener’s bind address and cannot be told its public URL.Fix: Nothing is broken. Keep the /webhooks/NAME path and prepend your domain or the canonical endpoint when giving the address to callers.GET / on the webhook domain returns 404
GET / on the webhook domain returns 404
404.Cause: The domain routes to the webhook listener on port 8644, which serves only /webhooks/NAME.Fix: None needed — a 404 at the root means the domain is wired correctly. Send a signed POST to a subscription path to test it.Logs show config-migrate WARNING This config predates version 12
Logs show config-migrate WARNING This config predates version 12
cpln logs '{gvc="GVC_NAME", workload="RELEASE_NAME-hermes-agent"}' --limit 50 --since 10m shows [config-migrate] WARNING: This config predates version 12 on every start.Cause: The config the chart seeds carries no _config_version, so upstream prints this notice. It is benign: the config is applied, MCP registers, and the memory database is healthy.Fix: None needed. Do not treat it as a defect.Upgrade fails with resources.cpu was RENAMED to resources.maxCpu
Upgrade fails with resources.cpu was RENAMED to resources.maxCpu
cpln helm upgrade stops at render with hermes-agent: resources.cpu was RENAMED to resources.maxCpu (or the resources.memory equivalent).Cause: Your values file still uses the 1.0.0 key names.Fix: Rename cpu to maxCpu and memory to maxMemory under resources, keep the same values, and upgrade again — see Upgrading From 1.0.0.Install fails because expose dashboard requires dashboard.enabled
Install fails because expose dashboard requires dashboard.enabled
publicAccess.expose 'dashboard' requires dashboard.enabled: true — there is no dashboard to front.Cause: dashboard.enabled is false while publicAccess.enabled is true with the default expose: dashboard.Fix: Re-enable the dashboard, or set publicAccess.expose to api or webhooks.An access change appears to do nothing
An access change appears to do nothing
publicAccess or internalAccess.type and the workload still behaves as before.Cause: Firewall changes take up to a few minutes to take effect.Fix: Wait a few minutes and test again before concluding the setting is wrong.Important Notes
- Create the dictionary secret before installing. A missing secret does not fail the install — the workload waits on it with no container logs to diagnose from.
- The gateway API key must be at least 16 characters, or the API never serves and the workload never becomes ready.
publicAccess.enabled: truepublishes a terminal-capable agent to the internet behind a basic-auth login (expose: dashboard) or a bearer token (expose: api). Use long random credentials and prefer restricting reach withinternalAccess. It is off by default.- Anthropic keys must be workspace-scoped — any other kind fails every request.
- An authenticated MCP server or paired chat platform acts as the person who connected it. For the pre-registered Control Plane MCP server that includes creating and deleting real infrastructure; it does nothing until you click Authenticate.
- Single replica by design — memory is a single-writer SQLite database. Do not scale out; a restart or upgrade is a brief outage.
- Failed model calls return HTTP 200 with the error in the body (
"finish_reason": "error"). Clients must inspect the body, not the status code. - Rotating a secret value needs a forced redeployment, and a rotated LLM API key also needs its stale line cleared from
/opt/data/.env— see Rotating a Secret Value. - Keep
maxCpuwithin 4×minCpuin both theresourcesandbrowser.resourcesblocks — the platform rejects a wider ratio on a stateful workload. browser.enabledadds a second container with its own resource floor — an ongoing cost. Leave it off unless the agent must drive a real browser.- Everything the agent remembers lives on the volume set and survives redeploys and upgrades.
cpln helm uninstalldeletes it; the final snapshot is kept for 7 days. Changing the secret and redeploying does not reset memory or config. - The prerequisite secret is not owned by the release — it survives
cpln helm uninstalland must be deleted separately if you no longer need it. - Upgrading from 1.2.1 or earlier can move the public endpoint from the API to the dashboard — see Upgrading From 1.2.1 or Earlier before you upgrade.