In the previous parts of this series, we did the following:
Now we are getting into the interesting part by connecting them together with n8n! While some may argue that βn8n is deadβ due to recently identified security vulnerabilities back in early 2026 and before, I would argue that if we can work around the resulting limitations (such as jobs being launched in isolated containers), we actually have a more robust, time-proven workflow engine with an okay UI. So letβs dive in!
In case you do not have n8n installed already, here is a little guide on how to deploy it in a docker environment together with a containerized PostgreSQL instance to keep our data persistent and more production-ready than when using a simple SQLite (you can otherwise skip to Step 2).
# Create directory and a docker-compose file
sudo mkdir -p /opt/n8n
cd /opt/n8n
sudo nano docker-compose.yml
services:
postgres:
image: postgres:16.6-alpine
container_name: n8n-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- n8n_postgres_data:/var/lib/postgresql/data
networks:
- n8n-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# No ports published to the host on purpose - only n8n on this network needs it.
mem_limit: 512m
cpus: 1.0
n8n:
image: docker.n8n.io/n8nio/n8n:2.30.3
container_name: n8n
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "5678:5678"
environment:
# --- General ---
- GENERIC_TIMEZONE=Europe/Prague # Set up your timezone
- TZ=Europe/Prague # Set up your timezone
- NODE_ENV=production
# --- Database (Postgres, standalone, colocated with this container) ---
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
# --- Credential encryption ---
# Set explicitly and back this up OUTSIDE the n8n_data volume.
# If this key is lost, every stored credential becomes unrecoverable.
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# --- Networking ---
# NOTE: We will set up TLS later
- N8N_HOST=n8n.bakalar.priv
- N8N_PORT=5678
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://1.2.3.4:5678/
- N8N_SECURE_COOKIE=false
# --- Code node permissions ---
# Loosened from defaults - our workflow does not require fs
- NODE_FUNCTION_ALLOW_BUILTIN=fs,path
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
- N8N_RESTRICT_ENVIRONMENT_VARIABLES_ACCESS=true
# --- Task runners ---
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MAX_OLD_SPACE_SIZE=1024
- N8N_RUNNERS_TASK_TIMEOUT=600
volumes:
- n8n_data:/home/node/.n8n
- ./local-files:/files
networks:
- n8n-network
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
mem_limit: 1g
cpus: 1.5
volumes:
n8n_data:
name: n8n_data
n8n_postgres_data:
name: n8n_postgres_data
networks:
n8n-network:
name: n8n-network
nano /opt/n8n/.env
# Copy this to .env (same directory as docker-compose.yml) and fill in real
# values. Do NOT commit .env to git - add it to .gitignore.
# Postgres password for the n8n database user.
POSTGRES_PASSWORD=change-me-to-a-strong-random-value
# n8n credential encryption key. Generate one with:
# openssl rand -base64 32
# Back this value up somewhere OTHER than the n8n_data volume (password
# manager, vault, encrypted note). Losing it makes every stored credential
# permanently unreadable, even if the volume itself is intact.
N8N_ENCRYPTION_KEY=change-me-generate-with-openssl
π‘ Note
If you are currently on an n8n set up with SQLite and want to upgrade it to PostgreSQL, you can export your current settings (workflows + credentials) to import them to the new DB (providing you have a path such as
./local-files/filesmounted):docker exec n8n n8n export:workflow --all --output=/files/workflows.json docker exec n8n n8n export:credentials --all --output=/files/credentials.json docker compose down # Extract the key from the SQLite instance and replace the 'N8N_ENCRYPTION_KEY' value with it in .env: docker run --rm -v n8n_data:/data alpine sh -c "apk add --no-cache jq >/dev/null 2>&1; jq -r .encryptionKey /data/config"
- Then, once you have launched the PostgreSQL + n8n instance, import them:
docker exec n8n n8n import:workflow --input=/files/workflows.json docker exec n8n n8n import:credentials --input=/files/credentials.json
# Deploy
docker compose pull
docker compose up -d
# Check status
docker compose logs -f
This is the most brainy part of the whole series - we will have two n8n workflows. The diagram below joins them together to showcase the entire workflow logic. The trigger is Patchmon, our source of truth regarding updates. If updates are found, an n8n workflow is triggered (see in Step 5), which resolves the host and acquires a per-group lock, optionally opens an Uptime Kuma maintenance window so the coming reboot or snapshot pause doesn't fire a false alert, then calls for a snapshot (if a Proxmox guest) by running a sub-workflow (Step 3 below).
Once a snapshot is taken, the main workflow kicks in by triggering the relevant Semaphore job template for the host's group. Yes, there is not just one template for patching; we need to take the nature of the host group into account (we configured them in Part 1 of this series). For example, with a web server running nginx, we will want to ensure that nginx is running after patching is complete.
Here is a diagram of the whole workflow logic (thanks, Claude, for putting my ideas together here):
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 0. TRIGGER β
β PatchMon webhook β n8n β
β payload: { hostname, event_type, severity, β
β host_id, pending_count, threshold } β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββ
β Hostname on the β
β EXCLUDED_HOSTNAMES β
β list? β
ββββββββββββββββββββββββββ
yes β β no
βΌ β
ββββββββββββββββββββββββ β
β SKIP - patch by hand β β
β (e.g. Proxmox hosts) β β
ββββββββββββββββββββββββ β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. RESOLVE HOST β
β Semaphore: run patchmon_lookup.yml, Limit=hostname β
β poll task β parse output β
β returns: groups[], is_proxmox_guest, proxmox node/type/vmid, β
β in_alert_cooldown β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Already in a 24h β
β alert cooldown? β
βββββββββββββββββββββββββ
yes β β no
βΌ β
ββββββββββββββββββββββββ β
β SKIP - already β β
β alerted, unresolved β β
ββββββββββββββββββββββββ β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. DECIDE β
β n8n: match groups[] against groupβtemplate rule table β
β β chosen Semaphore patch template β
β β lock_key (shared by hosts in the same group, e.g. Galera) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β Host is security-only β
β tier AND this alert is β
β a general (non-security)β
β event? β
βββββββββββββββββββββββββββ
yes β β no
βΌ β
ββββββββββββββββββββββββ β
β SKIP - security-only β β
β tier, patch by hand β β
ββββββββββββββββββββββββ β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. ACQUIRE LOCK (no database, no Postgres) β
β Semaphore: run patchmon_tag_host.yml, action=add β
β β’ Proxmox guest β tag "being_patched" on the Proxmox API, β
β check-then-set inside one Ansible task run β
β β’ non-guest β atomic "mkdir /run/patchmon-being-patched"β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Lock acquired? β
βββββββββββββββββββββββββ
no β β yes
βΌ β
βββββββββββββββββββββββββ β
β EXIT - another host β β
β in the lock group is β β
β already being patched β β
βββββββββββββββββββββββββ β
βΌ
ββββββββββββββββββββββββββββ
β UptimeKuma maintenance β
β enabled? (Part 3) β
ββββββββββββββββββββββββββββ
yes β β no
βΌ β
ββββββββββββββββββββββββ β
β Open Kuma maintenanceβ β
β window for this host β β
ββββββββββββββββββββββββ β
ββββββββ¬βββββββ
β
βΌ
βββββββββββββββββββββββββ
β Is a Proxmox guest? β
βββββββββββββββββββββββββ
yes β β no
βΌ β
βββββββββββββββββββββββββββββββββ β
β 4. SNAPSHOT (guests only) β β
β Semaphore: snapshot β β
β template, poll to done β β
βββββββββββββββββββββββββββββββββ β
β β
βΌ β
ββββββββββββββββββββββ β
β Snapshot succeeded?β β
ββββββββββββββββββββββ β
no β β yes β
βΌ ββββββββββ¬ββββββββ
ββββββββββββββββββ βΌ
β ABORT: alert, β ββββββββββββββββββββββββββββββββββββββββββββββββ
β release lock β β 5. PATCH β
ββββββββββββββββββ β Semaphore: run chosen template, Limit=hostβ
β poll (generous timeout, up to 45 min) β
ββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββ
β 6. VERIFY (via PatchMon's own count) β
β ansible ok + 0 pending β SUCCESS β
β ansible ok + still pending β PARTIALβ
β ansible error / timeout β FAILURE β
ββββββββββββββββββββββββββββββββββββββββ
success β partial β failure β
βΌ βΌ βΌ
ββββββββββββββ βββββββββββββββ ββββββββββββββ
β β
success β ββ οΈ partial +β β π₯ failure β
β alert β β 24h cooldownβ β alert w/ β
β β β tag + alert β β log excerptβ
ββββββββββββββ βββββββββββββββ ββββββββββββββ
β β β
βββββββββββββββ΄ββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β 7. CLEANUP (always runs once locked) β
β Semaphore: patchmon_tag_host.yml, β
β action=remove - this IS the lock β
β release (tag removed / dir deleted) β
β + remove Uptime Kuma maintenance β
β window (optional) β
ββββββββββββββββββββββββββββββββββββββββββ
Since the workflow is quite large, it is best to split it into a parent-child relationship. If we take a step back and think about on which occasions Semaphore would be called, it's the following:
being_patched .Import the .JSON workflow into n8n:
π [PatchMon - Poll Semaphore Sub-Task.json](attachments/PatchMon - Poll Semaphore Sub-Task.json)
This sub-workflow exists so the βkick off a Semaphore task and wait for itβ logic is written once instead of four times. The parent hands it a taskId and a timeoutMinutes value that's different per call - lookups, tag add/remove, and maintenance start/stop all get 2 minutes (POLL_TIMEOUT_MINUTES_TAG), snapshots get 10, patches get 45 (they need the room for reboots) - and it hands back a status and the raw task output text once the Semaphore task is done or the clock runs out.
ββββββββββββββββββββββββββββββββ
β On Execute (from parent) β β Execute Workflow nodeaces
β inputs: baseUrl, projectId, β 7 types: lookup, tag-add, snapshot,
β taskId, timeoutMinutes, β patch, tag-remove,
β pollIntervalSeconds β maintenance-start and stop
ββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββ
β Set Start Time β startedAt = now()
ββββββββββββββββββββββββββββββββ
β
βββββββββββββΌβββββββββββββββ
β Wait Before Poll βββββββββββββββββββββββββββββββ
β sleeps pollIntervalSecs β β
βββββββββββββ¬βββββββββββββββ β
βΌ β
ββββββββββββββββββββββββββββββββ β
β GET Semaphore Task Status β GET /tasks/{taskId} β
ββββββββββββββββββββββββββββββββ β
β β
βΌ β
ββββββββββββββββββββββββββββββββ β
β β β
β Merge Timing Fields β elapsedMinutes = β
β β (now β startedAt) / 60s β
ββββββββββββββββββββββββββββββββ β
β β
βΌ β
ββββββββββββββββββββββββββββ β
β Finished or timed out? β β
β status β {success,error, β β
β stopped} OR elapsed β₯ β β
β timeout β β
ββββββββββββββββββββββββββββ β
no β β yes β
ββββββββββββββββΌβββββββββββββββββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββ
β GET Semaphore Task Output β GET /tasks/{taskId}/raw_output
ββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Set Final Result β Status: success/error/stopped/timeout
β output: the task's β
β raw log text) β
βββββββββββββββββββββββ
β
βΌ
Returns to the parent's
Execute Workflow node
PatchMon - Poll Semaphore TaskFor the main workflow to work, we will need to launch two playbooks - one that gets the groups, the other that deals with adding a tag when a host is being patched.
In order to detect what are the groups that the host to be patched is a member of, we need to call a little harmless playbook in order to get the basics - no changes are made, no SSH connection is needed. Add this to your Gitea or other source version control system.
helpers/patchmon_lookup.yml Patchmon Lookup Host (or similar)helpers/patchmon_lookup.ymlThe second playbook is concerned with adding and removing a tag called being_patched (change it to something else if you prefer). It is done in such a way (using ansible.builtin.uri) that will work around the situation of us having more than one set of Proxmox credentials.
helpers/patchmon_tag_host.yml .Now when we have the ID of the sub-workflow, we can proceed with importing the main one.
π [PatchMon Auto-Patch Orchestrator.json](attachments/PatchMon Auto-Patch Orchestrator.json)
| Variable | Description |
|---|---|
SEMAPHORE_BASE_URL |
Base URL of your Semaphore instance, e.g. http://192.168.8.26:3000. |
SEMAPHORE_PROJECT_ID |
Semaphore project ID all templates below belong to. |
SEMAPHORE_LOOKUP_TEMPLATE_ID |
Template ID for the Lookup task (patchmon_lookup.yml). Resolves group membership, Proxmox identity, and alert-cooldown status. |
SEMAPHORE_TEMPLATE_SNAPSHOT |
Template ID for the Proxmox snapshot task (proxmox_snapshot_host.yml). |
SEMAPHORE_TEMPLATE_TAG_HOST |
Template ID for the lock acquire/release task (patchmon_tag_host.yml). |
SEMAPHORE_TEMPLATE_GALERA |
Template ID for the Galera patch playbook (patch_debian_galera.yml). |
SEMAPHORE_TEMPLATE_NGINX |
Template ID for the Nginx patch playbook (patch_debian_nginx.yml). |
SEMAPHORE_TEMPLATE_DEBIAN |
Template ID for the general Debian/Ubuntu patch playbook (patch_debian_single_hosts.yml). Fallback template for unmatched hosts. |
SEMAPHORE_TEMPLATE_UPTIMEKUMA_MAINTENANCE |
Template ID for starting/stopping an Uptime Kuma maintenance window (if you followed Part 3 of this series). |
| Variable | Description |
|---|---|
ALERT_WEBHOOK_URL |
Discord/Telegram webhook for success, partial, and failure alerts. Treat as a secret. |
| Variable | Description |
|---|---|
POLL_TIMEOUT_MINUTES_LOOKUP |
Minutes n8n polls a running Lookup task before timing out. |
POLL_TIMEOUT_MINUTES_TAG |
Same, for the tag add/remove (lock) task. |
POLL_TIMEOUT_MINUTES_SNAPSHOT |
Same, for the snapshot task. |
POLL_TIMEOUT_MINUTES_PATCH |
Same, for the patch task. Needs the most headroom: dist-upgrade plus reboot takes longest. |
POLL_INTERVAL_SECONDS |
How often n8n checks task status while polling. |
| Variable | Description |
|---|---|
EXCLUDED_HOSTNAMES |
Comma-separated hostnames that never trigger a patch run. |
SECURITY_ONLY_GROUPS |
Comma-separated inventory groups limited to security updates only. Empty: full updates everywhere. |
| Variable | Description |
|---|---|
UPTIMEKUMA_MAINTENANCE_ENABLED |
On/off switch for Uptime Kuma maintenance windows. Must be a String field ("true"/"false"), not Boolean. |
UPTIMEKUMA_DURATION_BUFFER_MINUTES |
Extra buffer minutes added to the estimated patch duration for the maintenance window. |
UPTIMEKUMA_INSTANCES |
JSON array of Uptime Kuma instances: name and url per entry. |
Normalize Trigger Payload + Global Config are the setup stage. Fill in manually as explained above.Is Host Excluded? / Skip - Host Excluded is the hard exclude gate.Start Semaphore Lookup Task through Parse Group Names is host resolution. Ensure the sub-workflow is mapped there.In Alert Cooldown? is the repeat-alert guard.Decide Template + Lock Key is the one Code node you will actually maintain as your fleet grows. It's a small priority-ordered rule table mapping Ansible groups to Semaphore template IDs and lock keys. Standard Update on Security-Only Host? is the security-tier gate. Start Tag-Add Task (ensure the sub-workflow is mapped there) through Lock Acquired? is the locking step (Proxmox tag or local mkdir, decided automatically inside the playbook by whether proxmox_vmid is defined). Is Proxmox Guest? untouched.Is Proxmox Guest? onward splits into snapshot-then-patch for guests, or patch-only for everything else.Patch Outcome is the three-way verification switch.Start Tag-Remove Task(ensure the sub-workflow is mapped there) / Poll Tag-Remove Task(again, check the mapping), which is both the alerting cleanup and the actual lock release.Start Tag-Remove Task - every alert path (success, partial, failure, even a failed snapshot) passes through it, so the window is always closed the same way it was opened, and a host is never left stuck "in maintenance."Incoming PatchMon Webhook [Trigger]
β
βΌ
Normalize Trigger Payload (hostname, eventType, severity, hostId, pendingCount, pendingThreshold)
β
βΌ
Global Config (every non-secret setting lives here, CE has no Variables/Environments panel)
β
βΌ
Is Host Excluded? ββyesβββΊ Skip - Host Excluded (exit)
βno
βΌ
Start Semaphore Lookup Task [sub-workflow] βββΊ Poll Lookup Task βββΊ Parse Group Names
β
βΌ
In Alert Cooldown? ββyesβββΊ Skip - In Alert Cooldown (exit)
βno
βΌ
Decide Template + Lock Key
β
βΌ
Standard Update on Security-Only Host? ββyesβββΊ Skip - Standard Update on Restricted Host (exit)
βno
βΌ
Start Tag-Add Task [sub-workflow] βββΊ Poll Tag-Add Task βββΊ Parse Tag-Add Result
β
βΌ
Lock Acquired? ββnoβββΊ Lock Busy - Exit (exit)
βyes
βΌ
UptimeKuma Maintenance Enabled? (Start) ββnoββββββββββββββββββββββββββ
βyes β
βΌ β
Start Maintenance-Start Task [sub-workflow] β
βββΊ Poll Maintenance-Start Task β
βββΊ Parse Maintenance-Start Result β
β β
βΌ β
Is Proxmox Guest? ββββββββββββββββββββββββββββββββββββββββββββββββββββ
βnoβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βyes β
βΌ β
Start Snapshot Task [sub-workflow] βββΊ Poll Task βββΊ Snapshot Ok? β
β β
βno yesβ β
βΌ βΌ β
Snapshot Failed Alert Context Start Patch Taskβββββββ
β [sub-workflow]
β β
β βΌ
β Poll Patch Task βββΊ Parse Patch Verify Result
β β
β ββββββββββββββ
β βΌ
β Patch Outcome (3-way)
β ββββββββββββββββββ¬ββββββββββββββββββ
β successβ partialβ failureβ
β βΌ βΌ βΌ
β Send Discord/Telegram Build Partial Build Failure
β Success Message Alert Context Alert Context
β β β β
β β βΌ βΌ
β β Send Discord/Telegram Alert
β β β β
β βΌ βΌ βΌ
βββββββββββββββββββββΊβββββββββββββββββΊββββββββββββββββββΊβββββ
ββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
UptimeKuma Maintenance Enabled? (Stop) ββnoβββββββββββββββββββ
βyes β
βΌ β
Start Maintenance-Stop Task [sub-workflow] β
βββΊ Poll Maintenance-Stop Task β
βββΊ Parse Maintenance-Stop Result β
β β
βΌ β
Start Tag-Remove Task ββββββββββββββββββββββββββββββ
[sub-workflow] βββΊ Poll Tag-Remove Task
β
βΌ
(Exit - releases the lock either way)
Before we can really run this workflow, we will need to configure Patchmon to send out webhooks when new updates are available. So run the test trigger for the workflow and head to Patchmon.
The whole workflow starts with Patchmon. It is this very system that serves as a source of truth in terms of what updates are pending, whether standard or security updates. We will need to configure it to send notifications to n8n, which will then trigger patching jobs with Semaphore UI. Then, we will trigger a re-check by a Patchmon agent and send alerts via Discord, if required.
Since we now have a webhook created in n8n, we can link it in Patchmon.
π‘ Note
Note: n8nβs test webhook differs from the production one - the test one is executed once when being actively triggered (waiting for a webhook to come), whereas the production webhook will trigger even when you do not actively press the βExecuteβ button. For the production trigger to work properly, always publish your workflow to ensure you are not triggering on an older version of your workflow.
With the webhook created, go to βEvent Rulesβ sub-section (within Reporting) and click on βAdd event ruleβ. Select these two events:
InformationalI recommend taking a quick look at the manual related to Patchmonβs thresholds. Patchmon needs to know on a per-host basis what is the acceptable threshold of accumulated updates before it fires the n8n workflow to apply a patch.
π‘ Note
Just be careful about not setting the limit for updates too low, or you may a large number of hosts to update, which may clog up your IO resources.
Getting it work initially will likely require a number of attempts. In my experience, it was best to configure updates coming out for just one host. Taking a manual snapshot of it is advisable before you verify that the workflow-driven one works.
mail1 - look at how badly outdated this iRedMail host was, it is almost embarrassing to share!Decide Template + Lock Key is loaded - you may need to configure playbooks as per your type of serversLots of things can and will go wrong, so letβs look into a few:
your_serever:port/reporting?tab=alerts) to see if a webhook even went out. If yes and it errored out, check what the error is. Error 404 indicates the webhook address is incorrect or there was nothing listening on that (when using the test hook, you need to click on the βExecuteβ button).group_vars/ folder.Once you are confident with the process, you can decrease the threshold for updates in Patchmon to ensure that your fleet gets patched.
Since this is quite a complex workflow, do let me know if you identify any bugs or make improvements that would be worth sharing with others.
Let's say that you have a group of hosts called webservers and you want them to receive security updates only. This group was already created in Part 1 of this series - the web1 and web2 VMs are tagged as βwebβ.
This is pretty awesome! You can consider applying this for your Proxmox hosts and other critical services that you want to keep up to date in terms of security, but would otherwise prefer to update manually.
The Part 4 of this series closes the loop: PatchMon spots the drift, n8n decides whether and how to act on it, Semaphore does the actual work, and Uptime Kuma keeps your alerting quiet while it happens and the monitors the hosts afterwards, in case something happens post-patching.
We have got a fleet that patches itself, snapshots itself first, verifies its own work, and tells us about it on Discord or Telegram - no database of its own anywhere in the loop, just Proxmox tags and a marker file standing in for locks.
A few things worth doing before you point this at production:
iptables, nftables, ufw or similar) that allows connection only from your server subnet.postgresql database, which helps with retrieval and locks. However, I wanted to simplify the process and not add another system to manage as part of the process. On a larger scale, however, it would likely be the right approach.Did you find a bug, or improve on something here? Let me know in the comments! I would rather fix it for everyone than to have five people independently trip over the same edge case. Knowledge is to share - primarily with other humans, then our fellow AI friends :)