From 617f2003100b2ea7d4e35ccefbbd26fde17ba7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:16:55 +0100 Subject: [PATCH] Update CLAUDE.md with comprehensive NUC infrastructure docs Expand from initial setup notes to full operational manual covering OpenClaw gateway, Palmr file sharing, MinIO storage, Deepgram MCP, Gitea auto-deploy workflows, Tailscale Funnel architecture, JSX artifact publishing, and OpenWrt router management. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 579 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 557 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18a6d28..1415ac5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,28 +14,32 @@ ssh nuc ## DNS & Tailscale Setup -### Why Tailscale IP for DNS +### DNS Strategy: Local IP + Tailscale Subnet Routing -All `.nuc.lan` domains resolve to the **Tailscale IP** (`100.113.153.45`) instead of the local IP (`192.168.1.3`). This ensures services work from **anywhere** regardless of your current network's subnet. +All `.nuc.lan` domains resolve to the **local LAN IP** (`192.168.1.3`). This allows any device on the LAN to access services without Tailscale. -**Problem solved:** When connecting from a remote network that also uses `192.168.x.x`, traffic to `192.168.1.3` stays local instead of going through Tailscale. Using Tailscale IP (`100.x.x.x`) avoids this conflict. +**Remote access (Tailscale)** works via two mechanisms: +1. **Split DNS**: Tailscale forwards `*.nuc.lan` queries to the OpenWrt router (`192.168.1.1`) through the tunnel +2. **Subnet routing**: The NUC advertises `192.168.1.0/24` as a Tailscale subnet route, so `192.168.1.3` is reachable from any Tailscale device remotely ### Configured Domains (OpenWrt Router DNS) | Domain | Resolves To | Service | |--------|-------------|---------| -| `nuc.lan` | `100.113.153.45` | NUC Portal | -| `nuc.local` | `100.113.153.45` | NUC Portal | -| `coolify.nuc.lan` | `100.113.153.45` | Coolify | -| `gitea.nuc.lan` | `100.113.153.45` | Gitea | -| `outline.nuc.lan` | `100.113.153.45` | Outline Wiki | -| `files.nuc.lan` | `100.113.153.45` | FileBrowser | -| `mail.nuc.lan` | `100.113.153.45` | Snappymail | -| `vault.nuc.lan` | `100.113.153.45` | Vaultwarden | -| `homepage.nuc.lan` | `100.113.153.45` | NUC Portal | -| `brand.nuc.lan` | `100.113.153.45` | Whyrating Brand | -| `templates.nuc.lan` | `100.113.153.45` | Whyrating Templates | -| `whyrating.nuc.lan` | `100.113.153.45` | Whyrating Hub | +| `nuc.lan` | `192.168.1.3` | NUC Portal | +| `nuc.local` | `192.168.1.3` | NUC Portal | +| `coolify.nuc.lan` | `192.168.1.3` | Coolify | +| `gitea.nuc.lan` | `192.168.1.3` | Gitea | +| `outline.nuc.lan` | `192.168.1.3` | Outline Wiki | +| `files.nuc.lan` | `192.168.1.3` | FileBrowser | +| `mail.nuc.lan` | `192.168.1.3` | Snappymail | +| `vault.nuc.lan` | `192.168.1.3` | Vaultwarden | +| `homepage.nuc.lan` | `192.168.1.3` | NUC Portal | +| `brand.nuc.lan` | `192.168.1.3` | Whyrating Brand | +| `templates.nuc.lan` | `192.168.1.3` | Whyrating Templates | +| `whyrating.nuc.lan` | `192.168.1.3` | Whyrating Hub | +| `whyops.nuc.lan` | `192.168.1.3` | WhyOps (scraping, pipelines, testing) | +| `arrio.nuc.lan` | `192.168.1.3` | Arrio (hotel check-in) | ### Traefik Routing (Dynamic Config) @@ -62,7 +66,7 @@ http: ssh nuc "ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 ' uci add dhcp domain uci set dhcp.@domain[-1].name=\"newservice.nuc.lan\" -uci set dhcp.@domain[-1].ip=\"100.113.153.45\" +uci set dhcp.@domain[-1].ip=\"192.168.1.3\" uci commit dhcp /etc/init.d/dnsmasq restart '" @@ -200,6 +204,7 @@ Task(subagent_type="general-purpose", prompt="Add services to Homepage...", desc | `mcp__ssh-manager__*` | Direct SSH commands, file transfers | | `mcp__n8n__*` | Workflow automation (if configured) | | `mcp__playwriter__*` | Browser automation fallback (see below) | +| `mcp__deepgram__*` | Audio transcription (STT) and text-to-speech (TTS) | ### Stalwart Mail MCP (Quick Guide) @@ -453,6 +458,10 @@ ssh nuc "docker exec " | Kopia | - | `http://100.113.153.45:51515` | kopia | | Dozzle | - | `http://100.113.153.45:9999` | dozzle | | CloudBeaver | - | `http://100.113.153.45:8978` | cloudbeaver-* | +| WhyOps | `http://whyops.nuc.lan` | `http://100.113.153.45:3002` | whyrating-dashboard | +| Palmr | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | `http://100.113.153.45:3334` | palmr | +| Arrio | `http://arrio.nuc.lan` | `http://100.113.153.45:3335` | web-tgksg0s8gocko4csggs0808c | +| Deepgram MCP | - | `http://100.113.153.45:8009` | deepgram-mcp | **Note:** Use Tailscale IP (`100.113.153.45`) instead of `192.168.1.3` to avoid subnet conflicts when remote. @@ -526,7 +535,11 @@ ssh nuc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep " ssh nuc "docker exec wget -qO- http://127.0.0.1:/healthz" ``` -7. **Creating API keys when no UI available** (e.g., n8n): +7. **localhost resolves to IPv6 on NUC**: Docker containers bind to IPv4 only. Always use `127.0.0.1` instead of `localhost` in Tailscale Funnel/Serve targets, curl commands inside SSH, etc. + +8. **Tailscale Funnel port not working externally**: Funnel ONLY supports ports **443, 8443, and 10000**. Other ports will show as "Funnel on" in status but won't route internet traffic. + +9. **Creating API keys when no UI available** (e.g., n8n): ```bash # Stop container, insert directly into SQLite, restart ssh nuc "docker run --rm -v :/data keinos/sqlite3 sqlite3 /data/database.sqlite \"\"" @@ -966,6 +979,7 @@ Coolify's "Gitea Source" uses GitHub App-style OAuth with JWT - **this doesn't w | whyrating-brand | http://brand.nuc.lan | `alezmad/whyrating-brand` | `r80gk0ccgg0okos8cw848kkk` | | whyrating-templates | http://templates.nuc.lan | `alezmad/whyrating-templates` | `qw80g4sog0kk8cc4wkcs8sgc` | | turbostarter | https://alezmad-nuc.tail58f5ad.ts.net | `alezmad/turbostarter` | `v4gogwwc8wkk4888ksscc4k4` (service) | +| arrio | http://arrio.nuc.lan | `alezmad/arrio` | `tgksg0s8gocko4csggs0808c` (service) | ### Turbostarter (Knosia) - Build & Deploy @@ -1059,15 +1073,24 @@ Cloudflare shared IPs get blocked by Spanish ISPs during LaLiga matches. Tailsca | **Tailscale IP** | `100.x.x.x` (stable, never changes) | | **Status** | `ssh nuc "tailscale funnel status"` | +**⚠️ CRITICAL: Funnel only supports ports 443, 8443, and 10000.** Other ports will appear to work locally (`tailscale funnel status` shows them) but traffic will NOT route from the public internet. Always use one of these three ports. + +**⚠️ CRITICAL: Always use `127.0.0.1`, NOT `localhost`** in Funnel/Serve targets. On the NUC, `localhost` resolves to IPv6 (`::1`) but Docker containers bind to IPv4 only, causing connection resets. + **Start Funnel for a service:** ```bash -# Expose port 3000 via Funnel -ssh nuc "tailscale funnel 3000" - -# Or with background (use screen/tmux) -ssh nuc "screen -dmS funnel tailscale funnel 3000" +# Expose port 3000 via Funnel (use 127.0.0.1, NOT localhost!) +ssh nuc "printf '\n' | sudo -S tailscale funnel --bg --https=8443 http://127.0.0.1:3000" ``` +**Current Funnel allocation:** + +| Port | Service | Target | +|------|---------|--------| +| 443 | Traefik (main) | `http://192.168.1.3:80` | +| 8443 | Palmr (file sharing) | `http://127.0.0.1:3334` | +| 10000 | Palmr MinIO (uploads) | `http://127.0.0.1:9379` | + ### Current Domain Routes | Domain | Destination | Method | @@ -1168,6 +1191,106 @@ Examples: - ``` +## Publishing JSX/React Artifacts Online + +Single-file React components (JSX) can be published as standalone web pages via the NUC's artifacts infrastructure. + +### How It Works + +``` +Public Internet → Tailscale Funnel (:443) → Traefik → artifacts-web (nginx) → /opt/artifacts/ +``` + +- **Funnel URL:** `https://alezmad-nuc.tail58f5ad.ts.net/artifacts//` +- **LAN URL:** `https://artifacts.nuc.lan//` +- **Nginx container:** `artifacts-web` (image: `nginx:alpine`, read-only mount of `/opt/artifacts`) +- **Traefik public route:** `Host(alezmad-nuc.tail58f5ad.ts.net) && PathPrefix(/artifacts)` → strips `/artifacts` → `artifacts-web:80` +- **Config file:** `/traefik/dynamic/nuc-services-public.yaml` (inside `coolify-proxy` container) + +### Quick Publish Steps + +```bash +# 1. Build self-contained HTML from JSX +# - Replace `import { useState, ... } from "react"` with `const { useState, ... } = React;` +# - Remove `export default ComponentName;` +# - Wrap in HTML with React 18 CDN + Babel standalone +# - Add `ReactDOM.createRoot(root).render()` at the end + +# 2. Copy to NUC artifacts directory +ssh nuc "echo '7vXHpSTD.' | sudo -S mkdir -p /opt/artifacts/" +scp /tmp/build/index.html nuc:~/tmp-artifact.html +ssh nuc "echo '7vXHpSTD.' | sudo -S mv ~/tmp-artifact.html /opt/artifacts//index.html" +ssh nuc "echo '7vXHpSTD.' | sudo -S chmod 644 /opt/artifacts//index.html" + +# 3. Done! No server restart needed — nginx serves it immediately. +``` + +### HTML Template for JSX Files + +```html + + + + + + TITLE + + + + + + + +
+ + + +``` + +### Build Script (for large JSX files) + +```bash +# Automated: strips import/export, wraps in HTML +cat > /tmp/build.html << 'HEADER' + + + + + + +
+ +FOOTER +``` + +### Currently Published + +| Path | Source | Public URL | +|------|--------|------------| +| `/opt/artifacts/checkin/` | `arrio/.scratch/checkin_demo_v1.jsx` | `https://alezmad-nuc.tail58f5ad.ts.net/artifacts/checkin/` | + ## OpenWrt Interaction Methods (Quick Reference) | Method | When to Use | Example | @@ -1179,6 +1302,198 @@ Examples: **Priority Order:** SSH > OpenWrt MCP > Chrome DevTools > Manual UI +## Palmr (File Sharing - Dropbox Alternative) + +Self-hosted file sharing platform (like WeTransfer/Dropbox) for sending and receiving files. + +### Access + +| Property | Value | +|----------|-------| +| **Public URL** | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | +| **Local URL** | `http://192.168.1.3:3334` | +| **Login page** | `/login` | +| **Container** | `palmr` | +| **Image** | `kyantech/palmr:latest` | +| **Network** | `coolify` (IP: `10.0.1.5`) | +| **Sudo pass** | `7vXHpSTD.` | + +### Users + +| Username | Email | Password | Admin | +|----------|-------|----------|-------| +| `alezmad` | `agutierrez@mineryreport.com` | *(set via UI)* | Yes | +| `michi` | `michi@nuc.lan` | `FlexiCar2025.` | No | + +### Architecture + +``` +External Browser → Funnel :8443 → 127.0.0.1:3334 → container:5487 (Next.js frontend) + Funnel :10000 → 127.0.0.1:9379 → container:9379 (MinIO uploads) + ↑ via port-fwd-palmr-minio +``` + +Palmr uses **presigned URLs** for file uploads — the browser uploads directly to MinIO, NOT through the backend. This means MinIO MUST be reachable from the client's browser. + +### Key Environment Variables + +| Variable | Value | Purpose | +|----------|-------|---------| +| `STORAGE_URL` | `https://alezmad-nuc.tail58f5ad.ts.net:10000` | Public MinIO URL for presigned upload URLs | +| `API_BASE_URL` | `http://127.0.0.1:3333` | Internal backend API (Next.js → Fastify) | + +### Volume Mounts + +| Volume | Mount Point | Contents | +|--------|-------------|----------| +| `5273abd0c...` (anonymous) | `/app/server` | SQLite DB, MinIO data, credentials | +| `palmr_uploads` | `/app/uploads` | Uploaded files | + +### Container Recreation + +```bash +ssh nuc "docker stop palmr && docker rm palmr && docker run -d \ + --name palmr \ + --network coolify \ + --restart unless-stopped \ + -p 3334:5487 \ + -v 5273abd0c536116056362397cdb568d2eab066b8289412dd91ecce58c174df68:/app/server \ + -v palmr_uploads:/app/uploads \ + -e STORAGE_URL=https://alezmad-nuc.tail58f5ad.ts.net:10000 \ + kyantech/palmr:latest" +``` + +### Port Forwarder (MinIO) + +MinIO runs inside the Palmr container on port 9379 (not exposed). A socat forwarder bridges it: + +```bash +ssh nuc "docker run -d --name port-fwd-palmr-minio --network coolify -p 9379:9379 \ + alpine/socat tcp-listen:9379,fork,reuseaddr tcp-connect:palmr:9379" +``` + +### Database Access + +Palmr uses SQLite at `/app/server/prisma/palmr.db`. Passwords are **bcrypt** hashed. + +```bash +# Copy DB locally for queries +ssh nuc "docker cp palmr:/app/server/prisma/palmr.db /tmp/palmr.db" +scp nuc:/tmp/palmr.db /tmp/palmr.db +sqlite3 /tmp/palmr.db "SELECT username, email, isAdmin FROM users;" + +# Generate bcrypt hash inside container +ssh nuc "docker exec palmr sh -c 'cd /app/palmr-app && node -e \"const bcrypt = require(\\\"bcryptjs\\\"); console.log(bcrypt.hashSync(\\\"PASSWORD\\\", 10));\"'" +``` + +### Troubleshooting + +1. **Upload stuck on loader**: Check `docker logs palmr` for `STORAGE_URL` errors. MinIO must be reachable from the client browser via the presigned URL. + +2. **Blank page externally**: Verify Funnel is on a supported port (443/8443/10000) and target uses `127.0.0.1` not `localhost`. + +3. **JWT token invalid after restart**: Expected — users must log in again after container recreation (JWT secret regenerated). + +4. **Config limits**: File size and storage limits are in `app_configs` table (values in bytes). Update via SQLite, copy DB back, restart. + +## MinIO Object Storage (Mac ↔ NUC File Transfer) + +### Architecture + +MinIO runs on the NUC as S3-compatible object storage. Used for Mac backups, file transfers, and app storage. + +``` +Mac (mc CLI) → Tailscale → 100.113.153.45:9000 → port-fwd (socat) → minio-xwowg8kswwsocssgocs8ss40:9000 +``` + +### Access + +| Property | Value | +|----------|-------| +| **API URL** | `http://100.113.153.45:9000` | +| **Console URL** | `http://100.113.153.45:9001` | +| **Access Key** | `minioadmin` | +| **Secret Key** | `minioadmin` | +| **Port Forwarder** | `minio-port-fwd` (socat, maps host :9000/:9001 → container) | +| **Main Container** | `minio-xwowg8kswwsocssgocs8ss40` | + +### Buckets + +| Bucket | Purpose | +|--------|---------| +| `mac-backups` | Database dumps, tar archives, important backups | +| `mac-downloads` | Files moved from ~/Downloads for archival | +| `mac-projects` | Archived project files | +| `nuc-portal-previews` | NUC Portal screenshot previews | +| `whyrating` | WhyRating app assets | + +### Mac CLI (mc) + +Installed at `/opt/homebrew/bin/mc`, alias `nuc` pre-configured. + +```bash +# List buckets +mc ls nuc/ + +# Upload file +mc cp file.tar nuc/mac-backups/ + +# Download file +mc cp nuc/mac-backups/file.tar ./ + +# Sync a directory +mc mirror ~/path/to/dir nuc/mac-projects/dirname/ + +# Check bucket size +mc du nuc/mac-backups +``` + +### nuc-sync CLI Tool + +Wrapper script at `~/.local/bin/nuc-sync` for daily operations: + +```bash +nuc-sync status # Check NUC + MinIO connectivity +nuc-sync upload file.tar # Upload to mac-backups bucket +nuc-sync upload file.sql mac-downloads # Upload to specific bucket +nuc-sync push ~/Desktop/project # rsync to NUC (excludes node_modules, .next, .git) +nuc-sync pull /opt/backups/mac/x . # rsync from NUC +nuc-sync list # List all buckets +nuc-sync list mac-backups # List bucket contents +``` + +### MinIO MCP Server + +MCP server for Claude Code integration: `minio-nuc` +- **Source:** `~/mcp-servers/minio-mcp-server/` +- **GitHub:** `https://github.com/Rafitis/minio-mcp-server` +- **Scope:** user (available in all projects) +- **Tools:** list buckets, upload/download objects, create/delete buckets + +### rsync Backup Directory + +```bash +# NUC backup path (owned by alezmad) +/opt/backups/mac/ +``` + +### Troubleshooting + +1. **MinIO unreachable (connection refused)**: The port forwarder may be down: + ```bash + ssh nuc "docker start minio-port-fwd" + ``` + +2. **mc alias not working**: Reconfigure: + ```bash + /opt/homebrew/bin/mc alias set nuc http://100.113.153.45:9000 minioadmin minioadmin + ``` + +3. **Console not loading**: Port 9001 needs the port-fwd container running. Check with: + ```bash + ssh nuc "docker ps | grep minio-port-fwd" + ``` + ## Next Steps / Migration Candidates ### Priority 1: Safe to Delete (Duplicates/Old Versions) @@ -1240,3 +1555,223 @@ mcp__coolify__service(action="create", type="prometheus", ...) - [ ] Verify MySQL data before deleting - [ ] Add CloudBeaver to Uptime Kuma monitoring - [ ] Configure OpenWrt MCP MQTT broker (optional) + +## OpenClaw (AI Assistant Gateway) + +Self-hosted AI assistant gateway running on the NUC via Docker Compose. Connects to messaging platforms (WhatsApp, Telegram, Discord, etc.) and routes messages through Claude. + +### Access + +| Property | Value | +|----------|-------| +| **Control UI (HTTPS)** | `https://alezmad-nuc.tail58f5ad.ts.net:8443` | +| **Gateway WS** | `ws://192.168.1.3:18789` | +| **Gateway Token** | `3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee` | +| **Model** | `anthropic/claude-sonnet-4-5-20250929` | +| **Repo on NUC** | `~/openclaw/` | +| **Config** | `~/.openclaw/openclaw.json` | +| **Version** | `2026.2.10` | + +**⚠️ HTTPS Required:** The Control UI requires a secure context (HTTPS or localhost). Access via Tailscale Serve on port 8443. + +### Tailscale Serve (HTTPS access) + +The gateway is exposed via Tailscale Serve (not Funnel - tailnet only, not public): + +```bash +# Start HTTPS proxy (requires sudo, must run from NUC terminal) +ssh nuc +sudo tailscale serve --bg --https=8443 http://localhost:18789 +# Password: 7vXHpSTD +``` + +**⚠️ The `--bg` flag makes it persistent.** Without it, Ctrl+C stops the proxy. + +### Docker Compose Management + +```bash +# Start gateway +ssh nuc "cd ~/openclaw && docker compose up -d openclaw-gateway" + +# Restart gateway +ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" + +# View logs +ssh nuc "docker logs openclaw-openclaw-gateway-1 2>&1 | tail -30" + +# Run CLI commands (use docker exec for commands that need gateway connection) +ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" + +# Run CLI commands that don't need gateway (use docker compose run) +ssh nuc "cd ~/openclaw && script -qc 'docker compose run --rm openclaw-cli ' /dev/null" +``` + +### Device Pairing + +When the Control UI shows "pairing required", approve the pending device: + +```bash +# List pending devices +ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices list --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" + +# Approve a device (use requestId from the list) +ssh nuc "docker exec openclaw-openclaw-gateway-1 node dist/index.js devices approve --token 3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee --url ws://127.0.0.1:18789" +``` + +**Dashboard URL with embedded token (auto-authenticates):** +``` +https://alezmad-nuc.tail58f5ad.ts.net:8443/#token=3547c3f2b7b4a33eb077cf804bcca446057f81ba1578b2045dbb3aa4e04346ee +``` + +### Channel Plugins + +Channels are plugins that must be enabled before use. 35 available, 4 loaded by default. + +```bash +# List all plugins +ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins list' /dev/null" + +# Enable a channel plugin +ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable ' /dev/null" + +# Then restart gateway +ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" +``` + +**Currently enabled channels:** WhatsApp (linked and active) + +**Available channel plugins:** +| Plugin ID | Channel | +|-----------|---------| +| `whatsapp` | WhatsApp | +| `telegram` | Telegram | +| `discord` | Discord | +| `slack` | Slack | +| `signal` | Signal | +| `matrix` | Matrix | +| `msteams` | Microsoft Teams | +| `googlechat` | Google Chat | +| `imessage` | iMessage | +| `irc` | IRC | + +### Setting Up Channels That Require QR Codes (WhatsApp, etc.) + +**⚠️ The CLI needs a TTY for QR display.** Cannot run directly via `ssh nuc "command"`. Use `script` to fake a TTY and capture output: + +```bash +# Step 1: Enable the plugin +ssh nuc "script -qc 'cd ~/openclaw && docker compose run --rm openclaw-cli plugins enable whatsapp' /dev/null" + +# Step 2: Restart gateway +ssh nuc "cd ~/openclaw && docker compose restart openclaw-gateway" + +# Step 3: Run login with TTY capture (captures QR to file) +ssh nuc "script -q /tmp/openclaw-qr.txt -c 'cd ~/openclaw && docker compose run --rm openclaw-cli channels login'" + +# Step 4: If QR needs to be viewed remotely, copy and render as image +scp nuc:/tmp/openclaw-qr.txt /tmp/ +# Then use Python + Pillow to convert Unicode block chars to PNG +``` + +**QR to PNG conversion (run locally on Mac):** +```python +from PIL import Image +import re + +with open("/tmp/openclaw-qr.txt", "rb") as f: + content = f.read().decode("utf-8", errors="replace") + +lines = content.split("\n") +qr_lines = [] +for l in lines: + clean = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", l).replace(chr(0), "") + if any(c in clean for c in ["\u2584", "\u2588", "\u2580"]): + qr_lines.append(clean) + +scale = 10 +width = max(len(l) for l in qr_lines) +height = len(qr_lines) * 2 +img = Image.new("RGB", (width * scale, height * scale), "white") + +for row_idx, line in enumerate(qr_lines): + for col_idx, ch in enumerate(line): + top_black = ch in ["\u2588", "\u2580"] + bot_black = ch in ["\u2588", "\u2584"] + for dy in range(scale): + for dx in range(scale): + if top_black: + img.putpixel((col_idx*scale+dx, row_idx*2*scale+dy), (0,0,0)) + if bot_black: + img.putpixel((col_idx*scale+dx, (row_idx*2+1)*scale+dy), (0,0,0)) + +img.save("/tmp/qr.png") +``` + +**Alternative: User scans directly** — If the user has SSH terminal access, they can run `channels login` interactively and scan the QR from their terminal. + +### Anthropic Authentication + +Uses a Claude Code OAuth token (valid 1 year). Set in both config and docker-compose env: + +**Config (`~/.openclaw/openclaw.json`):** +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-oat01-..." + } +} +``` + +**Docker env (`~/openclaw/.env`):** +``` +ANTHROPIC_API_KEY=sk-ant-oat01-... +``` + +**To regenerate token (run on Mac where `claude` CLI is installed):** +```bash +claude setup-token +# Copy the output token and update both config + .env on NUC +``` + +### Config Schema (current) + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-5-20250929" + } + } + }, + "gateway": { + "port": 18789, + "mode": "local", + "bind": "lan", + "auth": { + "mode": "token" + } + } +} +``` + +**⚠️ Config gotchas:** +- `agent.model` is **legacy** — use `agents.defaults.model.primary` instead +- Run `docker exec openclaw-openclaw-gateway-1 node dist/index.js doctor --fix` to migrate legacy keys +- The `gateway.pairing` key does NOT exist — device pairing is managed via the `devices` CLI, not config + +### Troubleshooting + +1. **"control ui requires HTTPS or localhost"**: Access via `https://alezmad-nuc.tail58f5ad.ts.net:8443` (Tailscale Serve), NOT `http://192.168.1.3:18789` + +2. **"pairing required"**: Approve the device via `devices approve` command (see Device Pairing section above) + +3. **"unauthorized: gateway token missing"**: Use the dashboard URL with `#token=...` hash to auto-authenticate + +4. **CLI commands fail with "gateway closed"**: Use `docker exec` into the running gateway container instead of `docker compose run` (the CLI container can't reach the gateway on its internal Docker IP) + +5. **Config "invalid" after edit**: Run `doctor --fix` inside the gateway container to clean up + +6. **Channel "unsupported"**: Enable the plugin first with `plugins enable `, then restart gateway + +7. **CLAUDE_AI_SESSION_KEY warnings**: Harmless — these are for Claude web session auth which isn't used when using API key