feat(nix): add Nextcloud Claude Bot integration

- Added configuration for Nextcloud Claude Bot, including NixOS module, secrets management, and example setup files.
- Introduced a Python-based HTTP server for handling webhook events and interacting with Nextcloud Talk.
- Integrated necessary dependencies and systemd service for seamless operation.
This commit is contained in:
Harald Hoyer 2026-02-03 15:40:57 +01:00
parent eb10ad018f
commit bc6091f63f
7 changed files with 728 additions and 0 deletions

View file

@ -0,0 +1,35 @@
nextcloud-claude-bot:
secret: ENC[AES256_GCM,data:I0YxTjU89dDFnpF/TwZYBliLDyre0kNZbWvJD5Jdleihe1LGEptcLuTN0lkO9I8z9U7GDGxoAprb8W+5d2MQrA==,iv:m/q82cfbFID0aW3KfXCZSIa7FhtGx/3TCxv5x8GXVk0=,tag:+IuHUKVqdGrU0RS18NUlPg==,type:str]
sops:
age:
- recipient: age1qur4kh3gay9ryk3jh2snvjp6x9eq94zdrmgkrfcv4fzsu7l6lumq4tr3uy
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUkZhbzhZbHZQRDJvZW1q
QjlIS2IxL1NaSnZ6L3JDU3hENEh4SFBBLzN3CnRvZkFmOUIzWUgybGdPblp4UmRH
U1JmUCt5WkNUc09EdktUdFBHY0lKUFkKLS0tIDZHRXRtZTBROGJJcFhMVDM4ZDJt
OGZOUElSNGJmaEtPalQ5MXBxQUFaRFkKu2EIbPsNMkejgc2rVC/nL5G2Hfp1IkiA
3CV36NHFXKRlo8Fxj+hl1Fi063TRlNW0TK5fc15u4En7tdMnCdfJ+A==
-----END AGE ENCRYPTED FILE-----
- recipient: age1dwcz3fmp29ju4svy0t0wz4ylhpwlqa8xpw4l7t4gmgqr0ev37qrsfn840l
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPQTd4U0ZVOHJKMUgyLzF5
RkpiVmMzYTRZS21ZUnNERTB3K2pDSXpFMlVjClkxNDl6WlcyN0xBT3MzYWVOWnNL
UldRZER4YVFuSHZ0S3BMSVZLQm5pRWcKLS0tIEpZVlA2RFZGbElUQWVWb3c5OSt3
WlpSVGx4OEJGYU52L2xkdmNteWdGUE0KS0Xa9GmwTiAURgC72OhNLHW1/XgHyHFZ
4yQ2qri2m14E5oheB8ELzMMY9K/yQUs90UqdZIS8UoSeaG4GqjEuQA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1cpm9xhgue7sjvq7zyeeaxwr96c93sfzxxxj76sxsq7s7kgnygvcq5jxren
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4YjNVSE9mdjhKY2hWVGJ3
d3VnVDBJODRyMkRyMUJUREwvT0ZUUkRtVUU0CjFOWCtFK05saHNTWGRoazQ2aVgw
bnlPMUNmdVVSUEFoVEtkaXcwVklETm8KLS0tIFBWMERoR0ZiMDJ1bW5May9RSWlv
VktQbU9STjNRVTh6TndIRVBLdFVFUVkKz0dBpDQ9+/Pp3FKsBpcmzuEROsZ65jkw
9LRQTMGF6kSrbLjRkBs21t5t2kunKgCriAmd8Nv+S/sG/NKqpQMJ6A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-02-03T14:36:03Z"
mac: ENC[AES256_GCM,data:y0gOLMHjzv2ER+Bvo0glkY2EC28K2uWtPPhv0EDzb4PczGDNgQWGHhdyFsN07+JJIf2LMpKV1u7BMp4e/dF1wDgZsR6wErZLxuLrXfZ6B7mTDOPGUR1rGo5PhbNIO90LL5uQ/aRLl38efqxgU8fHCkuXJkUtM38UQ9+7JN4PVic=,iv:2AKgYujqxeGiiVMhqC8FGFiYbTcogxZx/uUgh+8XowQ=,tag:3RwH2AboBU9T25fWjecsMQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0

View file

@ -12,6 +12,7 @@
./mailserver.nix
./network.nix
./nextcloud.nix
./nextcloud-claude-bot
./nginx.nix
./postgresql.nix
./rspamd.nix

View file

@ -0,0 +1,146 @@
# Nextcloud Claude Bot Setup
## Voraussetzungen
- NixOS Server mit Nextcloud (Talk App aktiviert)
- Claude Code CLI installiert und authentifiziert
- Nextcloud Talk Version 17+ (Nextcloud 26+)
## 1. Bot Secret generieren
```bash
openssl rand -hex 32 > /var/secrets/nextcloud-claude-bot
chmod 600 /var/secrets/nextcloud-claude-bot
```
## 2. NixOS Konfiguration
Kopiere die Dateien nach `/etc/nixos/nextcloud-claude-bot/` oder in dein Flake:
```
/etc/nixos/
├── configuration.nix
└── nextcloud-claude-bot/
├── module.nix
└── bot.py
```
Füge das Modul zu deiner `configuration.nix` hinzu (siehe `example-config.nix`).
## 3. System rebuilden
```bash
nixos-rebuild switch
```
## 4. Bot bei Nextcloud registrieren
```bash
# Als root oder mit sudo
cd /var/www/nextcloud # oder wo dein Nextcloud liegt
# Bot secret auslesen
BOT_SECRET=$(cat /var/secrets/nextcloud-claude-bot)
# Bot installieren
sudo -u nextcloud php occ talk:bot:install \
"Claude" \
"Claude AI Assistant" \
"http://127.0.0.1:8085/webhook" \
"$BOT_SECRET"
```
Falls der Bot extern erreichbar sein muss:
```bash
sudo -u nextcloud php occ talk:bot:install \
"Claude" \
"Claude AI Assistant" \
"https://cloud.example.com/_claude-bot/webhook" \
"$BOT_SECRET"
```
## 5. Bot aktivieren
Nach der Installation musst du den Bot für Konversationen aktivieren:
```bash
# Liste alle Bots
sudo -u nextcloud php occ talk:bot:list
# Bot für alle User verfügbar machen (optional)
sudo -u nextcloud php occ talk:bot:state <bot-id> 1
```
## 6. Testen
1. Öffne Nextcloud Talk
2. Starte einen neuen Chat mit dem Bot (suche nach "Claude")
3. Schreibe eine Nachricht
### Health Check
```bash
curl http://127.0.0.1:8085/health
```
### Logs prüfen
```bash
journalctl -u nextcloud-claude-bot -f
```
## Troubleshooting
### Bot antwortet nicht
1. Prüfe ob der Service läuft:
```bash
systemctl status nextcloud-claude-bot
```
2. Prüfe die Logs:
```bash
journalctl -u nextcloud-claude-bot -n 50
```
3. Teste den Webhook manuell:
```bash
curl -X POST http://127.0.0.1:8085/webhook \
-H "Content-Type: application/json" \
-d '{"actor":{"type":"users","id":"harald"},"message":{"message":"test","id":1},"conversation":{"token":"abc123","type":1}}'
```
### Claude CLI Fehler
Stelle sicher, dass Claude CLI als der Service-User funktioniert:
```bash
# Teste als der User
sudo -u nextcloud-claude-bot claude --print "Hello"
```
Die Claude CLI Config liegt in `/var/lib/nextcloud-claude-bot/.config/claude/`.
### Signature Fehler
Prüfe ob das Bot Secret in Nextcloud und im Service übereinstimmt:
```bash
# Secret im Service
cat /var/secrets/nextcloud-claude-bot
# Secret in Nextcloud (verschlüsselt gespeichert)
sudo -u nextcloud php occ talk:bot:list
```
## Befehle im Chat
- `/help` oder `/hilfe` Hilfe anzeigen
- `/clear` oder `/reset` Konversation zurücksetzen
## Sicherheitshinweise
- Der Bot läuft nur auf localhost und ist nicht direkt erreichbar
- Nur in `allowedUsers` gelistete Nutzer können den Bot verwenden
- Webhook-Signaturen werden verifiziert
- DynamicUser isoliert den Service

View file

@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
Nextcloud Talk Claude Bot
Receives webhooks from Nextcloud Talk and responds using Claude CLI.
"""
import asyncio
import hashlib
import hmac
import json
import logging
import os
import subprocess
from datetime import datetime
from typing import Optional
import httpx
from fastapi import FastAPI, Request, HTTPException, Header
from fastapi.responses import JSONResponse
# Configuration from environment
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/")
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude")
ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()]
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096"))
TIMEOUT = int(os.environ.get("TIMEOUT", "120"))
SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "")
# Bot secret from systemd credential
def get_bot_secret() -> str:
cred_path = os.environ.get("CREDENTIALS_DIRECTORY", "")
if cred_path:
secret_file = os.path.join(cred_path, "bot-secret")
if os.path.exists(secret_file):
with open(secret_file) as f:
return f.read().strip()
# Fallback for development
return os.environ.get("BOT_SECRET", "")
BOT_SECRET = get_bot_secret()
# Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger(__name__)
app = FastAPI(title="Nextcloud Claude Bot")
# Simple in-memory conversation history (per user)
# Format: {user_id: [(timestamp, role, message), ...]}
conversations: dict[str, list[tuple[datetime, str, str]]] = {}
MAX_HISTORY = 10 # Keep last N exchanges per user
def verify_signature(body: bytes, signature: str) -> bool:
"""Verify Nextcloud webhook signature."""
if not BOT_SECRET:
log.warning("No bot secret configured, skipping signature verification")
return True
expected = hmac.new(
BOT_SECRET.encode(),
body,
hashlib.sha256
).hexdigest()
# Nextcloud sends: sha256=<hex>
if signature.startswith("sha256="):
signature = signature[7:]
return hmac.compare_digest(expected, signature)
def build_prompt(user_id: str, message: str) -> str:
"""Build prompt with conversation history."""
history = conversations.get(user_id, [])
parts = []
if SYSTEM_PROMPT:
parts.append(f"System: {SYSTEM_PROMPT}\n")
# Add recent history
for ts, role, msg in history[-MAX_HISTORY:]:
prefix = "User" if role == "user" else "Assistant"
parts.append(f"{prefix}: {msg}")
# Add current message
parts.append(f"User: {message}")
return "\n\n".join(parts)
async def call_claude(prompt: str) -> str:
"""Call Claude CLI and return response."""
cmd = [CLAUDE_PATH, "--print"]
if MAX_TOKENS:
cmd.extend(["--max-tokens", str(MAX_TOKENS)])
log.info(f"Calling Claude: {' '.join(cmd)}")
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(prompt.encode()),
timeout=TIMEOUT
)
if proc.returncode != 0:
log.error(f"Claude CLI error: {stderr.decode()}")
return f"❌ Fehler beim Aufruf von Claude: {stderr.decode()[:200]}"
return stdout.decode().strip()
except asyncio.TimeoutError:
log.error(f"Claude CLI timeout after {TIMEOUT}s")
return f"⏱️ Timeout: Claude hat nicht innerhalb von {TIMEOUT}s geantwortet."
except Exception as e:
log.exception("Error calling Claude")
return f"❌ Fehler: {str(e)}"
async def send_reply(conversation_token: str, message: str, reply_to: int = None):
"""Send reply back to Nextcloud Talk."""
if not NEXTCLOUD_URL:
log.error("NEXTCLOUD_URL not configured")
return
url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message"
headers = {
"OCS-APIRequest": "true",
"Content-Type": "application/json",
}
# Bot authentication
if BOT_SECRET:
# Generate random string for request
import secrets
random = secrets.token_hex(32)
digest = hmac.new(
BOT_SECRET.encode(),
(random + message).encode(),
hashlib.sha256
).hexdigest()
headers["X-Nextcloud-Talk-Bot-Random"] = random
headers["X-Nextcloud-Talk-Bot-Signature"] = digest
payload = {
"message": message,
"referenceId": hashlib.sha256(f"{conversation_token}-{datetime.now().isoformat()}".encode()).hexdigest()[:32],
}
if reply_to:
payload["replyTo"] = reply_to
async with httpx.AsyncClient() as client:
try:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code not in (200, 201):
log.error(f"Failed to send reply: {resp.status_code} {resp.text}")
else:
log.info(f"Reply sent to conversation {conversation_token}")
except Exception as e:
log.exception("Error sending reply to Nextcloud")
@app.post("/webhook")
async def handle_webhook(
request: Request,
x_nextcloud_talk_signature: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Signature"),
):
"""Handle incoming webhook from Nextcloud Talk."""
body = await request.body()
# Verify signature
if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature):
log.warning("Invalid webhook signature")
raise HTTPException(status_code=401, detail="Invalid signature")
try:
data = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON")
log.info(f"Received webhook: {json.dumps(data, indent=2)[:500]}")
# Extract message info
# Nextcloud Talk Bot API structure
actor = data.get("actor", {})
actor_id = actor.get("id", "")
actor_type = actor.get("type", "")
message_data = data.get("message", {})
message_text = message_data.get("message", "")
message_id = message_data.get("id")
conversation = data.get("conversation", {})
conversation_token = conversation.get("token", "")
conversation_type = conversation.get("type", 0)
# Only respond to user messages in one-on-one chats (type 1)
if actor_type != "users":
log.info(f"Ignoring non-user actor: {actor_type}")
return JSONResponse({"status": "ignored", "reason": "not a user message"})
if conversation_type != 1: # 1 = one-to-one
log.info(f"Ignoring non-DM conversation type: {conversation_type}")
return JSONResponse({"status": "ignored", "reason": "not a direct message"})
# Check allowed users
if ALLOWED_USERS and actor_id not in ALLOWED_USERS:
log.warning(f"User {actor_id} not in allowed list")
await send_reply(
conversation_token,
"🚫 Du bist nicht berechtigt, diesen Bot zu nutzen.",
reply_to=message_id
)
return JSONResponse({"status": "rejected", "reason": "user not allowed"})
if not message_text.strip():
return JSONResponse({"status": "ignored", "reason": "empty message"})
log.info(f"Processing message from {actor_id}: {message_text[:100]}")
# Store user message in history
if actor_id not in conversations:
conversations[actor_id] = []
conversations[actor_id].append((datetime.now(), "user", message_text))
# Handle special commands
if message_text.strip().lower() in ("/clear", "/reset", "/neu"):
conversations[actor_id] = []
await send_reply(
conversation_token,
"🧹 Konversation zurückgesetzt.",
reply_to=message_id
)
return JSONResponse({"status": "ok", "action": "cleared"})
if message_text.strip().lower() in ("/help", "/hilfe"):
help_text = """🤖 **Claude Bot Hilfe**
Schreib mir einfach eine Nachricht und ich antworte dir.
**Befehle:**
`/clear` oder `/reset` Konversation zurücksetzen
`/help` oder `/hilfe` Diese Hilfe anzeigen
Der Bot merkt sich die letzten Nachrichten für Kontext."""
await send_reply(conversation_token, help_text, reply_to=message_id)
return JSONResponse({"status": "ok", "action": "help"})
# Build prompt and call Claude
prompt = build_prompt(actor_id, message_text)
response = await call_claude(prompt)
# Store assistant response in history
conversations[actor_id].append((datetime.now(), "assistant", response))
# Trim history
if len(conversations[actor_id]) > MAX_HISTORY * 2:
conversations[actor_id] = conversations[actor_id][-MAX_HISTORY * 2:]
# Send response
await send_reply(conversation_token, response, reply_to=message_id)
return JSONResponse({"status": "ok"})
@app.get("/health")
async def health():
"""Health check endpoint."""
return {
"status": "ok",
"nextcloud_url": NEXTCLOUD_URL,
"claude_path": CLAUDE_PATH,
"allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8085)

View file

@ -0,0 +1,31 @@
{ config, ... }:
{
imports = [ ./module.nix ];
services.nextcloud-claude-bot = {
enable = true;
nextcloudUrl = "https://nc.hoyer.xyz";
botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path;
allowedUsers = [ "harald" ];
};
sops.secrets."nextcloud-claude-bot/secret" = {
sopsFile = ../../../../.secrets/hetzner/nextcloud-claude-bot.yaml;
restartUnits = [ "nextcloud-claude-bot.service" ];
};
# Nginx location for Nextcloud to send webhooks to the bot
services.nginx.virtualHosts."nc.hoyer.xyz".locations."/_claude-bot/" = {
proxyPass = "http://127.0.0.1:8085/";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Only allow from localhost (Nextcloud on same server)
allow 127.0.0.1;
deny all;
'';
};
}

View file

@ -0,0 +1,80 @@
# Example NixOS configuration for the Nextcloud Claude Bot
# Add this to your configuration.nix or a separate module
{ config, pkgs, ... }:
{
imports = [
./nextcloud-claude-bot/module.nix
];
# Install Claude Code CLI
# Note: You'll need to either:
# 1. Use the official package if available in nixpkgs
# 2. Package it yourself
# 3. Use a binary wrapper
# Option 1: If claude-code is in nixpkgs (check latest state)
# environment.systemPackages = [ pkgs.claude-code ];
# Option 2: Manual binary installation wrapper
nixpkgs.overlays = [
(final: prev: {
claude-code = final.writeShellScriptBin "claude" ''
# Assumes claude is installed via npm globally or similar
exec ${final.nodejs}/bin/node /opt/claude-code/cli.js "$@"
'';
})
];
# Create bot secret
# Generate with: openssl rand -hex 32
# Store in a file, e.g., /var/secrets/nextcloud-claude-bot
services.nextcloud-claude-bot = {
enable = true;
port = 8085;
host = "127.0.0.1";
nextcloudUrl = "https://cloud.example.com";
botSecretFile = "/var/secrets/nextcloud-claude-bot";
# Only allow specific users
allowedUsers = [ "harald" ];
# Claude settings
maxTokens = 4096;
timeout = 120;
# Optional system prompt
systemPrompt = ''
Du bist ein hilfreicher Assistent. Antworte auf Deutsch,
es sei denn der Nutzer schreibt auf Englisch.
'';
};
# Ensure secrets directory exists with proper permissions
systemd.tmpfiles.rules = [
"d /var/secrets 0750 root root -"
];
# If Nextcloud runs locally, bot can stay on localhost.
# If you need external access (e.g., Nextcloud on different server):
services.nginx.virtualHosts."cloud.example.com" = {
# ... your existing Nextcloud config ...
locations."/_claude-bot/" = {
proxyPass = "http://127.0.0.1:8085/";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Only allow from Nextcloud itself
allow 127.0.0.1;
deny all;
'';
};
};
}

View file

@ -0,0 +1,141 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.nextcloud-claude-bot;
botScript = pkgs.python3Packages.buildPythonApplication {
pname = "nextcloud-claude-bot";
version = "0.1.0";
format = "other";
propagatedBuildInputs = with pkgs.python3Packages; [
fastapi
uvicorn
httpx
];
dontUnpack = true;
installPhase = ''
mkdir -p $out/bin
cp ${./bot.py} $out/bin/nextcloud-claude-bot
chmod +x $out/bin/nextcloud-claude-bot
'';
};
in {
options.services.nextcloud-claude-bot = {
enable = mkEnableOption "Nextcloud Talk Claude Bot";
port = mkOption {
type = types.port;
default = 8085;
description = "Port for the webhook listener";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Host to bind to";
};
nextcloudUrl = mkOption {
type = types.str;
example = "https://cloud.example.com";
description = "Base URL of your Nextcloud instance";
};
botSecretFile = mkOption {
type = types.path;
description = "Path to file containing the bot secret (shared with Nextcloud)";
};
claudePath = mkOption {
type = types.path;
default = "${pkgs.claude-code}/bin/claude";
description = "Path to claude CLI binary";
};
allowedUsers = mkOption {
type = types.listOf types.str;
default = [];
example = [ "harald" "admin" ];
description = "Nextcloud usernames allowed to talk to the bot (empty = all)";
};
maxTokens = mkOption {
type = types.int;
default = 4096;
description = "Max tokens for Claude response";
};
timeout = mkOption {
type = types.int;
default = 120;
description = "Timeout in seconds for Claude CLI";
};
systemPrompt = mkOption {
type = types.nullOr types.str;
default = null;
example = "Du bist ein hilfreicher Assistent.";
description = "Optional system prompt for Claude";
};
};
config = mkIf cfg.enable {
systemd.services.nextcloud-claude-bot = {
description = "Nextcloud Talk Claude Bot";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
BOT_HOST = cfg.host;
BOT_PORT = toString cfg.port;
NEXTCLOUD_URL = cfg.nextcloudUrl;
CLAUDE_PATH = cfg.claudePath;
ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers;
MAX_TOKENS = toString cfg.maxTokens;
TIMEOUT = toString cfg.timeout;
SYSTEM_PROMPT = cfg.systemPrompt or "";
};
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.python3Packages.uvicorn}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}";
Restart = "always";
RestartSec = 5;
# Security hardening
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
MemoryDenyWriteExecute = false; # Python needs this
LockPersonality = true;
# Bot secret
LoadCredential = "bot-secret:${cfg.botSecretFile}";
# Claude CLI needs home for config
StateDirectory = "nextcloud-claude-bot";
Environment = "HOME=/var/lib/nextcloud-claude-bot";
};
};
# Nginx reverse proxy config (optional, if you want external access)
# services.nginx.virtualHosts."cloud.example.com".locations."/claude-bot/" = {
# proxyPass = "http://${cfg.host}:${toString cfg.port}/";
# };
};
}