Initial skills documentation — 25 categories, all SKILL.md + references + scripts

This commit is contained in:
root
2026-07-15 17:42:20 -04:00
commit 1b4fcd4427
976 changed files with 188344 additions and 0 deletions
@@ -0,0 +1,60 @@
# Debt Recovery Platform — Full Architecture
## Overview
Debt Recovery Experts (DRE) operates at debtrecoveryexperts.com. Contractors/owner-operators submit unpaid debt claims via the portal. DRE reviews, notarizes an LPOA, serves the debtor via certified mail, collects via ACH, deducts a fee, and disburses the balance.
## Technology Stack
| Component | Service | Status |
|---|---|---|
| **Signing** | DocuSeal (self-hosted, sign.itpropartner.com) | ✅ Live |
| **Notarization** | Proof (formerly Notarize) — $25-35/session | 📋 Planned |
| **Certified mail** | LetterStream — $8.34/letter | 📋 Planned |
| **Payments** | Stripe ACH (0.8%, capped $5) or checks | 📋 Planned |
| **AI Analysis** | Claude Opus 4.7 (via admin-ai) — document scoring | 📋 Planned |
| **Portal** | Caddy reverse proxy on Core (152.53.192.33) | 🟡 Mockup done |
## Domain Plan
| Service | URL | DNS |
|---|---|---|
| **Signing** | sign.itpropartner.com | SiteGround A record → 152.53.192.33 (Caddy HTTPS) |
| **DRE website** | debtrecoveryexperts.com | Cloudflare Zone |
| **DRE portal** | portal.debtrecoveryexperts.com | Cloudflare A record → 152.53.192.33 |
| **WordPress** | debtrecoveryexperts.com | wphost02 (RunCloud) |
## Cost Per Claim
- Notarization (RON): ~$25-35
- Identity verification: ~$4
- Certified mail: ~$8.34
- ACH processing: ~$5 max
- **Total: ~$42-52 per claim** (passed to debtor)
## AI Claim Analysis Flow
1. Customer uploads documents (contracts, invoices, correspondence)
2. Portal sends docs to Claude Opus 4.7 for analysis
3. Model scores: documentation quality, debtor solvency, TX legal standing, amount reasonableness
4. Public case records (TX) are compared — similar case count, avg recovery rate, median time
5. Internal DRE dashboard shows results — **never visible to customers**
## Legal Documents (Drafts)
- Terms of Service: `/root/TERMS_OF_SERVICE_DRAFT.md` ⚠️ Needs TX attorney review
- Privacy Policy: `/root/PRIVACY_POLICY_DRAFT.md` ⚠️ Needs TX attorney review
- Internal Compliance Manual: `/root/DRE_Compliance_Manual.md` ⚠️ Needs TX attorney review
## Texas Law Key Findings (Jul 7, 2026 Research)
| Topic | Finding |
|---|---|
| License/Bond | Likely required for third-party debt collectors in TX |
| LPOA | Must be in writing, notarized, specifically enumerate debt collection powers |
| Fee caps | No statutory cap in TX — set own %, must disclose in writing |
| SOL (written contracts) | 4 years — reject claims past SOL |
| Service of process | Certified mail (restricted delivery) is valid per TRCP 106(a)(2) |
| FDCPA | Full compliance required: validation notice within 5 days, Mini-Miranda, cease comms |
| Data breach | Must notify within 60 days per TX Bus. & Com. Code § 521 |
| Debtor calling | Prior express consent for autodial; manual dial only; 3-call-per-7-day cap |
@@ -0,0 +1,20 @@
Generated Jul 9, 2026 by research subagent. Full document at /root/.hermes/references/docker-management-research.md.
## Tools compared
| Tool | Multi-Host | REST API | Free | Web GUI |
|------|-----------|----------|------|---------|
| **Komodo** | ✅ Periphery Agent | ✅ REST + WS + 3 clients | ✅ 100% FOSS | ⭐⭐⭐⭐ |
| **Arcane** | ✅ Agent (Direct+Edge) | ✅ OpenAPI 3.1 | ✅ 100% FOSS | ⭐⭐⭐⭐ |
| **Portainer** | ✅ Agent | ✅ Full API | 3 nodes free | ⭐⭐⭐⭐⭐ |
| Dockge | ✅ Agents (v1.4+) | ❌ No API | ✅ FOSS | ⭐⭐⭐⭐ |
| Dockhand | ✅ Agent/TLS | ⏳ Roadmap | ✅ Homelab free | ⭐⭐⭐⭐⭐ |
| Lazydocker | ❌ Single host | ❌ | ✅ FOSS | ❌ TUI only |
## Recommendation
1. **Komodo** — best API story, GPL-3.0, unlimited servers, built-in backup/restore CLI
2. **Arcane** — BSD-3-Clause, beautiful UI, no MongoDB, Edge mode for NAT'd hosts
3. **Portainer** — most mature, best documented, 3-node free tier
**Ruled out:** Dockge (no API), Dockhand (BSL license, API is future), Lazydocker (single host).
@@ -0,0 +1,145 @@
# Docker Multi-Host Management Tools — Comparison
**Researched:** 2026-07-09
**Context:** IT Pro Partner multi-server Docker Compose environment (Core, ai.itpropartner.com, docker, unms, etc.). Need single-pane visibility across hosts, REST API for Hermes scripting, and self-hosted.
## Top 3 Recommendation
### 🏆 1st: **Komodo** — API-first multi-host Docker management
- **Architecture:** Core (web UI + API) + Periphery (agent on each host)
- **Why:** Best API story — REST + WebSocket + CLI + Rust/npm client libraries. Perfect for Hermes scripting. GPL-3.0, truly free, unlimited servers. Built-in backup/restore, webhooks, GitOps.
- **Trade-off:** Needs MongoDB (or FerretDB/Postgres). Heavier setup than alternatives.
- **Setup:** Docker Compose (Core + Mongo) + Python install script for Periphery agents
- **Docs:** https://komo.do/docs/intro
- **GitHub:** https://github.com/moghtech/komodo
### 🥈 2nd: **Arcane** — Modern, BSD-3-Clause, no-MongoDB option
- **Architecture:** Manager (Go backend + SvelteKit UI) + Agent (Direct or Edge mode)
- **Why:** 100% free BSD-3-Clause. Beautiful UI. OpenAPI 3.1 REST API. Edge mode handles NAT/firewall'd hosts (agents dial out). Built-in vulnerability scanning. iOS mobile app.
- **Trade-off:** Newer project (6.3k stars), smaller community than Portainer.
- **Setup:** Single Docker container for Manager, one per Agent
- **Docs:** https://getarcane.app/docs/setup/installation
- **GitHub:** https://github.com/getarcaneapp/arcane
### 🥉 3rd: **Portainer** — Most mature, best documented
- **Architecture:** Portainer Server + lightweight Go Agent on each host
- **Why:** Industry standard. Extensive docs. Free BE tier covers 3 nodes with full features. Comprehensive REST API. Agent auto-updates, mTLS.
- **Trade-off:** Beyond 3 nodes costs money. Heavier/fuller-featured than needed for Compose-only.
- **Setup:** `docker run` agent command on each host, add via UI
- **Docs:** https://docs.portainer.io/
- **GitHub:** https://github.com/portainer/portainer
## All Evaluated Tools
| Tool | Multi-Host | REST API | Free | Web GUI | License | Stars | Setup |
|---|---|---|---|---|---|---|---|
| **Portainer** | ✅ Agent | ✅ Full API | 3 nodes free | ⭐⭐⭐⭐⭐ | Proprietary/MIT | ~30k | Easy |
| **Arcane** | ✅ Agent (Direct+Edge) | ✅ OpenAPI 3.1 | ✅ 100% FOSS | ⭐⭐⭐⭐ | BSD-3-Clause | 6.3k | Easy |
| **Komodo** 🏆 | ✅ Periphery Agent | ✅ REST + WS + 3 libs | ✅ 100% FOSS | ⭐⭐⭐⭐ | GPL-3.0 | ~3k | Medium |
| **Dockge** | ✅ Agents (v1.4+) | ❌ No API | ✅ 100% FOSS | ⭐⭐⭐⭐ | MIT | 23.7k | Easy |
| **Dockhand** | ✅ Agent/TLS/Hawser | ⏳ Roadmap (distant) | ✅ Homelab free | ⭐⭐⭐⭐⭐ | BSL 1.1 | 5.1k | Easy |
| **Lazydocker** | ❌ Single host | ❌ No API | ✅ 100% FOSS | ❌ TUI only | MIT | 38.7k | Very Easy |
## Why the Others Don't Fit
- **Dockge:** No API, no webhooks, compose-only. Great for single-user compose management but can't be scripted by Hermes.
- **Dockhand:** No API (roadmap item, "distant future"). BSL license (not truly open source). Paid for commercial use. Beautiful UI but unusable for automation.
- **Lazydocker:** Single host only. Terminal-only. Good for quick SSH checks, not a management platform.
## Quick Deploy Commands
### Komodo (Correct v2 Env Vars — See references/komodo-deployment.md)
**CRITICAL:** Komodo v2 uses `KOMODO_DATABASE_ADDRESS` (not `MONGO_CONNECTION_STRING`), `KOMODO_PORT` (not `KOMODO_SERVER_PORT`). Using wrong env vars causes startup failure with _"Failed to initialize database"_ errors.
```yaml
# Core (management server) — full reference in references/komodo-deployment.md
services:
mongo:
image: mongo
restart: unless-stopped
command: --quiet --wiredTigerCacheSizeGB 0.25
volumes:
- ./data/mongo-data:/data/db
core:
image: ghcr.io/moghtech/komodo-core:2
init: true
restart: unless-stopped
ports:
- 9120:9120
depends_on:
- mongo
volumes:
- ./backups:/backups
- ./keys:/config/keys
environment:
- KOMODO_DATABASE_ADDRESS=mongo:27017
- KOMODO_PORT=9120
- KOMODO_HOST=https://komodo.itpropartner.com
- KOMODO_TITLE=Komodo
- KOMODO_LOCAL_AUTH=true
- KOMODO_INIT_ADMIN_USERNAME=admin
- KOMODO_INIT_ADMIN_PASSWORD=changeme
- KOMODO_FIRST_SERVER_NAME=core
- KOMODO_PERIPHERY_PUBLIC_KEY=file:/config/keys/periphery.pub
- KOMODO_DISABLE_CONFIRM_DIALOG=false
- KOMODO_DISABLE_INIT_RESOURCES=false
- KOMODO_JWT_SECRET=change-me
- KOMODO_JWT_TTL=1-day
- KOMODO_MONITORING_INTERVAL=15-sec
- KOMODO_RESOURCE_POLL_INTERVAL=1-hr
```
```bash
# Periphery (each managed host)
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py \
| python3 - \
--core-address="https://komodo.itpropartner.com" \
--connect-as="$(hostname)" \
--onboarding-key="O-..."
```
### Arcane
```bash
# Manager
docker run -d --name arcane -p 3552:3552 \
-v arcane_data:/data \
ghcr.io/getarcaneapp/manager:latest
# Agent (each remote host)
docker run -d --name arcane-agent -p 3553:3553 \
-v /var/run/docker.sock:/var/run/docker.sock \
-e AGENT_MODE=true \
-e AGENT_TOKEN=<token> \
-e MANAGER_API_URL=http://manager:3552 \
ghcr.io/getarcaneapp/agent:latest
```
### Portainer
```bash
# Agent on each remote host
docker run -d -p 9001:9001 --name portainer_agent --restart=always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/lib/docker/volumes:/var/lib/docker/volumes \
portainer/agent:latest
```
## Key Links
| Tool | Website | Docs | GitHub |
|---|---|---|---|
| Portainer | https://www.portainer.io/ | https://docs.portainer.io/ | https://github.com/portainer/portainer |
| Arcane | https://getarcane.app/ | https://getarcane.app/docs/setup/installation | https://github.com/getarcaneapp/arcane |
| Komodo | https://komo.do/ | https://komo.do/docs/intro | https://github.com/moghtech/komodo |
| Dockge | https://github.com/louislam/dockge | README only | https://github.com/louislam/dockge |
| Dockhand | https://dockhand.pro/ | https://dockhand.pro/manual/ | https://github.com/Finsys/dockhand |
| Lazydocker | https://lazydocker.com/ | README only | https://github.com/jesseduffield/lazydocker |
## Multi-Host Agent Architecture Patterns
All multi-host tools use the same pattern: a central management server + lightweight agents on each Docker host.
| Pattern | Used By | How It Works |
|---|---|---|
| **Agent polls out** | Arcane Edge, Dockhand Hawser | Agent behind NAT dials out to manager. No inbound port needed. |
| **Agent listens** | Portainer Agent, Arcane Direct | Manager connects to agent on a TCP port. Requires inbound access. |
| **Systemd agent** | Komodo Periphery | Agent runs as systemd service (or Docker). Bi-directional WebSocket to Core. |
@@ -0,0 +1,75 @@
# Docker Volume Migration Pitfalls
Common failures when moving Docker services between hosts.
## Permission denied on data volume (n8n, Jul 10 2026)
**Symptom:** Container starts but immediately crashes with `EACCES: permission denied, open '/home/node/.n8n/crash.journal'`
**Root cause:** Docker named volumes copied from host A (`scp` the `_data` directory) carry the UID/GID from host A's filesystem. The container on host B runs as a different UID (n8n uses UID 1000, node user). The files are owned by `root` or `ippadmin` from the copy operation, not UID 1000.
**Fix:**
```bash
VOL=$(docker volume inspect <volume_name> --format "{{.Mountpoint}}")
chown -R 1000:1000 "$VOL"
docker compose restart <service>
```
**For n8n specifically:** UID 1000 is the `node` user inside the container. Both `n8n_data` and `postgres_data` volumes may need this fix.
**Prevention:** After copying volume data to a new host, always check ownership before starting containers:
```bash
ls -la /var/lib/docker/volumes/<volume>/_data/ | head -5
```
## Volume data owned by wrong user (general)
Any Docker container that writes to a bind-mounted or named volume will have files owned by the container's internal UID. When copying between hosts:
1. Identify the container's UID: `docker run --rm <image> id`
2. Apply: `chown -R <UID>:<UID> <volume_path>`
3. Then start the container
## SSL redirect loop after migration (n8n, Jul 10 2026)
**Symptom:** n8n returns 308 redirect loop when accessed through Caddy with HTTPS
**Root cause:** n8n's `N8N_PROTOCOL` is set to `https` but Caddy terminates SSL. n8n sees the request came via HTTP (from Caddy's proxy) and redirects to HTTPS, creating an infinite loop.
**Fix:** Set `N8N_PROTOCOL=http` in `.env` — Caddy handles SSL termination, n8n should trust the proxy.
## Caddy `handle` fallback overrides path routes (Jul 10, 2026)
**Symptom:** Browser shows "app1 — /n8n/ /webui/ /litellm/" instead of the app.
**Root cause:** A `handle { respond "..." 200 }` at the bottom of the Caddyfile catches ALL requests — including `/n8n/signin` — because Caddy evaluates `handle` blocks before `handle_path` directives.
**Fix:** Replace the `handle { respond ... }` with a bare `reverse_proxy` as the catch-all. Path-specific `handle_path` routes take priority over the catch-all reverse_proxy, but NOT over a catch-all `handle` block.
**Lesson:** Only use `handle` blocks for specific path prefixes. For a true catch-all, use `reverse_proxy` directly without wrapping in `handle`. Never put `handle { respond }` as a fallback when you also have `handle_path` routes — it overrides them.
**Symptom:** `app1.itpropartner.com/n8n/` shows the app but all assets (JS, CSS, images) 404
**Root cause:** `handle_path /n8n/*` correctly strips the prefix before proxying, so n8n sees `/signin` instead of `/n8n/signin`. But n8n's HTML references assets at `/assets/...`, `/static/...` — these paths DON'T match `/n8n/*` and fall through to the default handler.
**Fix:** Give the service its own subdomain instead of a sub-path. Path-based routing only works for apps that support a `baseUrl` config option. n8n does not.
**Lesson:** Use sub-domain routing (`n8n.itpropartner.com`) for apps without baseUrl support, sub-path routing (`domain.com/app/`) only for apps that support it.
## Let's Encrypt validation blocked by UFW / DNS (Jul 10, 2026)
**Symptom:** Caddy log shows `authorization failed: HTTP 400 ... Timeout during connect (likely firewall problem)`. Cert never issues.
**Root cause:** UFW default deny blocks HTTP-01 challenge from Let's Encrypt's servers, OR DNS still points to wrong IP (stale SiteGround records during Cloudflare migration).
**Fix:** (1) Ensure `ufw allow 80/tcp` and `ufw allow 443/tcp` on the target server. (2) Verify DNS resolves to the correct IP from outside the server: `ping app1.itpropartner.com`. Core's DNS may be stale while your laptop's is fresh — test from an external source.
## n8n Caddy deployment checklist (Jul 10, 2026)
When deploying n8n behind Caddy on a new host:
1. `N8N_PROTOCOL=http` — Caddy terminates SSL
2. `N8N_HOST=<domain>` — match the public domain
3. `WEBHOOK_URL=https://<domain>/` — for webhook callbacks
4. Caddy: `reverse_proxy 127.0.0.1:5678 { flush_interval -1 }` — WebSocket support
5. Caddy: `header { X-Forwarded-Proto https; X-Forwarded-Host {host} }` — proxy headers
6. UFW: allow 80/tcp + 443/tcp for Let's Encrypt validation
7. DNS must resolve to this server's IP BEFORE starting Caddy (otherwise cert issuance blocks on first request)
@@ -0,0 +1,47 @@
# DocuSeal Deployment on Core
Deployed Jul 7, 2026 via Docker on Core (netcup, 152.53.192.33).
## Docker Compose
```yaml
services:
docuseal:
image: docuseal/docuseal:latest
container_name: docuseal
restart: always
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./data:/data
environment:
- HOST=http://localhost:3000
- FORCE_SSL=false
```
Location: `/root/docker/docuseal/docker-compose.yml`
Data: `/root/docker/docuseal/data/`
## HTTPS
Caddy reverse proxy at https://sign.itpropartner.com → 127.0.0.1:3000
Caddy config at `/etc/caddy/Caddyfile`:
```
sign.itpropartner.com {
reverse_proxy 127.0.0.1:3000
}
```
## API
Token: created in DocuSeal UI (Settings → API Tokens). Linked to info@itpropartner.com.
Test: `curl -s -H "X-Auth-Token: <token>" https://sign.itpropartner.com/api/user`
## Key decisions
- DocuSeal chosen over Documenso for this use case (AGPL, free embedding SDKs, Docker image 227MB, SQLite, no PostgreSQL needed)
- Both self-hosted options are ESIGN Act / UETA compliant
- Documenso would require ~$300/yr for embedding SDKs and PostgreSQL — overkill for this scale
- Dual use: family via web UI, DRE portal via API
@@ -0,0 +1,38 @@
# DRE Fee Structure
## Proposed — July 2026
## For discussion with Tony (DRE business partner)
### Standard Fee Schedule
**Tier 1 — Soft Touch** (automated email + payment link)
| Claim Amount | DRE Fee | Client Receives |
|---|---|---|
| $1,000 $5,000 | 25% (min $250) | 75% |
| $5,000 $15,000 | 22% | 78% |
| $15,000+ | 20% | 80% |
**Tier 2 — Formal Demand** (certified mail)
- 30% flat across all amounts
**Tier 3 — Escalation** (final notice + intensive contact)
- 33% flat across all amounts
**Tier 4 — Legal Action** (referral to partner law firm)
- DRE: 10% referral fee
- Law firm: 25% litigation fee
- Client: 65%
### Example: $15,000 claim
| Scenario | DRE | Client | Timeline |
|---|---|---|---|
| Paid at Tier 1 | $3,300 (22%) | $11,700 | 2-14 days |
| Paid at Tier 2 | $4,500 (30%) | $10,500 | 15-30 days |
| Paid at Tier 3 | $4,950 (33%) | $10,050 | 30-60 days |
| Resolved at Tier 4 | $1,500 (10%) + $3,750 (25% firm) | $9,750 | 60-180 days |
### Out-of-pocket costs (passed to debtor or deducted)
- Online notarization (LPOA): ~$25-35
- Identity verification: ~$4
- Certified mail: ~$8.34 per letter
- Court filing fees: ~$250-400 (Tier 4 only)
@@ -0,0 +1,170 @@
# Komodo Deployment — Reference
**Installed:** 2026-07-09 on Core (netcup RS 2000)
**Version:** Komodo v2.2.0
**Purpose:** Multi-host Docker management across ITPP servers (Core, ai, docker, unms, etc.)
**Architecture:** Core (web UI + API server, port 9120) + MongoDB → Periphery agents on each managed host
## Directory Structure
```
/root/docker/komodo/
├── docker-compose.yml # Core + MongoDB
├── data/
│ ├── mongo-data/ # MongoDB persistent storage
│ └── mongo-config/ # MongoDB config
├── backups/ # Database backups
└── keys/
├── core.key # Core private key (auto-generated)
└── core.pub # Core public key (auto-generated)
```
## Docker Compose (Core)
```yaml
services:
mongo:
image: mongo
restart: unless-stopped
command: --quiet --wiredTigerCacheSizeGB 0.25
volumes:
- ./data/mongo-data:/data/db
- ./data/mongo-config:/data/configdb
networks:
- komodo-net
deploy:
resources:
limits:
memory: 1G
core:
image: ghcr.io/moghtech/komodo-core:2
init: true
restart: unless-stopped
ports:
- 9120:9120
depends_on:
- mongo
volumes:
- ./backups:/backups
- ./keys:/config/keys
environment:
# Database
- KOMODO_DATABASE_ADDRESS=mongo:27017
# Server
- KOMODO_PORT=9120
- KOMODO_HOST=https://komodo.itpropartner.com
- KOMODO_TITLE=Komodo
# Auth
- KOMODO_LOCAL_AUTH=true
- KOMODO_INIT_ADMIN_USERNAME=admin
- KOMODO_INIT_ADMIN_PASSWORD=changeme
# First server (this host)
- KOMODO_FIRST_SERVER_NAME=core
# Periphery auth
- KOMODO_PERIPHERY_PUBLIC_KEY=file:/config/keys/periphery.pub
# Secrets
- KOMODO_WEBHOOK_SECRET=komodo-webhook-secret
- KOMODO_JWT_SECRET=komodo-jwt-secret-change-me
- KOMODO_JWT_TTL=1-day
# Monitoring
- KOMODO_MONITORING_INTERVAL=15-sec
- KOMODO_RESOURCE_POLL_INTERVAL=1-hr
# UI
- KOMODO_DISABLE_CONFIRM_DIALOG=false
- KOMODO_TRANSPARENT_MODE=false
networks:
- komodo-net
deploy:
resources:
limits:
memory: 512M
networks:
komodo-net:
driver: bridge
```
## Critical Env Variable Pitfalls (V2)
- **Do NOT use `MONGO_CONNECTION_STRING`** — Komodo v2 uses `KOMODO_DATABASE_ADDRESS=mongo:27017`
- **Do NOT use `KOMODO_SERVER_PORT`** — use `KOMODO_PORT=9120`
- **Do NOT use `KOMODO_SERVER_HOST`** — use `KOMODO_BIND_IP=[::]` (default)
- **`KOMODO_PERIPHERY_PUBLIC_KEY`** must be set with `file:/config/keys/periphery.pub` for auto-generation
- **`init: true`** is essential on the core container (handles PID 1 zombie reaping properly)
- **`KOMODO_HOST` must be set** to the URL users will access (used for OAuth redirects, webhook suggestions)
## Startup Log Verification
Successful startup shows:
```
Server starting on http://[::]:9120
Creating init admin user...
Successfully created init admin user.
Creating initial system resources...
```
The admin user is created on first launch only. Core v2.2.0 auto-creates starting resources: "Backup Core Database" procedure and "Global Auto Update" procedure.
## Admin Login (API)
```bash
# Get JWT token
curl -s -X POST http://127.0.0.1:9120/auth/login \
-H "Content-Type: application/json" \
-d '{"type":"Local","username":"admin","password":"changeme"}'
```
Note: the login body requires `"type":"Local"` — missing this field returns: _"Failed to deserialize the JSON body into the target type: missing field `type`"_
## Onboarding Key
The onboarding key is available in the Komodo web UI after login:
1. Go to the **Servers** page
2. Click **Add Server / Onboard**
3. Copy the onboarding key (starts with `O-`)
Or retrieve via the API with a valid JWT:
```bash
curl -s http://127.0.0.1:9120/api/core -H "Authorization: Bearer <token>"
# Extract onboarding_key from response
```
## Periphery Installation (on each managed host)
```bash
# Via systemd script (recommended for production)
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py \
| python3 - \
--core-address="https://komodo.itpropartner.com" \
--connect-as="$(hostname)" \
--onboarding-key="O-..."
```
The Periphery agent communicates with Core via WebSocket. It listens on port 8120 by default. The agent runs as a systemd service when installed via the Python script.
## Docker Periphery (alternative, for containers)
```yaml
services:
periphery:
image: ghcr.io/moghtech/komodo-periphery:2
init: true
restart: unless-stopped
depends_on:
- core
env_file: ./compose.env
volumes:
- keys:/config/keys
- /var/run/docker.sock:/var/run/docker.sock
- /proc:/proc
- ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}:${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}
```
## Public Key Authentication
Core uses Noise protocol key pairs for Periphery authentication.
- Core keys auto-generated at: `/config/keys/core.key` and `/config/keys/core.pub`
- Periphery public key expected at: `/config/keys/periphery.pub` (auto-generated if `KOMODO_PERIPHERY_PUBLIC_KEY` is set and file doesn't exist)
- Example public key: `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAo+xXlRx+KcxbFkscAx3JcmGKudDwoID3AkGnzjFb1AE=\n-----END PUBLIC KEY-----`
@@ -0,0 +1,66 @@
# LiteLLM Config Precedence Pitfalls
## Master Key Config Override (Jul 2026)
### The Problem
LiteLLM reads `general_settings.master_key` from config.yaml when started with `--config /app/config.yaml`. This value takes precedence over the `LITELLM_MASTER_KEY` environment variable. If config.yaml has a stale/stub key, the stub silently becomes the UI login password.
### How It Manifests
- Admin UI login at `/v2/login` returns `401 Unauthorized`
- Error: `"Invalid credentials used to access UI. Check 'UI_USERNAME', 'UI_PASSWORD' in .env file"`
- Container logs: `ProxyException` at `login_utils.py` line 316 in `authenticate_user()`
- The `/v1/chat/completions` endpoint may still work normally (API keys are separate from UI password)
- User knows the correct master key (e.g., from `.env`) but login still fails
### Root Cause
In `login_utils.py`, the `authenticate_user()` function:
1. Calls `get_ui_credentials(master_key)` which reads `UI_USERNAME` (default "admin") and `UI_PASSWORD` (env var or `master_key` parameter)
2. The `master_key` parameter comes from LiteLLM's internal state, which loaded it from `general_settings.master_key` in config.yaml
3. Compares the login form password against this config-file-derived key using `secrets.compare_digest()`
4. If the comparison fails and no database user matches, throws the 401 error
### Detection Workflow
```bash
# 1. Check container logs for login failures
docker logs litellm --tail 50 2>&1 | grep -A 5 'login_v2\|401 Unauthorized'
# 2. Compare config sources by hash and length (NOT raw value)
ENV_KEY=$(grep LITELLM_MASTER_KEY .env | cut -d= -f2)
CFG_KEY=$(grep 'master_key:' config.yaml | sed 's/.*master_key: //')
echo "env: len=${#ENV_KEY} sha=$(echo -n "$ENV_KEY" | sha256sum | cut -c1-16)"
echo "cfg: len=${#CFG_KEY} sha=$(echo -n "$CFG_KEY" | sha256sum | cut -c1-16)"
# 3. Verify what the container actually sees
docker exec litellm printenv | grep LITELLM_MASTER_KEY
# 4. If lengths/hashes differ → config.yaml has a stale key
# 5. Fix by removing master_key from config.yaml
sed -i '/^ master_key:/d' config.yaml
docker compose restart litellm
# 6. Verify fix with a direct login test
MASTER_KEY=$(grep LITELLM_MASTER_KEY .env | cut -d= -f2)
curl -s -X POST http://127.0.0.1:4000/v2/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$MASTER_KEY\"}"
# Expected: {"user_id":"...","key":"sk-...","user_role":"proxy_admin",...}
```
### Why API Still Works While Login Fails
LiteLLM has two separate auth paths:
- **API key auth** (`/v1/chat/completions`): Validates keys stored in `LiteLLM_VerificationToken` table, independent of the master_key
- **UI login** (`/v2/login`): Compares against the in-memory `master_key` which came from config.yaml
This is why a config.yaml with a stale key breaks UI login but leaves API traffic unaffected.
### Prevention
1. **Remove `master_key` from config.yaml** — rely on `LITELLM_MASTER_KEY` env var only
2. **Audit dual-source configs** — any service with both config file AND env var for the same setting risks this category of mismatch
3. **Use hash comparison for credentials** — never log or transmit raw secret values; compare by hash to detect mismatches without exposing secrets
@@ -0,0 +1,74 @@
# LiteLLM env-key migration: encrypted DB rows after host move
When migrating LiteLLM with `store_model_in_db: true`, `/v1/models` can return a healthy-looking catalog while `/chat/completions` fails or hangs. If logs show:
```text
Error decrypting value for key: api_key
Did your master_key/salt key change recently?
nacl.exceptions.CryptoError: Decryption failed
```
then the migrated Postgres DB contains encrypted model/provider params that the new container cannot decrypt.
## Safe diagnosis
Proceed read-only first. Do not print raw keys.
1. Compare old and new container env fingerprints:
- `LITELLM_MASTER_KEY`
- `LITELLM_SALT_KEY`
- `DATABASE_URL`
- `STORE_MODEL_IN_DB`
2. Compare by `sha256:<first16> len:<n>` only.
3. Check model/credential/token table counts:
- `LiteLLM_ProxyModelTable`
- `LiteLLM_CredentialsTable`
- `LiteLLM_VerificationToken`
4. Confirm public `/v1/models` and local `/health/readiness` separately from completions.
## Minimal fix when fingerprints differ
Scope the change to the new host only.
1. Backup the new host `.env` first:
```bash
cp /root/docker/litellm/.env /root/docker/litellm/.env.pre-keyfix-$(date -u +%Y%m%d-%H%M%S)
chmod 600 /root/docker/litellm/.env.pre-keyfix-*
```
2. Copy only these values from the old working host into the new host `.env`:
- `LITELLM_MASTER_KEY`
- `LITELLM_SALT_KEY`
3. Verify the new host `.env` fingerprints match old.
4. **Recreate the LiteLLM container; restart is not enough.** Docker container env is baked at create time:
```bash
cd /root/docker/litellm
docker compose --env-file .env up -d --force-recreate --no-deps litellm
```
5. Verify the recreated container env fingerprints, not just the file.
## Verification
Run public HTTPS completion probes after readiness passes:
```bash
curl -sS https://admin-ai.example.com/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-chat","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":128}'
```
Verify at least:
- `deepseek-v4-pro`
- `deepseek-chat`
- `gemini-pro-latest`
- `gemini-flash-latest`
Use enough `max_tokens` for reasoning models. A too-small token budget can produce blank `content` even though the call succeeded.
## Interpretation
If non-OpenAI models work but `gpt-5.5` returns `429 insufficient_quota`, the migration/encryption issue is fixed; the remaining problem is upstream OpenAI billing/quota.
## User handling standard
For Germaine, migrations touching env/secrets/service restarts must be handled as: read-only compare first, report fingerprints and exact planned minimal change, get approval, change one scoped item, verify, then proceed. Do not bundle extra config changes or unrelated cleanup.
@@ -0,0 +1,32 @@
# LiteLLM DB Migration Pitfall — Encrypted Models Don't Survive pg_dump
**Date:** Jul 13, 2026
**Root cause:** LiteLLM encrypts model parameters (api_key, model name, provider) in `LiteLLM_ProxyModelTable` using the `LITELLM_SALT_KEY`. A `pg_dump` + `pg_restore` to a new server will list the models in `/v1/models` but completions fail with "Invalid model name passed."
**Symptom:** Same Docker image (v1.84.0), same SALT_KEY, same DB dump. Models appear in the listing but every completion returns `400: Invalid model name passed in model=X. Call /v1/models to view available models for your key.`
**Why:** The encryption uses not just SALT_KEY but also some per-installation factor (possibly container hostname, Docker network IP, or a runtime-generated nonce) that differs between hosts. Identical SALT_KEY, identical DB, identical image — still can't decrypt.
**Fix:** Do NOT rely on DB migration for models. Instead:
1. Export the list of model names from the old box
2. Recreate each model fresh on the new box via the `/model/new` endpoint
3. Test each model with a completion
```bash
# List models on old box
curl -s https://admin-ai.itpropartner.com/v1/models -H "Authorization: Bearer $KEY" | jq -r '.data[].id' > models.txt
# Recreate on new box
while read model; do
curl -s http://127.0.0.1:4000/model/new -X POST \
-H "Authorization: Bearer $MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model_name\":\"$model\",\"litellm_params\":{\"model\":\"$model\",\"custom_llm_provider\":\"openai\",\"api_base\":\"https://admin-ai.itpropartner.com/v1\",\"api_key\":\"$ADMIN_AI_KEY\"}}"
done < models.txt
```
**Alternative:** If migrating TO a self-hosted LiteLLM that replaces the old admin-ai proxy, configure models to use OpenRouter directly instead of routing through admin-ai. This avoids the proxy-in-a-loop problem post-DNS-cutover.
**Credentials table survives** — The `LiteLLM_CredentialsTable` decrypts fine after migration. Only `LiteLLM_ProxyModelTable` entries fail.
**Post-migration config.yaml requirement:** The new LiteLLM needs a `config.yaml` with `database_url`, `store_model_in_db: true`, and `enforce_database: true`. Without it, models created via API are lost on restart.
@@ -0,0 +1,43 @@
# n8n Migration: WEBHOOK_URL + Workflow Webhook Fix
When migrating n8n from one domain to another, two things MUST be updated:
## 1. Environment Variable — .env
```
WEBHOOK_URL=https://NEW_DOMAIN/
```
This controls the base URL for all webhook nodes. Restart n8n after changing.
## 2. Existing Workflow Webhook URLs — PostgreSQL
n8n stores webhook URLs in the `nodes` JSON column of `workflow_entity`. After changing `WEBHOOK_URL`, existing workflows still have old URLs baked in. Update them directly:
```sql
UPDATE workflow_entity
SET nodes = regexp_replace(
nodes::text,
'https://old\.domain\.com',
'https://new.domain.com',
'g'
)::json;
```
Then restart n8n to reload workflows from the database.
## Full Migration Steps
1. `docker compose down` on old server
2. `tar czf n8n-data.tar.gz` Postgres volume + n8n data volume
3. `scp` to new server
4. Update `.env`: `N8N_HOST`, `N8N_PROTOCOL`, `WEBHOOK_URL` to new domain
5. `docker compose up -d` on new server
6. Fix volume permissions: `chown -R 1000:1000` on n8n data volume (node user inside container = UID 1000)
7. Update workflow webhook URLs via SQL above
8. Restart n8n: `docker compose restart n8n`
9. Verify: `SELECT nodes::text LIKE '%old.domain.com%' FROM workflow_entity WHERE name LIKE '%workflow_name%';` → should return `f`
## Common Pitfall
Sub-path routing (e.g., `app1.domain.com/n8n/`) breaks n8n because its SPA uses absolute asset paths (`/assets/`, `/static/`). Always use a dedicated subdomain (`n8n.domain.com`).
@@ -0,0 +1,87 @@
# Open WebUI ConfigVar Cheat Sheet
Open WebUI persists connection settings in `webui.db``config` table as `ConfigVar` values. After first launch, env vars are **ignored** — the DB is authoritative. Changing `-e OPENAI_API_BASE_URL=...` and restarting does nothing.
## Config Table Schema
The `config` table is key-value with JSON values:
```sql
SELECT key, value FROM config WHERE key LIKE '%ollama%' OR key LIKE '%openai%';
```
## Connection Keys (as of Jul 2026)
### Ollama Connection
| Key | Type | Example |
|-----|------|---------|
| `ollama.enable` | bool | `true` / `false` |
| `ollama.base_urls` | JSON array | `["http://ollama:11434"]` |
| `ollama.api_configs` | JSON object | `{"0": {"enable": true, "tags": [], "prefix_id": "", "model_ids": [], "connection_type": "local", "auth_type": "bearer", "key": ""}}` |
### OpenAI Connection (used for admin-ai / LiteLLM)
| Key | Type | Example |
|-----|------|---------|
| `openai.enable` | bool | `true` / `false` |
| `openai.api_base_urls` | JSON array | `["https://admin-ai.itpropartner.com/v1"]` |
| `openai.api_keys` | JSON array | `["sk-lg_..."]` |
| `openai.api_configs` | JSON object | `{"0": {"enable": true, "tags": [], "prefix_id": "", "model_ids": [], "connection_type": "external", "auth_type": "bearer"}}` |
### RAG / Embeddings
| Key | Type | Example |
|-----|------|---------|
| `rag.ollama.base_url` | string | `"http://host.docker.internal:11434"` |
| `rag.ollama.api_key` | string | `""` |
| `rag.openai.api_base_url` | string | `"http://litellm:4000/v1"` |
| `rag.openai.api_key` | string | `"sk-..."` |
### Audio (TTS/STT)
| Key | Type | Example |
|-----|------|---------|
| `audio.tts.openai.api_base_url` | string | `"http://litellm:4000/v1"` |
| `audio.tts.openai.api_key` | string | `"sk-..."` |
| `audio.stt.openai.api_base_url` | string | `"http://litellm:4000/v1"` |
| `audio.stt.openai.api_key` | string | `"sk-..."` |
### Image Generation
| Key | Type | Example |
|-----|------|---------|
| `image_generation.openai.api_base_url` | string | `"http://127.0.0.0:4000/v1"` |
| `image_generation.openai.api_key` | string | `"sk-..."` |
## Common Operations
### Disable Ollama (stop pointing to Ollama)
```bash
sqlite3 /var/lib/docker/volumes/openwebui_data/_data/webui.db \
"UPDATE config SET value = 'false' WHERE key = 'ollama.enable';"
sqlite3 /var/lib/docker/volumes/openwebui_data/_data/webui.db \
"UPDATE config SET value = json_set(value, '\$.\"0\".enable', json('false')) WHERE key = 'ollama.api_configs';"
docker restart openwebui
```
### Change OpenAI base URL (switch admin-ai endpoint)
```bash
sqlite3 /var/lib/docker/volumes/openwebui_data/_data/webui.db \
"UPDATE config SET value = json_set(value, '\$[0]', 'https://new-url/v1') WHERE key = 'openai.api_base_urls';"
docker restart openwebui
```
### Verify changes took effect
```bash
sqlite3 /var/lib/docker/volumes/openwebui_data/_data/webui.db \
"SELECT key, value FROM config WHERE key IN ('ollama.enable', 'openai.api_base_urls', 'ollama.api_configs');"
```
### Full audit — dump all connection-related config
```bash
sqlite3 /var/lib/docker/volumes/openwebui_data/_data/webui.db \
"SELECT key, value FROM config WHERE key LIKE '%ollama%' OR key LIKE '%openai%' ORDER BY key;"
```
## Pitfalls
- **`OLLAMA_BASE_URL=/ollama` is baked into the image** — omitting the env var doesn't clear it; the image default applies. After first launch, it's persisted in `ollama.base_urls` in the DB.
- **`ENABLE_PERSISTENT_CONFIG=false` disables ALL UI settings persistence** — use only for one-off fixes, not as permanent config.
- **The container image doesn't include `sqlite3`** — install on the host: `apt-get install -y sqlite3`.
- **After DB changes, `docker restart` is required** — the config is read at startup.
- **JSON path syntax is strict** — `\$.\"0\".enable` for object keys that start with digits (SQLite's `json_set` requires double-quoted keys for numeric-looking keys).
@@ -0,0 +1,44 @@
# SearXNG Self-Hosted Search on Core
Deployed Jul 7, 2026 via Docker on Core. Port 8888 (127.0.0.1 only).
## Docker Compose
```yaml
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: always
ports:
- "127.0.0.1:8888:8080"
volumes:
- ./searxng-data:/etc/searxng:rw
- ./searxng-themes:/usr/local/searxng/searx/static/themes:rw
environment:
- SEARXNG_BASE_URL=http://localhost:8080
- SEARXNG_SECRET_KEY=<auto-generated>
```
Location: `/root/docker/searxng/docker-compose.yml`
Settings: `/root/docker/searxng/searxng-data/settings.yml` (added `search.formats: [html, json]` to enable JSON API)
## Usage
Search via JSON API:
```
curl -s "http://127.0.0.1:8888/search?q=query&format=json"
```
## Motivation
- Hermes web tools (web_search, web_extract) require Firecrawl API key — not yet configured
- SearXNG provides unlimited, self-hosted search as fallback
- Used via terminal curl in Hermes — not as seamless as native tools but free and unlimited
## Relation to Firecrawl
SearXNG + Firecrawl complement each other:
- SearXNG: unlimited search (finding URLs)
- Firecrawl: page content extraction (getting text from URLs)
- With both, Firecrawl only handles extraction — free tier lasts much longer
@@ -0,0 +1,42 @@
# SearXNG — Self-Hosted Search Engine
Deployed on **Core** (`/root/docker/searxng/`) via Docker Compose. Used as a local search engine when Firecrawl's API key is not configured.
## Connection details
- **URL:** `http://127.0.0.1:8888`
- **JSON API:** `http://127.0.0.1:8888/search?q=QUERY&format=json`
- **Container:** `searxng`
- **Auto-start:** Docker restart policy
- **Settings:** `/root/docker/searxng/searxng-data/settings.yml`
## Configuration
The settings.yml file enables both HTML and JSON formats:
```yaml
search:
formats:
- html
- json
```
## Usage from agent
```bash
curl -s "http://127.0.0.1:8888/search?q=QUERY&format=json" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for r in d.get('results',[])[:N]:
print(f'{r.get(\"title\",\"?\")} - {r.get(\"url\",\"?\")}')
"
```
## Limitations
- **No extraction.** SearXNG returns search results (title + URL + snippet) but does not extract full page content. For full page extraction, use Firecrawl or `curl` + HTML stripping.
- **Local only.** Bound to 127.0.0.1 — not accessible from the network. Access via terminal on Core.
- **Rate limited.** Default rate limit is 120 requests/hour from a single IP.
## Relationship to Firecrawl
| Capability | SearXNG | Firecrawl |
|---|---|---|
| Search (find links) | ✅ Unlimited | ⚠️ 500/mo free |
| Extract (get page content) | ❌ | ✅ Clean markdown |
| API key needed | No | Yes |
| Self-hosted | Yes | No (cloud) |
@@ -0,0 +1,41 @@
# Service Health Check (no_agent watchdog)
A reusable pattern for no_agent cron jobs that monitor ALL critical services and only report on failure.
## Design
```bash
#!/bin/bash
# silent on success, report failures only
CHECKS=()
FAILED=0
# Each check:
command || { CHECKS+=("Service Name"); FAILED=1; }
if [ $FAILED -eq 1 ]; then
echo "⚠️ Service Health Alert — $(date)"
for c in "${CHECKS[@]}"; do echo "$c"; done
exit 1
fi
exit 0
```
## What to check
- Systemd services (caddy, mysql-tunnel)
- Docker containers (docker ps --format)
- Local HTTP endpoints (curl -sf)
- Remote reachability (ping through tunnel, SSH)
- Database connectivity (mysql -e "SELECT 1")
- API token validity (curl + verify response)
## Pitfalls
- **Must be no_agent: true** in cron config — LLM processing is wasted on binary health checks and causes typing indicators
- **Include the exit code** — exit 0 = silent, exit 1 = output delivered. Without the right exit code, cron can't tell success from failure
- **Test with `bash script.sh` and check `echo $?`** before deploying
- **MySQL checks need `--skip-ssl`** for local tunnel connections
- **Don't check too many things** — 10-12 checks is the sweet spot. More than 15 and the script gets brittle
- **SSH checks need `-o BatchMode=yes -o ConnectTimeout=5`** to avoid hanging on unreachable hosts
@@ -0,0 +1,127 @@
# TwentyCRM Docker Compose Deployment
**Channel:** Core VPS, served at https://crm.debtrecoveryexperts.com
**Port:** 3001 (DocuSeal occupies 3000)
**Image:** `twentycrm/twenty:latest` (v2.20.0 pre-release as of 2026-07-07)
**Stack dir:** `~/docker/twenty/`
## Services (4 containers)
| Container | Image | Purpose | Depends on |
|---|---|---|---|
| `twenty-server-1` | `twentycrm/twenty:latest` | NestJS API + frontend SPA on port 3000 | db (healthy), redis (healthy) |
| `twenty-worker-1` | `twentycrm/twenty:latest` | Background job processor (BullMQ) | db, server (healthy) |
| `twenty-db-1` | `postgres:16` | PostgreSQL database | — |
| `twenty-redis-1` | `redis` (noeviction policy) | Queue + cache | — |
## docker-compose.yml
Located at `~/docker/twenty/docker-compose.yml`. Based on upstream's `packages/twenty-docker/docker-compose.yml` with changes:
- **Host port 3000 → 3001** to avoid conflict with DocuSeal.
- **Added `REACT_APP_SERVER_BASE_URL: ${SERVER_URL}`** to server container environment.
## .env
Based on `packages/twenty-docker/.env.example`. Key values:
```
TAG=latest
PG_DATABASE_USER=postgres
PG_DATABASE_PASSWORD=<generated>
PG_DATABASE_HOST=db
PG_DATABASE_PORT=5432
PG_DATABASE_NAME=default
REDIS_URL=redis://redis:6379
SERVER_URL=https://crm.debtrecoveryexperts.com
ENCRYPTION_KEY=<openssl rand -base64 32>
STORAGE_TYPE=local
```
**CRITICAL:** `SERVER_URL` must match the public domain exactly. Setting it to `http://localhost:3000` or `http://localhost:3001` will cause API calls to fail from the browser because the frontend uses `SERVER_URL` (via `REACT_APP_SERVER_BASE_URL`) for GraphQL requests.
### Generate the encryption key
```bash
openssl rand -base64 32
```
**Warning:** Losing `ENCRYPTION_KEY` means losing access to every secret stored in the database (OAuth tokens, TOTP secrets, etc.). Back it up.
## Initial deployment
```bash
git clone https://github.com/twentyhq/twenty.git ~/docker/twenty
cp ~/docker/twenty/packages/twenty-docker/docker-compose.yml ~/docker/twenty/
cp ~/docker/twenty/packages/twenty-docker/.env.example ~/docker/twenty/.env
# Edit .env with encryption key + password + SERVER_URL
# Adjust port in docker-compose.yml if needed
# ADD REACT_APP_SERVER_BASE_URL: ${SERVER_URL} to server environment
cd ~/docker/twenty && docker compose up -d
```
## PITFALL: REACT_APP_SERVER_BASE_URL required for reverse proxy
TwentyCRM's Docker image serves both the React frontend and NestJS backend
from the same container. The React build includes a runtime env injection step
(`inject-runtime-env.sh`) that reads `REACT_APP_SERVER_BASE_URL` from the
container environment.
Without this env var, the frontend defaults API requests to `http://localhost:3000`,
which breaks when accessed through Caddy/nginx because the browser sends API
requests to `localhost` instead of the public domain.
**Fix:** Add to docker-compose.yml server environment:
```yaml
environment:
NODE_PORT: 3000
PG_DATABASE_URL: ...
SERVER_URL: ${SERVER_URL}
REACT_APP_SERVER_BASE_URL: ${SERVER_URL} # MUST ADD
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
```
After adding, restart: `docker compose up -d server`
## Caddy Reverse Proxy
```caddy
crm.debtrecoveryexperts.com {
reverse_proxy 127.0.0.1:3001
}
```
## First-time setup (browser)
1. Navigate to https://crm.debtrecoveryexperts.com/
2. Click "Continue with Email"
3. Create workspace admin account (sign-up form)
4. Configure workspace name, timezone, currency
5. Optionally set up Google/Microsoft calendar + email integrations
## Verification
```bash
# All 4 containers healthy
docker ps --filter name=twenty --format '{{.Names}} {{.Status}}'
# Frontend serving
curl -sI https://crm.debtrecoveryexperts.com/ | head -3
# Backend health
curl -s https://crm.debtrecoveryexperts.com/healthz
# GraphQL API responding
curl -s https://crm.debtrecoveryexperts.com/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{__typename}"}'
# Console: check env config injected in HTML
curl -sL https://crm.debtrecoveryexperts.com/ | grep -A3 'twenty-env-config'
```
## Backup
```bash
docker exec twenty-db-1 pg_dump -U postgres default > backup_$(date +%Y%m%d).sql
```
@@ -0,0 +1,112 @@
# TwentyCRM — DRE CRM Deployment Notes
## Architecture (Docker Compose on Core)
4 containers: `twenty-server` (NestJS API + React frontend), `twenty-worker` (BullMQ jobs), `twenty-db` (PostgreSQL 16), `twenty-redis` (cache/queue).
## PITFALL: Frontend API calls go to localhost
TwentyCRM's frontend SPA is served by the same NestJS server. At startup, the container runs `inject-runtime-env.sh` which reads `REACT_APP_SERVER_BASE_URL` from the environment and injects it into the HTML. If this env var is missing, the frontend defaults to `http://localhost:3000` for all API calls.
**Fix:** Both `SERVER_URL` AND `REACT_APP_SERVER_BASE_URL` must be set in docker-compose.yml:
```yaml
environment:
NODE_PORT: 3000
SERVER_URL: ${SERVER_URL}
REACT_APP_SERVER_BASE_URL: ${SERVER_URL} # ← Critical
```
**Test:** After restart, verify:
```bash
curl -s https://crm.debtrecoveryexperts.com/ | grep 'REACT_APP_SERVER_BASE_URL'
# Should show the real domain, not localhost
```
## PITFALL: .env changes need full restart
After changing `.env`, run `docker compose down && docker compose up -d`. Just restarting the server container is NOT enough — the frontend build is baked into the image and runs the inject script at container startup.
## PITFALL: DNS propagation for new subdomains
When creating a new subdomain (e.g., crm.debtrecoveryexperts.com), the initial A record (non-proxied) can take 5-15 minutes to propagate. If the user gets ERR_NAME_NOT_RESOLVED from their browser, switch to Cloudflare proxied (orange cloud) mode for instant resolution.
## PITFALL: Port conflict with DocuSeal (port 3000)
DocuSeal already uses port 3000. Map TwentyCRM to 3001:
```yaml
ports:
- "3001:3000"
```
## API Field Creation
**REST endpoint for field creation (TEXT, NUMBER, CURRENCY, DATE, etc.):**
```
POST https://crm.debtrecoveryexperts.com/metadata
Body: {"query":"mutation{createOneField(input:{field:{name:\"fieldName\",label:\"Field Label\",type:TEXT,objectMetadataId:\"...\"}}){id name}}"}
```
**RESERVED field names:** Avoid `address` as a field name — TwentyCRM rejects it with validation errors. Use `physicalAddress` or `mailingAddress` instead.
**RELATION fields:** Creating relationships (foreign keys) via the REST API requires a `relationCreationPayload` parameter whose exact shape is not well documented. The UI is the reliable path for creating relationships.
**Object IDs for DRE custom objects (July 7, 2026):**
- Claims: `0c6808cf-b5e8-4f39-8c4f-6dc6b7d029f3`
- Debtors: `bac887ba-22a9-46fe-b988-e1985ee19b29`
- Payments: `bde74e7b-b1c2-48eb-be5b-f3ea2207873c`
- Case Notes: `176736a7-6d3b-4129-ac03-67cea102d618`
## DRE Custom Data Model
### Claims Fields
| Field | Type |
|-------|------|
| claimNumber | TEXT |
| claimAmount | NUMBER |
| debtType | TEXT |
| currentTier | TEXT |
| status | TEXT |
| state | TEXT |
| dateFiled | DATE |
| aiScore | NUMBER |
| description | TEXT |
### Debtors Fields
| Field | Type |
|-------|------|
| businessName | TEXT |
| contactName | TEXT |
| email | TEXT |
| phone | TEXT |
| address | TEXT |
| businessType | TEXT |
| notes | TEXT |
### Payments Fields
| Field | Type |
|-------|------|
| paymentAmount | NUMBER |
| paymentDate | DATE |
| paymentMethod | TEXT |
| dreFee | NUMBER |
| costsDeducted | NUMBER |
| netToClient | NUMBER |
### Case Notes Fields
| Field | Type |
|-------|------|
| content | TEXT |
| timestamp | DATE |
## Default Workflows to Delete
Twenty ships with sample workflows: "Create company when adding a new person" and "Quick Lead". Delete via REST:
```bash
for wid in "id1" "id2"; do
curl -X DELETE "https://crm.debtrecoveryexperts.com/rest/workflows/$wid" -H "Authorization: Bearer $TOKEN"
done
```
Check remaining: `curl -s https://crm.debtrecoveryexperts.com/rest/workflows -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; [print(w['name']) for w in json.load(sys.stdin)['data']['workflows']]"`
Relationships still need to be set up via the UI: Claims → Debtor, Payments → Claim, Case Notes → Claim.
@@ -0,0 +1,18 @@
# Vaultwarden Deployment Reference (Core via Caddy)
**Server:** Core (netcup, 152.53.192.33)
**URL:** https://core.itpropartner.com/vault/
**Caddy:** handle_path /vault/* → reverse_proxy localhost:8080
**Image:** vaultwarden/server:latest
**Data volume:** vaultwarden-data
## ⚠️ DOMAIN env var pitfall (Jul 12, 2026)
**Setting `DOMAIN` causes Vaultwarden to return 404 on ALL routes.** Omit `DOMAIN` entirely. The Caddy reverse proxy handles URL routing transparently.
## Pending
- Replace plain-text ADMIN_TOKEN with Argon2 PHC string via `vaultwarden hash`
- Add Tailscale Serve to any future service needing internal HTTPS
- Set up SMTP if multi-user support is needed later
@@ -0,0 +1,122 @@
# Web Scraping & Search Toolkit
Technical reference for the three-tier web research stack configured on Core.
## Tier 1 — SearXNG (self-hosted, unlimited)
- **URL:** `http://127.0.0.1:8888`
- **Docker:** `~/docker/searxng/`
- **Port:** 8888 (localhost only)
- **API:** `http://127.0.0.1:8888/search?q=query&format=json`
- **Config:** `searxng-data/settings.yml` — must enable JSON format: `search.formats: [html, json]`
- **Auth:** None (localhost only, no auth needed)
- **Capacity:** Unlimited — self-hosted, no rate limits
Used for: quick web lookups, private search, government site search (basic). Does NOT render JavaScript.
## Tier 2 — Firecrawl (cloud, free tier)
- **API key:** in `~/.hermes/.env` or per-profile `.env`
- **Plan:** Starter (1,000 credits/mo)
- **Endpoint:** `https://api.firecrawl.dev/v1/scrape`
- **Native Hermes tools:** `web_search`, `web_extract` (loaded per session)
- **Config:** `web.backend: firecrawl` in config.yaml
Used for: content extraction from URLs, web search grounded in current data. Free tier good for ~30 lookups/day. Upgrade to Standard (~$50-100/mo) for 5,000 credits if needed.
**Note:** The native `web_search` and `web_extract` tools require the API key to be present in `.env` at session START. Adding the key mid-session won't enable them until the next `/reset` or new Hermes invocation.
## Tier 3 — ScrapingAnt (cloud, headless Chrome)
- **API key:** in `~/.hermes/.env` or per-profile `.env`
- **Plan:** Free (10,000 credits), then $19/mo for 100,000 credits
- **Endpoint:** `https://api.scrapingant.com/v2/general?url=...&x-api-key=...`
- **JS rendering:** `browser=true` parameter for headless Chrome execution
Used for: JS-heavy sites (LinkedIn, Indeed, CBDriver, government portals, SPA apps). Essential for any site that serves blank HTML without JavaScript execution.
## Usage Decision Matrix
| Site type | Tool | Timeout |
|---|---|---|
| Simple HTML pages, blogs, docs | SearXNG (free, instant) | 5s |
| JSON API documentation, content extraction | Firecrawl | 10s |
| JavaScript-heavy, SPAs, login gates | ScrapingAnt (browser=true) | 30s |
| Government sites (Texas Legislature, court records) | ScrapingAnt (browser=true) | 30s+ |
| People search (CBDriver, Indeed) | ScrapingAnt | 15s |
| LinkedIn public profiles | ScrapingAnt (proxy_country=US) | 30s |
## Credit Tracking
Usage logged at `~/.hermes/scripts/firecrawl-usage.json` via `track-firecrawl.py` cron. Daily check at 9 AM. Also tracked in portal at `capabilities.html`.
## Sharing Across Profiles
When another profile (e.g. Anita's) needs web tools, the API keys must be added to THEIR profile's `.env` — the main profile's `.env` is NOT inherited:
```bash
# ~/.hermes/profiles/anita/.env
FIRECRAWL_API_KEY=...
SCRAPINGANT_API_KEY=...
SEARXNG_BASE_URL=http://127.0.0.1:8888
```
Also add to their `config.yaml`:
```yaml
web:
backend: firecrawl
use_gateway: true
```
Enable the web toolset: `hermes tools enable web --profile <name>`
**CRITICAL: Profile Isolation — Config changes need gateway restart AND Telegram token**
1. After config changes, the gateway must be RESTARTED — values are NOT hot-reloaded
2. If the gateway restarts and comes up with NO messaging platforms connected, the Telegram bot token is likely missing from that profile's `.env`
3. The main profile's Telegram token is NOT inherited by child profiles
4. Each profile that needs Telegram must have `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS`, and `TELEGRAM_HOME_CHANNEL` in its own `.env`
5. Anita's gateway died during config update because the restart cron killed the old process and spawned one with web API keys but no Telegram token
6. Fix: copy Telegram env vars from main `.env` to the profile's `.env`, then kill the orphaned gateway PID and restart
## Data Broker Removal
Full action plan at `/root/data-broker-removal-action-plan.md`. Recommendation: use Kanary ($84/yr) for automated removal across 50+ sites. No tools needed beyond the search stack — the same ScrapingAnt + Firecrawl combo can scan for a person's presence on data broker sites.
## Caddy Multi-Domain Reverse Proxy
Caddy handles HTTPS termination and routing for multiple domains on the same server (Core, 152.53.192.33). Each domain gets a separate block in `/etc/caddy/Caddyfile`:
```caddy
sign.itpropartner.com {
reverse_proxy 127.0.0.1:3000
}
core.itpropartner.com {
header Access-Control-Allow-Origin "*"
@vehicles path /vehicles.json
handle @vehicles {
root * /var/www/static
file_server
}
}
app.itpropartner.com {
reverse_proxy 127.0.0.1:8081
}
```
**Caddy commands:**
- `caddy fmt --overwrite /etc/caddy/Caddyfile` — format the config file
- `systemctl reload caddy` — hot-reload without downtime
- `systemctl restart caddy` — full restart (needed when adding new domains)
- Caddy auto-provisions Let's Encrypt certificates for each domain
- After adding a new domain to the Caddyfile, Caddy needs ~10-15s to provision the certificate before the domain starts serving HTTPS
**New domain DNS setup:**
1. Add A record at DNS provider → 152.53.192.33
2. Add `domain.com { ... }` block to Caddyfile
3. `caddy fmt --overwrite` + `systemctl restart caddy`
4. Wait ~15s for Let's Encrypt
5. HTTPS works automatically
**Port 443 note:** If Tailscale Serve is running, it holds port 443. Run `tailscale serve off` to free it before starting Caddy. This disables all Tailscale Serve routes.