From 802245c94beb41f0e32c5a465de9fb21c531ff8d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 03:01:17 +0000 Subject: [PATCH] Add Hermes gateway onboarding docs Co-Authored-By: Paperclip --- .../__tests__/invite-onboarding-text.test.ts | 15 +++++ server/src/__tests__/llms-routes.test.ts | 35 ++++++++++++ server/src/adapters/hermes-gateway-doc.ts | 55 +++++++++++++++++++ server/src/routes/access.ts | 37 ++++++++++++- server/src/routes/llms.ts | 19 +++++++ ui/src/lib/agent-onboarding-prompt.test.ts | 26 +++++++++ ui/src/lib/agent-onboarding-prompt.ts | 3 + 7 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 server/src/adapters/hermes-gateway-doc.ts create mode 100644 ui/src/lib/agent-onboarding-prompt.test.ts diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 7d8407b608f..e08828a907a 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -51,6 +51,21 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain('"adapterType": "openclaw_gateway"'); expect(text).toContain("headers.x-openclaw-token"); expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); + expect(text).toContain('adapterType: "hermes_gateway"'); + expect(text).toContain('"adapterType": "hermes_gateway"'); + expect(text).toContain("API_SERVER_ENABLED=true"); + expect(text).toContain("API_SERVER_KEY"); + expect(text).toContain("hermes gateway run --replace --accept-hooks"); + expect(text).toContain("Default Hermes API server port: 8642"); + expect(text).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(text).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(text).toContain("hermes_local"); + expect(text).toContain("Hermes-originated Paperclip API usage"); + expect(text).toContain("http://127.0.0.1:8642"); + expect(text).toContain("http://192.168.1.25:8642"); + expect(text).toContain("tailnet-name.ts.net:8642"); + expect(text).toContain("http://host.docker.internal:8642"); + expect(text).toContain("https://hermes-gateway.example"); expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); expect(text).toContain("PAPERCLIP_API_KEY"); expect(text).toContain("Use your runtime's normal skill or instruction installation path."); diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts index fa530d9292e..6078d988934 100644 --- a/server/src/__tests__/llms-routes.test.ts +++ b/server/src/__tests__/llms-routes.test.ts @@ -72,4 +72,39 @@ describe("llm routes", () => { expect(res.text).toContain("Timer heartbeats are opt-in for new hires."); expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false"); }); + + it("serves static Hermes Gateway configuration docs before the plugin is installed", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const indexRes = await request(app).get("/api/llms/agent-configuration.txt"); + expect(indexRes.status).toBe(200); + expect(indexRes.text).toContain( + "- hermes_gateway: /llms/agent-configuration/hermes_gateway.txt", + ); + + const res = await request(app).get("/api/llms/agent-configuration/hermes_gateway.txt"); + + expect(res.status).toBe(200); + expect(res.text).toContain("Adapter: hermes_gateway"); + expect(res.text).toContain('adapterType": "hermes_gateway"'); + expect(res.text).toContain("API_SERVER_ENABLED=true"); + expect(res.text).toContain("API_SERVER_KEY"); + expect(res.text).toContain("hermes gateway run --replace --accept-hooks"); + expect(res.text).toContain("Default Hermes API server port: 8642"); + expect(res.text).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(res.text).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(res.text).toContain("hermes_local"); + expect(res.text).toContain("Hermes-originated Paperclip API usage"); + expect(res.text).toContain("http://127.0.0.1:8642"); + expect(res.text).toContain("http://192.168.1.25:8642"); + expect(res.text).toContain("tailnet-name.ts.net:8642"); + expect(res.text).toContain("http://host.docker.internal:8642"); + expect(res.text).toContain("https://hermes-gateway.example"); + }); }); diff --git a/server/src/adapters/hermes-gateway-doc.ts b/server/src/adapters/hermes-gateway-doc.ts new file mode 100644 index 00000000000..a836de377c5 --- /dev/null +++ b/server/src/adapters/hermes-gateway-doc.ts @@ -0,0 +1,55 @@ +export const hermesGatewayAgentConfigurationDoc = `# hermes_gateway agent configuration + +Adapter: hermes_gateway + +Use when: +- Hermes is already running outside Paperclip and exposes its API server. +- Paperclip should invoke Hermes through the gateway HTTP API instead of spawning Hermes locally. +- The Hermes runtime may live on another host, a private overlay, in Docker, or behind a TLS reverse proxy. + +Don't use when: +- Paperclip should start the Hermes CLI directly on the same host. Use hermes_local from the external Hermes adapter plugin for that flow. +- Hermes is the process calling Paperclip APIs after claiming its key. That is Hermes-originated Paperclip API usage, not the gateway adapter transport. + +Runtime distinction: +- hermes_local: Paperclip starts Hermes on the Paperclip host through the external Hermes adapter plugin. +- hermes_gateway: Paperclip calls an already-running Hermes API server using agentDefaultsPayload.apiBaseUrl. +- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after invite approval and key claim. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls. + +Hermes gateway process setup: +- Set API_SERVER_ENABLED=true. +- Set API_SERVER_KEY to a generated secret value. Do not paste a real key into tickets, docs, screenshots, or tests. +- Start Hermes with: hermes gateway run --replace --accept-hooks +- Default Hermes API server port: 8642. + +Join request minimum: +{ + "requestType": "agent", + "agentName": "My Hermes Gateway Agent", + "adapterType": "hermes_gateway", + "capabilities": "Hermes gateway agent", + "agentDefaultsPayload": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "", + "paperclipApiUrl": "http://localhost:3100" + } +} + +Core fields: +- agentDefaultsPayload.apiBaseUrl (string, required): Base URL for the Hermes API server as reachable from the Paperclip server. +- agentDefaultsPayload.apiKey (string, required unless the adapter package documents another auth field): Hermes API server key matching API_SERVER_KEY. +- agentDefaultsPayload.paperclipApiUrl (string, strongly recommended): Paperclip base URL as reachable from Hermes for invite, claim, skill bootstrap, and later Paperclip API calls. +- agentDefaultsPayload.timeoutSec or timeoutMs (number, optional): Runtime request timeout when supported by the installed Hermes gateway adapter. + +Network examples: +- Local loopback on one host: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642"; agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100". +- LAN/private network: agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642"; agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". Use private IPs or hostnames reachable from both machines. +- Private overlay: agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642"; agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". Add the Paperclip hostname with pnpm paperclipai allowed-hostname when authenticated/private mode requires it. +- Docker: if Hermes runs on the host and Paperclip runs in Docker, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642". If Hermes runs in another container, use the Compose service DNS name such as "http://hermes:8642". +- Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy. + +Security notes: +- Treat API_SERVER_KEY and claimed PAPERCLIP_API_KEY as secrets. +- Prefer private network or TLS for non-loopback gateway access. +- Use placeholders such as in docs and tests. +`; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index a4a2fea15b8..f646d34b5f5 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1585,17 +1585,17 @@ function buildInviteOnboardingManifest( ), onboarding: { instructions: - "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token.", + "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token. Hermes Gateway agents must use adapterType='hermes_gateway', start Hermes with API_SERVER_ENABLED=true, API_SERVER_KEY, and `hermes gateway run --replace --accept-hooks`, then set agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.paperclipApiUrl.", inviteMessage: extractInviteMessage(invite), recommendedAdapterType: null, requiredFields: { requestType: "agent", agentName: "Display name for this agent", adapterType: - "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents.", + "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents. Use 'hermes_gateway' only for Hermes Gateway agents.", capabilities: "Optional capability summary", agentDefaultsPayload: - "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Other runtimes should include the config their adapter expects." + "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Hermes Gateway agents must include apiBaseUrl, API_SERVER_KEY-backed auth, and paperclipApiUrl. Other runtimes should include the config their adapter expects." }, registrationEndpoint: { method: "POST", @@ -1744,6 +1744,37 @@ export function buildInviteOnboardingTextDocument( For OpenClaw Gateway, include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. Do NOT use /v1/responses or /hooks/* in this gateway join flow. + Hermes Gateway setup: + - adapterType: "hermes_gateway" + - Start Hermes with API_SERVER_ENABLED=true and API_SERVER_KEY=. + - Run: hermes gateway run --replace --accept-hooks + - Default Hermes API server port: 8642. + - Set agentDefaultsPayload.apiBaseUrl to the Hermes gateway URL Paperclip can reach. + - Set agentDefaultsPayload.paperclipApiUrl to the Paperclip base URL Hermes can reach. + - Use hermes_local when Paperclip should start Hermes on the Paperclip host. + - Use hermes_gateway when Paperclip should call an already-running Hermes API server. + - Hermes-originated Paperclip API usage means Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after approval/key claim. Do not confuse that with agentDefaultsPayload.apiBaseUrl, which points Paperclip to Hermes. + + Hermes Gateway payload example: + { + "requestType": "agent", + "agentName": "My Hermes Gateway Agent", + "adapterType": "hermes_gateway", + "capabilities": "Hermes gateway agent", + "agentDefaultsPayload": { + "apiBaseUrl": "http://127.0.0.1:8642", + "apiKey": "", + "paperclipApiUrl": "http://localhost:3100" + } + } + + Hermes Gateway network examples: + - Local loopback: agentDefaultsPayload.apiBaseUrl = "http://127.0.0.1:8642" and agentDefaultsPayload.paperclipApiUrl = "http://127.0.0.1:3100". + - LAN/private network: use reachable private addresses, for example agentDefaultsPayload.apiBaseUrl = "http://192.168.1.25:8642" and agentDefaultsPayload.paperclipApiUrl = "http://192.168.1.10:3100". + - Private overlay: use overlay DNS names, for example agentDefaultsPayload.apiBaseUrl = "http://hermes-host.tailnet-name.ts.net:8642" and agentDefaultsPayload.paperclipApiUrl = "http://paperclip-host.tailnet-name.ts.net:3100". + - Docker: if Paperclip runs in Docker and Hermes runs on the host, use agentDefaultsPayload.apiBaseUrl = "http://host.docker.internal:8642"; if both run in Compose, use the Hermes service name. + - Reverse proxy/TLS: use HTTPS origins, for example agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example" and agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". + Expected response includes: - request id - one-time claimSecret diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts index ff5f36dca48..2d0d75eb608 100644 --- a/server/src/routes/llms.ts +++ b/server/src/routes/llms.ts @@ -3,8 +3,13 @@ import type { Db } from "@paperclipai/db"; import { AGENT_ICON_NAMES } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { listServerAdapters } from "../adapters/index.js"; +import { hermesGatewayAgentConfigurationDoc } from "../adapters/hermes-gateway-doc.js"; import { agentService } from "../services/agents.js"; +const pluginOnlyAdapterDocs = new Map([ + ["hermes_gateway", hermesGatewayAgentConfigurationDoc], +]); + function hasCreatePermission(agent: { role: string; permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record).canCreateAgents); @@ -34,6 +39,15 @@ export function llmRoutes(db: Db) { "Installed adapters:", ...adapters.map((adapter) => `- ${adapter.type}: /llms/agent-configuration/${adapter.type}.txt`), "", + "Plugin-only adapter docs:", + ...Array.from(pluginOnlyAdapterDocs.keys()) + .filter( + (adapterType) => !adapters.some((adapter) => adapter.type === adapterType), + ) + .map( + (adapterType) => `- ${adapterType}: /llms/agent-configuration/${adapterType}.txt`, + ), + "", "Related API endpoints:", "- GET /api/companies/:companyId/agent-configurations", "- GET /api/agents/:id/configuration", @@ -72,6 +86,11 @@ export function llmRoutes(db: Db) { const adapterType = req.params.adapterType as string; const adapter = listServerAdapters().find((entry) => entry.type === adapterType); if (!adapter) { + const pluginOnlyDoc = pluginOnlyAdapterDocs.get(adapterType); + if (pluginOnlyDoc) { + res.type("text/plain").send(pluginOnlyDoc); + return; + } res.status(404).type("text/plain").send(`Unknown adapter type: ${adapterType}`); return; } diff --git a/ui/src/lib/agent-onboarding-prompt.test.ts b/ui/src/lib/agent-onboarding-prompt.test.ts new file mode 100644 index 00000000000..1166fce2adb --- /dev/null +++ b/ui/src/lib/agent-onboarding-prompt.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentOnboardingPrompt } from "./agent-onboarding-prompt"; + +describe("buildAgentOnboardingPrompt", () => { + it("includes Hermes Gateway setup guidance for external agents", () => { + const prompt = buildAgentOnboardingPrompt({ + onboardingTextUrl: "http://localhost:3100/api/invites/token-123/onboarding.txt", + connectionCandidates: ["http://192.168.1.10:3100"], + }); + + expect(prompt).toContain('adapterType: "hermes_gateway"'); + expect(prompt).toContain("API_SERVER_ENABLED=true"); + expect(prompt).toContain("API_SERVER_KEY"); + expect(prompt).toContain("hermes gateway run --replace --accept-hooks"); + expect(prompt).toContain("default Hermes API server port is `8642`"); + expect(prompt).toContain("agentDefaultsPayload.apiBaseUrl"); + expect(prompt).toContain("agentDefaultsPayload.paperclipApiUrl"); + expect(prompt).toContain("http://127.0.0.1:8642"); + expect(prompt).toContain("http://:8642"); + expect(prompt).toContain("http://:8642"); + expect(prompt).toContain("http://host.docker.internal:8642"); + expect(prompt).toContain("https://hermes-gateway.example"); + expect(prompt).toContain("`hermes_local` runs Hermes on the Paperclip host"); + expect(prompt).toContain("Hermes-originated Paperclip API calls"); + }); +}); diff --git a/ui/src/lib/agent-onboarding-prompt.ts b/ui/src/lib/agent-onboarding-prompt.ts index bac83f46009..99e9308ceb0 100644 --- a/ui/src/lib/agent-onboarding-prompt.ts +++ b/ui/src/lib/agent-onboarding-prompt.ts @@ -55,6 +55,9 @@ Join flow: OpenClaw Gateway note: If you are an OpenClaw Gateway agent, use \`adapterType: "openclaw_gateway"\`, set \`agentDefaultsPayload.url\` to your \`ws://\` or \`wss://\` gateway URL, and include \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token. Do not use \`/v1/responses\` or \`/hooks/*\` in that join flow. +Hermes Gateway note: +If you are a Hermes Gateway agent, use \`adapterType: "hermes_gateway"\`. Start Hermes with \`API_SERVER_ENABLED=true\`, \`API_SERVER_KEY=\`, and \`hermes gateway run --replace --accept-hooks\`; the default Hermes API server port is \`8642\`. Set \`agentDefaultsPayload.apiBaseUrl\` to the Hermes gateway URL Paperclip can reach and \`agentDefaultsPayload.paperclipApiUrl\` to the Paperclip base URL Hermes can reach. For local loopback use \`http://127.0.0.1:8642\`; for LAN/private network use \`http://:8642\`; for a private overlay use \`http://:8642\`; from Docker use \`http://host.docker.internal:8642\`; behind reverse proxy/TLS use \`https://hermes-gateway.example\`. \`hermes_local\` runs Hermes on the Paperclip host; \`hermes_gateway\` calls an already-running Hermes API server; Hermes-originated Paperclip API calls use the claimed \`PAPERCLIP_API_KEY\` and \`PAPERCLIP_API_URL\`, not \`agentDefaultsPayload.apiBaseUrl\`. + After you have connected to Paperclip, review and follow the full onboarding instructions in onboarding.txt. `; }