Hermes Agent Runtime Integration README
Status (2026-06-01): This document is partially stale. The Hermes
/v1/chatruntime 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 inapi/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 newslack/*.tsfiles.- Per-org Slack
signing_secret(#1219, BLK-004, 2026-06-01):SLACK_SIGNING_SECRETandSLACK_BOT_TOKENare now dev-only fallbacks; production resolves both per-org fromintegration_credentials.- RIG hard-cut (#696): the agent-service
/v1/tasks*runtime and API/tools/executewere 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-servicedefines the/v1/*contract surface the Platform calls.feat/hermes-agentcontains 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:
- Slack sends an Events API payload to
POST /channels/slack/events. ChannelsControllerverifies or accepts the event according to configured Slack signing settings.- The Slack event is normalized into a Humanwork channel message.
- Slack integration credentials are inspected to resolve the owning org from
Slack metadata such as
team_id,app_id, orchannel_id. ConversationsService.findOrCreateByChannel()creates or reuses the Slack conversation.ConversationsService.sendMessage()persists the user message and callsAgentClient.chat().AgentClientcalls the Python agent service/v1/chatcontract endpoint./v1/chattranslates the v1 request into the Hermes runtime request shape.- The Python runtime shells out to the real
hermes chatCLI, without Hermes gateway. - Hermes returns an assistant reply and, when available, a Hermes session id.
- The agent service maps the runtime response back into the v1 contract.
- 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/teamIdenterprise_id/enterpriseIdapp_id/appIdchannel_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.pyagent/chat_helpers.pyagent/hermes_client.pyagent/main.pyagent/prompts_loader.pyagent/routers/deferred.pyagent/routers/configure.pyagent/routers/knowledge.pyagent/routers/tasks.pyagent/routers/corrections.pyagent/corrections_client.py
API/platform integration:
api/src/common/agent.client.tsapi/src/conversations/conversations.service.tsapi/src/channels/channels.controller.tsapi/src/learning/learning.controller.tsapi/src/learning/learning.service.ts
Verification:
agent/evals/test_contract.pyagent/tests/test_chat.pyapi/test/agent-client.spec.tsapi/test/conversations.spec.tsapi/test/channels.spec.tsapi/test/agent-runtime-e2e.spec.tsapi/test/slack-real-hermes-e2e.spec.ts
Contract docs:
agent/AGENT_API_SPEC.mddocs/api/agent-service-openapi.yaml
Environment Variablesโ
Python agent runtime:
| Variable | Purpose |
|---|---|
AGENT_SERVICE_SECRET | Shared secret expected as X-Agent-Secret. |
HERMES_CLI | Path to the real Hermes binary. Defaults to hermes. |
HERMES_PROVIDER | Hermes provider, for example openrouter. |
HERMES_MODEL | Provider model id, for example openai/gpt-4o-mini. |
HERMES_TOOLSETS | Hermes 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_SEC | Runtime timeout for Hermes subprocess execution. |
HUMANWORK_WORKSPACE_ROOT | Workspace root for runtime sessions/artifacts. |
API/platform:
| Variable | Purpose |
|---|---|
AGENT_SERVICE_URL | Python agent base URL. |
AGENT_SERVICE_SECRET | Shared secret forwarded to the Python agent. |
SLACK_SIGNING_SECRET | Enables Slack HMAC verification when configured. |
SLACK_BOT_TOKEN | Enables 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:
- The API constructs an
AgentRunwith atoolsManifestthat describes the tools available to that run (sourced fromtool_specialist_bindings+ per-Specialist policy). - The agent-runtime's tool gateway client reads
toolsManifestwhen it spawns the Hermes subprocess and passes the resolved toolset names down viaHERMES_TOOLSETSin the subprocess environment. - 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
openrouterandopenai/gpt-4o-mini. - Real Python
/v1/chatsmoke passed withHERMES_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_TOKENor per-org Slack integration credential withbot_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):
- HMAC
verifyHmaccrashes on wrong-length signatures. Inapi/src/agent-api/agent-api.service.ts,crypto.timingSafeEqualthrows aRangeErrorwhen the attacker-supplied signature is a different byte length than the expected HMAC. The route should normalize and return401, not surface a 500. โ GitHub issue to be filed. has_morepagination bug ingetConversationMessages. The flag is computed against the unfiltered total instead of the returned page, sohas_moreis incorrect after filtering. Clients that paginate by followinghas_morecan loop or miss the end. The check should comparemsgs.length === limit. โ GitHub issue to be filed.- Non-timing-safe
PLATFORM_API_TOKENcomparison.assertPlatformTokeninapi/src/learning/learning.controller.tsuses!==instead ofcrypto.timingSafeEqual. Low impact (single fixed token, narrow surface) but easy to harden. โ GitHub issue to be filed.