Skip to content

Hermes Agent Host

The hermes-agent-host is the external worker that executes agent jobs. It runs the NousResearch Hermes agent CLI in one container per organization, claims queued jobs from the Backend API as an AGENT-role bot, and proposes reviewable output back through the ordinary API — it is a pure API consumer with no database access of its own.

It is the counterpart to the platform's in-process agent sessions: those run an LLM conversation inside the backend, this runs autonomous work outside it.


Overview

flowchart TB
    subgraph Host [hermes-agent-host]
        GW[agent-gateway<br/>shared OAuth login]
        D[dispatcher<br/>bot credential + Docker socket]
        W1["hermes-org-A<br/>own HERMES_HOME"]
        W2["hermes-org-B<br/>own HERMES_HOME"]
        L[launcher<br/>per-org dashboards]
    end

    D -->|poll / claim| API[Backend API]
    D -->|spawn · deliver job + token · reap| W1
    D --> W2
    W1 -->|sartiq CLI, job-scoped token| API
    W2 --> API
    W1 --> GW
    W2 --> GW
    L -.->|start on demand| W1
Service Role
agent-gateway Holds one OAuth login and exposes an OpenAI-compatible endpoint on the compose network. Every other container points its model config at it — one login, one token refresher, no refresh race between containers
dispatcher The only holder of the bot credential and the Docker socket: poll → claim → spawn → deliver → reap
hermes-org-\<id> One worker container per organization, with its own Hermes home. Receives only the job-scoped token, never the bot credential
launcher An index over all organizations that starts a per-org dashboard on demand. It never runs an agent — it lists directories and starts containers
dashboard (per org) Chat, sessions, memory and skills for a single organization, mounted with only that org's home and workspaces

There is deliberately no all-orgs dashboard: its chat page runs a real agent, so pointing one at every organization would put every tenant's memory one prompt injection apart.


Job flow

sequenceDiagram
    participant D as Dispatcher
    participant API as Backend API
    participant W as Org worker
    participant GW as agent-gateway

    loop per job type
        D->>API: GET /agent-jobs?status=QUEUED&type=… (bot token)
    end
    D->>API: POST /agent-jobs/{id}/claim (bot token)
    API-->>D: job-scoped token + organization_id + manifest
    D->>W: ensure container, deliver {job, token}
    W->>W: prepare per-job workspace, download manifest
    W->>GW: run Hermes headless with the job-type prompt
    W->>API: sartiq call … (job-scoped token)
    W->>API: heartbeat / events
    W->>API: complete | fail | release

Polling is one request per job type because the list endpoint defaults to product ingest. Each worker processes its organization's jobs serially — a single writer per org, so concurrent orgs never race even though they run at the same time.

Recovering a dead worker

A job whose worker was killed would otherwise stay CLAIMED/RUNNING forever, because polling only looks at queued jobs. The backend already re-claims a job whose claimant stopped heartbeating — the dispatcher simply asks it to. Attempts are counted from the job's own event log (so the count survives a dispatcher restart) and capped.

This is safe only because the worker heartbeats: the backend judges staleness from the last heartbeat, and a long guidelines run would otherwise look stale while perfectly healthy.


Safety model

Boundary How it is enforced
Credential separation The bot credential lives only in the dispatcher. A worker gets only the job-scoped token from the claim
Organization boundary Derived from the backend's organization_id in host code, never from anything the model says — prompt injection cannot switch org. Workers additionally reject any job not addressed to their org
Token scope The job token is org-scoped with a curated, default-deny allowlist on the backend: reads, presign, its capability's write surface, and its own job endpoints. Granting a new endpoint is an explicit one-line change, never a prompt change
Data isolation Each org has its own container and its own Hermes home, so memories, skills, sessions and state are isolated by Hermes' own per-instance boundary rather than by a mapping we invented
Human review The agent only ever proposes; a human confirms in the webapp
Opt-in mutation Claiming is gated (CLAIM_ENABLED) — by default the dispatcher only reads the queue and logs what it would claim. Terminal reporting is gated separately
Gateway gate If the model gateway serves neither the worker nor the vision model, the dispatcher does not poll or claim at all — an expired login leaves the queue untouched instead of dragging every job through a spawn and a release

What an organization accumulates

Each org is a standalone Hermes instance with a host-visible home directory, so an operator can read what that org's agent has learned without entering a container.

  • Memory is Hermes' own loop end to end: the agent writes it with its memory tool, Hermes injects it at session start. The host deliberately does neither half — writing to the memory file from outside trips Hermes' external-drift detection and permanently blocks the agent from consolidating its own memory.
  • Skills are stock: the agent authors one and it is discovered on every later job. Bundled skills are read from the image, not copied per org, so rebuilding updates them everywhere at once. Authored skills are capped — past the limit the least-recently-used ones are disabled (never deleted), with usage read from Hermes' own state.
  • Identity comes from a host-generated SOUL.md: a versioned block the host owns plus a free section it never touches. The agent cannot rewrite the generated block — jobs scrape third-party storefronts, and an identity file the agent could rewrite from that context would let hostile page content redefine the agent for every future session.
  • Each job starts a fresh session, on purpose. Sessions do persist and resuming is available, but continuity should come from what the agent chose to keep — memory and skills — not from every wrong turn it happened to take, which would grow the context window until it dominated the cost of every later job.

Capabilities

A capability is one kind of job the host knows how to run: a prompt, a workspace shape, a write surface and a completion gate. Today the host runs the two job types the backend defines — product_ingest and guidelines_extraction — with one prompt file per type; the runner itself stays generic and fails loudly on an unknown type.

Long guidelines runs are checkpointed: results are persisted per finished category, and a run that hits its turn limit resumes the same session automatically. The job is reported complete only when both the final response and the durable results agree.

Planned: job chaining

A model_selection job chained from a completed guidelines_extraction job (per-category subject assignment) is designed but not yet enabled — see SAR-1899.


How the worker calls the backend

The worker ships a small sartiq CLI that self-discovers the live OpenAPI document and attaches the job token from the environment — no MCP server and no generated client to keep in sync:

sartiq ops <substring>                       # discover operationIds
sartiq call <operationId> [--path k=v] [--query k=v] [--json '<body>']

The agent shell-calls it. The token reaches the CLI through the environment; the model never sees it and never picks the organization.