chore: sync accumulated infra changes and docs

Removals: openwebui and netclaw compose + Traefik routes (both decommissioned).
Updates: litellm, n8n, vaultwarden, ai-agents, media, traefik routes, CLAUDE.md
(nodes/ports/known-issues), agent definitions.
Additions: Rongorongo tree, LinkedStorm plan, rename-bart spec, vllm brief,
tls-options and security-headers Traefik config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:19:21 -05:00
parent 47ac49a039
commit 27f6d95bf6
34 changed files with 8589 additions and 322 deletions
+1
View File
@@ -142,6 +142,7 @@ When creating documentation:
2. Full content in markdown format 2. Full content in markdown format
3. Mermaid diagrams embedded within the documentation 3. Mermaid diagrams embedded within the documentation
4. Summary of what was documented and any follow-up actions needed 4. Summary of what was documented and any follow-up actions needed
5. Update the change logs
When providing information to other agents: When providing information to other agents:
1. The specific information requested 1. The specific information requested
+2 -1
View File
@@ -36,7 +36,8 @@
"Bash(docker version:*)", "Bash(docker version:*)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)", "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)", "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)" "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)",
"Bash(sleep 20 && sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.100 \"docker ps --filter name=n8n --format 'table {{.Names}}\\\\t{{.Status}}' && echo '---' && docker logs n8n --tail 15 2>&1\")"
] ]
} }
} }
+19 -13
View File
@@ -36,6 +36,7 @@ hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, ha
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server ├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm. ├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm.
├── [COMPOSE] LinkedStorm (:8300 pipeline, :8310 api, cron no-port) -- whole-home audio assistant. VAD+Whisper STT+speaker-ID. Deployed 2026-05-28. Public: linkedstorm.hobbs.farm -> :8310.
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04 ├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -58,6 +59,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL) ├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL)
├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy ├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation ├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
├── [COMPOSE] LifeOS (:8095) -- personal life/goals/habits tracker (PWA). Local mode: Flask+SQLite, no Supabase. LAN-only (no auth in backend). AI companion -> Ollama on .150.
├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -140,8 +142,9 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Home Assistant: `yoda.hobbs.farm` - Home Assistant: `yoda.hobbs.farm`
- Vaultwarden: `vault.hobbs.farm` - Vaultwarden: `vault.hobbs.farm`
- Gitea: `git.hobbs.farm` - Gitea: `git.hobbs.farm`
- VoiceCoach: `coach.hobbs.farm` (LiveKit WebRTC: `voice.hobbs.farm`) - FRIDAY Voice: `coach.hobbs.farm` (replaced VoiceCoach 2026-07-05; runs on .150 :8320)
- Rongorongo: `rongorongo.hobbs.farm` - Rongorongo: `rongorongo.hobbs.farm`
- LinkedStorm: `linkedstorm.hobbs.farm`
### Internal Services by Node ### Internal Services by Node
@@ -167,7 +170,8 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Farm Manager: :8888 (dashboard + API) - Farm Manager: :8888 (dashboard + API)
- Farm Agent: :8889 - Farm Agent: :8889
- Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden). - Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden).
- ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04 - LinkedStorm: :8300 (pipeline/VAD+STT+speaker-ID), :8310 (API/dashboard), cron (no port). Public: linkedstorm.hobbs.farm -> :8310. Source: /opt/linkedstorm (rsync from WSL). Data: /mnt/docker-data/configs/linkedstorm/. STT via Speaches GPU machine :8000, LLM via Ollama :11434 (qwen2.5:14b).
- ~~VoiceCoach~~ -- fully decommissioned 2026-07-05, replaced by FRIDAY Voice on .150 (see GPU Machine section + memory `friday-voice`)
**hf-pdocker-02 (192.168.86.100):** **hf-pdocker-02 (192.168.86.100):**
- Farm Agent: :8889 - Farm Agent: :8889
@@ -179,6 +183,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Actual Budget: :5006 - Actual Budget: :5006
- MCPO: :8001 - MCPO: :8001
- Trivy: :4954 - Trivy: :4954
- LifeOS: :8095 (host) -> :8080 (container). LAN-only, deliberately NOT behind Traefik: the Flask backend is single-user with NO auth. Image `lifeos:local` built on-node from /opt/lifeos (upstream https://github.com/lifeos-app/lifeos, pinned 3009bf6). SQLite at /var/lib/lifeos/data.db on LOCAL disk (NFS breaks SQLite WAL locking). Compose: compose/hf-pdocker-02/lifeos/. Note: host 8080 is taken by unifi-os-server.
**bart (192.168.86.167):** **bart (192.168.86.167):**
- Farm Agent: :8889 - Farm Agent: :8889
@@ -190,17 +195,14 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Jellyseerr: :5055 - Jellyseerr: :5055
- OpenVAS: :9392 - OpenVAS: :9392
**GPU Machine (192.168.86.150, RTX 3090, 15GB RAM):** **GPU Machine (192.168.86.150, RTX 3090):**
- Ollama: :11434 (llama3.1:8b for VoiceCoach LLM, qwen2.5:14b, others) - **NOTE (2026-07-05):** .150 is actually the user's **Windows workstation** (runs the WSL/Claude Code session), not a headless Linux server. It runs **Docker Desktop** (WSL2 backend, RTX 3090 GPU passthrough working) alongside **UniFi OS Server** (Podman). Docker Desktop panics if UniFi/Podman claims `\\.\pipe\docker_engine` first — start Docker Desktop before UniFi OS Server. See memory `docker-desktop-unifi-podman-conflict`.
- [COMPOSE] VoiceCoach (all services co-located): - **FRIDAY Voice replaced VoiceCoach (2026-07-05).** VoiceCoach fully decommissioned (v1+v2 removed from hf-pdocker-01; voice.hobbs.farm route removed). See memory `friday-voice`.
- voicecoach-livekit: :7880/:7881, UDP 50000-50100 (public at voice.hobbs.farm) - [COMPOSE] **FRIDAY Voice stack (all on .150 Docker Desktop, via `docker.exe -c desktop-linux`):**
- voicecoach-speaches-gpu: :8000 (GPU Whisper STT + Kokoro TTS, CUDA float16) - **voicecoach-speaches-gpu**: :8000 — GPU Whisper large-v3-turbo (STT) + Kokoro-82M af_nova (TTS), CUDA float16. Compose: `compose/gpu-machine/voicecoach-speaches/`.
- voicecoach-speechbrain: :8001 (emotion analysis) - **hermes**: `nousresearch/hermes-agent:latest` gateway — FRIDAY's reasoning agent. Settings transferred from WSL `~/.hermes` into named volume `hermes-data`. Compose: `compose/gpu-machine/hermes/`.
- voicecoach-token-server: :8080 (token + settings API) - **friday-voice**: :8320 — push-to-talk console. Calls speaches (`host.docker.internal:8000`) + hermes (`docker exec hermes hermes -Q` via wrapper). Public: **coach.hobbs.farm** → :8320. Compose: `compose/gpu-machine/friday-voice/`.
- voicecoach-pipecat: pipeline agent (LLM via local Ollama llama3.1:8b) - Ollama: :11434 (qwen2.5:14b, Hermes-4.3-36B-GGUF, others)
- voicecoach-web: :8085 (public at coach.hobbs.farm)
- voicecoach-openclaw: :3100 (idle, kept for future Claude API routing)
- Compose: ~/voicecoach-speaches/docker-compose.yml
### Port Conflict Notes ### Port Conflict Notes
@@ -297,3 +299,7 @@ All persistent data on NAS at `/mnt/docker-data/`:
9. **Guacamole schema**: App image pinned to `guacamole/guacamole:1.6.0` + `guacd:1.6.0` with Watchtower disabled. DB schema was reset to fresh 1.6.0 on 2026-05-20 (old schema was non-standard). Recreate connections after any DB reset. 9. **Guacamole schema**: App image pinned to `guacamole/guacamole:1.6.0` + `guacd:1.6.0` with Watchtower disabled. DB schema was reset to fresh 1.6.0 on 2026-05-20 (old schema was non-standard). Recreate connections after any DB reset.
10. **Gitea image**: Pinned to `gitea/gitea:1.26.1` with Watchtower disabled (2026-05-20). Watchtower previously pulled broken `:latest` images causing missing git binary errors. 10. **Gitea image**: Pinned to `gitea/gitea:1.26.1` with Watchtower disabled (2026-05-20). Watchtower previously pulled broken `:latest` images causing missing git binary errors.
11. **Home Assistant reverse proxy**: Config at `/mnt/docker-data/configs/homeassistant/configuration.yaml` (no hyphen). Must have `http: use_x_forwarded_for: true` + `trusted_proxies: [172.16.0.0/12, 127.0.0.1]` or proxied requests return 400. 11. **Home Assistant reverse proxy**: Config at `/mnt/docker-data/configs/homeassistant/configuration.yaml` (no hyphen). Must have `http: use_x_forwarded_for: true` + `trusted_proxies: [172.16.0.0/12, 127.0.0.1]` or proxied requests return 400.
12. **hf-pdocker-01 memory pressure (2026-07-05)**: Node runs hot (~15/23GB, chronic swap). Mitigations applied: `vm.swappiness` lowered 60→10 (persisted in `/etc/sysctl.d/99-swappiness.conf`), and VoiceCoach (v1+v2, ~10 containers) fully removed (replaced by FRIDAY Voice on .150). Frigate is the main consumer (~9GB/12GB). Frigate has no semantic-search/face/LPR features enabled.
13. **Docker Desktop vs UniFi OS Server pipe conflict (Windows box .150, 2026-07-05)**: Both fight over `\\.\pipe\docker_engine`; Docker Desktop's backend nil-panics ("backend exited before becoming ready") if Podman wins. Fix: let Docker Desktop (auto-starts at login) claim the pipe first, launch UniFi OS Server manually after. Full detail in memory `docker-desktop-unifi-podman-conflict`.
14. **MySQL InnoDB corruption on NFS**: `semaphore-db` (mysql:8.0 on NFS) hit system-tablespace page corruption and crash-looped 3789× (2026-07-11), same class as the Guacamole-on-NFS issue (#9). Recovered in-place via `--innodb-force-recovery=1` → mysqldump → fresh volume → reimport (DB was empty, no data lost); admin recreated with `semaphore user add`. Any MySQL DB on NFS is at risk — prefer LOCAL storage like guacamole-db. See memory `semaphore-db-nfs-corruption`.
15. **litellm hangs after DB blip**: litellm does NOT self-recover from a transient `litellm-db` disconnect — it loops on "Prisma DB reconnect failed" forever; needs manual `docker restart litellm`. Its healthcheck was fixed from `curl` (absent from image, permanent false-unhealthy) to a python urllib check (2026-07-11).
+322
View File
@@ -0,0 +1,322 @@
# RONGORONGO_DEPLOY.md
# Rongorongo Decipherment Engine — Claude Code Deployment Guide
# Target: hobbs.farm Docker Swarm infrastructure
# Subdomain: rongorongo.hobbs.farm
---
## CONTEXT
This document instructs Claude Code to build, push, and deploy the Rongorongo
Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
The app is a single-file HTML/JS application served by nginx. It calls the
Anthropic API directly from the browser. No backend, no database. All session
state is stored in browser localStorage.
Infrastructure assumptions (from existing hobbs.farm stack):
- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
- External overlay network: traefik-public
- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
- Portainer CE available for stack management
- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
---
## REPOSITORY STRUCTURE
After cloning or creating the project, verify this structure exists:
```
rongorongo/
├── Dockerfile
├── nginx.conf
├── stack.yml
├── frontend/
│ └── index.html
└── RONGORONGO_DEPLOY.md (this file)
```
---
## STEP 1 — VERIFY PREREQUISITES
Run these checks before proceeding. Stop and report any failures.
```bash
# Confirm Docker and Swarm context
docker info | grep -E "Swarm:|Manager:|Nodes:"
# Confirm traefik-public network exists
docker network ls | grep traefik-public
# Confirm Traefik is running
docker service ls | grep traefik
# Test registry connectivity (if using local registry)
curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
# Confirm you are on a Swarm manager node
docker node ls
```
If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
at the bottom of this document.
---
## STEP 2 — BUILD THE IMAGE
```bash
cd rongorongo/
# Build image
docker build -t registry.hobbs.farm/rongorongo:latest .
# Verify image was created
docker images | grep rongorongo
```
Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
should be ~25–40MB (nginx:alpine base + small HTML file).
---
## STEP 3 — PUSH TO REGISTRY
```bash
# If registry requires auth:
docker login registry.hobbs.farm
# Push
docker push registry.hobbs.farm/rongorongo:latest
```
If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
---
## STEP 4 — DEPLOY STACK
```bash
# Deploy or update the stack
docker stack deploy -c stack.yml rongorongo --with-registry-auth
# Watch rollout
docker service ps rongorongo_rongorongo --no-trunc
```
Wait for the service to show `Running` status. This typically takes 15–30 seconds.
```bash
# Confirm healthy
docker service ls | grep rongorongo
curl -s http://localhost/health # or via container exec
```
---
## STEP 5 — DNS VERIFICATION
The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
```bash
# Check AdGuard Home has the record (or wildcard covers it)
nslookup rongorongo.hobbs.farm
# Test internal access
curl -sk https://rongorongo.hobbs.farm/health
```
If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
configured for hairpin NAT, no additional DNS entry is needed.
---
## STEP 6 — SMOKE TEST
Open https://rongorongo.hobbs.farm in a browser and verify:
1. App loads with "Rongorongo · Decipherment Engine" title
2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
3. Enter an Anthropic API key in the sidebar footer input
4. Upload a test image to the corpus
5. Navigate to Pattern Analyzer and run an analysis
6. Confirm AI response appears in the result box
---
## UPDATING THE APP
When index.html is modified:
```bash
cd rongorongo/
# Rebuild
docker build -t registry.hobbs.farm/rongorongo:latest .
# Push
docker push registry.hobbs.farm/rongorongo:latest
# Force service update (pull new image)
docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
# Watch update
docker service ps rongorongo_rongorongo
```
---
## ROLLBACK
If something breaks after an update:
```bash
docker service rollback rongorongo_rongorongo
docker service ps rongorongo_rongorongo
```
---
## TEARDOWN
To remove the service without destroying data (there is no persistent volume):
```bash
docker stack rm rongorongo
```
---
## ALTERNATIVE DEPLOYMENT (no registry)
Use this if registry.hobbs.farm is unavailable. This method builds the image
directly on the Swarm manager and uses `docker service create` instead of
`stack deploy`.
```bash
# On the Swarm manager node:
cd rongorongo/
# Build locally
docker build -t rongorongo:latest .
# Create service manually (adjust Traefik labels as needed)
docker service create \
--name rongorongo_rongorongo \
--network traefik-public \
--label "traefik.enable=true" \
--label "traefik.docker.network=traefik-public" \
--label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
--label "traefik.http.routers.rongorongo.entrypoints=websecure" \
--label "traefik.http.routers.rongorongo.tls=true" \
--label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
--label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
--replicas 1 \
--restart-condition on-failure \
rongorongo:latest
# Monitor
docker service ps rongorongo_rongorongo
```
NOTE: With this method, to update you must rebuild locally and run:
```bash
docker service update --image rongorongo:latest --force rongorongo_rongorongo
```
This only works if your current node is the one running the replica. If you
have a multi-node swarm, you should set up the registry to avoid placement issues.
---
## PORTAINER DEPLOYMENT (optional GUI method)
If you prefer Portainer CE:
1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
2. Select the Swarm endpoint
3. Go to Stacks → Add Stack
4. Name: `rongorongo`
5. Paste the contents of stack.yml into the web editor
6. Set `registry.hobbs.farm/rongorongo:latest` as the image
(you still need to build and push first)
7. Click Deploy the stack
---
## ENVIRONMENT NOTES
The app does NOT use environment variables or Docker secrets. The Anthropic API
key is entered by the user at runtime and stored in their browser's localStorage.
This is intentional — the app is designed to be used by a single user (Jeremy)
and no server-side key storage is needed or appropriate.
If you want to pre-populate the API key for convenience (only if the deployment
is strictly private/internal), you can inject it at build time by modifying
frontend/index.html before building:
Find this line:
```javascript
function getApiKey() {
return document.getElementById('apiKeyInput').value.trim()
```
And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
---
## TRAEFIK ENTRYPOINT NAMES
The stack.yml assumes:
- HTTP entrypoint name: `web`
- HTTPS entrypoint name: `websecure`
- Cert resolver name: `cloudflare`
These match the standard hobbs.farm Traefik configuration. If your Traefik uses
different names, edit the labels in stack.yml before deploying.
---
## TROUBLESHOOTING
**Service won't start:**
```bash
docker service ps rongorongo_rongorongo --no-trunc
docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
```
**Traefik not routing:**
```bash
# Check Traefik logs for rongorongo
docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
# Confirm labels are applied
docker service inspect rongorongo_rongorongo | grep -A2 Labels
```
**TLS cert not issuing:**
```bash
# Wildcard cert should already cover rongorongo.hobbs.farm
# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
```
**API calls blocked (CORS/CSP):**
The nginx.conf includes a Content-Security-Policy header that allows
`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
console, verify the CSP header is present:
```bash
curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
```
**App loads but AI calls fail:**
- Check API key is entered correctly in the sidebar
- Anthropic API key must start with `sk-ant-`
- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
- The app sends `anthropic-dangerous-direct-browser-access: true` header which
is required for direct browser API access
---
## END OF DEPLOYMENT GUIDE
@@ -0,0 +1,18 @@
FROM nginx:1.25-alpine
# Remove default nginx static content
RUN rm -rf /usr/share/nginx/html/*
# Copy app
COPY frontend/index.html /usr/share/nginx/html/index.html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/health || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
@@ -0,0 +1,322 @@
# RONGORONGO_DEPLOY.md
# Rongorongo Decipherment Engine — Claude Code Deployment Guide
# Target: hobbs.farm Docker Swarm infrastructure
# Subdomain: rongorongo.hobbs.farm
---
## CONTEXT
This document instructs Claude Code to build, push, and deploy the Rongorongo
Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
The app is a single-file HTML/JS application served by nginx. It calls the
Anthropic API directly from the browser. No backend, no database. All session
state is stored in browser localStorage.
Infrastructure assumptions (from existing hobbs.farm stack):
- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
- External overlay network: traefik-public
- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
- Portainer CE available for stack management
- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
---
## REPOSITORY STRUCTURE
After cloning or creating the project, verify this structure exists:
```
rongorongo/
├── Dockerfile
├── nginx.conf
├── stack.yml
├── frontend/
│ └── index.html
└── RONGORONGO_DEPLOY.md (this file)
```
---
## STEP 1 — VERIFY PREREQUISITES
Run these checks before proceeding. Stop and report any failures.
```bash
# Confirm Docker and Swarm context
docker info | grep -E "Swarm:|Manager:|Nodes:"
# Confirm traefik-public network exists
docker network ls | grep traefik-public
# Confirm Traefik is running
docker service ls | grep traefik
# Test registry connectivity (if using local registry)
curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
# Confirm you are on a Swarm manager node
docker node ls
```
If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
at the bottom of this document.
---
## STEP 2 — BUILD THE IMAGE
```bash
cd rongorongo/
# Build image
docker build -t registry.hobbs.farm/rongorongo:latest .
# Verify image was created
docker images | grep rongorongo
```
Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
should be ~25–40MB (nginx:alpine base + small HTML file).
---
## STEP 3 — PUSH TO REGISTRY
```bash
# If registry requires auth:
docker login registry.hobbs.farm
# Push
docker push registry.hobbs.farm/rongorongo:latest
```
If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
---
## STEP 4 — DEPLOY STACK
```bash
# Deploy or update the stack
docker stack deploy -c stack.yml rongorongo --with-registry-auth
# Watch rollout
docker service ps rongorongo_rongorongo --no-trunc
```
Wait for the service to show `Running` status. This typically takes 15–30 seconds.
```bash
# Confirm healthy
docker service ls | grep rongorongo
curl -s http://localhost/health # or via container exec
```
---
## STEP 5 — DNS VERIFICATION
The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
```bash
# Check AdGuard Home has the record (or wildcard covers it)
nslookup rongorongo.hobbs.farm
# Test internal access
curl -sk https://rongorongo.hobbs.farm/health
```
If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
configured for hairpin NAT, no additional DNS entry is needed.
---
## STEP 6 — SMOKE TEST
Open https://rongorongo.hobbs.farm in a browser and verify:
1. App loads with "Rongorongo · Decipherment Engine" title
2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
3. Enter an Anthropic API key in the sidebar footer input
4. Upload a test image to the corpus
5. Navigate to Pattern Analyzer and run an analysis
6. Confirm AI response appears in the result box
---
## UPDATING THE APP
When index.html is modified:
```bash
cd rongorongo/
# Rebuild
docker build -t registry.hobbs.farm/rongorongo:latest .
# Push
docker push registry.hobbs.farm/rongorongo:latest
# Force service update (pull new image)
docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
# Watch update
docker service ps rongorongo_rongorongo
```
---
## ROLLBACK
If something breaks after an update:
```bash
docker service rollback rongorongo_rongorongo
docker service ps rongorongo_rongorongo
```
---
## TEARDOWN
To remove the service without destroying data (there is no persistent volume):
```bash
docker stack rm rongorongo
```
---
## ALTERNATIVE DEPLOYMENT (no registry)
Use this if registry.hobbs.farm is unavailable. This method builds the image
directly on the Swarm manager and uses `docker service create` instead of
`stack deploy`.
```bash
# On the Swarm manager node:
cd rongorongo/
# Build locally
docker build -t rongorongo:latest .
# Create service manually (adjust Traefik labels as needed)
docker service create \
--name rongorongo_rongorongo \
--network traefik-public \
--label "traefik.enable=true" \
--label "traefik.docker.network=traefik-public" \
--label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
--label "traefik.http.routers.rongorongo.entrypoints=websecure" \
--label "traefik.http.routers.rongorongo.tls=true" \
--label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
--label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
--replicas 1 \
--restart-condition on-failure \
rongorongo:latest
# Monitor
docker service ps rongorongo_rongorongo
```
NOTE: With this method, to update you must rebuild locally and run:
```bash
docker service update --image rongorongo:latest --force rongorongo_rongorongo
```
This only works if your current node is the one running the replica. If you
have a multi-node swarm, you should set up the registry to avoid placement issues.
---
## PORTAINER DEPLOYMENT (optional GUI method)
If you prefer Portainer CE:
1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
2. Select the Swarm endpoint
3. Go to Stacks → Add Stack
4. Name: `rongorongo`
5. Paste the contents of stack.yml into the web editor
6. Set `registry.hobbs.farm/rongorongo:latest` as the image
(you still need to build and push first)
7. Click Deploy the stack
---
## ENVIRONMENT NOTES
The app does NOT use environment variables or Docker secrets. The Anthropic API
key is entered by the user at runtime and stored in their browser's localStorage.
This is intentional — the app is designed to be used by a single user (Jeremy)
and no server-side key storage is needed or appropriate.
If you want to pre-populate the API key for convenience (only if the deployment
is strictly private/internal), you can inject it at build time by modifying
frontend/index.html before building:
Find this line:
```javascript
function getApiKey() {
return document.getElementById('apiKeyInput').value.trim()
```
And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
---
## TRAEFIK ENTRYPOINT NAMES
The stack.yml assumes:
- HTTP entrypoint name: `web`
- HTTPS entrypoint name: `websecure`
- Cert resolver name: `cloudflare`
These match the standard hobbs.farm Traefik configuration. If your Traefik uses
different names, edit the labels in stack.yml before deploying.
---
## TROUBLESHOOTING
**Service won't start:**
```bash
docker service ps rongorongo_rongorongo --no-trunc
docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
```
**Traefik not routing:**
```bash
# Check Traefik logs for rongorongo
docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
# Confirm labels are applied
docker service inspect rongorongo_rongorongo | grep -A2 Labels
```
**TLS cert not issuing:**
```bash
# Wildcard cert should already cover rongorongo.hobbs.farm
# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
```
**API calls blocked (CORS/CSP):**
The nginx.conf includes a Content-Security-Policy header that allows
`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
console, verify the CSP header is present:
```bash
curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
```
**App loads but AI calls fail:**
- Check API key is entered correctly in the sidebar
- Anthropic API key must start with `sk-ant-`
- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
- The app sends `anthropic-dangerous-direct-browser-access: true` header which
is required for direct browser API access
---
## END OF DEPLOYMENT GUIDE
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# CSP — allows Anthropic API calls and Google Fonts
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always;
# Gzip
gzip on;
gzip_types text/html text/css application/javascript;
gzip_min_length 1024;
# Health endpoint
location /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Cache static assets aggressively if you add any
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
@@ -0,0 +1,48 @@
version: "3.8"
services:
rongorongo:
image: registry.hobbs.farm/rongorongo:latest
networks:
- traefik-public
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# HTTP → HTTPS redirect
- "traefik.http.routers.rongorongo-http.rule=Host(`rongorongo.hobbs.farm`)"
- "traefik.http.routers.rongorongo-http.entrypoints=web"
- "traefik.http.routers.rongorongo-http.middlewares=redirect-to-https"
# HTTPS router
- "traefik.http.routers.rongorongo.rule=Host(`rongorongo.hobbs.farm`)"
- "traefik.http.routers.rongorongo.entrypoints=websecure"
- "traefik.http.routers.rongorongo.tls=true"
- "traefik.http.routers.rongorongo.tls.certresolver=cloudflare"
# Service
- "traefik.http.services.rongorongo.loadbalancer.server.port=80"
# Middlewares
- "traefik.http.routers.rongorongo.middlewares=rongorongo-headers"
- "traefik.http.middlewares.rongorongo-headers.headers.customResponseHeaders.X-Robots-Tag=noindex,nofollow"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
traefik-public:
external: true
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -114,7 +114,7 @@ services:
- /mnt/docker-data/configs/nzbhydra:/config - /mnt/docker-data/configs/nzbhydra:/config
ports: ports:
- "5076:5076" - "5076:5076"
mem_limit: 512M mem_limit: 768M
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"]
interval: 30s interval: 30s
@@ -0,0 +1,17 @@
# Overlay on the friday-voice app image that adds the docker CLI + a wrapper so
# the container can reach the hermes agent via `docker exec hermes hermes ...`.
# Runs as root to access the mounted /var/run/docker.sock.
FROM friday-voice:latest
USER root
# Static docker *client* only (~small), for `docker exec` into the hermes container.
RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \
| tar -xz --strip-components=1 -C /usr/local/bin docker/docker \
&& chmod +x /usr/local/bin/docker \
&& docker --version
COPY hermes-wrap /usr/local/bin/hermes-wrap
RUN chmod +x /usr/local/bin/hermes-wrap
# Keep the base image's CMD (uvicorn friday_voice.app:app :8320).
@@ -0,0 +1,49 @@
# FRIDAY Voice on .150 (Docker Desktop) — replaces VoiceCoach.
# STT/TTS via local GPU speaches (:8000). Reasoning via the hermes container
# (invoked through the docker-exec wrapper). Runs as root for socket access.
services:
friday-voice:
build:
context: .
dockerfile: Dockerfile.hermes
image: friday-voice-hermes:latest
container_name: friday-voice
restart: unless-stopped
user: "0:0"
ports:
- "8320:8320"
volumes:
- friday-voice-data:/data
- /var/run/docker.sock:/var/run/docker.sock
environment:
- DATA_DIR=/data
- SPEACHES_URL=http://host.docker.internal:8000
- HERMES_COMMAND=/usr/local/bin/hermes-wrap
- HERMES_SOURCE=friday-voice
- WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
- TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
- TTS_VOICE=af_nova
- SPEAKER_CONFIRMED_THRESHOLD=0.85
- SPEAKER_PROBABLE_THRESHOLD=0.70
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8320/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
volumes:
friday-voice-data:
networks:
default:
name: friday-voice
@@ -0,0 +1,5 @@
#!/bin/sh
# friday-voice invokes: hermes-wrap chat -q "<text>" --source friday-voice
# We forward into the hermes container and add -Q (quiet) so only the final
# response text is returned (no banner/spinner/box art) for clean TTS.
exec docker exec hermes hermes "$@" -Q
@@ -36,6 +36,7 @@ services:
- HOME=/home/glyptic - HOME=/home/glyptic
- USER=glyptic - USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- WIKIJS_API_KEY=${WIKIJS_API_KEY} - WIKIJS_API_KEY=${WIKIJS_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.spirit - OPENCLAW_STATE_DIR=/home/glyptic/.spirit
- OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json - OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json
@@ -43,6 +44,8 @@ services:
- /mnt/infrastructure/spirit:/home/glyptic/.spirit - /mnt/infrastructure/spirit:/home/glyptic/.spirit
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /mnt/infrastructure/spirit/ssh:/root/.ssh:ro - /mnt/infrastructure/spirit/ssh:/root/.ssh:ro
- /mnt/docker-data/configs/prometheus:/prometheus-config
- /mnt/docker-data/configs/alertmanager:/alertmanager-config
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 3G mem_limit: 3G
memswap_limit: 3G memswap_limit: 3G
@@ -52,74 +55,3 @@ services:
max-size: "50m" max-size: "50m"
max-file: "3" max-file: "3"
bob:
image: margaret-openclaw:latest
container_name: bob
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.bob
- OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json
volumes:
- /mnt/infrastructure/bob:/home/glyptic/.bob
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
clippy:
image: margaret-openclaw:latest
container_name: clippy
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.clippy
- OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json
volumes:
- /mnt/infrastructure/clippy:/home/glyptic/.clippy
command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
travis:
image: travis-nemoclaw:latest
container_name: travis
restart: unless-stopped
network_mode: host
user: "0:0"
environment:
- NODE_ENV=production
- HOME=/home/glyptic
- USER=glyptic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENCLAW_STATE_DIR=/home/glyptic/.travis
- OPENCLAW_CONFIG_PATH=/home/glyptic/.travis/openclaw.json
volumes:
- /mnt/infrastructure/travis:/home/glyptic/.travis
command: ["sh", "-c", "mkdir -p /home/glyptic && openclaw plugins install /opt/nemoclaw 2>/dev/null; node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"]
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -17,7 +17,7 @@ services:
litellm-db: litellm-db:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:4000/health || exit 1"] test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:4000/health/liveliness',timeout=5).status==200 else 1)\""]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -1,29 +0,0 @@
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
ports:
- "3010:8080"
env_file:
- .env
environment:
- TZ=America/Chicago
- OLLAMA_BASE_URL=http://192.168.86.196:11434
volumes:
- /mnt/docker-data/configs/openwebui:/app/backend/data
mem_limit: 1G
memswap_limit: 1G
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
+8 -48
View File
@@ -6,24 +6,13 @@ services:
ports: ports:
- "5678:5678" - "5678:5678"
environment: environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=n8n-db
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD}
- N8N_PORT=5678
- GENERIC_TIMEZONE=America/Chicago - GENERIC_TIMEZONE=America/Chicago
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} - N8N_SECURE_COOKIE=false
- NODE_OPTIONS=--max-old-space-size=1500
volumes: volumes:
- /mnt/docker-data/configs/n8n:/home/node/.n8n - n8n_data:/home/node/.n8n
depends_on: mem_limit: 2g
n8n-db: memswap_limit: 2g
condition: service_healthy
networks:
- n8n-internal
mem_limit: 512M
memswap_limit: 512M
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"]
interval: 30s interval: 30s
@@ -36,35 +25,6 @@ services:
max-size: "50m" max-size: "50m"
max-file: "3" max-file: "3"
n8n-db: volumes:
image: postgres:15-alpine n8n_data:
container_name: n8n-db external: true
restart: unless-stopped
user: "1024:100"
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${N8N_DB_PASSWORD}
volumes:
- /mnt/docker-data/databases/n8n-db:/var/lib/postgresql/data
networks:
- n8n-internal
labels:
- "com.centurylinklabs.watchtower.enable=false"
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
n8n-internal:
driver: bridge
@@ -1,2 +0,0 @@
ANTHROPIC_API_KEY=sk-ant-...
MATRIX_ACCESS_TOKEN=syt_...
-38
View File
@@ -1,38 +0,0 @@
FROM node:20-bookworm
# Install system dependencies
RUN apt-get update && apt-get install -y \
python3 python3-pip python3-venv python3-full \
git curl jq openssh-client iputils-ping net-tools \
&& rm -rf /var/lib/apt/lists/*
# Install uv (Python package manager used by some MCP servers)
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Install OpenClaw globally
RUN npm install -g openclaw@latest
# Clone NetClaw
WORKDIR /opt/netclaw
RUN git clone https://github.com/automateyournetwork/netclaw.git .
# Create openclaw home directory
RUN mkdir -p /root/.openclaw/workspace
# Copy NetClaw workspace config into openclaw home
RUN cp -r workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
RUN cp config/openclaw.json /root/.openclaw/openclaw.json 2>/dev/null || true
# Install Python dependencies for core MCP servers
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir pyats genie netmiko paramiko requests pyyaml jinja2 2>/dev/null || true
# Create entrypoint script
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh
EXPOSE 1179
ENTRYPOINT ["/opt/entrypoint.sh"]
@@ -1,20 +0,0 @@
services:
netclaw:
build:
context: .
dockerfile: Dockerfile
container_name: netclaw-aaron
restart: unless-stopped
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN}
- MATRIX_HOMESERVER=http://192.168.86.192:8008
volumes:
- /mnt/docker-data/configs/netclaw:/root/.openclaw/data
mem_limit: 2G
memswap_limit: 2G
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -1,72 +0,0 @@
#!/bin/bash
set -e
# Configure OpenClaw environment
mkdir -p /root/.openclaw
# Write .env with API key
cat > /root/.openclaw/.env << EOF
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
EOF
# Write openclaw config for gateway mode
cat > /root/.openclaw/openclaw.json << 'OCEOF'
{
"mode": "gateway",
"provider": "anthropic",
"model": "claude-sonnet-4-6",
"fallbackModel": "claude-haiku-4-5-20251001",
"name": "Aaron",
"identity": "Aaron - NetClaw AI Network Engineering Agent"
}
OCEOF
# Copy workspace skills if they exist
if [ -d /opt/netclaw/workspace/skills ]; then
cp -r /opt/netclaw/workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true
fi
# Copy personality/identity docs
for doc in SOUL.md AGENTS.md IDENTITY.md USER.md TOOLS.md; do
if [ -f /opt/netclaw/$doc ]; then
cp /opt/netclaw/$doc /root/.openclaw/ 2>/dev/null || true
fi
done
# Send Matrix DM to Jeremy to announce availability
if [ -n "$MATRIX_ACCESS_TOKEN" ] && [ -n "$MATRIX_HOMESERVER" ]; then
echo "Sending Matrix DM to Jeremy..."
# Create a direct message room with Jeremy
ROOM_RESPONSE=$(curl -sf -X POST "${MATRIX_HOMESERVER}/_matrix/client/r0/createRoom" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
-d "{
\"is_direct\": true,
\"invite\": [\"@jeremy:hobbs.farm\"],
\"preset\": \"trusted_private_chat\"
}" 2>/dev/null || echo "")
ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || echo "")
if [ -n "$ROOM_ID" ]; then
# Send the message
curl -sf -X PUT "${MATRIX_HOMESERVER}/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/$(date +%s%N)" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \
-d "{
\"msgtype\": \"m.text\",
\"body\": \"Hey Jeremy - Aaron here. I'm online and ready to help with network engineering tasks. I'm running NetClaw with CCIE-level networking skills. Ask me about device health checks, routing analysis, security audits, or anything network-related.\"
}" 2>/dev/null || echo "Failed to send DM"
echo "DM sent to @jeremy:hobbs.farm in room $ROOM_ID"
else
echo "Warning: Could not create DM room with Jeremy. Response: $ROOM_RESPONSE"
fi
fi
# Start OpenClaw gateway
echo "Starting NetClaw (Aaron) gateway..."
exec openclaw gateway 2>&1 || {
echo "OpenClaw gateway failed to start, keeping container alive for debugging..."
tail -f /dev/null
}
@@ -5,6 +5,8 @@ http:
service: yoda service: yoda
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers-allow-frames
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -13,6 +15,8 @@ http:
service: mess service: mess
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -21,6 +25,8 @@ http:
service: mess service: mess
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -29,6 +35,8 @@ http:
service: wellknown service: wellknown
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -37,6 +45,8 @@ http:
service: solo service: solo
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers-allow-frames
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -45,6 +55,8 @@ http:
service: vault service: vault
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@@ -53,9 +65,41 @@ http:
service: git service: git
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
browserXssFilter: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
security-headers-allow-frames:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: false
customFrameOptionsValue: "SAMEORIGIN"
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
browserXssFilter: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
services: services:
yoda: yoda:
loadBalancer: loadBalancer:
@@ -6,6 +6,7 @@ services:
environment: environment:
- DOMAIN=https://vault.hobbs.farm - DOMAIN=https://vault.hobbs.farm
- SIGNUPS_ALLOWED=false - SIGNUPS_ALLOWED=false
- ADMIN_TOKEN=${ADMIN_TOKEN}
volumes: volumes:
- /mnt/docker-data/configs/vaultwarden:/data - /mnt/docker-data/configs/vaultwarden:/data
ports: ports:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
# Rename bart → hf-pdocker-03 Design
**Date:** 2026-03-11
**Server:** 192.168.86.167 (currently "bart", becoming "hf-pdocker-03")
## Overview
Rename the server "bart" to "hf-pdocker-03" across the entire infrastructure — hostname, repository files, NAS paths, Docker Swarm node, monitoring, and documentation. The IP address 192.168.86.167 does not change.
## Scope
### Server-Level
- Change hostname from `bart` to `hf-pdocker-03` via `hostnamectl`
- Update `/etc/hosts` on the server itself
- Docker Swarm: drain → remove → change hostname → re-join with new name
- Swarm global services (Telegraf, Node-exporter) auto-reschedule on new node name
### NAS Path Rename
- `/mnt/docker-data/compose/bart/` → `/mnt/docker-data/compose/hf-pdocker-03/`
- **Onyx exception:** `compose/hf-pdocker-03/onyx/docker-compose.yml` has hardcoded volume mounts referencing `/mnt/docker-data/compose/bart/onyx`. These must be updated in the compose file AND the Onyx containers redeployed after the NAS rename.
- All other running containers are unaffected (they don't reference the compose file path at runtime)
### Repository Files
**Directory rename:**
- `compose/bart/` → `compose/hf-pdocker-03/` (14 files across 8 subdirectories)
- Active services: `media/`, `openvas/`, `watchtower/`, `onyx/`
- Stale failover dirs (preserved for reference): `smarthome/`, `matrix/`, `guacamole/`, `ai-agents/`
**File inventory (all files containing "bart"):**
| File | Type | Action |
|------|------|--------|
| `CLAUDE.md` | Config | Must update — service distribution, storage, internal services |
| `stacks/monitoring/prometheus.yml` | Config | Must update — hostname label, job name `http-bart` → `http-hf-pdocker-03` |
| `stacks/monitoring/alerts.yml` | Config | Must update — PromQL `hostname="bart"` matchers. Review: elevated 90% CPU threshold was for Frigate, which is now on hf-pdocker-01, not this node. Consider reverting to standard 80% threshold. |
| `stacks/monitoring/matrix-receiver-config.yml` | Config | Must update — "bart" in comment. Note: file appears stale (still references old IPs). Update comment only. |
| `stacks/frigate/docker-compose.yml` | Stale Swarm def | Update for consistency — has `node.hostname == bart` placement constraint. This is an unused Swarm stack (Frigate runs as standalone Compose on hf-pdocker-01). |
| `compose/bart/onyx/docker-compose.yml` | Active compose | **CRITICAL** — has hardcoded volume mount `/mnt/docker-data/compose/bart/onyx:/mnt/gitea_patch:ro`. Must update path AND redeploy Onyx containers. |
| `.claude/agents/gitea-backup.md` | Agent def | Must update |
| `.claude/agents/alert-manager.md` | Agent def | Must update |
| `.claude/agents/documentation-keeper.md` | Agent def | Must update |
| `DEPLOYMENT_SUMMARY.md` | Doc | Must update |
| `docs/plans/2026-03-08-hf-pdocker-01-phase2-migration.md` | Historical | Update for consistency |
| `docs/plans/2026-03-08-hf-pdocker-01-rebuild.md` | Historical | Update for consistency |
| `docs/plans/2026-03-07-frigate-notify-v2-plan.md` | Historical | Update for consistency |
| `docs/plans/2026-03-05-farm-manager.md` | Historical | Update for consistency |
| `docs/plans/2026-03-05-farm-manager-design.md` | Historical | Update for consistency |
**Memory/auto-memory files:**
- `MEMORY.md` — ~30 references to "bart"
- `memory/migration-details.md` — bart references
### Documentation
- Wiki pages — infrastructure overview, service access, changelog
## Order of Operations
1. **Repo file updates** — rename `compose/bart/` directory, update all file contents (including Onyx compose volume path). No service impact yet.
2. **SCP updated Onyx compose file** to NAS at the current path (`/mnt/docker-data/compose/bart/onyx/docker-compose.yml`) with the NEW volume path (`/mnt/docker-data/compose/hf-pdocker-03/onyx`). This prepares for the rename.
3. **NAS path rename** — `mv /mnt/docker-data/compose/bart /mnt/docker-data/compose/hf-pdocker-03` (via SSH). Atomic on NFS.
4. **Redeploy Onyx** — `cd /mnt/docker-data/compose/hf-pdocker-03/onyx && docker compose up -d` to pick up the new volume mount path.
5. **Server hostname change** — `sudo hostnamectl set-hostname hf-pdocker-03` + update `/etc/hosts` on 192.168.86.167.
6. **Docker Swarm re-join** — on hf-pdocker-01: drain old bart node, remove it. On 192.168.86.167: leave swarm, re-join with new hostname. Global services auto-schedule. ~30s gap.
7. **Prometheus + alerts** — SCP updated prometheus.yml and alerts.yml, hot-reload.
8. **Update /etc/hosts on other nodes** — if hf-pdocker-01 or hf-pdocker-02 have "bart" in /etc/hosts, update to hf-pdocker-03.
9. **MEMORY.md + Wiki** — update all documentation.
10. **Commit + push to Gitea**.
## Risk Assessment
- **Low risk:** Running Compose services (Jellyfin, Sonarr, Radarr, etc.) are unaffected — containers keep running.
- **Onyx restart required:** Onyx containers need redeployment after NAS rename to pick up new volume mount path. Brief downtime for Onyx (~30s).
- **Low risk:** Swarm global services have ~30s gap during node re-join.
- **No IP changes:** All service-to-service communication uses IPs, not hostnames. Nothing breaks at the network level.
- **NAS path rename is atomic:** `mv` on NFS is atomic for same-filesystem renames.
## Verification
After completion:
- `hostname` on 192.168.86.167 returns `hf-pdocker-03`
- `docker node ls` on hf-pdocker-01 shows `hf-pdocker-03` instead of `bart`
- All containers on 192.168.86.167 still running (`docker ps`)
- Onyx containers running with new volume mount path
- Prometheus targets for hf-pdocker-03 are UP
- `grep -r bart` in repo returns zero matches (excluding git history)
+2
View File
@@ -4,6 +4,8 @@ http:
rule: "Host(`git.hobbs.farm`)" rule: "Host(`git.hobbs.farm`)"
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
service: gitea service: gitea
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
-26
View File
@@ -1,26 +0,0 @@
http:
routers:
openwebui:
rule: "Host(`open.hobbs.farm`)"
entryPoints:
- websecure
service: openwebui
tls:
certResolver: letsencrypt
openwebui-http:
rule: "Host(`open.hobbs.farm`)"
entryPoints:
- web
middlewares:
- openwebui-https-redirect
service: openwebui
middlewares:
openwebui-https-redirect:
redirectScheme:
scheme: https
permanent: true
services:
openwebui:
loadBalancer:
servers:
- url: "http://192.168.86.192:3010"
@@ -0,0 +1,60 @@
http:
middlewares:
security-headers:
headers:
# HSTS - force browsers to use HTTPS for 1 year, include subdomains
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
# Prevent MIME-type sniffing
contentTypeNosniff: true
# Block rendering in iframes from other origins
frameDeny: true
# Referrer policy - send origin only on cross-origin requests
referrerPolicy: "strict-origin-when-cross-origin"
# Permissions policy - restrict browser features
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
# Prevent browsers from caching sensitive responses
browserXssFilter: true
# Remove server identity header
customResponseHeaders:
X-Powered-By: ""
Server: ""
# Variant that allows microphone (for VoiceCoach)
security-headers-allow-mic:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(self), geolocation=(), payment=()"
browserXssFilter: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
# BasicAuth for sensitive services (Guacamole)
solo-basicauth:
basicAuth:
users:
- "glyptic:$2y$05$6AcmRIjMX6YrxY3fACeDhui4ghBGZtjmiPHbzWDb.qZ.8Fdip4GM."
realm: "Restricted"
# Variant that allows framing (for Guacamole, Home Assistant embeds)
security-headers-allow-frames:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
contentTypeNosniff: true
frameDeny: false
customFrameOptionsValue: "SAMEORIGIN"
referrerPolicy: "strict-origin-when-cross-origin"
permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
browserXssFilter: true
customResponseHeaders:
X-Powered-By: ""
Server: ""
+18
View File
@@ -0,0 +1,18 @@
tls:
options:
default:
minVersion: VersionTLS12
cipherSuites:
# TLS 1.3 ciphers are always enabled and not configurable
# These are the strong TLS 1.2 ciphers
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
curvePreferences:
- X25519
- CurveP256
- CurveP384
sniStrict: true
+2
View File
@@ -4,6 +4,8 @@ http:
rule: "Host(`vault.hobbs.farm`)" rule: "Host(`vault.hobbs.farm`)"
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- security-headers
service: vault service: vault
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
+215
View File
@@ -0,0 +1,215 @@
# Deployment Brief: vLLM serving Hermes 4.3 for OpenClaw/Pluto
**Audience:** Claude Code, executing autonomously on Jeremy's homelab.
**Goal:** Stand up a vLLM OpenAI-compatible inference server hosting NousResearch Hermes 4.3, with native Hermes tool-call parsing, and wire it into the existing OpenClaw gateway as Pluto's reasoning model.
---
## 0. Why this exists (context you need)
We tried serving Hermes 4.3 36B (Q4_K_M GGUF) through Ollama on the Windows box. The model loads and runs at ~98% GPU after forcing `num_gpu 64`, but **structured tool calling fails** — Ollama returns empty `content` and no `tool_calls` array, because Ollama's parser doesn't extract Hermes's `<tool_call>` XML format into the OpenAI-structured field on a self-imported GGUF. This is a known, recurring Ollama sharp edge.
vLLM solves this directly: the `--tool-call-parser hermes` flag parses the XML `<tool_call>` format natively into the OpenAI-compatible response schema, no post-processing. That is the entire reason for this migration.
**Do not** spend effort trying to fix the Ollama path. The decision to move to vLLM is final.
---
## 1. Hard constraints — read before doing anything
1. **The GPU is in the Windows desktop, NOT the Linux swarm.**
- Host: `DESKTOP-5AHPRKR` — ASUS Z790 (TUF GAMING Z790-PLUS WIFI), i7-13700KF, 32GB RAM, **RTX 3090 (24GB VRAM)**.
- The Docker Swarm nodes (`hf-pdocker-01`, `hf-pdocker-02`, `bart`) are CPU-only and **cannot** run a 36B model usefully. Do not attempt to schedule vLLM onto the swarm.
- vLLM is **Linux/CUDA only**. On Windows this means running inside **WSL2** (Ubuntu) with CUDA passthrough, using the official vLLM Docker image via Docker Desktop's WSL2 backend, OR a native WSL2 Python install. Docker image is preferred for reproducibility.
2. **24GB VRAM is the binding limit.** Hermes 4.3 36B in FP16 is ~72GB — impossible here. Options, in order of preference:
- **(A)** A pre-quantized **AWQ or GPTQ 4-bit** build of Hermes 4.3 36B (~18-20GB weights). Tight but workable with `--gpu-memory-utilization 0.95`, `--max-model-len 8192`, and `--enforce-eager` (skip CUDA graph capture to save VRAM). **First choice if a quant exists.**
- **(B)** If no 36B AWQ/GPTQ exists, fall back to **Hermes 4.3 in a smaller parameter size** if Nous published one (e.g. a 14B), served in FP16 or AWQ — fits comfortably, full context, fast. **Strongly preferred over fighting the 36B on 24GB.**
- **(C)** Last resort: serve the **GGUF Q4** through vLLM (vLLM supports GGUF). Weaker than AWQ in vLLM and partially defeats the point, but the hermes parser still works. Only if A and B are unavailable.
3. **WSL2 will fight you for RAM.** Jeremy's box already had `VmmemWSL` consuming ~13GB. There may now be a `C:\Users\Jeremy\.wslconfig` capping it. Check it. vLLM's model loading stages through system RAM, so ensure WSL2's memory cap is high enough (≥24GB) for the load, OR raise it temporarily. Coordinate: Docker Desktop restarts WSL2 automatically, so don't rely on `wsl --shutdown` to free RAM permanently.
4. **Stop the Ollama server before testing** to avoid port/VRAM contention. Ollama holds VRAM while a model is resident. `ollama stop hermes` and confirm `ollama ps` is empty, or quit the Ollama app entirely. vLLM and Ollama cannot both hold the 3090 at once.
---
## 2. Pre-flight verification (do these first, report results)
Run inside WSL2 (Ubuntu):
```bash
# Confirm CUDA reaches WSL2 and sees the 3090
nvidia-smi
# Expect: "NVIDIA GeForce RTX 3090", 24576 MiB total, driver present.
# If this fails, CUDA-on-WSL2 isn't set up — STOP and report.
# Confirm Docker can see the GPU
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
# Expect same output from inside a container.
```
If `nvidia-smi` works on the Windows side but not in WSL2, the fix is installing the WSL2 CUDA toolkit (NOT a full driver — the Windows driver provides the WSL2 GPU stub). Report before proceeding so Jeremy can confirm.
Also check current RAM headroom and the WSL config:
```bash
cat /mnt/c/Users/Jeremy/.wslconfig 2>/dev/null || echo "no .wslconfig"
free -h
```
---
## 3. Model selection (decide, then download)
**Step 3a — Find the best-fitting quant.** Search HuggingFace for, in priority order:
1. `Hermes-4.3-36B` AWQ (search orgs: NousResearch, and community quantizers like `casperhansen`, `solidrust`, `bartowski` for AWQ/GPTQ variants)
2. A smaller Hermes 4.3 (14B-class) if one exists — this is genuinely the easier, faster path on 24GB
3. GGUF Q4 as fallback (we already have `hf.co/NousResearch/Hermes-4.3-36B-GGUF:Q4_K_M`)
**Report what you find and which option (A/B/C from §1.2) you're proceeding with before downloading 20GB.** If a 36B AWQ exists, confirm its repo name. If the only realistic fit is a smaller Hermes 4.3, say so — Jeremy explicitly prefers a model that runs 100% on-GPU with full context over grinding a 36B into a too-small card.
**Step 3b — Download to a persistent WSL2 path** (not the container, so it survives restarts):
```bash
mkdir -p ~/models
pip install -U "huggingface_hub[cli]"
huggingface-cli download <RESOLVED_REPO_ID> --local-dir ~/models/hermes-4.3
```
---
## 4. Launch vLLM (Docker, OpenAI-compatible)
Create `~/vllm-hermes/docker-compose.yml`. Template for **Option A (36B AWQ)** — adjust `--quantization`, `--model`, and `--max-model-len` per the resolved model:
```yaml
services:
vllm-hermes:
image: vllm/vllm-openai:latest
container_name: vllm-hermes
runtime: nvidia
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
ports:
- "8000:8000"
volumes:
- ~/models/hermes-4.3:/models/hermes-4.3
ipc: host
command: >
--model /models/hermes-4.3
--served-model-name hermes
--quantization awq
--gpu-memory-utilization 0.95
--max-model-len 8192
--enforce-eager
--enable-auto-tool-choice
--tool-call-parser hermes
--host 0.0.0.0
--port 8000
restart: unless-stopped
```
Key flag rationale:
- `--enable-auto-tool-choice --tool-call-parser hermes` — **the whole point.** Native Hermes XML tool-call parsing into OpenAI schema.
- `--quantization awq` — set to `gptq` for GPTQ, omit entirely for GGUF (use `--quantization gguf` and point `--model` at the .gguf file) or for an unquantized smaller model.
- `--enforce-eager` — disables CUDA graph capture, saving ~1-3GB VRAM. **Critical on the tight 24GB 36B path.** Remove it if you have headroom (smaller model) for a latency improvement.
- `--max-model-len 8192` — keep KV cache small to fit. Raise cautiously if VRAM allows; watch for OOM on startup.
- `--gpu-memory-utilization 0.95` — aggressive, appropriate for a dedicated inference box. Drop to 0.90 if the desktop also drives a display off the 3090 (it does — there's a monitor at 1680x1050 on it; budget ~0.5-1GB for the desktop, so 0.90 may be safer).
Launch and watch logs:
```bash
cd ~/vllm-hermes
docker compose up -d
docker compose logs -f vllm-hermes
# Wait for: "Application startup complete" and "Uvicorn running on http://0.0.0.0:8000"
```
**If it OOMs on startup:** lower `--max-model-len` to 4096, confirm `--enforce-eager` is present, drop `--gpu-memory-utilization` to 0.90. If it still won't fit, that's the signal Option A is not viable on this card — fall back to Option B (smaller Hermes 4.3) and report.
---
## 5. Verification — the test that actually matters
This is the exact test that FAILED on Ollama. It must pass on vLLM.
```bash
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "hermes",
"messages": [{"role": "user", "content": "What is 8347 multiplied by 291? Use the calculator tool."}],
"tools": [{
"type": "function",
"function": {
"name": "calculator",
"description": "Multiply two numbers",
"parameters": {
"type": "object",
"properties": {"a": {"type": "number"}, "b": {"type": "number"}},
"required": ["a", "b"]
}
}
}],
"tool_choice": "auto"
}' | python3 -m json.tool
```
**PASS criteria:** the response `choices[0].message` has a populated `tool_calls` array containing `function.name == "calculator"` and `function.arguments` parsing to `{"a": 8347, "b": 291}` (or equivalent), with `content` empty/null. That is structured tool calling working.
**FAIL:** if `tool_calls` is null/absent and the JSON is dumped in `content` as a string, the parser flag didn't take — verify `--tool-call-parser hermes` is in the running command (`docker compose logs` shows the args at boot) and that the model's chat template actually emits `<tool_call>` tags. Report the raw response.
Also sanity-check throughput:
```bash
# Plain generation latency
curl -s http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
-d '{"model":"hermes","messages":[{"role":"user","content":"Say hello in one sentence."}]}' \
| python3 -m json.tool
nvidia-smi # confirm VRAM occupancy and that it's the vLLM process
```
---
## 6. Wire into OpenClaw (Pluto's brain)
OpenClaw is model-agnostic and speaks the OpenAI API. Point it at the vLLM endpoint. The vLLM server is on the Windows box at `http://<WINDOWS_LAN_IP>:8000/v1` (or `http://localhost:8000/v1` if OpenClaw runs on the same Windows box; confirm where Pluto's gateway actually runs — it may be on the swarm).
In OpenClaw's model/provider config:
- **Base URL:** `http://<WINDOWS_LAN_IP>:8000/v1`
- **Model name:** `hermes` (matches `--served-model-name`)
- **API key:** any non-empty string (vLLM ignores it by default; use `EMPTY` or `sk-noauth`)
**Networking notes:**
- vLLM in the WSL2 container binds `0.0.0.0:8000`, but WSL2 networking means the port must be reachable from the Windows host and the LAN. WSL2 usually auto-forwards localhost; for LAN access you may need a `netsh interface portproxy` rule on Windows forwarding the host's LAN IP:8000 to the WSL2 IP:8000, plus a Windows Firewall inbound rule for port 8000 **scoped to the LAN subnet only**. Set this up and verify from another machine with `curl http://<WINDOWS_LAN_IP>:8000/v1/models`.
- Do **not** expose port 8000 to the internet. If remote access is wanted later, that goes behind Traefik with forward-auth on the swarm, tunneled to the Windows box — a separate task, not part of this brief.
After wiring, restart the OpenClaw gateway and run a Pluto interaction that should trigger a tool (whatever real tools Pluto has registered) to confirm end-to-end tool execution, not just the synthetic calculator test.
---
## 7. Operational notes / gotchas
- **Display + inference share the 3090.** The desktop's monitor runs off the same card. Under VRAM pressure this can cause display glitches or vLLM OOM. The `0.90` utilization setting accounts for this; if Jeremy ever runs the box headless (RDP only, which the earlier session showed — there was a Remote Display Adapter), more VRAM frees up and utilization can go to 0.95+.
- **vLLM holds VRAM for its lifetime.** It does not unload like Ollama. That's expected and desirable for an always-on Pluto brain, but it means the 3090 is committed — no gaming / other GPU work while it runs.
- **Auto-start on boot:** `restart: unless-stopped` handles container restarts, but WSL2 + Docker Desktop must themselves start on Windows login. For true always-on, enable Docker Desktop "Start on login" and consider WSL2 boot via Task Scheduler, OR (better long-term) move this whole stack to a dedicated headless Linux box with the 3090 if one becomes available. Flag this to Jeremy as a follow-up if always-on reliability matters for VoiceCoach.
- **Context length is capped at 8192 here** for VRAM reasons. Hermes 4.3 supports far more (trained to 512K), but you can't afford the KV cache on 24GB. If Pluto workflows need longer context, that's a hardware conversation (bigger card / second GPU), not a config tweak.
- **Version pinning:** `vllm/vllm-openai:latest` is convenient but pin to a specific tag for production stability once verified working, so an image update doesn't silently break the tool parser.
---
## 8. Report back to Jeremy with
1. `nvidia-smi` output from inside WSL2 (GPU visible y/n).
2. Which model option (A/B/C) you proceeded with and the exact repo ID.
3. The full JSON from the §5 calculator tool-call test (the pass/fail evidence).
4. Measured tokens/sec and VRAM occupancy from `nvidia-smi`.
5. Whether OpenClaw end-to-end tool execution worked with the real Pluto toolset.
6. Any networking steps needed for LAN access (portproxy/firewall rules added).
Do not mark this complete until the §5 tool-call test passes with a structured `tool_calls` array. That single result is the definition of done for the inference layer.