Replacing Telegram with a Signed HTTP Transport for Hermes and OpenClaw
How I wired Hermes, task-worker, and OpenClaw together with signed HTTP requests, local webhooks, OpenAI-compatible chat completions, and a small broker service.
I recently finished wiring Hermes, task-worker, and OpenClaw together without using Telegram as the inter-agent transport. The goal was simple: Hermes should be able to delegate work, OpenClaw should execute it, and the result should flow back into Hermes through a clean local HTTP path.
The final setup uses a small task-worker service as the broker. Hermes signs an outbound request, task-worker verifies it, forwards the payload to OpenClaw’s OpenAI-compatible chat completions endpoint, then posts the result back into Hermes through a signed webhook route.
This post documents the working shape, the pieces that mattered, and the checks that finally proved the whole loop was alive.
The final topology
In plain terms:
- Hermes sends delegated work to task-worker.
- task-worker verifies the Hermes signature.
- task-worker forwards the accepted task to OpenClaw.
- OpenClaw returns a standard chat completion response.
- task-worker extracts the useful completion text.
- task-worker posts the result back into Hermes through a webhook route.
The important design decision was keeping Hermes and OpenClaw loosely coupled. Neither one needs to know too much about the other. The broker owns transport details, signature checks, forwarding, and result normalization.
The local service map
The working local endpoints were:
Hermes webhook listener: http://127.0.0.1:8644
task-worker: http://127.0.0.1:9000
OpenClaw gateway: http://127.0.0.1:18789OpenClaw accepted requests at:
POST /v1/chat/completions
Authorization: Bearer <OPENCLAW_API_KEY>That mattered because it let task-worker treat OpenClaw like an OpenAI-compatible gateway. The forwarder did not need a special SDK or transport plugin. It only needed fetch, a bearer token, and a normal chat completions payload.
Hermes needs a webhook route
Hermes has to expose a route where task-worker can send results. In this setup, that route lives under top-level platforms.webhook, and the working callback route name is task-worker-result.
The important shape looks like this:
platforms:
webhook:
enabled: true
extra:
port: 8644
secret: "global-fallback-secret"
routes:
task-worker-result:
secret: "<shared webhook secret>"
prompt: |
OpenClaw returned a delegated task result.
Task ID: {task_id}
Conversation ID: {conversation_id}
Status: {status}
Summary: {summary}
Error: {error}
Details:
{details}
deliver: logFor local routing, Hermes also needed:
security:
allow_private_urls: trueWithout that, the local callback and task routes are likely to fail before your app logic gets a chance to do anything useful.
Signing the Hermes to task-worker hop
The first security boundary is between Hermes and task-worker. Hermes signs the exact JSON bytes it sends using HMAC SHA–256 and places the signature in x-hermes-signature.
That detail is easy to overlook: sign the exact bytes you post. If you sign one JSON representation and send another, even small whitespace or serialization differences can break verification.
The core signing function is small:
import hashlib
import hmac
def sign(body: bytes, secret: str) -> str:
digest = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return f"sha256={digest}"The outbound request then includes the signature:
body = json.dumps(payload).encode("utf-8")
headers = {
"content-type": "application/json",
"x-request-id": context.get("parent_session_id", ""),
"x-hermes-signature": sign(body, TASK_WORKER_SECRET),
}
await client.post(TASK_WORKER_URL, content=body, headers=headers)The critical secret pair is:
Hermes TASK_WORKER_SECRET == task-worker HERMES_SECRETIf those values differ, task-worker rejects the request with bad_signature.
task-worker as the broker
The broker exposes two endpoints:
POST /task receives signed delegated work from Hermes
POST /result receives signed callbacks from OpenClaw, if callback mode is usedThe working implementation uses raw-body HMAC verification, stores routing state in memory, forwards accepted tasks to OpenClaw, and relays results back to Hermes.
The forwarder sends a normal chat completions payload:
async function forwardToOpenClaw(envelope) {
const url = process.env.OPENCLAW_URL;
const apiKey = process.env.OPENCLAW_API_KEY;
const body = JSON.stringify({
model: "openclaw/default",
messages: [
{
role: "system",
content:
"You are OpenClaw receiving a delegated task from Hermes through task-worker. " +
"Read the provided JSON payload, execute the task, and respond concisely.",
},
{
role: "user",
content: JSON.stringify({
task_id: envelope.task_id,
conversation_id: envelope.conversation_id,
goal: envelope.goal,
context: envelope.context,
constraints: envelope.constraints,
expected_output: envelope.expected_output,
metadata: {
trace_id: envelope.trace_id,
received_at: envelope.received_at,
},
}),
},
],
stream: false,
});
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${apiKey}`,
},
body,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`OpenClaw forward failed: ${response.status} ${text}`);
}
return response.json();
}Once OpenClaw responds, task-worker extracts:
json?.choices?.[0]?.message?.content;Then it posts that result back to Hermes with the task ID, conversation ID, status, summary, details, error state, and trace ID.
The environment variables that matter
The task-worker environment ended up looking like this:
AGENT_NAME=Claw
HOST=127.0.0.1
PORT=9000
HERMES_SECRET=<same value as TASK_WORKER_SECRET in Hermes>
OPENCLAW_SECRET=<shared secret for /result verification if OpenClaw signs callbacks>
OPENCLAW_URL=http://127.0.0.1:18789/v1/chat/completions
OPENCLAW_API_KEY=<OpenClaw gateway token>
HERMES_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/task-worker-result
HERMES_WEBHOOK_SECRET=<same value as Hermes webhook route secret>The two most common mistakes are easy to predict:
- TASK_WORKER_SECRET and HERMES_SECRET do not match.
- OPENCLAW_URL points to the gateway root instead of /v1/chat/completions.
Both produce failures that look like transport problems, but they are really configuration problems.
The systemd environment trap
The hardest bug was not in Hermes, task-worker, or OpenClaw. It was in the runtime environment.
Hermes was running as a systemd --user service, and the .env values I expected were not actually visible to the running hermes-gateway process. The definitive check was not:
systemctl --user show hermes-gateway --property=EnvironmentThe useful check was reading the actual running process environment:
PID=$(systemctl --user show hermes-gateway.service --property=MainPID --value)
tr '\0' '\n' < /proc/$PID/environ | grep -E 'TASK_WORKER|HERMES_WEBHOOK'The fix was to create a systemd user environment file:
[Service]
EnvironmentFile=/home/larry/.config/systemd/user/hermes-gateway.envThen reload and restart:
systemctl --user daemon-reload
systemctl --user restart hermes-gatewayAfter that, the /proc/$PID/environ check confirmed Hermes was running with the expected task-worker and webhook values.
Verification checklist
Once the services were configured, I verified the setup in four passes.
Check the listeners
ss -ltnp | grep -E '8644|9000|18789'
curl http://127.0.0.1:9000/
curl http://127.0.0.1:8644/healthThe expected state:
Hermes: 8644
task-worker: 9000
OpenClaw: 18789Check the Hermes webhook
A signed POST to the Hermes webhook should return something like:
{
"status": "accepted",
"route": "task-worker-result",
"event": "unknown",
"delivery_id": "..."
}That confirms task-worker has a valid path back into Hermes.
Check OpenClaw directly
curl -v http://127.0.0.1:18789/v1/chat/completions \
-H "Authorization: Bearer <OPENCLAW_API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"model": "openclaw/default",
"messages": [
{ "role": "user", "content": "Hello from direct OpenClaw test" }
],
"stream": false
}'The important proof is a standard response with:
choices[0].message.contentCheck the full /task path
The final test is a Hermes-style signed request into task-worker:
SECRET='<TASK_WORKER_SECRET>'
BODY='{
"task_id": "test-forward-openclaw",
"goal": "Test Hermes -> task-worker -> OpenClaw -> Hermes round-trip",
"context": {
"source_event": "manual_test",
"parent_session_id": "manual-session",
"child_role": "tester",
"child_status": "ok",
"duration_ms": 1234,
"emitted_at": "2026-05-05T16:47:00Z"
},
"constraints": {
"timeout_sec": 600,
"tools_allowed": []
},
"expected_output": {
"format": "json"
}
}'
DIGEST=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
SIG="sha256=$DIGEST"
curl -v http://127.0.0.1:9000/task \
-H "content-type: application/json" \
-H "x-hermes-signature: $SIG" \
-H "x-request-id: manual-openclaw-test" \
-d "$BODY"The working response is:
HTTP/1.1 202 AcceptedThat confirms the whole route: signature verification, task acceptance, forward into OpenClaw, and eventual relay back into Hermes.
Why this setup feels better than Telegram transport
Telegram was useful as a quick bridge, but HTTP is easier to reason about in a local agent system. It gives me clear request boundaries, local ports, HMAC verification, standard health checks, and normal service logs.
The new transport also makes each system’s role cleaner:
| Component | Responsibility |
| ----------- | --------------------------------------------------------------- |
| Hermes | Decide when work should be delegated and receive the result |
| task-worker | Verify, route, normalize, and relay task messages |
| OpenClaw | Execute the delegated task through a chat completions interface |
| systemd | Keep the runtime environment stable and repeatable |That separation is the point. Hermes does not need to become an OpenClaw client. OpenClaw does not need to know how Hermes wants results delivered. The broker does the glue work.
Final working status
The final transport is:
Hermes hook
-> signed POST /task
task-worker
-> verified HMAC
-> POST OpenClaw /v1/chat/completions
OpenClaw
-> chat completion response
task-worker
-> signed Hermes webhook result
Hermes
-> logs or handles the delegated resultThis replaces Telegram with a signed local HTTP transport while keeping Hermes and OpenClaw loosely coupled. The result is easier to debug, easier to test, and easier to extend into a more durable agent orchestration layer.
Adjacent essays

Kubernetes Flow
Learn how Kubernetes orchestrates containers through its control plane, pods, services, and ingress in this beginner-friendly introduction.

What is Kubernetes?
Explore how Kubernetes orchestrates containers through intelligent scheduling, self-healing, and automated scaling across distributed infrastructure.