Replace Clay with n8n, Ollama, SearXNG and browserless; add Telegram assistant
Remove the Clay role (Claude Code web UI) and its Anthropic API key. New roles: - ollama: local CPU LLM (qwen3:4b-instruct), internal network only - browserless: headless Chromium for JavaScript-heavy pages - searxng: metasearch backend for the chat web_search tool - n8n at agent.ladkau.de: web chat (/chat), scheduled workflows, Telegram bot; workflows and credentials are imported from the repo Telegram bot: Gemini 3.8 -> Gemini 3.5 -> local Qwen fallback chain, /qwen to force the local model, /stocks and a weekday schedule (07:30, 13:00, 17:40) for the MTE.DE / PTX.F stock report. Also: dashboard and check-services entries for the chat, vault checks for the new secrets (n8n, SearXNG, Gemini, Telegram, SMTP), updated CLAUDE.md, runbooks and README.
This commit is contained in:
@@ -75,17 +75,46 @@ The mail role deploys four containers on `mail_internal`: `mail-db` (PostgreSQL
|
|||||||
- Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist
|
- Fetchmail 6.6.x rcfile syntax: `ssl` is a user-level option (inline after `password`), port is `smtphost dovecot/24` (slash-separated), `smtpport` keyword does not exist
|
||||||
- `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured
|
- `mail.ladkau.de` is the Roundcube webmail client, not an MTA — no outgoing SMTP server is configured
|
||||||
|
|
||||||
### Clay (Claude Code web interface)
|
### n8n (AI chat and scheduled digests)
|
||||||
|
|
||||||
Clay (`agent.ladkau.de`) provides a browser-based UI for Claude Code. The role
|
n8n (`agent.ladkau.de`) runs the chat page and the scheduled workflows. The `n8n` role templates its compose file to `/opt/n8n` and imports the repo's workflows into it. Key details:
|
||||||
builds a Docker image from `ansible/roles/clay/files/Dockerfile` (Node 20 +
|
|
||||||
`@anthropic-ai/claude-code` + `clay-server`) at deploy time. Key details:
|
|
||||||
|
|
||||||
- `ANTHROPIC_API_KEY` is passed as an environment variable from the vault
|
- Traefik basic auth (`n8n-auth` middleware) uses `n8n_users` from the vault; n8n has its own owner login behind it
|
||||||
- Sessions and config are persisted at `/opt/clay/data` → `/root/.clay` in container
|
- `/chat` is redirected by a Traefik `redirectregex` middleware to the hosted chat webhook `/webhook/<n8n_chat_webhook_id>/chat`; `n8n_chat_webhook_id` must match `webhookId` in `roles/n8n/files/workflows/chat.json`
|
||||||
- Projects (git repos) are mounted from `/opt/clay/workspace` → `/workspace`
|
- Workflows are `roles/n8n/files/workflows/*.json`; credentials are imported from `roles/n8n/files/credentials/ollama.json` and a template rendered from the vault (`smtp_user`, `smtp_password`). Import (credentials, workflows, publish `"active": true` ones, restart) runs as a handler only when those files change
|
||||||
- Traefik basic auth (`clay-auth` middleware) uses `clay_users` from the vault
|
- Imports replace workflows by id: edits made in the n8n UI are lost unless exported back into the repo
|
||||||
- Scheduled agent workflows use Gitea Actions `on.schedule` + the existing act_runners
|
- Data and the SQLite database are in `/opt/n8n/data`; `n8n_encryption_key` protects stored credentials and must never change after the first deploy
|
||||||
|
- n8n joins `ollama_internal` and `browserless_internal`; neither backend has a public route
|
||||||
|
- The `fetch_page` tool and the digest workflow call browserless `POST /function`, which runs a small Puppeteer script and returns the page text
|
||||||
|
- The `web_search` tool (`web-search.json`) queries SearXNG's JSON API and returns the top 6 results
|
||||||
|
- n8n also joins `searxng_internal`
|
||||||
|
- Telegram: the `Telegram chat` workflow answers one Telegram user (`telegram_user_id`); other users and non-text messages are ignored
|
||||||
|
- Model routing (`Route` step): messages go to the Gemini chain (`gemini_api_key` from the vault); messages starting with `/qwen` go to the local Qwen agent. The Gemini chain is `gemini-3.8-flash`, then `gemini-3.5-flash` if the answer is empty, then Qwen. Free-tier Gemini limits are per model; the reply ends with a note when a fallback answered
|
||||||
|
- Model names in the workflow JSON are plain strings. `__OLLAMA_MODEL__` is replaced by `ollama_model` and `__TELEGRAM_USER_ID__` by `telegram_user_id` when Ansible copies the files; keep the placeholders when exporting workflows from the UI
|
||||||
|
- All agents have the `web_search` and `fetch_page` tools, 4 iterations at most, and the instruction to make one `fetch_page` call per web address or one `web_search` per question. The Gemini agents share one conversation memory; the Qwen agent has its own small memory
|
||||||
|
- Scheduled workflows (`digest-telegram.json`) post through the `Post message` entry point of `Telegram chat`, so the text lands in the chat and in its memory
|
||||||
|
- The `Telegram chat` workflow has a schedule (`Stock price schedule`, three cron expressions, weekdays) and a manual trigger. Both send `/stocks` through `Route`, which expands it to `STOCK_QUESTION` (names the two Yahoo quote pages); `/stocks` typed in Telegram does the same and `/qwen /stocks` sends it to Qwen
|
||||||
|
- Telegram's servers call n8n at `/webhook/<n8n_telegram_webhook_id>/webhook`. That route has no basic auth; Traefik only admits `telegram_webhook_source_ranges` and n8n checks Telegram's secret token header. `n8n_telegram_webhook_id` must match `webhookId` in `telegram-chat.json`
|
||||||
|
- Replies are converted from Markdown to Telegram HTML by Code nodes and sent with `parse_mode` HTML
|
||||||
|
- Chat memory (Window Buffer Memory) lives in n8n's process memory, per workflow, and is lost on every n8n restart (including every deploy that changes workflows)
|
||||||
|
|
||||||
|
### SearXNG
|
||||||
|
|
||||||
|
The `searxng` role runs a metasearch engine (`searxng/searxng`) on the internal network `searxng_internal` (`http://searxng:8080`), used only by the n8n `web_search` tool. `settings.yml` is rendered from a template with `searxng_secret_key` from the vault; the JSON output format is enabled and the bot limiter is off because there is no public route. Some upstream engines (DuckDuckGo, Brave) answer with captchas or rate limits; SearXNG skips them and uses the others.
|
||||||
|
|
||||||
|
### Browserless
|
||||||
|
|
||||||
|
The `browserless` role runs headless Chromium (`ghcr.io/browserless/chromium`) on the internal network `browserless_internal` (`http://browserless:3000`), one session at a time with a memory limit (`browserless_mem_limit`).
|
||||||
|
|
||||||
|
### Ollama (local LLM)
|
||||||
|
|
||||||
|
The `ollama` role runs `ollama/ollama` (CPU only) on the internal Docker network `ollama_internal` — no Traefik route, no public port. n8n joins that network (as `external`) and reaches it at `http://ollama:11434`.
|
||||||
|
|
||||||
|
- Model, context length and memory limit come from `ollama_*` in `vars.yml` (default `qwen3:4b-instruct-2507-q4_K_M`, 16k context, 4.5 GB limit — the VPS has 8 GB shared with ~24 containers)
|
||||||
|
- Models persist in `/opt/ollama/models`; the role pulls `ollama_model` if it is missing
|
||||||
|
- `ollama_keep_alive` unloads the model from RAM when idle
|
||||||
|
- Ollama starts 2 threads on this 2-core/4-thread host; the Ollama chat model nodes in the workflows set `numThread` to 4
|
||||||
|
- Changing `ollama_model` and re-running `--tags ollama` pulls the new model; old models are removed manually with `docker exec ollama ollama rm <model>`
|
||||||
|
|
||||||
### Gitea Actions runners
|
### Gitea Actions runners
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="logo.png" alt="cloud.ladkau.de logo" width="200">
|
||||||
|
</p>
|
||||||
|
|
||||||
Info:
|
Info:
|
||||||
--
|
--
|
||||||
Server:
|
Server:
|
||||||
@@ -20,6 +24,9 @@ DNS Names:
|
|||||||
mail.ladkau.de
|
mail.ladkau.de
|
||||||
cr.ladkau.de
|
cr.ladkau.de
|
||||||
k8s.ladkau.de
|
k8s.ladkau.de
|
||||||
|
vault.ladkau.de
|
||||||
|
dl.ladkau.de
|
||||||
|
agent.ladkau.de
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
--
|
--
|
||||||
@@ -36,7 +43,7 @@ Provisioning: Ansible
|
|||||||
|
|
||||||
Service runtime: Docker Compose
|
Service runtime: Docker Compose
|
||||||
Each service runs as a Docker Compose stack. Compose files live inside
|
Each service runs as a Docker Compose stack. Compose files live inside
|
||||||
their respective Ansible roles (roles/<service>/files/docker-compose.yml).
|
their respective Ansible roles (roles/<service>/templates/docker-compose.yml.j2).
|
||||||
This keeps service definition and deployment config together.
|
This keeps service definition and deployment config together.
|
||||||
|
|
||||||
Reverse proxy / TLS: Traefik
|
Reverse proxy / TLS: Traefik
|
||||||
@@ -49,7 +56,7 @@ Repo layout:
|
|||||||
ansible/
|
ansible/
|
||||||
inventory.ini # host address and connection vars
|
inventory.ini # host address and connection vars
|
||||||
site.yml # master playbook — runs all roles in order
|
site.yml # master playbook — runs all roles in order
|
||||||
group_vars/all.yml # shared variables (domains, image versions, ...)
|
group_vars/all/ # vars.yml (shared variables) and vault.yml (encrypted secrets)
|
||||||
roles/
|
roles/
|
||||||
base/ # OS hardening, non-root user, SSH, ufw firewall
|
base/ # OS hardening, non-root user, SSH, ufw firewall
|
||||||
docker/ # Docker Engine + Compose plugin install
|
docker/ # Docker Engine + Compose plugin install
|
||||||
@@ -57,16 +64,26 @@ Repo layout:
|
|||||||
gitea/ # self-hosted Git
|
gitea/ # self-hosted Git
|
||||||
nextcloud/ # file storage and collaboration
|
nextcloud/ # file storage and collaboration
|
||||||
sso/ # Single Sign-On
|
sso/ # Single Sign-On
|
||||||
mail/ # mail server
|
mail/ # Roundcube webmail, Dovecot, Fetchmail
|
||||||
registry/ # container registry (cr.ladkau.de)
|
registry/ # container registry (cr.ladkau.de)
|
||||||
k8s/ # k3s Kubernetes node
|
k8s/ # placeholder page (k8s.ladkau.de)
|
||||||
|
act_runner/ # Gitea Actions runners
|
||||||
|
vaultwarden/ # password vault (vault.ladkau.de)
|
||||||
|
dl/ # download server (dl.ladkau.de)
|
||||||
|
dashboard/ # status dashboard (cloud.ladkau.de)
|
||||||
|
ollama/ # local LLM server (internal network only)
|
||||||
|
browserless/ # headless Chromium (internal network only)
|
||||||
|
searxng/ # metasearch for the chat web_search tool (internal network only)
|
||||||
|
n8n/ # AI chat and scheduled digests (agent.ladkau.de)
|
||||||
docs/
|
docs/
|
||||||
runbook-provisioning.md # step-by-step reinstall instructions
|
runbook-provisioning.md # step-by-step reinstall instructions
|
||||||
runbook-configuration.md # first-run service configuration
|
runbook-configuration.md # first-run service configuration
|
||||||
|
runbook-mail-import.md # migrating mbox mail to Maildir++
|
||||||
scripts/
|
scripts/
|
||||||
run-bootstrap.sh # bootstrap deploy user on a fresh server
|
run-bootstrap.sh # bootstrap deploy user on a fresh server
|
||||||
bootstrap-deploy-user.sh # runs on server as root — creates deploy user
|
bootstrap-deploy-user.sh # runs on server as root — creates deploy user
|
||||||
check-vault.sh # verify vault.yml has all required secrets
|
check-vault.sh # verify vault.yml has all required secrets
|
||||||
|
check-services.sh # verify all service endpoints respond
|
||||||
keys/
|
keys/
|
||||||
*.pub # public SSH keys (private keys are gitignored)
|
*.pub # public SSH keys (private keys are gitignored)
|
||||||
|
|
||||||
|
|||||||
@@ -125,12 +125,56 @@ dl_sftp_port: 2223
|
|||||||
# Status dashboard (public — cloud.ladkau.de root)
|
# Status dashboard (public — cloud.ladkau.de root)
|
||||||
dashboard_data_dir: /opt/dashboard
|
dashboard_data_dir: /opt/dashboard
|
||||||
|
|
||||||
# Clay — Claude Code web interface (agent.ladkau.de)
|
# Ollama — local LLM server, internal network only (no public route)
|
||||||
domain_clay: "agent.{{ domain_base }}"
|
ollama_version: "0.35.1"
|
||||||
clay_data_dir: /opt/clay
|
ollama_data_dir: /opt/ollama
|
||||||
clay_users: [] # override in vault with [{username: ..., password: ...}]
|
ollama_model: "qwen3:4b-instruct-2507-q4_K_M" # non-thinking variant: the thinking model needs minutes per answer on this CPU
|
||||||
|
ollama_context_length: 16384
|
||||||
|
ollama_mem_limit: 4608m
|
||||||
|
ollama_keep_alive: "1h" # unload the model after this idle time to free RAM
|
||||||
|
|
||||||
|
# Browserless — headless Chromium for rendering JavaScript-heavy pages (internal only)
|
||||||
|
browserless_version: "v2.57.0"
|
||||||
|
browserless_data_dir: /opt/browserless
|
||||||
|
browserless_mem_limit: 1g
|
||||||
|
|
||||||
|
# SearXNG — metasearch backend for the n8n web_search tool (internal only)
|
||||||
|
searxng_version: "2026.10.2-19ffbcd30"
|
||||||
|
searxng_data_dir: /opt/searxng
|
||||||
|
searxng_mem_limit: 384m
|
||||||
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
||||||
# anthropic_api_key: "" # from console.anthropic.com → API keys
|
# searxng_secret_key: "" # generate: openssl rand -hex 32
|
||||||
# clay_users:
|
|
||||||
|
# n8n — workflow automation, AI chat and scheduled digests (agent.ladkau.de)
|
||||||
|
domain_n8n: "agent.{{ domain_base }}"
|
||||||
|
n8n_version: "2.41.6"
|
||||||
|
n8n_data_dir: /opt/n8n
|
||||||
|
n8n_timezone: "Europe/Berlin"
|
||||||
|
n8n_mem_limit: 768m
|
||||||
|
# Fixed webhook id of the chat workflow: the chat page is served at
|
||||||
|
# https://agent.<domain>/webhook/<id>/chat and https://agent.<domain>/chat redirects there.
|
||||||
|
# Must match "webhookId" in roles/n8n/files/workflows/chat.json.
|
||||||
|
n8n_chat_webhook_id: "3f2b6c1e-8a4d-4e57-9b0a-6d1c2e7f5a10"
|
||||||
|
# Fixed webhook id of the Telegram trigger; must match "webhookId" in roles/n8n/files/workflows/telegram-chat.json.
|
||||||
|
n8n_telegram_webhook_id: "7c1d4e92-5b3a-4f08-a6e1-2d9b8c0f3a47"
|
||||||
|
# Source addresses allowed to call the Telegram webhook (Telegram's webhook servers, see
|
||||||
|
# core.telegram.org/bots/webhooks). Set to [] to disable the address filter.
|
||||||
|
telegram_webhook_source_ranges:
|
||||||
|
- 149.154.160.0/20
|
||||||
|
- 91.108.4.0/22
|
||||||
|
n8n_users: [] # override in vault with [{username: ..., password: ...}] — basic auth in front of n8n
|
||||||
|
|
||||||
|
# Outgoing mail for n8n (Strato SMTP)
|
||||||
|
smtp_host: "smtp.strato.de"
|
||||||
|
smtp_port: 465
|
||||||
|
smtp_secure: true # true = implicit TLS (port 465); false = STARTTLS (port 587)
|
||||||
|
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
||||||
|
# n8n_encryption_key: "" # generate: openssl rand -hex 32
|
||||||
|
# gemini_api_key: "" # Google AI Studio API key (default model of the Telegram chat)
|
||||||
|
# telegram_bot_token: "" # from @BotFather
|
||||||
|
# telegram_user_id: "" # your numeric Telegram user id (the only user the bot answers)
|
||||||
|
# smtp_user: "" # full Strato mailbox address
|
||||||
|
# smtp_password: ""
|
||||||
|
# n8n_users:
|
||||||
# - username: admin
|
# - username: admin
|
||||||
# password: "your-password"
|
# password: "your-password"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Restart browserless
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ browserless_data_dir }}"
|
||||||
|
state: present
|
||||||
|
recreate: always
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
- name: Create Browserless directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ browserless_data_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
tags: browserless
|
||||||
|
|
||||||
|
- name: Deploy Docker Compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ browserless_data_dir }}/docker-compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart browserless
|
||||||
|
tags: browserless
|
||||||
|
|
||||||
|
- name: Start Browserless
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ browserless_data_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: missing
|
||||||
|
tags: browserless
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Managed by Ansible — do not edit manually
|
||||||
|
services:
|
||||||
|
browserless:
|
||||||
|
image: ghcr.io/browserless/chromium:{{ browserless_version }}
|
||||||
|
container_name: browserless
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# One browser session at a time, short queue — keeps RAM use predictable on the shared VPS
|
||||||
|
CONCURRENT: "1"
|
||||||
|
QUEUED: "4"
|
||||||
|
TIMEOUT: "60000"
|
||||||
|
shm_size: 512m
|
||||||
|
mem_limit: {{ browserless_mem_limit }}
|
||||||
|
networks:
|
||||||
|
- browserless_internal
|
||||||
|
|
||||||
|
# Not exposed via Traefik. Containers that need the API join this network
|
||||||
|
# (declared as external there) and reach it at http://browserless:3000.
|
||||||
|
networks:
|
||||||
|
browserless_internal:
|
||||||
|
name: browserless_internal
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
# Claude Code needs git and bash to operate on repositories
|
|
||||||
RUN apk add --no-cache git bash curl
|
|
||||||
|
|
||||||
# Install Claude Code CLI
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
# Install clay web server
|
|
||||||
RUN npm install -g clay-server
|
|
||||||
|
|
||||||
# MCP servers and skills — add packages here to survive container rebuilds
|
|
||||||
# RUN npm install -g @clay-ai/clay-ralph
|
|
||||||
# RUN npm install -g @modelcontextprotocol/server-filesystem
|
|
||||||
|
|
||||||
RUN mkdir -p /workspace /root/.clay
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
|
|
||||||
EXPOSE 2633
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Rebuild and restart clay
|
|
||||||
community.docker.docker_image:
|
|
||||||
name: clay:local
|
|
||||||
build:
|
|
||||||
path: "{{ clay_data_dir }}"
|
|
||||||
source: build
|
|
||||||
force_source: true
|
|
||||||
state: present
|
|
||||||
notify: Restart clay
|
|
||||||
|
|
||||||
- name: Restart clay
|
|
||||||
community.docker.docker_compose_v2:
|
|
||||||
project_src: "{{ clay_data_dir }}"
|
|
||||||
state: present
|
|
||||||
recreate: always
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Create clay directories
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: "{{ item }}"
|
|
||||||
state: directory
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0755"
|
|
||||||
loop:
|
|
||||||
- "{{ clay_data_dir }}"
|
|
||||||
- "{{ clay_data_dir }}/data"
|
|
||||||
- "{{ clay_data_dir }}/workspace"
|
|
||||||
tags: clay
|
|
||||||
|
|
||||||
- name: Deploy Dockerfile
|
|
||||||
ansible.builtin.copy:
|
|
||||||
src: Dockerfile
|
|
||||||
dest: "{{ clay_data_dir }}/Dockerfile"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
notify: Rebuild and restart clay
|
|
||||||
tags: clay
|
|
||||||
|
|
||||||
- name: Deploy Docker Compose file
|
|
||||||
ansible.builtin.template:
|
|
||||||
src: docker-compose.yml.j2
|
|
||||||
dest: "{{ clay_data_dir }}/docker-compose.yml"
|
|
||||||
owner: root
|
|
||||||
group: root
|
|
||||||
mode: "0644"
|
|
||||||
notify: Restart clay
|
|
||||||
tags: clay
|
|
||||||
|
|
||||||
- name: Build clay image
|
|
||||||
community.docker.docker_image:
|
|
||||||
name: clay:local
|
|
||||||
build:
|
|
||||||
path: "{{ clay_data_dir }}"
|
|
||||||
source: build
|
|
||||||
state: present
|
|
||||||
tags: clay
|
|
||||||
|
|
||||||
- name: Start clay
|
|
||||||
community.docker.docker_compose_v2:
|
|
||||||
project_src: "{{ clay_data_dir }}"
|
|
||||||
state: present
|
|
||||||
tags: clay
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Managed by Ansible — do not edit manually
|
|
||||||
services:
|
|
||||||
clay:
|
|
||||||
image: clay:local
|
|
||||||
build:
|
|
||||||
context: {{ clay_data_dir }}
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: clay
|
|
||||||
restart: unless-stopped
|
|
||||||
tty: true
|
|
||||||
environment:
|
|
||||||
- ANTHROPIC_API_KEY={{ anthropic_api_key }}
|
|
||||||
volumes:
|
|
||||||
- {{ clay_data_dir }}/data:/root/.clay
|
|
||||||
- {{ clay_data_dir }}/workspace:/workspace
|
|
||||||
command: ["clay-server", "--yes", "-p", "2633"]
|
|
||||||
networks:
|
|
||||||
- traefik_public
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.clay.rule=Host(`{{ domain_clay }}`)"
|
|
||||||
- "traefik.http.routers.clay.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.clay.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.clay.loadbalancer.server.scheme=https"
|
|
||||||
- "traefik.http.services.clay.loadbalancer.server.port=2633"
|
|
||||||
- "traefik.http.routers.clay.middlewares=clay-auth"
|
|
||||||
{% set ns = namespace(entries=[]) %}
|
|
||||||
{% for user in clay_users %}
|
|
||||||
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
|
|
||||||
{% endfor %}
|
|
||||||
- "traefik.http.middlewares.clay-auth.basicauth.users={{ ns.entries | join(',') }}"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik_public:
|
|
||||||
external: true
|
|
||||||
@@ -25,7 +25,8 @@ SERVICES = [
|
|||||||
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
|
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
|
||||||
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
|
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
|
||||||
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
|
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
|
||||||
("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"),
|
("AI Chat", "https://{{ domain_n8n }}/chat", 401, "https://{{ domain_n8n }}/chat"),
|
||||||
|
("n8n", "https://{{ domain_n8n }}", 401, "https://{{ domain_n8n }}"),
|
||||||
]
|
]
|
||||||
{% raw %}
|
{% raw %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "ollama0000000001",
|
||||||
|
"name": "Ollama (local)",
|
||||||
|
"type": "ollamaApi",
|
||||||
|
"data": {
|
||||||
|
"baseUrl": "http://ollama:11434",
|
||||||
|
"apiKey": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
"id": "chat00000000001",
|
||||||
|
"name": "Chat",
|
||||||
|
"active": true,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"name": "Chat",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"webhookId": "3f2b6c1e-8a4d-4e57-9b0a-6d1c2e7f5a10",
|
||||||
|
"parameters": {
|
||||||
|
"public": true,
|
||||||
|
"mode": "hostedChat",
|
||||||
|
"authentication": "none",
|
||||||
|
"initialMessages": "Hi! Ask me anything or paste a link and I will read the page.",
|
||||||
|
"options": {
|
||||||
|
"loadPreviousSession": "notSupported",
|
||||||
|
"responseMode": "streaming",
|
||||||
|
"title": "Local AI chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2",
|
||||||
|
"name": "AI Agent",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"position": [
|
||||||
|
300,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "auto",
|
||||||
|
"text": "={{ $json.chatInput }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You are a concise assistant. Answer in the language of the user. For questions about current events, weather, news, prices or anything you cannot know for sure, call the web_search tool first with a short search query, then answer from the results and name the source URLs. When the user gives a web address, or a search result needs more detail, call the fetch_page tool with the full URL and base your answer only on what it returns. If a tool fails, say so. Do not invent facts or page content."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c3",
|
||||||
|
"name": "Ollama Chat Model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
200,
|
||||||
|
260
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"model": "__OLLAMA_MODEL__",
|
||||||
|
"options": {
|
||||||
|
"numCtx": 16384,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"numThread": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"ollamaApi": {
|
||||||
|
"id": "ollama0000000001",
|
||||||
|
"name": "Ollama (local)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c4",
|
||||||
|
"name": "Window Buffer Memory",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||||
|
"typeVersion": 1.3,
|
||||||
|
"position": [
|
||||||
|
380,
|
||||||
|
260
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"sessionIdType": "fromInput",
|
||||||
|
"contextWindowLength": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c5",
|
||||||
|
"name": "fetch_page",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
560,
|
||||||
|
260
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"name": "fetch_page",
|
||||||
|
"description": "Opens a web page in a real browser (JavaScript is executed) and returns its title and readable text, truncated to about 3000 characters. Input: the full URL.",
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "fetchpage0000001",
|
||||||
|
"mode": "id"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"url": "={{ $fromAI('url', 'The full URL of the page to open, including https://', 'string') }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"id": "url",
|
||||||
|
"displayName": "url",
|
||||||
|
"required": false,
|
||||||
|
"defaultMatch": false,
|
||||||
|
"display": true,
|
||||||
|
"canBeUsedToMatch": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c6",
|
||||||
|
"name": "web_search",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
740,
|
||||||
|
260
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Searches the web and returns the top results (title, URL, short snippet). Input: a short search query.",
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "websearch000001",
|
||||||
|
"mode": "id"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"query": "={{ $fromAI('query', 'A short web search query', 'string') }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"id": "query",
|
||||||
|
"displayName": "query",
|
||||||
|
"required": false,
|
||||||
|
"defaultMatch": false,
|
||||||
|
"display": true,
|
||||||
|
"canBeUsedToMatch": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Chat": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Ollama Chat Model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Window Buffer Memory": {
|
||||||
|
"ai_memory": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent",
|
||||||
|
"type": "ai_memory",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fetch_page": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent",
|
||||||
|
"type": "ai_tool",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web_search": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent",
|
||||||
|
"type": "ai_tool",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"id": "digesttg00000001",
|
||||||
|
"name": "Weekly page digest to Telegram (example)",
|
||||||
|
"active": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "g1",
|
||||||
|
"name": "Every Monday 06:00",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "0 6 * * 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g2",
|
||||||
|
"name": "Sites",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
240,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// One item per page to summarise. Edit url and prompt.\nreturn [\n { json: { url: 'https://example.com', prompt: 'Summarise this page in five bullet points.' } },\n];\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g3",
|
||||||
|
"name": "Fetch page text",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflow",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
480,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"source": "database",
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "fetchpage0000001",
|
||||||
|
"mode": "id"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"url": "={{ $json.url }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"waitForSubWorkflow": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4",
|
||||||
|
"name": "Summarise",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||||
|
"typeVersion": 1.7,
|
||||||
|
"position": [
|
||||||
|
720,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $('Sites').item.json.prompt + '\\n\\n---\\n' + $json.response }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g5",
|
||||||
|
"name": "Ollama Chat Model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
720,
|
||||||
|
240
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"model": "__OLLAMA_MODEL__",
|
||||||
|
"options": {
|
||||||
|
"numCtx": 16384,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"numThread": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"ollamaApi": {
|
||||||
|
"id": "ollama0000000001",
|
||||||
|
"name": "Ollama (local)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g6",
|
||||||
|
"name": "Format message",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
1000,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "f1",
|
||||||
|
"name": "message",
|
||||||
|
"type": "string",
|
||||||
|
"value": "={{ ('Digest: ' + $('Sites').item.json.url + '\\n\\n' + $json.text).slice(0, 4000) }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g7",
|
||||||
|
"name": "Post to Telegram chat",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflow",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
1240,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"source": "database",
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "telegramchat0001",
|
||||||
|
"mode": "id"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"message": "={{ $json.message }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"waitForSubWorkflow": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 06:00": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Sites",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Sites": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch page text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch page text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarise",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Ollama Chat Model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarise",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarise": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format message",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Format message": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Post to Telegram chat",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
{
|
||||||
|
"id": "digest0000000001",
|
||||||
|
"name": "Weekly page digest (example)",
|
||||||
|
"active": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "d1",
|
||||||
|
"name": "Every Monday 06:00",
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "0 6 * * 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d2",
|
||||||
|
"name": "Sites",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
240,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// One item per page to summarise. Edit url and prompt.\nreturn [\n { json: { url: 'https://example.com', prompt: 'Summarise this page in five bullet points.', to: 'you@example.com' } },\n];\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d3",
|
||||||
|
"name": "Fetch page text",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflow",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [
|
||||||
|
480,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"source": "database",
|
||||||
|
"workflowId": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "fetchpage0000001",
|
||||||
|
"mode": "id"
|
||||||
|
},
|
||||||
|
"workflowInputs": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"url": "={{ $json.url }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [],
|
||||||
|
"attemptToConvertTypes": false,
|
||||||
|
"convertFieldsToString": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"waitForSubWorkflow": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d4",
|
||||||
|
"name": "Summarise",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||||
|
"typeVersion": 1.7,
|
||||||
|
"position": [
|
||||||
|
720,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $('Sites').item.json.prompt + '\\n\\n---\\n' + $json.response }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d5",
|
||||||
|
"name": "Ollama Chat Model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
720,
|
||||||
|
240
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"model": "__OLLAMA_MODEL__",
|
||||||
|
"options": {
|
||||||
|
"numCtx": 16384,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"numThread": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"ollamaApi": {
|
||||||
|
"id": "ollama0000000001",
|
||||||
|
"name": "Ollama (local)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d6",
|
||||||
|
"name": "Send mail",
|
||||||
|
"type": "n8n-nodes-base.emailSend",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [
|
||||||
|
1020,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"fromEmail": "={{ $credentials.user }}",
|
||||||
|
"toEmail": "={{ $('Sites').item.json.to }}",
|
||||||
|
"subject": "={{ 'Digest: ' + $('Sites').item.json.url }}",
|
||||||
|
"emailFormat": "text",
|
||||||
|
"text": "={{ $json.text }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"smtp": {
|
||||||
|
"id": "smtp00000000001",
|
||||||
|
"name": "Strato SMTP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 06:00": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Sites",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Sites": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Fetch page text",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Fetch page text": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarise",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Ollama Chat Model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarise",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarise": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send mail",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"id": "fetchpage0000001",
|
||||||
|
"name": "Fetch page text",
|
||||||
|
"active": true,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"name": "When called by another workflow",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"inputSource": "workflowInputs",
|
||||||
|
"workflowInputs": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "url",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n2",
|
||||||
|
"name": "Render page in browserless",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
260,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://browserless:3000/function",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ code: \"export default async function ({ page, context }) {\\n await page.goto(context.url, { waitUntil: 'networkidle2', timeout: 40000 });\\n const title = await page.title();\\n const text = await page.evaluate(() => {\\n document.querySelectorAll('script,style,noscript,nav,footer,header,iframe,svg').forEach(e => e.remove());\\n return (document.body.innerText || '').replace(/\\\\n{3,}/g, '\\\\n\\\\n').trim();\\n });\\n return { data: { title, text: text.slice(0, context.maxChars || 3000) }, type: 'application/json' };\\n}\\n\", context: { url: $json.url, maxChars: 3000 } }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 90000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n3",
|
||||||
|
"name": "Format result",
|
||||||
|
"type": "n8n-nodes-base.set",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
520,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"assignments": {
|
||||||
|
"assignments": [
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"name": "response",
|
||||||
|
"type": "string",
|
||||||
|
"value": "={{ 'Title: ' + $json.data.title + '\\n\\n' + $json.data.text }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When called by another workflow": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Render page in browserless",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Render page in browserless": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"id": "websearch000001",
|
||||||
|
"name": "Web search",
|
||||||
|
"active": true,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "When called by another workflow",
|
||||||
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"inputSource": "workflowInputs",
|
||||||
|
"workflowInputs": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"name": "query",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"name": "Query SearXNG",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [
|
||||||
|
260,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "http://searxng:8080/search",
|
||||||
|
"sendQuery": true,
|
||||||
|
"queryParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "q",
|
||||||
|
"value": "={{ $json.query }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "format",
|
||||||
|
"value": "json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s3",
|
||||||
|
"name": "Format results",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
520,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const results = ($input.first().json.results || []).slice(0, 6);\nif (!results.length) return [{ json: { response: 'No search results found.' } }];\nconst text = results.map((r, i) => (i + 1) + '. ' + r.title + '\\n ' + r.url + '\\n ' + (r.content || '').slice(0, 300)).join('\\n\\n');\nreturn [{ json: { response: text.slice(0, 3500) } }];\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When called by another workflow": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Query SearXNG",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Query SearXNG": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Format results",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
- name: Restart n8n
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ n8n_data_dir }}"
|
||||||
|
state: present
|
||||||
|
recreate: always
|
||||||
|
|
||||||
|
# "Import n8n content" is a topic, not a single handler: the tasks below all listen
|
||||||
|
# on it and run in the order they are defined (handlers cannot be blocks).
|
||||||
|
# Credentials first (workflows reference them by id), then workflows.
|
||||||
|
# Imported workflows start inactive; the ones marked "active": true are published.
|
||||||
|
# n8n only registers triggers/webhooks at startup, so restart afterwards.
|
||||||
|
- name: Wait for n8n to be ready (database migrations finished)
|
||||||
|
ansible.builtin.command: docker exec n8n wget -qO- http://localhost:5678/healthz/readiness
|
||||||
|
listen: Import n8n content
|
||||||
|
register: n8n_ready
|
||||||
|
changed_when: false
|
||||||
|
retries: 40
|
||||||
|
delay: 3
|
||||||
|
until: n8n_ready.rc == 0
|
||||||
|
|
||||||
|
- name: Import n8n credentials
|
||||||
|
ansible.builtin.command: docker exec n8n n8n import:credentials --separate --input=/import/credentials
|
||||||
|
listen: Import n8n content
|
||||||
|
no_log: true
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Import n8n workflows
|
||||||
|
ansible.builtin.command: docker exec n8n n8n import:workflow --separate --input=/import/workflows
|
||||||
|
listen: Import n8n content
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Publish active n8n workflows
|
||||||
|
ansible.builtin.command: docker exec n8n n8n publish:workflow --id={{ item }}
|
||||||
|
listen: Import n8n content
|
||||||
|
loop: "{{ n8n_publish_ids | from_json }}"
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Restart n8n to register triggers
|
||||||
|
ansible.builtin.command: docker restart n8n
|
||||||
|
listen: Import n8n content
|
||||||
|
changed_when: true
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
- name: Create n8n directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner }}"
|
||||||
|
group: "{{ item.owner }}"
|
||||||
|
mode: "{{ item.mode }}"
|
||||||
|
loop:
|
||||||
|
- { path: "{{ n8n_data_dir }}", owner: root, mode: "0755" }
|
||||||
|
# n8n runs as uid 1000 (node) inside the container
|
||||||
|
- { path: "{{ n8n_data_dir }}/data", owner: "1000", mode: "0700" }
|
||||||
|
- { path: "{{ n8n_data_dir }}/import", owner: "1000", mode: "0700" }
|
||||||
|
- { path: "{{ n8n_data_dir }}/import/workflows", owner: "1000", mode: "0700" }
|
||||||
|
- { path: "{{ n8n_data_dir }}/import/credentials", owner: "1000", mode: "0700" }
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Deploy Docker Compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ n8n_data_dir }}/docker-compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart n8n
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
# __TELEGRAM_USER_ID__ and __OLLAMA_MODEL__ in the workflow files are replaced by telegram_user_id and ollama_model
|
||||||
|
- name: Deploy workflows
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "{{ lookup('ansible.builtin.file', item) | replace('__TELEGRAM_USER_ID__', telegram_user_id | string) | replace('__OLLAMA_MODEL__', ollama_model) }}"
|
||||||
|
dest: "{{ n8n_data_dir }}/import/workflows/{{ item | basename }}"
|
||||||
|
owner: "1000"
|
||||||
|
group: "1000"
|
||||||
|
mode: "0600"
|
||||||
|
with_fileglob:
|
||||||
|
- "workflows/*.json"
|
||||||
|
notify: Import n8n content
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Deploy Ollama credential
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: credentials/ollama.json
|
||||||
|
dest: "{{ n8n_data_dir }}/import/credentials/ollama.json"
|
||||||
|
owner: "1000"
|
||||||
|
group: "1000"
|
||||||
|
mode: "0600"
|
||||||
|
notify: Import n8n content
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Deploy SMTP credential
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: smtp-credential.json.j2
|
||||||
|
dest: "{{ n8n_data_dir }}/import/credentials/smtp.json"
|
||||||
|
owner: "1000"
|
||||||
|
group: "1000"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
notify: Import n8n content
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
# Workflows with "active": true in their JSON are published after import
|
||||||
|
- name: Collect workflow ids to publish
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
n8n_publish_ids: >-
|
||||||
|
{%- set ns = namespace(ids=[]) -%}
|
||||||
|
{%- for f in query('fileglob', role_path ~ '/files/workflows/*.json') -%}
|
||||||
|
{%- set wf = lookup('file', f) | from_json -%}
|
||||||
|
{%- if wf.active | default(false) -%}{%- set ns.ids = ns.ids + [wf.id] -%}{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{{ ns.ids | to_json }}
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Deploy Telegram credential
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: telegram-credential.json.j2
|
||||||
|
dest: "{{ n8n_data_dir }}/import/credentials/telegram.json"
|
||||||
|
owner: "1000"
|
||||||
|
group: "1000"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
notify: Import n8n content
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Deploy Gemini credential
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: gemini-credential.json.j2
|
||||||
|
dest: "{{ n8n_data_dir }}/import/credentials/gemini.json"
|
||||||
|
owner: "1000"
|
||||||
|
group: "1000"
|
||||||
|
mode: "0600"
|
||||||
|
no_log: true
|
||||||
|
notify: Import n8n content
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
- name: Start n8n
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ n8n_data_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: missing
|
||||||
|
tags: n8n
|
||||||
|
|
||||||
|
# Handlers run at the end of the play; flush so the import happens before the role finishes
|
||||||
|
- name: Apply pending handlers
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
tags: n8n
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Managed by Ansible — do not edit manually
|
||||||
|
services:
|
||||||
|
n8n:
|
||||||
|
image: n8nio/n8n:{{ n8n_version }}
|
||||||
|
container_name: n8n
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
N8N_HOST: "{{ domain_n8n }}"
|
||||||
|
N8N_PROTOCOL: "https"
|
||||||
|
N8N_PORT: "5678"
|
||||||
|
# Public URL used to build webhook / chat links behind Traefik
|
||||||
|
WEBHOOK_URL: "https://{{ domain_n8n }}/"
|
||||||
|
N8N_PROXY_HOPS: "1"
|
||||||
|
N8N_ENCRYPTION_KEY: "{{ n8n_encryption_key }}"
|
||||||
|
GENERIC_TIMEZONE: "{{ n8n_timezone }}"
|
||||||
|
TZ: "{{ n8n_timezone }}"
|
||||||
|
N8N_DIAGNOSTICS_ENABLED: "false"
|
||||||
|
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
|
||||||
|
N8N_TEMPLATES_ENABLED: "false"
|
||||||
|
# Keep the SQLite database small: drop execution history after 7 days
|
||||||
|
EXECUTIONS_DATA_PRUNE: "true"
|
||||||
|
EXECUTIONS_DATA_MAX_AGE: "168"
|
||||||
|
volumes:
|
||||||
|
- {{ n8n_data_dir }}/data:/home/node/.n8n
|
||||||
|
# Workflows and credentials imported by Ansible (see roles/n8n/tasks/main.yml)
|
||||||
|
- {{ n8n_data_dir }}/import:/import:ro
|
||||||
|
mem_limit: {{ n8n_mem_limit }}
|
||||||
|
networks:
|
||||||
|
- traefik_public
|
||||||
|
- ollama_internal
|
||||||
|
- browserless_internal
|
||||||
|
- searxng_internal
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
|
||||||
|
# Editor, webhooks and chat page — all behind basic auth
|
||||||
|
- "traefik.http.routers.n8n.rule=Host(`{{ domain_n8n }}`)"
|
||||||
|
- "traefik.http.routers.n8n.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.n8n.service=n8n"
|
||||||
|
- "traefik.http.routers.n8n.middlewares=n8n-auth"
|
||||||
|
# Short link: /chat redirects to the hosted chat page of the chat workflow
|
||||||
|
- "traefik.http.routers.n8n-chat.rule=Host(`{{ domain_n8n }}`) && Path(`/chat`)"
|
||||||
|
- "traefik.http.routers.n8n-chat.priority=100"
|
||||||
|
- "traefik.http.routers.n8n-chat.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.n8n-chat.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.n8n-chat.service=n8n"
|
||||||
|
- "traefik.http.routers.n8n-chat.middlewares=n8n-auth,n8n-chat-redirect"
|
||||||
|
- "traefik.http.middlewares.n8n-chat-redirect.redirectregex.regex=^https://{{ domain_n8n | replace('.', '\\\\.') }}/chat/?$$"
|
||||||
|
- "traefik.http.middlewares.n8n-chat-redirect.redirectregex.replacement=https://{{ domain_n8n }}/webhook/{{ n8n_chat_webhook_id }}/chat"
|
||||||
|
# Telegram bot webhook: called by Telegram's servers, so no basic auth. Restricted to Telegram's
|
||||||
|
# address ranges; n8n additionally checks Telegram's secret token header and the workflow
|
||||||
|
# only answers the user id in telegram_user_id.
|
||||||
|
- "traefik.http.routers.n8n-telegram.rule=Host(`{{ domain_n8n }}`) && Path(`/webhook/{{ n8n_telegram_webhook_id }}/webhook`) && Method(`POST`)"
|
||||||
|
- "traefik.http.routers.n8n-telegram.priority=200"
|
||||||
|
- "traefik.http.routers.n8n-telegram.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.n8n-telegram.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.n8n-telegram.service=n8n"
|
||||||
|
{% if telegram_webhook_source_ranges | length > 0 %}
|
||||||
|
- "traefik.http.routers.n8n-telegram.middlewares=n8n-telegram-ips"
|
||||||
|
- "traefik.http.middlewares.n8n-telegram-ips.ipallowlist.sourcerange={{ telegram_webhook_source_ranges | join(',') }}"
|
||||||
|
{% endif %}
|
||||||
|
{% set ns = namespace(entries=[]) %}
|
||||||
|
{% for user in n8n_users %}
|
||||||
|
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
|
||||||
|
{% endfor %}
|
||||||
|
- "traefik.http.middlewares.n8n-auth.basicauth.users={{ ns.entries | join(',') }}"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_public:
|
||||||
|
external: true
|
||||||
|
ollama_internal:
|
||||||
|
external: true
|
||||||
|
browserless_internal:
|
||||||
|
external: true
|
||||||
|
searxng_internal:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "gemini00000001",
|
||||||
|
"name": "Google Gemini",
|
||||||
|
"type": "googlePalmApi",
|
||||||
|
"data": {
|
||||||
|
"host": "https://generativelanguage.googleapis.com",
|
||||||
|
"apiKey": {{ gemini_api_key | to_json }}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "smtp00000000001",
|
||||||
|
"name": "Strato SMTP",
|
||||||
|
"type": "smtp",
|
||||||
|
"data": {
|
||||||
|
"user": {{ smtp_user | to_json }},
|
||||||
|
"password": {{ smtp_password | to_json }},
|
||||||
|
"host": {{ smtp_host | to_json }},
|
||||||
|
"port": {{ smtp_port }},
|
||||||
|
"secure": {{ smtp_secure | to_json }},
|
||||||
|
"disableStartTls": false,
|
||||||
|
"hostName": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "telegram0000001",
|
||||||
|
"name": "Telegram bot",
|
||||||
|
"type": "telegramApi",
|
||||||
|
"data": {
|
||||||
|
"accessToken": {{ telegram_bot_token | to_json }},
|
||||||
|
"baseUrl": "https://api.telegram.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Restart ollama
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ ollama_data_dir }}"
|
||||||
|
state: present
|
||||||
|
recreate: always
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
- name: Create Ollama directories
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
loop:
|
||||||
|
- "{{ ollama_data_dir }}"
|
||||||
|
- "{{ ollama_data_dir }}/models"
|
||||||
|
tags: ollama
|
||||||
|
|
||||||
|
- name: Deploy Docker Compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ ollama_data_dir }}/docker-compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart ollama
|
||||||
|
tags: ollama
|
||||||
|
|
||||||
|
- name: Start Ollama
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ ollama_data_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: missing
|
||||||
|
tags: ollama
|
||||||
|
|
||||||
|
- name: List installed Ollama models
|
||||||
|
ansible.builtin.command: docker exec ollama ollama list
|
||||||
|
register: ollama_installed
|
||||||
|
changed_when: false
|
||||||
|
retries: 10
|
||||||
|
delay: 3
|
||||||
|
until: ollama_installed.rc == 0
|
||||||
|
tags: ollama
|
||||||
|
|
||||||
|
- name: Pull Ollama model
|
||||||
|
ansible.builtin.command: docker exec ollama ollama pull {{ ollama_model }}
|
||||||
|
when: ollama_model not in ollama_installed.stdout
|
||||||
|
changed_when: true
|
||||||
|
tags: ollama
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Managed by Ansible — do not edit manually
|
||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:{{ ollama_version }}
|
||||||
|
container_name: ollama
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
OLLAMA_CONTEXT_LENGTH: "{{ ollama_context_length }}"
|
||||||
|
OLLAMA_FLASH_ATTENTION: "1"
|
||||||
|
OLLAMA_KV_CACHE_TYPE: "q8_0"
|
||||||
|
# One model, one request at a time — keeps RAM use predictable on the shared VPS
|
||||||
|
OLLAMA_MAX_LOADED_MODELS: "1"
|
||||||
|
OLLAMA_NUM_PARALLEL: "1"
|
||||||
|
# Unload the model after this idle time to free RAM for the other services
|
||||||
|
OLLAMA_KEEP_ALIVE: "{{ ollama_keep_alive }}"
|
||||||
|
volumes:
|
||||||
|
- {{ ollama_data_dir }}/models:/root/.ollama
|
||||||
|
mem_limit: {{ ollama_mem_limit }}
|
||||||
|
networks:
|
||||||
|
- ollama_internal
|
||||||
|
|
||||||
|
# Not exposed via Traefik. Containers that need the API join this network
|
||||||
|
# (declared as external there) and reach it at http://ollama:11434.
|
||||||
|
networks:
|
||||||
|
ollama_internal:
|
||||||
|
name: ollama_internal
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
- name: Restart searxng
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ searxng_data_dir }}"
|
||||||
|
state: present
|
||||||
|
recreate: always
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
- name: Create SearXNG directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ searxng_data_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
tags: searxng
|
||||||
|
|
||||||
|
- name: Deploy SearXNG settings
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: settings.yml.j2
|
||||||
|
dest: "{{ searxng_data_dir }}/settings.yml"
|
||||||
|
owner: "977"
|
||||||
|
group: "977"
|
||||||
|
mode: "0640"
|
||||||
|
no_log: true
|
||||||
|
notify: Restart searxng
|
||||||
|
tags: searxng
|
||||||
|
|
||||||
|
- name: Deploy Docker Compose file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ searxng_data_dir }}/docker-compose.yml"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Restart searxng
|
||||||
|
tags: searxng
|
||||||
|
|
||||||
|
- name: Start SearXNG
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ searxng_data_dir }}"
|
||||||
|
state: present
|
||||||
|
pull: missing
|
||||||
|
tags: searxng
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Managed by Ansible — do not edit manually
|
||||||
|
services:
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:{{ searxng_version }}
|
||||||
|
container_name: searxng
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SEARXNG_BASE_URL: "http://searxng:8080/"
|
||||||
|
volumes:
|
||||||
|
- {{ searxng_data_dir }}/settings.yml:/etc/searxng/settings.yml:ro
|
||||||
|
mem_limit: {{ searxng_mem_limit }}
|
||||||
|
networks:
|
||||||
|
- searxng_internal
|
||||||
|
|
||||||
|
# Not exposed via Traefik. Containers that need the API join this network
|
||||||
|
# (declared as external there) and reach it at http://searxng:8080.
|
||||||
|
networks:
|
||||||
|
searxng_internal:
|
||||||
|
name: searxng_internal
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Managed by Ansible — do not edit manually
|
||||||
|
use_default_settings: true
|
||||||
|
|
||||||
|
general:
|
||||||
|
instance_name: "search"
|
||||||
|
enable_metrics: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
secret_key: "{{ searxng_secret_key }}"
|
||||||
|
# Internal use only (no public route), so the bot limiter is not needed
|
||||||
|
limiter: false
|
||||||
|
image_proxy: false
|
||||||
|
|
||||||
|
search:
|
||||||
|
# json is required by the n8n web_search tool
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json
|
||||||
|
safe_search: 0
|
||||||
|
autocomplete: ""
|
||||||
|
|
||||||
|
outgoing:
|
||||||
|
request_timeout: 6.0
|
||||||
|
max_request_timeout: 10.0
|
||||||
@@ -30,9 +30,6 @@ entryPoints:
|
|||||||
imaps:
|
imaps:
|
||||||
address: ":993"
|
address: ":993"
|
||||||
|
|
||||||
serversTransport:
|
|
||||||
insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633
|
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
docker:
|
docker:
|
||||||
exposedByDefault: false
|
exposedByDefault: false
|
||||||
|
|||||||
+4
-1
@@ -15,5 +15,8 @@
|
|||||||
- k8s
|
- k8s
|
||||||
- vaultwarden
|
- vaultwarden
|
||||||
- dl
|
- dl
|
||||||
- clay
|
- ollama
|
||||||
|
- browserless
|
||||||
|
- searxng
|
||||||
|
- n8n
|
||||||
- dashboard
|
- dashboard
|
||||||
|
|||||||
+245
-139
@@ -25,7 +25,8 @@ The script checks these endpoints and verifies the expected HTTP status code:
|
|||||||
| `https://k8s.ladkau.de` | 200 | Placeholder page |
|
| `https://k8s.ladkau.de` | 200 | Placeholder page |
|
||||||
| `https://vault.ladkau.de` | 200 | Vaultwarden web vault |
|
| `https://vault.ladkau.de` | 200 | Vaultwarden web vault |
|
||||||
| `https://dl.ladkau.de` | 200 | Public download server |
|
| `https://dl.ladkau.de` | 200 | Public download server |
|
||||||
| `https://agent.ladkau.de` | 401 | Clay — basic-auth prompt, correct without credentials |
|
| `https://agent.ladkau.de` | 401 | n8n — basic-auth prompt, correct without credentials |
|
||||||
|
| `https://agent.ladkau.de/chat` | 401 | AI chat — basic-auth prompt, correct without credentials |
|
||||||
|
|
||||||
A `000` result means the connection was refused or timed out — a container that
|
A `000` result means the connection was refused or timed out — a container that
|
||||||
did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on
|
did not start. Keycloak and Nextcloud may return `502` for up to 90 seconds on
|
||||||
@@ -470,163 +471,268 @@ Traefik routes `/v2/` (Docker API) to the registry container and everything else
|
|||||||
to the UI container — both on the same domain, so no CORS configuration is
|
to the UI container — both on the same domain, so no CORS configuration is
|
||||||
needed. `docker push` and `docker pull` work exactly as before.
|
needed. `docker push` and `docker pull` work exactly as before.
|
||||||
|
|
||||||
## 9. Clay — Claude Code web interface
|
## 9. n8n — AI chat and scheduled digests
|
||||||
|
|
||||||
Clay is a browser-based front end for Claude Code, served at
|
n8n is served at `https://agent.ladkau.de` behind HTTP basic auth
|
||||||
`https://agent.ladkau.de`. Access is protected by HTTP basic auth using the
|
(`n8n_users` from the vault). It provides:
|
||||||
`clay_users` credentials from the vault.
|
|
||||||
|
|
||||||
### 9.1 Add projects
|
- an AI chat page at `https://agent.ladkau.de/chat` that can search the web and read web pages
|
||||||
|
- a Telegram bot that answers with Gemini (default) or the local Qwen model (`/qwen`), and that scheduled workflows can also post to
|
||||||
|
- scheduled workflows (scrape a page, summarise it with the local model, send the summary by mail)
|
||||||
|
|
||||||
The container mounts `/opt/clay/workspace` at `/workspace`. Clone Gitea
|
Components: `n8n` (workflows), `browserless` (headless Chromium for
|
||||||
repositories there to make them available to Claude Code:
|
JavaScript-heavy pages), `searxng` (web search) and `ollama` (local model,
|
||||||
|
section 9.5). Ollama, browserless and SearXNG are reachable only on internal
|
||||||
|
Docker networks.
|
||||||
|
|
||||||
|
### 9.1 First login
|
||||||
|
|
||||||
|
Open `https://agent.ladkau.de`, pass the basic-auth prompt and create the n8n
|
||||||
|
owner account. The Ollama credential ("Ollama (local)") and the SMTP
|
||||||
|
credential ("Strato SMTP") are imported by Ansible; nothing needs to be
|
||||||
|
entered in the UI.
|
||||||
|
|
||||||
|
### 9.2 Chat
|
||||||
|
|
||||||
|
`https://agent.ladkau.de/chat` redirects to the hosted chat page of the
|
||||||
|
`Chat` workflow (`/webhook/<n8n_chat_webhook_id>/chat`). The chat is an AI
|
||||||
|
agent with two tools:
|
||||||
|
|
||||||
|
- `web_search` — queries SearXNG and returns the top 6 results (title, URL, snippet)
|
||||||
|
- `fetch_page` — opens a URL in browserless and returns the readable text
|
||||||
|
|
||||||
|
- Every page load starts a new conversation. The last 6 messages are kept as context; history is held in memory and is lost when n8n restarts.
|
||||||
|
- `fetch_page` returns at most about 3000 characters of page text (title plus visible text without navigation). Tool results are kept in the chat memory.
|
||||||
|
|
||||||
|
### 9.3 Workflows in git
|
||||||
|
|
||||||
|
Workflows live in `ansible/roles/n8n/files/workflows/*.json` and are imported
|
||||||
|
by Ansible on every deploy that changes them:
|
||||||
|
|
||||||
|
| File | Workflow | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `chat.json` | Chat | Hosted chat page with the `fetch_page` tool |
|
||||||
|
| `fetch-page.json` | Fetch page text | Sub-workflow: renders a URL in browserless and returns title and text |
|
||||||
|
| `web-search.json` | Web search | Sub-workflow: queries SearXNG and returns the top results |
|
||||||
|
| `digest.json` | Weekly page digest (example) | Schedule → fetch page → summarise → mail; inactive until configured |
|
||||||
|
| `telegram-chat.json` | Telegram chat | Telegram bot: Gemini agent by default, Qwen agent for `/qwen`, both with the search and page tools; also has a `Post message` entry point used by other workflows |
|
||||||
|
| `digest-telegram.json` | Weekly page digest to Telegram (example) | Schedule → fetch page → summarise → post to the Telegram chat; inactive until configured |
|
||||||
|
|
||||||
|
A workflow with `"active": true` in its JSON is published on import. Sub-workflows
|
||||||
|
that are used as tools (`fetch-page`, `web-search`) must be published too; n8n
|
||||||
|
refuses to run an unpublished workflow, even when another workflow calls it. The
|
||||||
|
import replaces workflows with the same id, so changes made in the editor must
|
||||||
|
be exported to the repo or they are overwritten by the next import:
|
||||||
|
|
||||||
|
1. In the n8n editor open the workflow, then **… → Download** (or copy it with Ctrl+A, Ctrl+C).
|
||||||
|
2. Save the JSON as `ansible/roles/n8n/files/workflows/<name>.json`. Keep the `id` and, for the chat workflow, the `webhookId`.
|
||||||
|
3. Set `"active": true` in the file if the workflow should be published.
|
||||||
|
4. Commit, then deploy with `--tags n8n`.
|
||||||
|
|
||||||
|
Workflows call each other by id (`fetchpage0000001`, `websearch000001`, `telegramchat0001`).
|
||||||
|
They reference credentials by id: `ollama0000000001` ("Ollama (local)")
|
||||||
|
and `smtp00000000001` ("Strato SMTP").
|
||||||
|
|
||||||
|
To force a re-import without changing a file, remove `/opt/n8n/import/workflows`
|
||||||
|
on the server and run the role again.
|
||||||
|
|
||||||
|
### 9.4 Scheduled digest
|
||||||
|
|
||||||
|
Copy `digest.json` (mail) or `digest-telegram.json` (Telegram, section 9.8), then in the editor adjust:
|
||||||
|
|
||||||
|
- **Every Monday 06:00** — cron expression (`n8n_timezone` applies)
|
||||||
|
- **Sites** — one item per page: `url`, `prompt` and recipient `to`
|
||||||
|
- **Send mail** (mail version) — sender is the `smtp_user` address
|
||||||
|
|
||||||
|
Export the result to the repo (section 9.3) and set `"active": true`. Run it
|
||||||
|
once manually with **Execute workflow** before relying on the schedule.
|
||||||
|
A summary of a long page takes several minutes on the CPU-only model.
|
||||||
|
|
||||||
|
### 9.5 Ollama — local LLM server
|
||||||
|
|
||||||
|
The `ollama` role runs Ollama (CPU only). It is reachable only on the internal
|
||||||
|
Docker network `ollama_internal` at `http://ollama:11434`; there is no public
|
||||||
|
route.
|
||||||
|
|
||||||
|
Settings in `ansible/group_vars/all/vars.yml`:
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `ollama_version` | `0.35.1` | Image tag |
|
||||||
|
| `ollama_model` | `qwen3:4b-instruct-2507-q4_K_M` | Model pulled on deploy; also written into the workflows (placeholder `__OLLAMA_MODEL__`) |
|
||||||
|
| `ollama_context_length` | `16384` | Context window in tokens |
|
||||||
|
| (workflow option) `numThread` | `4` | Threads used by the Ollama chat model nodes; Ollama defaults to 2 on this 2-core/4-thread host, 4 reads prompts about 60% faster |
|
||||||
|
| `ollama_mem_limit` | `4608m` | Container memory cap (VPS has 8 GB shared with ~25 containers) |
|
||||||
|
| `ollama_keep_alive` | `1h` | Idle time before the model is unloaded from RAM |
|
||||||
|
|
||||||
|
Deploy and verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148
|
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags ollama --ask-vault-pass
|
||||||
cd /opt/clay/workspace
|
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
|
||||||
git clone https://gitea.ladkau.de/<username>/<repo>.git
|
"sudo docker exec ollama ollama list && sudo docker stats --no-stream ollama"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then in the clay web UI, use **Add project** and point it at `/workspace/<repo>`.
|
Changing `ollama_model` and redeploying pulls the new model; also change the
|
||||||
|
`model` field of the Ollama Chat Model nodes in the workflow JSON files.
|
||||||
|
Remove unused models with `docker exec ollama ollama rm <model>`. A CPU-only 4B
|
||||||
|
model at 16k context uses most of the memory available on this VPS; check
|
||||||
|
`docker stats` after the first model load.
|
||||||
|
|
||||||
### 9.2 MCP servers and skills
|
### 9.6 Browserless
|
||||||
|
|
||||||
Clay configuration (MCP server entries, session history, project registrations)
|
The `browserless` role runs headless Chromium (one session at a time, 1 GB
|
||||||
is stored in `~/.clay`, which is mounted from `/opt/clay/data` on the host and
|
memory limit) on the internal network `browserless_internal` at
|
||||||
survives container rebuilds.
|
`http://browserless:3000`. Check it with:
|
||||||
|
|
||||||
MCP server **binaries** installed interactively inside a running container (e.g.
|
|
||||||
via `npm install -g` in a clay terminal) are part of the container filesystem
|
|
||||||
and are lost when the image is rebuilt.
|
|
||||||
|
|
||||||
To make a package survive rebuilds, add it to
|
|
||||||
`ansible/roles/clay/files/Dockerfile` and redeploy:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# MCP servers and skills — add packages here to survive container rebuilds
|
|
||||||
RUN npm install -g @clay-ai/clay-ralph
|
|
||||||
RUN npm install -g @modelcontextprotocol/server-filesystem
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
|
|
||||||
```
|
|
||||||
|
|
||||||
Ansible detects the Dockerfile change and triggers a rebuild automatically.
|
|
||||||
|
|
||||||
### 9.3 Rebuild after clay updates
|
|
||||||
|
|
||||||
The Docker image is built from `ansible/roles/clay/files/Dockerfile` at deploy
|
|
||||||
time. To pull upstream changes to `clay-server` or `@anthropic-ai/claude-code`,
|
|
||||||
rebuild the image and restart the container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags clay --ask-vault-pass
|
|
||||||
```
|
|
||||||
|
|
||||||
The Ansible role forces a rebuild whenever the Dockerfile changes; to force a
|
|
||||||
rebuild without a Dockerfile change, delete the cached image first:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
|
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
|
||||||
"sudo docker image rm clay:local && sudo docker compose -f /opt/clay/docker-compose.yml up -d"
|
"sudo docker run --rm --network browserless_internal curlimages/curl -s http://browserless:3000/pressure"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.4 Interactive sessions vs. scheduled runs
|
### 9.7 SearXNG
|
||||||
|
|
||||||
Clay and Gitea Actions serve different purposes and are independent of each
|
The `searxng` role runs SearXNG on the internal network `searxng_internal` at
|
||||||
other:
|
`http://searxng:8080`. Its settings (`/opt/searxng/settings.yml`) are rendered
|
||||||
|
from `roles/searxng/templates/settings.yml.j2`. The JSON format must stay
|
||||||
|
enabled; the `web_search` tool depends on it. Check it with:
|
||||||
|
|
||||||
| | Clay | Gitea Actions |
|
```bash
|
||||||
|---|---|---|
|
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 \
|
||||||
| **Trigger** | You, in the browser | Schedule or manual button click |
|
"sudo docker run --rm --network searxng_internal curlimages/curl -s 'http://searxng:8080/search?q=test&format=json' | head -c 300"
|
||||||
| **Use case** | Exploratory, iterative work | Recurring automated tasks |
|
|
||||||
| **Project** | Repos cloned into `/opt/clay/workspace/` | Checked out fresh per run |
|
|
||||||
| **Output** | Interactive UI | Logs + optional email |
|
|
||||||
|
|
||||||
Clay has no scheduling API — there is no way to trigger a Clay session
|
|
||||||
programmatically. For recurring agent tasks, Gitea Actions runs Claude Code
|
|
||||||
directly on the act_runners, independent of Clay. If both operate on the same
|
|
||||||
git repository the context is identical, but the sessions are separate.
|
|
||||||
|
|
||||||
### 9.5 Scheduled Claude Code runs via Gitea Actions
|
|
||||||
|
|
||||||
The act_runner containers can run Claude Code on a recurring schedule using
|
|
||||||
Gitea's `on.schedule` trigger. Adding `workflow_dispatch` alongside it allows
|
|
||||||
the same workflow to be triggered manually from the Gitea UI without waiting
|
|
||||||
for the next scheduled run.
|
|
||||||
|
|
||||||
**Set up secrets**
|
|
||||||
|
|
||||||
In the repository go to **Settings → Secrets → Actions** (or
|
|
||||||
**Site Administration → Actions → Secrets** for org-wide secrets) and add:
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| `ANTHROPIC_API_KEY` | API key from the vault |
|
|
||||||
| `SMTP_USERNAME` | Gmail address used as the SMTP sender |
|
|
||||||
| `SMTP_PASSWORD` | Gmail App Password — myaccount.google.com → Security → App passwords |
|
|
||||||
|
|
||||||
**Workflow file**
|
|
||||||
|
|
||||||
Add `.gitea/workflows/weekly-agent.yml` to the repository:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Weekly agent run
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
|
|
||||||
workflow_dispatch: # also runnable manually from the Gitea Actions UI
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
agent:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Claude Code
|
|
||||||
run: npm install -g @anthropic-ai/claude-code
|
|
||||||
|
|
||||||
- name: Run agent
|
|
||||||
env:
|
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
run: |
|
|
||||||
claude --print "Your prompt here" \
|
|
||||||
--output-format text \
|
|
||||||
| tee agent-output.txt
|
|
||||||
|
|
||||||
- name: Send report email
|
|
||||||
env:
|
|
||||||
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
python3 - <<'EOF'
|
|
||||||
import smtplib, os
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
|
|
||||||
with open("agent-output.txt") as f:
|
|
||||||
body = f.read()
|
|
||||||
|
|
||||||
msg = MIMEText(body)
|
|
||||||
msg["Subject"] = "Weekly agent report"
|
|
||||||
msg["From"] = os.environ["SMTP_USERNAME"]
|
|
||||||
msg["To"] = os.environ["SMTP_USERNAME"]
|
|
||||||
|
|
||||||
with smtplib.SMTP("smtp.gmail.com", 587) as s:
|
|
||||||
s.starttls()
|
|
||||||
s.login(os.environ["SMTP_USERNAME"], os.environ["SMTP_PASSWORD"])
|
|
||||||
s.send_message(msg)
|
|
||||||
EOF
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Adjust the `cron` expression, prompt, and `msg["To"]` address as needed.
|
Some upstream engines (DuckDuckGo, Brave) may answer with captchas or rate
|
||||||
|
limits from the server's IP address; SearXNG then returns results from the
|
||||||
|
remaining engines. The search may return fewer results at times.
|
||||||
|
|
||||||
**Run manually**
|
### 9.8 Telegram bot
|
||||||
|
|
||||||
Go to **Repository → Actions**, select **Weekly agent run** from the workflow
|
The `Telegram chat` workflow lets you chat with the assistant from the Telegram
|
||||||
list, and click **Run workflow**. The run appears immediately in the Actions log.
|
app and lets scheduled workflows post to the same conversation. The bot answers
|
||||||
|
only the Telegram user id in `telegram_user_id`; messages from anyone else and
|
||||||
|
non-text messages are ignored.
|
||||||
|
|
||||||
**Verify the schedule is active**
|
**Models**
|
||||||
|
|
||||||
After pushing the workflow file, scheduled runs appear automatically in
|
| Message | Model |
|
||||||
**Repository → Actions** at the configured time.
|
|---------|-------|
|
||||||
|
| `what is the weather in Osnabrück?` | Google Gemini `gemini-3.8-flash`, then `gemini-3.5-flash` if that fails, then local Qwen |
|
||||||
|
| `/qwen what is the weather in Osnabrück?` | Local Qwen via Ollama (slow, free, private) |
|
||||||
|
|
||||||
|
If a Gemini model fails (daily quota, overload, anything else), the same
|
||||||
|
question goes to the next stage, and the reply ends with a note saying which
|
||||||
|
model answered. The stages are separate agents in `telegram-chat.json`
|
||||||
|
(`Gemini 3.8 Agent`, `Gemini 3.5 Agent`, `Qwen Agent`); the checks
|
||||||
|
`Gemini 3.8 ok?` and `Gemini 3.5 ok?` look for an empty answer.
|
||||||
|
|
||||||
|
All agents have the `web_search` and `fetch_page` tools. The Gemini agents share
|
||||||
|
one conversation memory (last 8 messages, session `telegram-main`), so the Gemini
|
||||||
|
models can be switched in the middle of a conversation. The Qwen agent has its own
|
||||||
|
memory (last 2 messages, session `telegram-qwen`).
|
||||||
|
|
||||||
|
The Gemini models are set in the `Gemini 3.8` and `Gemini 3.5` nodes of
|
||||||
|
`telegram-chat.json`. The model name is a plain string such as
|
||||||
|
`models/gemini-3.8-flash`. List the models a key can use with
|
||||||
|
`curl "https://generativelanguage.googleapis.com/v1beta/models?key=<KEY>"`; a
|
||||||
|
listed model can still be unavailable, so check it with a direct
|
||||||
|
`generateContent` call before using it.
|
||||||
|
|
||||||
|
Gemini API limits:
|
||||||
|
|
||||||
|
- The free tier limits requests per model, per minute and per day. The limit that was hit is named in the API error message. A question that uses a tool costs at least two requests (tool call, then answer).
|
||||||
|
- When a model is over its limit or overloaded, the agent retries once after 3 seconds and then the next stage answers.
|
||||||
|
- The system prompt asks for one `fetch_page` per web address or one `web_search` per question, never the same call twice. Agents stop after 4 iterations (`maxIterations`).
|
||||||
|
|
||||||
|
Gemini requests send messages, search results and page contents to Google;
|
||||||
|
check the terms of the API tier in use. If every stage fails the bot replies
|
||||||
|
with `Error: ...`.
|
||||||
|
|
||||||
|
The Qwen model is the non-thinking `qwen3:4b-instruct-2507` variant. The web chat
|
||||||
|
at `/chat` and the digests use the local Qwen model only.
|
||||||
|
|
||||||
|
When `ollama_model` changes, remove the old model on the server with
|
||||||
|
`docker exec ollama ollama rm <old model>`.
|
||||||
|
|
||||||
|
**Message formatting**
|
||||||
|
|
||||||
|
Replies are sent with `parse_mode` HTML (the n8n Telegram node otherwise uses
|
||||||
|
Telegram's legacy Markdown mode). The `Format reply` and `Format posted message`
|
||||||
|
steps escape the text and convert bold, italics, inline code, code blocks,
|
||||||
|
headings, bullets and `[text](https://…)` links to Telegram HTML. Tables are shown
|
||||||
|
as plain text.
|
||||||
|
|
||||||
|
**Get a Gemini API key**
|
||||||
|
|
||||||
|
Create a key in Google AI Studio (`aistudio.google.com`, **Get API key**) and
|
||||||
|
store it in the vault as `gemini_api_key`.
|
||||||
|
|
||||||
|
**Create the bot (once)**
|
||||||
|
|
||||||
|
1. In Telegram open `@BotFather`, send `/newbot` and follow the prompts. Copy the bot token.
|
||||||
|
2. Open a chat with your new bot and send it any message (for example `/start`).
|
||||||
|
3. Find your numeric user id. Before the first deploy (while no webhook is set) run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.telegram.org/bot<TOKEN>/getUpdates"
|
||||||
|
```
|
||||||
|
|
||||||
|
and read `message.from.id` in the result.
|
||||||
|
4. Add `gemini_api_key`, `telegram_bot_token` and `telegram_user_id` to the vault
|
||||||
|
(`ansible-vault edit ansible/group_vars/all/vault.yml`), check with
|
||||||
|
`bash scripts/check-vault.sh`, and deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/site.yml --tags n8n --ask-vault-pass
|
||||||
|
```
|
||||||
|
|
||||||
|
On start n8n registers the webhook `https://agent.ladkau.de/webhook/<n8n_telegram_webhook_id>/webhook` with Telegram.
|
||||||
|
|
||||||
|
**Use it**
|
||||||
|
|
||||||
|
- Write to the bot like in the web chat. Replying to an earlier message (swipe on the message) adds the quoted text as context.
|
||||||
|
- Gemini answers within seconds; `/qwen` answers take a minute or more on the CPU-only model.
|
||||||
|
- Conversation memory holds the last 8 messages and is lost whenever n8n restarts; replying to a message restores its context.
|
||||||
|
|
||||||
|
**Stock price report**
|
||||||
|
|
||||||
|
The `Telegram chat` workflow asks for the stock prices of MTE.DE and PTX.F three
|
||||||
|
times a day on weekdays at 07:30, 13:00 and 17:40 (time zone
|
||||||
|
`n8n_timezone`). The answer arrives in your Telegram chat through the normal
|
||||||
|
model chain, so you can ask follow-up questions. It can also be started:
|
||||||
|
|
||||||
|
- by sending `/stocks` to the bot (Gemini chain), or `/qwen /stocks` to test the local model with the same question
|
||||||
|
- from the n8n editor: open `Telegram chat` and run the `Run stock price now` node with **Execute step**; the answer is sent to Telegram
|
||||||
|
|
||||||
|
The question is the constant `STOCK_QUESTION` in the `Route` node. It names the
|
||||||
|
two Yahoo quote pages and tells the model to call `fetch_page` for each; adding
|
||||||
|
a ticker means adding its URL to that text. The model chooses the tool calls;
|
||||||
|
there is no fixed fetch step. The times are the three cron expressions of the
|
||||||
|
`Stock price schedule` node (a standard 5-field cron expression, where `1-5`
|
||||||
|
means Monday to Friday). Change either in
|
||||||
|
the editor, export the workflow to the repo (section 9.3) and deploy. The
|
||||||
|
schedule only runs while the workflow is published; every deploy that changes
|
||||||
|
workflows restarts n8n and registers it again.
|
||||||
|
|
||||||
|
**Scheduled posts**
|
||||||
|
|
||||||
|
`digest-telegram.json` ends with the sub-workflow call `Post to Telegram chat`.
|
||||||
|
The summary is sent to your chat and added to the chat memory, so a follow-up
|
||||||
|
question such as "what about the second point?" has the context. Any workflow
|
||||||
|
can do the same by calling the `Telegram chat` workflow with a `message`
|
||||||
|
input.
|
||||||
|
|
||||||
|
**Security notes**
|
||||||
|
|
||||||
|
- The Telegram webhook route has no basic auth because Telegram's servers call it. Traefik admits only the address ranges in `telegram_webhook_source_ranges` (Telegram's webhook servers; set the list to `[]` to turn the filter off if Telegram's ranges change and the bot stops receiving messages). n8n verifies Telegram's secret token header and the workflow filters on `telegram_user_id`.
|
||||||
|
- Telegram bot messages are not end-to-end encrypted. Summaries and questions pass through Telegram's servers.
|
||||||
|
- If the bot token leaks, revoke it with `/revoke` in `@BotFather`, update the vault and redeploy.
|
||||||
|
|
||||||
|
**Troubleshooting**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i keys/notroot_cloud_ladkau_de deploy@217.154.207.148 "sudo docker logs --tail 50 n8n"
|
||||||
|
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
|
||||||
|
```
|
||||||
|
|
||||||
|
`getWebhookInfo` shows the registered URL and the last delivery error.
|
||||||
|
|||||||
@@ -122,12 +122,17 @@ registry_users:
|
|||||||
- username: alice
|
- username: alice
|
||||||
password: "your-password"
|
password: "your-password"
|
||||||
|
|
||||||
# Clay — Claude Code web interface at agent.ladkau.de
|
# n8n — AI chat and scheduled digests at agent.ladkau.de
|
||||||
# Generate API key at console.anthropic.com → API keys
|
n8n_users: # basic auth in front of n8n
|
||||||
anthropic_api_key: ""
|
|
||||||
clay_users:
|
|
||||||
- username: admin
|
- username: admin
|
||||||
password: "your-password"
|
password: "your-password"
|
||||||
|
n8n_encryption_key: "" # generate: openssl rand -hex 32 — never change after first deploy
|
||||||
|
searxng_secret_key: "" # generate: openssl rand -hex 32
|
||||||
|
smtp_user: "" # full Strato mailbox address
|
||||||
|
smtp_password: ""
|
||||||
|
gemini_api_key: "" # Google AI Studio API key, see runbook-configuration.md section 9.8
|
||||||
|
telegram_bot_token: "" # from @BotFather, see runbook-configuration.md section 9.8
|
||||||
|
telegram_user_id: "" # your numeric Telegram user id
|
||||||
|
|
||||||
# Gitea Actions runners — token obtained after Gitea is running
|
# Gitea Actions runners — token obtained after Gitea is running
|
||||||
# See step 3.4 of runbook-configuration.md; replace after Gitea admin account is created
|
# See step 3.4 of runbook-configuration.md; replace after Gitea admin account is created
|
||||||
@@ -229,8 +234,11 @@ This runs all roles in order:
|
|||||||
| 10 | `k8s` | Placeholder page at k8s.ladkau.de |
|
| 10 | `k8s` | Placeholder page at k8s.ladkau.de |
|
||||||
| 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de |
|
| 11 | `vaultwarden` | Vaultwarden password vault at vault.ladkau.de |
|
||||||
| 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload |
|
| 12 | `dl` | Public download server at dl.ladkau.de — nginx HTTPS + SFTP upload |
|
||||||
| 13 | `clay` | Claude Code web interface at agent.ladkau.de — basic auth, Anthropic API key required |
|
| 13 | `ollama` | Local LLM server (Ollama, CPU) on the internal `ollama_internal` network — pulls `ollama_model` on first run |
|
||||||
| 14 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
|
| 14 | `browserless` | Headless Chromium for rendering JavaScript-heavy pages — internal network only |
|
||||||
|
| 15 | `searxng` | Metasearch engine for the chat web_search tool — internal network only |
|
||||||
|
| 16 | `n8n` | n8n at agent.ladkau.de — AI chat (`/chat`), scheduled digests, basic auth; imports the workflows in the repo |
|
||||||
|
| 17 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats |
|
||||||
|
|
||||||
> **Note:** The `act_runner` role requires `gitea_runner_registration_token` in the
|
> **Note:** The `act_runner` role requires `gitea_runner_registration_token` in the
|
||||||
> vault, which can only be obtained after Gitea is running and an admin account has
|
> vault, which can only be obtained after Gitea is running and an admin account has
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ CHECKS=(
|
|||||||
"https://vault.ladkau.de 200 10"
|
"https://vault.ladkau.de 200 10"
|
||||||
"https://dl.ladkau.de 200 10"
|
"https://dl.ladkau.de 200 10"
|
||||||
"https://agent.ladkau.de 401 10"
|
"https://agent.ladkau.de 401 10"
|
||||||
|
"https://agent.ladkau.de/chat 401 10"
|
||||||
)
|
)
|
||||||
|
|
||||||
if ! command -v curl &>/dev/null; then
|
if ! command -v curl &>/dev/null; then
|
||||||
|
|||||||
@@ -44,7 +44,13 @@ required_scalars = [
|
|||||||
"roundcube_db_password",
|
"roundcube_db_password",
|
||||||
"roundcube_des_key",
|
"roundcube_des_key",
|
||||||
"vaultwarden_admin_token",
|
"vaultwarden_admin_token",
|
||||||
"anthropic_api_key",
|
"n8n_encryption_key",
|
||||||
|
"searxng_secret_key",
|
||||||
|
"smtp_user",
|
||||||
|
"smtp_password",
|
||||||
|
"gemini_api_key",
|
||||||
|
"telegram_bot_token",
|
||||||
|
"telegram_user_id",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Required non-empty lists (must contain at least one entry)
|
# Required non-empty lists (must contain at least one entry)
|
||||||
@@ -52,7 +58,7 @@ required_lists = [
|
|||||||
"traefik_dashboard_users",
|
"traefik_dashboard_users",
|
||||||
"registry_users",
|
"registry_users",
|
||||||
"dovecot_users",
|
"dovecot_users",
|
||||||
"clay_users",
|
"n8n_users",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optional scalars: validated only when present — (key, validator_fn, hint)
|
# Optional scalars: validated only when present — (key, validator_fn, hint)
|
||||||
|
|||||||
Reference in New Issue
Block a user