Skip to main content

Hermes Agent Runtime Integration README

Status (2026-06-01): This document is partially stale. The Hermes /v1/chat runtime path described below is still live, but two large refactors have moved the moving parts referenced here:

  • Slack module extraction (#1212 / #1158, 2026-06-01): Slack inbound is no longer in api/src/channels/channels.controller.ts. It lives in api/src/channels/slack/{slack.controller,slack.service,slack-engagement.service,slack-search.service}.ts. Step 2 below should read "SlackController", and the Files-to-Review list at the bottom must add the new slack/*.ts files.
  • Per-org Slack signing_secret (#1219, BLK-004, 2026-06-01): SLACK_SIGNING_SECRET and SLACK_BOT_TOKEN are now dev-only fallbacks; production resolves both per-org from integration_credentials.
  • RIG hard-cut (#696): the agent-service /v1/tasks* runtime and API /tools/execute were removed; only /v1/chat (described here) remains as the live Hermes path.

This README started life as the reviewer guide for PR #194 (merged to dev 2026-05-05) and now serves as the operational guide for the Hermes-backed agent runtime. It documents the functional integration between the Humanwork agent-service contract and the Hermes runtime, with the Slack dogfood path as the primary slice.

Goalโ€‹

Paul's requested EOD goal was not full Humanwork MVP completion. The target was a functional integration PR that makes the Hermes-flavor runtime usable by h.work, with Slack dogfood as the northstar.

The integration problem was:

  • feat/agent-service defines the /v1/* contract surface the Platform calls.
  • feat/hermes-agent contains the real Hermes-backed runtime.
  • The contract branch had stub/fake endpoint behavior.
  • The Hermes runtime existed, but was not fully wired behind the Platform contract surface or the Slack ingestion path.

This PR wires the contract surface to the real runtime and keeps POST-MVP surfaces explicit instead of pretending they are implemented.

Primary Runtime Pathโ€‹

The main customer-facing Slack path is:

  1. Slack sends an Events API payload to POST /channels/slack/events.
  2. ChannelsController verifies or accepts the event according to configured Slack signing settings.
  3. The Slack event is normalized into a Humanwork channel message.
  4. Slack integration credentials are inspected to resolve the owning org from Slack metadata such as team_id, app_id, or channel_id.
  5. ConversationsService.findOrCreateByChannel() creates or reuses the Slack conversation.
  6. ConversationsService.sendMessage() persists the user message and calls AgentClient.chat().
  7. AgentClient calls the Python agent service /v1/chat contract endpoint.
  8. /v1/chat translates the v1 request into the Hermes runtime request shape.
  9. The Python runtime shells out to the real hermes chat CLI, without Hermes gateway.
  10. Hermes returns an assistant reply and, when available, a Hermes session id.
  11. The agent service maps the runtime response back into the v1 contract.
  12. The API persists the agent message and posts a Slack reply when a bot token is configured.

Important Implementation Detailsโ€‹

No Fake Hermes On The Critical Pathโ€‹

The production path uses agent/hermes_client.py, which shells out to HERMES_CLI or hermes:

hermes chat -q <prompt> -Q -m <model> --provider <provider> -t <toolsets> --source tool --yolo

The fake Hermes fixture remains test-only. It is used by the fast opt-in API smoke at api/test/agent-runtime-e2e.spec.ts, but the real Slack dogfood smoke uses the actual local Hermes binary.

Session Mapping Fixโ€‹

Humanwork conversation ids are not Hermes session ids. Passing a fresh Humanwork conversation id directly as hermes --resume <id> fails with real Hermes because the session does not exist yet.

The runtime now resumes Hermes only when a cached Humanwork-to-Hermes mapping exists:

  • First turn: no --resume; Hermes creates a real session.
  • Runtime parses Hermes session_id.
  • Runtime stores Humanwork session key -> Hermes session id.
  • Later turns: runtime passes the cached Hermes session id to --resume.

This is the core difference that fake-Hermes tests did not catch.

Slack Org Resolutionโ€‹

Slack inbound messages now try to resolve orgId from stored Slack integration credentials before calling the conversation service. The resolver checks common metadata/config spellings:

  • team_id / teamId
  • enterprise_id / enterpriseId
  • app_id / appId
  • channel_id / channelId

If no mapping is found, the route remains tolerant and creates the conversation without an org, matching the previous loose webhook behavior.

Conversation Id Propagationโ€‹

ConversationsService.sendMessage() now passes the actual Platform conversation id into AgentClient.chat(). That means /v1/chat receives the contract-level conversation id instead of falling back to a Slack customer id.

Deferred POST-MVP Surfacesโ€‹

The task/config/knowledge v1 endpoints are intentionally explicit 501 deferred responses for POST-MVP behavior. This avoids fake success responses on surfaces that are not part of the Slack/Hermes EOD slice.

Files To Reviewโ€‹

Agent runtime and contract adapter:

  • agent/routers/chat.py
  • agent/chat_helpers.py
  • agent/hermes_client.py
  • agent/main.py
  • agent/prompts_loader.py
  • agent/routers/deferred.py
  • agent/routers/configure.py
  • agent/routers/knowledge.py
  • agent/routers/tasks.py
  • agent/routers/corrections.py
  • agent/corrections_client.py

API/platform integration:

  • api/src/common/agent.client.ts
  • api/src/conversations/conversations.service.ts
  • api/src/channels/channels.controller.ts
  • api/src/learning/learning.controller.ts
  • api/src/learning/learning.service.ts

Verification:

  • agent/evals/test_contract.py
  • agent/tests/test_chat.py
  • api/test/agent-client.spec.ts
  • api/test/conversations.spec.ts
  • api/test/channels.spec.ts
  • api/test/agent-runtime-e2e.spec.ts
  • api/test/slack-real-hermes-e2e.spec.ts

Contract docs:

  • agent/AGENT_API_SPEC.md
  • docs/api/agent-service-openapi.yaml

Environment Variablesโ€‹

Python agent runtime:

VariablePurpose
AGENT_SERVICE_SECRETShared secret expected as X-Agent-Secret.
HERMES_CLIPath to the real Hermes binary. Defaults to hermes.
HERMES_PROVIDERHermes provider, for example openrouter.
HERMES_MODELProvider model id, for example openai/gpt-4o-mini.
HERMES_TOOLSETSHermes toolsets, for example delegation. Dynamic since #1502/#1598: the platform now populates this env var per-run from AgentRun.toolsManifest via the tool gateway client โ€” not a static config knob. The value an agent process sees is derived from the manifest attached to its AgentRun. See toolsManifest flow below.
HERMES_TIMEOUT_SECRuntime timeout for Hermes subprocess execution.
HUMANWORK_WORKSPACE_ROOTWorkspace root for runtime sessions/artifacts.

API/platform:

VariablePurpose
AGENT_SERVICE_URLPython agent base URL.
AGENT_SERVICE_SECRETShared secret forwarded to the Python agent.
SLACK_SIGNING_SECRETEnables Slack HMAC verification when configured.
SLACK_BOT_TOKENEnables outbound Slack chat.postMessage replies.

toolsManifest-driven Hermes tooling (#1502 / #1598)โ€‹

Prior to #1502/#1598, HERMES_TOOLSETS was a static environment variable set once on the agent-runtime deployment. After #1502 (and follow-up wiring in #1598), the agent runtime derives the toolset list per AgentRun from AgentRun.toolsManifest on the platform side:

  1. The API constructs an AgentRun with a toolsManifest that describes the tools available to that run (sourced from tool_specialist_bindings + per-Specialist policy).
  2. The agent-runtime's tool gateway client reads toolsManifest when it spawns the Hermes subprocess and passes the resolved toolset names down via HERMES_TOOLSETS in the subprocess environment.
  3. Hermes loads only those toolsets; nothing else is wired in.

This means the env-var table above describes the interface between the agent runtime and Hermes, but the source of truth for which toolsets are active is the platform-side toolsManifest. Static HERMES_TOOLSETS set on the runtime deployment will be overridden per-run; treat any deployment-level value as a fallback only.

Verification Commandsโ€‹

Run agent tests:

cd agent
.venv/bin/python -m pytest tests evals -q

Run API build and default tests under the repo-compatible Node version:

cd api
source ~/.nvm/nvm.sh
nvm use 22.22.0
npm run build
npm test -- --runInBand

Run the fake-Hermes contract E2E:

cd api
source ~/.nvm/nvm.sh
nvm use 22.22.0
RUN_AGENT_RUNTIME_E2E=true npm test -- agent-runtime-e2e.spec.ts --runInBand

Run the real Slack/Hermes E2E:

cd api
source ~/.nvm/nvm.sh
nvm use 22.22.0
RUN_SLACK_REAL_HERMES_E2E=true npm test -- slack-real-hermes-e2e.spec.ts --runInBand

The real E2E starts a local Python agent service and points it at the real Hermes CLI. It sends a simulated Slack Events API payload through the Nest Slack endpoint and waits for a real Hermes-generated agent reply to be persisted.

Verified In This PRโ€‹

The latest pushed verification evidence for PR #194 (merged to dev 2026-05-05):

  • Real Hermes CLI smoke passed with openrouter and openai/gpt-4o-mini.
  • Real Python /v1/chat smoke passed with HERMES_CLI=/Users/koristuvac/.local/bin/hermes.
  • Real Slack/Hermes E2E passed: RUN_SLACK_REAL_HERMES_E2E=true npm test -- slack-real-hermes-e2e.spec.ts --runInBand.
  • Agent full suite passed: 491 passed, 14 skipped.
  • API build passed under Node 22.22.0.
  • API Jest passed under Node 22.22.0: 32 suites passed, 2 skipped; 374 passed, 376 total.
  • PR checks are green.

Known Boundariesโ€‹

  • Live outbound Slack posting requires a real SLACK_BOT_TOKEN or per-org Slack integration credential with bot_token. The code path is wired and tested with the Slack WebClient mocked; the real-Hermes E2E disables outbound Slack posting so it does not need a live workspace token.
  • Full v1 contract coverage is a stretch goal beyond the Slack EOD path. The non-Slack POST-MVP endpoints return explicit deferred 501 envelopes instead of fake success responses.

Post-Merge Notesโ€‹

PR #194 merged with three known issues flagged by the Greptile security review. They were accepted as non-blocking for the merge but are tracked as follow-ups (GitHub issues to be filed):

  1. HMAC verifyHmac crashes on wrong-length signatures. In api/src/agent-api/agent-api.service.ts, crypto.timingSafeEqual throws a RangeError when the attacker-supplied signature is a different byte length than the expected HMAC. The route should normalize and return 401, not surface a 500. โ†’ GitHub issue to be filed.
  2. has_more pagination bug in getConversationMessages. The flag is computed against the unfiltered total instead of the returned page, so has_more is incorrect after filtering. Clients that paginate by following has_more can loop or miss the end. The check should compare msgs.length === limit. โ†’ GitHub issue to be filed.
  3. Non-timing-safe PLATFORM_API_TOKEN comparison. assertPlatformToken in api/src/learning/learning.controller.ts uses !== instead of crypto.timingSafeEqual. Low impact (single fixed token, narrow surface) but easy to harden. โ†’ GitHub issue to be filed.