Skip to main content

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 with an OpenAI-compatible API for your clients, a web dashboard, optional browser automation, and optional external webhooks, with all agent state on a persistent volume.

Architecture

  • Hermes Agent — A single-replica stateful workload running the supervised gateway. It serves the OpenAI-compatible API on port 8642 (bearer auth), the web dashboard on port 9119 (basic auth), and an optional HMAC-signed webhook listener on port 8644. With public access enabled, the single canonical HTTPS endpoint fronts one of these — publicAccess.expose picks which (the dashboard by default).
  • Chromium sidecar (optional) — When browser.enabled is set, a second container running headless Chromium is added to the workload, exposing the Chrome DevTools Protocol (CDP) on loopback. The agent attaches to it so browser tools actually work. Off by default because it is a real CPU/memory cost.
  • Control Plane MCP (pre-registered, on by default) — With cplnMcp.enabled, the Control Plane MCP server is registered in the agent’s config so it can manage resources in your cpln org. It stays inert until you authenticate it once via OAuth in the dashboard.
  • Model — External. Every completion is dispatched to your LLM provider using a key you supply; the workload holds no model weights.

What Gets Created

  • Stateful Hermes Agent Workload — Single replica. Gateway API on port 8642 (bearer auth), web dashboard on port 9119 (basic auth), and an optional webhook listener on port 8644 (HMAC-signed). With public access enabled, publicAccess.expose selects which one the canonical HTTPS endpoint serves; the others stay internal-only.
  • Chromium Sidecar Container (optional) — Added to the same workload when browser.enabled: true. Headless Chromium reachable by the agent over loopback CDP; no published port and no health probe.
  • Volume Set — 10 GiB persistent storage at /opt/data for the SQLite memory database, sessions, learned skills, agent config, and MCP OAuth tokens. Supports optional autoscaling.
  • Identity & Policy — An identity bound to the workload with reveal access to exactly the one prerequisite secret, and nothing else.
This template does not create a GVC. You must deploy it into an existing GVC.

Prerequisites

Hermes Agent needs an LLM API key and a dictionary secret that you create before installing. No credential is passed through values.
1

Get an LLM API key

Obtain a key from your provider — Anthropic, OpenAI, or any OpenAI-compatible endpoint (OpenRouter, Ollama, vLLM, LM Studio, a proxy) reached via 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; recreate it inside a workspace if you see that.
2

Generate a gateway API key

Generate the bearer token your clients will present to the gateway API. It must be at least 16 characters — generate one with openssl rand -hex 32.
3

Create a dictionary secret

Create a dictionary secret holding the values below. Name the keys however you like — map them with secret.keys at install, so an existing secret works unchanged. Pass the secret’s name as secret.name.With the default key names — my-hermes-secret matches the chart’s default secret.name, so no values override is needed:
  • A Control Plane domain — only if you want external webhooks reachable over a custom hostname while the dashboard or API stay public on the canonical endpoint. You create the domain and point it at the workload’s webhook port; see Webhooks for the full steps. This is not needed for the canonical-endpoint webhook path (publicAccess.expose: webhooks).
Hermes rejects an API server key shorter than 16 characters. The gateway still starts, but the API never serves and the workload never becomes ready.
A missing prerequisite secret wedges the deployment silentlycpln logs returns nothing because the container never starts. If the workload never becomes ready, run cpln workload get-deployments {release}-hermes-agent --gvc {gvc} -o yaml and read status.versions[].message; it names the missing secret. Recovery is automatic once the secret exists (up to ~6 minutes), or force it with cpln workload force-redeployment.
Once your secret exists, install the template using your preferred method:

UI

Browse, install, and manage templates visually

CLI

Manage templates from your terminal

Terraform

Declare templates in your Terraform configurations

Pulumi

Declare templates in your Pulumi programs

Configuration

The default values.yaml for this template:

Model

  • image — The Hermes Agent container image.
  • model.provider — The LLM provider:
  • model.name — Model override, e.g. claude-opus-4-6, gpt-5. Empty uses the provider default. Recommended for non-Anthropic providers.
  • model.baseUrl — The OpenAI-compatible endpoint. Required when provider is custom.
  • model.reasoningEffortnone, low, medium, or high (default medium).
model.name takes the provider’s bare, hyphenated model ID (e.g. claude-opus-4-6) — the template adds the anthropic/ prefix itself. A pre-prefixed value (anthropic/claude-opus-4-6) renders a doubled anthropic/anthropic/… prefix.
To reach OpenRouter or any other OpenAI-compatible service, use provider: custom:
Hermes sends a reasoning effort with every request, and models that do not support reasoning reject it with 400: Unsupported parameter: 'reasoning.effort'. Set reasoningEffort: none for those (e.g. gpt-4o); leave the default for reasoning-capable models (e.g. gpt-5, claude-opus-4-6).
model.baseUrl has no effect with provider: anthropic — the Anthropic client ignores it. The template fails at render if you set both, so use provider: custom to route through a proxy.

Secret

  • secret.name — Name of the dictionary secret you created. See Prerequisites.
  • secret.keys.apiKey / secret.keys.apiServerKey / secret.keys.dashboardPassword / secret.keys.webhookSecret — The key names within your secret that hold each value. Override these to match an existing secret rather than renaming its keys. webhookSecret is only read when webhooks are enabled.

Dashboard

  • dashboard.enabled — Serve the web dashboard on port 9119. Public on the canonical HTTPS endpoint under the default publicAccess.expose: dashboard (see Access); otherwise internal-only.
  • dashboard.username — Basic-auth username. The password comes from your secret.
  • dashboard.publicUrl — Base URL used for MCP OAuth callbacks and asset URLs. Empty uses the canonical endpoint (auto-set under expose: dashboard). Set this only when you front the workload with your own custom domain, so callbacks and asset URLs use that domain instead of the canonical endpoint.

Browser sidecar

  • browser.enabled — Add a headless-Chromium sidecar so the agent’s browser tools work. Off by default; it is a real CPU/memory cost.
  • browser.image — The pinned community headless-Chromium image (chromedp/headless-shell).
  • browser.cdpPort — The loopback CDP port the app reaches the sidecar on.
  • browser.resources — The sidecar’s own CPU/memory floor and ceiling. Keep maxCpu under 4× minCpu (stateful workload).
See Browser automation for how it works.

Control Plane MCP

  • cplnMcp.enabled — Pre-register the Control Plane MCP server (https://mcp.cpln.io) in the agent’s config. On by default.
The server shows up on the dashboard’s MCP page ready to authenticate. It is inert until you click Authenticate (OAuth) — nothing reaches your cpln org before that. Once authenticated, the agent can manage resources in your org, and the OAuth session acts as you; tokens persist on the volume across restarts. Set cplnMcp.enabled: false to skip registering it. See Connecting MCP servers that need OAuth.

Webhook listener

  • webhooks.enabled — Turn on the HMAC-signed webhook listener on port 8644. Off by default. The signing secret comes from secret.keys.webhookSecret.
See Webhooks for the two ways to expose the listener externally.

WhatsApp

  • whatsapp.enabled — Link the agent to a personal WhatsApp account via the image’s built-in Baileys bridge. Off by default.
When enabled, pair the account after install by scanning a QR — see Messaging Platforms. First pairing installs the bridge’s Node dependencies to the volume (~6 s, one-time, reaching only registry.npmjs.org — covered by the default open outbound) and stores the session on the volume, so it survives restarts. The WhatsApp Cloud (business) API is a separate integration this template does not wire.

Resources

  • resources.minCpu / resources.minMemory — The idle floor.
  • resources.maxCpu / resources.maxMemory — The burst ceiling for heavy agent turns and tool work.
The min→max spread is the elasticity: the workload idles at the floor and bursts toward the ceiling during heavy work. Keep maxCpu under 4× minCpu — the platform rejects a wider ratio. (The optional browser sidecar has its own separate browser.resources block.)
Upgrading from 1.0.0: the ceiling keys were renamed from cpu/memory to maxCpu/maxMemory. Values that still carry the old names are refused at render — rename them before upgrading.

Storage

  • volumeset.capacity — Initial volume size in GiB (minimum 10) for the memory database, sessions, skills, config, and MCP OAuth tokens.
  • volumeset.autoscaling.enabled — Automatically expand the volume as state grows. When enabled:
    • maxCapacity — Maximum volume size in GiB.
    • minFreePercentage — Trigger a scale-up when free space drops below this percentage.
    • scalingFactor — Multiply the current capacity by this factor when scaling up.

Access

  • publicAccess.enabled — Expose the workload on the public canonical HTTPS endpoint. Defaults to false. Read the security note below before enabling.
  • publicAccess.expose — Which surface the canonical endpoint fronts: dashboard (default), api, or webhooks. A workload gets one canonical HTTPS endpoint, and it fronts a single port — this knob picks which. Only meaningful with publicAccess.enabled: true.
The default expose: dashboard puts a basic-auth login form on the internet when you enable public access — the dashboard password in your secret must be strong (openssl rand -hex 32), because whoever logs in operates a terminal-capable agent. It also requires dashboard.enabled: true.
Keeping the API internal is a valid, safer posture for event-driven deployments. The gateway API is a terminal-capable agent behind a single bearer token, so publicAccess.expose: webhooks — exposing only the HMAC-signed webhook listener while the API stays on the private internal endpoint — is a deliberate hardening choice, not a limitation.
  • internalAccess.type — Internal firewall scope of the workload:
publicAccess.enabled: true publishes a terminal-capable agent to the internet. With the default expose: dashboard that is a basic-auth login form; with expose: api it is a bearer-guarded API. Either way, whoever gets in operates the agent with full file access as the container user. It is off by default — before enabling it, use a long random password/key (openssl rand -hex 32) and prefer restricting reach via internalAccess.

Connecting

The dashboard’s supported browser access is the public canonical endpoint (default expose: dashboard). A private install’s dashboard is internal-only.
Example request against the gateway API:

Browser automation

The agent’s browser tools cannot run on the Hermes image alone — it ships no launchable browser, and browser tool calls silently fall back to text-level page fetching (web_extract). Set browser.enabled: true and the chart adds a pinned headless-Chromium container (chromedp/headless-shell) to the workload. Containers in one workload share a network namespace, so the agent reaches Chrome’s DevTools Protocol on loopback (http://127.0.0.1:9222); the chart seeds browser.cdp_url there automatically. No extra setup, and the browser profile is ephemeral (fresh navigation per turn).
  • It is a real cost (a second container with its own CPU/memory floor), so it is off by default.
  • The sidecar has no published port and no health probe — CDP binds loopback only, which nothing outside the replica can reach.
  • Verified on this platform: with the sidecar enabled, the agent resolves the CDP websocket and drives a real page navigation (confirmed in /opt/data/logs/agent.log) rather than falling back to web_extract. If the browser is ever unavailable the agent quietly falls back again, so read the reply rather than assuming the tool ran.

Webhooks

Set webhooks.enabled: true to turn on the listener on port 8644 (the chart also enables the hermes webhook subscribe CLI). Each subscription carries its own HMAC signing secret: hermes webhook subscribe auto-generates one and prints it at creation, or you can pass --secret "$WEBHOOK_SECRET" to reuse the shared secret from secret.keys.webhookSecret (exposed to the container as $WEBHOOK_SECRET). Add the webhookSecret key to your prerequisite secret before enabling webhooks. Sign each event as HMAC-SHA256 of the body in the X-Webhook-Signature header (the gateway recommends the timestamped X-Webhook-Signature-V2 form for replay protection). There are two ways to expose the listener externally: Both paths are HTTPS. Use expose: webhooks when webhooks are the only public surface, or a custom domain to keep the dashboard/API public too.

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 — the chart never creates it (a domain is an org-level resource, so a chart that owned it would delete it on helm uninstall, and only you can add DNS records).
GET / on the webhook domain returns 404 by design — the domain routes to the webhook listener (8644), which only serves /webhooks/<name>, not a root page. A 404 at the root means the domain is wired correctly, not that anything is broken.
1

Enable the listener

Install or upgrade with 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 8644.
2

Create the domain resource

Route 443 → the webhook port 8644 (cpln apply -f domain.yaml):
3

Add the two DNS records

Read them back from cpln domain get webhooks.example.com -o yaml under status.dnsConfig (the CNAME target is the GVC alias):
4

Wait for the domain to reach ready

Check cpln domain get webhooks.example.com — it passes through pendingDnsConfigpendingCertificateready as Let’s Encrypt issues over HTTP-01 (a minute or two once DNS resolves).
5

Register a subscription

On the dashboard Webhooks page (or hermes webhook subscribe <name> --events "*"). Each subscription has its own HMAC secret; copy it, or pass --secret "$WEBHOOK_SECRET" to reuse the shared one.
6

Send a signed event

Send to https://webhooks.example.com/webhooks/<name> — the body’s HMAC-SHA256 in X-Webhook-Signature:
A correct signature returns 200/202; a wrong one returns 401.
The dashboard and CLI always display the webhook URL as http://localhost:8644/..., no matter how you expose it. This is an upstream cosmetic bug — the app can’t be told its own public URL. Ignore the displayed host; only the path matters. The same subscription is fully reachable and behaves identically at your real external base: take the /webhooks/<name> path and prepend your domain (or the canonical endpoint), dropping localhost:8644. So http://localhost:8644/webhooks/myhook shown in the UI is served at https://<your-domain>/webhooks/myhook — give external callers that.

Messaging Platforms

Hermes supports chat-platform gateways such as Telegram, Discord, and Slack. These are configured after install using Hermes’s own interactive setup — not through this template’s values:
Follow the prompts for your platform; the configuration is stored on the data volume. Notes from testing:
  • Telegram works out of the box — a bot token from @BotFather is all it needs.
  • Slack requires an app manifest. Two gotchas: Slack caps an app at 25 slash commands but Hermes’s generated manifest emits ~50 — trim it to 25 or fewer before creating the app, or Slack rejects the manifest. And Slack’s user allowlist fails closed when empty — an empty allowlist silently rejects everyone, so set the allowed users explicitly.
  • WhatsApp (personal account) works via the image’s built-in Baileys bridge when you set whatsapp.enabled: true. Pair it after install: open the dashboard’s WhatsApp setup and scan the QR with WhatsApp on your phone (Settings → Linked devices → Link a device), or run cpln workload exec {release}-hermes-agent --gvc {gvc} --container hermes -- hermes whatsapp for a terminal QR. First pairing installs the bridge’s dependencies to the volume (a few seconds, one-time) and stores the session on the volume, where it is retained across restarts. Once linked, Hermes runs as that WhatsApp account: when someone messages your linked number, Hermes replies with a pairing code that you approve on the dashboard Pairing page (or pre-authorize people with an allowlist during setup). The WhatsApp Cloud (business) API is a separate integration this template does not wire.
See the Hermes Agent documentation for each platform’s requirements, such as bot tokens.
An OAuth-connected MCP server, and a connected chat platform, act as the person who authenticated or paired it. Chat requests can then invoke those tools with that person’s permissions — for Control Plane’s own MCP that includes creating and deleting real infrastructure. Connect write-capable MCP servers deliberately.

Connecting MCP servers that need OAuth

Many MCP servers authenticate with OAuth. Control Plane’s own MCP server (https://mcp.cpln.io/mcp) is pre-registered for you (cplnMcp.enabled: true) — it appears on the dashboard’s MCP page ready to authenticate, so you skip the “add server” step and go straight to Authenticate, a single OAuth click. 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 straight back to the dashboard. This works because the chart sets the dashboard’s public URL automatically; tokens persist on the volume across restarts and redeploys.
  • With a custom domain, set dashboard.publicUrl to your domain’s base URL so callbacks and asset URLs use it instead of the canonical endpoint.
  • With expose: api the dashboard has no public URL for OAuth callbacks, so use the one-time CLI flow instead: cpln workload connect {release}-hermes-agent --gvc {gvc} --container hermes, then hermes mcp login <name> — open the printed URL, and when it lands on a 127.0.0.1:27890/callback connection error (expected), paste that full URL back into the shell.
MCP auth state is not badged in the dashboard — the Test button (or hermes mcp test) is the truth for whether a server is authenticated.

Important Notes

  • publicAccess.enabled: true publishes a terminal-capable agent to the internet — guarded by a basic-auth login (expose: dashboard) or a bearer token (expose: api). Whoever gets in operates the agent with full file access as the container user, so use a long random password/key (openssl rand -hex 32) and prefer restricting reach via internalAccess. It is off by default.
  • The API server key must be at least 16 characters — Hermes rejects anything shorter, and the workload will not become ready.
  • browser.enabled adds a second container with its own resource floor — a real, ongoing cost. Leave it off unless the agent needs to drive a real browser.
  • WhatsApp (personal) is opt-in (whatsapp.enabled: true) and pairs via a QR after install — the bridge’s dependencies install to the volume on first pairing and the session is stored on the volume, retained across restarts. It links your own WhatsApp account and runs as it; new people are gated by a pairing code you approve on the dashboard Pairing page (or an allowlist). The WhatsApp Cloud (business) API is not wired by this template. See Messaging Platforms.
  • An OAuth-connected MCP server acts as the person who authenticated it — for Control Plane’s own MCP (pre-registered, cplnMcp.enabled: true) that includes creating and deleting real infrastructure. It stays inert until you authenticate it once via OAuth in the dashboard; connect write-capable MCP servers deliberately.
  • Single replica by design — memory is a single-writer SQLite database and upstream forbids two gateways sharing one data directory. Do not scale up. State persists on the volume across restarts and the agent resumes; only in-flight work and brief downtime are lost.
  • The model is external — cost and rate limits are governed by your LLM provider, not this workload.
  • Failed model calls return HTTP 200 with the error inside the body ("finish_reason": "error", "hermes": {"failed": true}). A client that checks only the HTTP status will read a provider failure as success — inspect the body, or the agent log at /opt/data/logs/agent.log.
  • Keep maxCpu under 4× minCpu (both the app and browser blocks) — the platform rejects a wider ratio on a stateful workload.
  • Access-knob changes take up to a couple of minutes to propagate — after toggling publicAccess, re-poll rather than concluding it is broken.
  • Rotating a value in your prerequisite secret does not reach a running workloadcpln:// references resolve at replica start and are never re-resolved, so the old credential keeps working silently. After any rotation, run cpln workload force-redeployment {release}-hermes-agent --gvc {gvc}.
  • Resetting requires an uninstall — changing the secret and redeploying does not wipe existing memory or config on the volume. cpln helm uninstall deletes the volume set.

External References

Hermes Agent Documentation

Official Hermes Agent documentation

Hermes Agent Repository

Source, issues, and release notes

Nous Research

The team behind Hermes Agent

Hermes Agent Template

View the source files, default values, and chart definition