09 July 2026

`apt` on a no-subscription Proxmox host: the 401, and installing around it

A quick one, because it bites everyone who runs Proxmox without a subscription and it's not obvious the first time. You go to install something, and apt falls over before it does anything useful:

E: Failed to fetch https://enterprise.proxmox.com/debian/pve/dists/bookworm/InRelease  401  Unauthorized
E: The repository 'https://enterprise.proxmox.com/debian/pve bookworm InRelease' is not signed.

That's the enterprise repository, which needs a paid subscription key. Without one it returns 401, and because apt-get update exits non-zero, anything that runs update first — including a lot of vendor install scripts (curl ... | sh) — aborts before installing your actual package. Maddening, because the package you wanted may have nothing to do with Proxmox at all.

The quick way through (one package)

apt-get install uses the package lists already on disk; it doesn't insist on a fresh update. So if the lists for the repo you need were fetched at all, just install directly and let the enterprise 401 be someone else's problem:

DEBIAN_FRONTEND=noninteractive apt-get install -y <package>

That got a third-party tool onto a box for me even with the enterprise repo still throwing 401s in the background.

The proper fix (do this once)

If you're genuinely running no-subscription, switch to the no-subscription repo — that's the supported free channel, and crucially it's where pve-headers live (which you'll want the moment you build a DKMS module):

echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
  > /etc/apt/sources.list.d/pve-no-subscription.list
# and disable the enterprise list so update stops erroring:
# comment out the line in /etc/apt/sources.list.d/pve-enterprise.list
apt-get update
apt-get install -y pve-headers-$(uname -r)

(There's also a ceph enterprise list that 401s the same way if you use Ceph — same treatment.)

Notably, none of this requires turning off the enterprise repo to install around it in a pinch — but for a box you'll keep, sort the sources out properly so update is clean and DKMS rebuilds don't surprise you after a kernel bump.

I hope this saves you the head-scratching. Enjoy!

Clicking "I accept" on a captive portal — for a headless server

Captive portals are a fact of life on guest wifi: connect, get redirected to a page, tick a box or log in, and you're online. Fine for a laptop. A proper pain for a headless server that has no browser and no one sitting in front of it. Here's the trick I used to get a headless box past a per-MAC portal, and it generalises nicely.

Why you can't just do it from your laptop

The portal authorises per device — it remembers the MAC (and/or IP) that completed the login and lifts the walled garden for that client. So logging in from your laptop's browser authorises your laptop, not the server. The server's own connection is still walled. You have to make the login traffic originate from the server itself.

The trick: proxy a browser through the server

If you can SSH to the box (over a wired path, a second interface, whatever), turn it into a SOCKS proxy:

ssh -D 1080 -N user@server

Now anything you send into localhost:1080 is originated by the server and exits its network connection. Point a browser at that proxy and, from the portal's point of view, it's the server knocking — correct MAC, correct session. Chromium does remote DNS over SOCKS5, so name resolution goes out the server's link too.

I drove it headlessly with Playwright through the proxy:

chromium --proxy-server="socks5://localhost:1080"
# or, in Playwright, launch with proxy={"server":"socks5://localhost:1080"}

Navigate to anything over plain HTTP (http://neverssl.com is the classic), let the portal redirect fire, accept the terms / sign in, and the gateway authorises the server's MAC. Once that's done you can tear the proxy and browser down entirely — the authorisation lives at the gateway, keyed to the MAC, not in the browser session.

Making it stick

Two wrinkles for unattended use:

  • Sessions expire. Wrap the same headless flow in a small script and have a watchdog run it whenever a connectivity check (curl -s -o /dev/null -w '%{http_code}' http://connectivitycheck.gstatic.com/generate_204, want 204) comes back non-204. A persistent browser profile keeps any "remember me" cookie between runs.
  • Registration vs. login. Some portals (Sky's "The Cloud", BT Wi-fi and friends) want an account. A throwaway mailbox with an API — I used mail.tm — lets you register and, if needed, script the catching of a verification email.

It feels a bit like sawing through the bars from the inside, but it's entirely above board — you're authenticating the device that's actually using the connection. The SOCKS-proxy-through-the-box idea is the reusable part; keep it for the next headless thing stuck behind a portal.

I hope this resolves any difficulties you may be experiencing. Enjoy!

A Realtek RTL8821AU on kernel 6.8, and the `new_id` trap that hung my reboot

I plugged a USB wifi dongle into a headless Linux box and got... nothing. No wl interface, no driver bound. What followed was a proper little saga involving an out-of-tree driver, the wrong driver grabbing the device, and a reboot that wouldn't reboot. Here's the whole thing so you can skip the bits I didn't.

Identifying the chipset (and the usual gripe)

lsusb
... ID 2357:0120 TP-Link 802.11ac WLAN Adapter

USB 2357:0120 is a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. The product string just says "802.11ac WLAN Adapter", which is no help at all — and TP-Link cheerfully reuse that string and shuffle USB IDs across hardware revisions. (When will manufacturers learn not to make us reverse-engineer which silicon we actually bought?)

On kernel 6.8 there's no in-tree driver that binds it: rtl8xxxu lists a clutch of 2357:01xx IDs but not 0120, and rtw88 covers newer chips. So it's the out-of-tree DKMS driver.

Building the driver

morrownr's maintained fork does the job. On a Debian/Proxmox box you'll need the matching kernel headers and a toolchain first:

apt-get install -y dkms git build-essential
# headers matching `uname -r` (on a no-subscription Proxmox host, from the no-subscription repo)
git clone https://github.com/morrownr/8821au-20210708.git
cd 8821au-20210708 && ./install-driver.sh NoPrompt

DKMS reported rtl8821au/5.12.5.2 ... installed and the module loaded — but still no interface. That's where it got interesting.

The trap: the wrong driver had already grabbed it

While poking about earlier I'd modprobe'd one of the rtw88 USB drivers and added the device with a runtime new_id override. That driver had claimed the dongle — so the correct 8821au driver couldn't bind it, even though it was loaded. Worse, when I tried to unbind it:

echo -n "1-3:1.0" > /sys/bus/usb/drivers/rtw_8822bu/unbind   # <-- hung

The write blocked in uninterruptible sleep (D state). You cannot kill a D-state process; it's wedged in the kernel waiting on the driver's disconnect path. And here's the sting in the tail: that stuck process then hung the rebootsystemd-shutdown waited 90 seconds for it, failed to remount the root filesystem read-only ("Device or resource busy"), forced the reboot anyway, and I finished it with a hard power-cycle. (No harm done — ext4 journalled-recovered on the way back up.)

The fix that stuck

Blacklist the drivers that shouldn't touch it, so on boot only the right one matches:

# /etc/modprobe.d/wifi-dongle.conf
blacklist rtw88_8822bu
blacklist rtw88_8821cu
blacklist rtl8xxxu

Reboot. With the imposters out of the way, 8821au claimed the device and wlx<mac> appeared, type managed, ready for wpa_supplicant.

The lesson I'll carry: new_id overrides are runtime-only and brilliant for experimenting, but if one goes wrong, don't fight the live binding — a reboot clears the runtime state and a blacklist makes the result permanent. Trying to unbind a wedged USB driver by hand is how you end up power-cycling a server.

Happy prototyping!

Is your firewall man-in-the-middling you? Check the certificate issuer.

A VPN I was setting up simply refused to connect — no error I could act on, just a sulk. The thing that explained it took one command, and it's a trick worth having in your pocket whenever a service mysteriously won't talk on a network you don't control.

The one command

echo | openssl s_client -connect controlplane.example-vpn.com:443 -servername controlplane.example-vpn.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject

What came back was not the certificate I expected:

issuer=C=US, O=Fortinet, OU=Certificate Authority, CN=<appliance-serial>
subject=CN=controlplane.example-vpn.com

That O=Fortinet issuer is the smoking gun. A FortiGate firewall was doing deep SSL inspection — terminating the TLS, presenting its own certificate signed by the appliance's CA, and re-encrypting onwards. My client validates that endpoint's certificate against the public trust store, the Fortinet CA isn't in it, the handshake fails, and the whole thing quietly gives up. (The VPN's own logs, to its credit, even said the cert "looks like Fortinet equipment" — they ship a detector for exactly this.)

The genuinely useful bit: map what's tampered with

Interception is rarely applied to everything — it's expensive, and it breaks things. So check a handful of endpoints and compare issuers; the pattern tells you your firewall's policy:

Endpoint Issuer seen Verdict
controlplane.example-vpn.com Fortinet intercepted
api.cloudflare.com Google Trust Services clean
github.com Sectigo clean

In my case the interception was targeted — the VPN's control plane specifically, everything else untouched. That immediately reframed the problem: stop fighting the blocked thing, and pick a transport the firewall leaves alone (a Cloudflare tunnel egressed perfectly happily, real cert and all).

Why this matters beyond "my VPN broke"

If a corporate box is inspecting your TLS, it can read everything that crosses it that it has decrypted — which is the point of the appliance, but worth being clear-eyed about. The cert issuer is the quickest way to find out whether and where it's happening. A real public CA (Let's Encrypt, Google Trust Services, Sectigo, DigiCert) on the cert means end-to-end; your employer's name, or "Fortinet"/"Palo Alto"/"Zscaler", means the conversation is being opened in transit.

Go and openssl s_client a few of your own endpoints — you may be surprised who's signing them.

Ta ta for now, and I hope you found this helpful.

ARP resolves but ping doesn't — the asymmetric-routing trap, and a one-line fix

I had two machines plugged into the same switch and couldn't get from one to the other. No ping, no SSH, nothing — yet the cable was fine and the other box was very much alive. The symptom that cracked it is worth knowing, because it points straight at the cause.

The tell

Ping timed out, but the ARP entry resolved perfectly:

arp -n | grep 192.168.50.2
192.168.50.2   ether   04:d4:c4:xx:xx:xx   C   enx...

ARP is layer 2. If the MAC resolves, the wire and the switch are doing their job — the other machine has answered an ARP broadcast at some point. So a resolving ARP entry alongside dead ICMP/TCP tells you the problem is layer 3, and specifically the return path.

Proving it with tcpdump

Capture on the interface while pinging:

sudo tcpdump -i enx... -n host 192.168.50.2

What I saw, each time I pinged: my echo request arrived, and the other box immediately fired off an ARP request for its default gateway — and got no reply. It had received my packet and was trying to route the response back, but my source address (on a different subnet, via a /16 that happened to cover the target) wasn't on its local segment, so it needed a gateway. That gateway was dead. The reply never left the building.

This is asymmetric routing: the outbound packet gets there, the reply can't find its way home. ARP keeps working throughout because ARP is broadcast on the local segment and doesn't care about gateways — which is exactly why you get the confusing "MAC resolves but nothing else works" picture.

The one-line fix

Give your machine a second address on the target's own subnet. Now your packets are sourced from an address the target can reach directly over ARP — no gateway required for the reply.

sudo ip addr add 192.168.50.50/24 dev enx...
ping -c3 -I 192.168.50.50 192.168.50.2
ssh -b 192.168.50.50 root@192.168.50.2

-I (ping) and -b (ssh) force the new source address so the reply path stays local. Remove it again with sudo ip addr del 192.168.50.50/24 dev enx... when you're done.

One aside that cost me a few minutes: even after this, ICMP stayed firewalled on the target while TCP worked fine. So don't let a still-failing ping convince you it hasn't worked — try the actual port you care about. The real fix for the target, of course, is to give it a working default gateway; the source-address trick is the get-in-now workaround.

I hope this saves you the tcpdump session. Enjoy!

09-two-claude-accounts-one-machine

title: "Running Two Claude Accounts Side by Side on the Same Machine"
date: 2026-06-23
tags: [claude, ai, homelab, linux]


Running Two Claude Accounts Side by Side on the Same Machine

I have two Claude subscriptions — a personal one and a work one through the University of Salford. Anthropic's rate limits apply per account, so when one hits the wall mid-session (and it does, usually at the worst possible moment) I'd like to be able to switch to the other and carry on, ideally without losing the thread of what I was doing. This turned out to be straightforward, once I understood how Claude Code manages its authentication.

How Claude Code Picks an Account

Claude Code stores its OAuth token in ~/.claude/.credentials.json. Everything else — settings, memory, session history, installed plugins, custom slash commands — lives alongside it in ~/.claude/. The key insight is that the binary respects a CLAUDE_CONFIG_DIR environment variable: point it somewhere else and that becomes the active profile. The credentials file in that directory determines which account you're logged into.

So: two directories, one credential each, everything else shared. That's the whole trick.

The Setup

I created ~/.claude-work/ as the work account's config directory. A small script, ~/.local/bin/claude-work, handles the rest: it creates the directory, symlinks the account-agnostic config from ~/.claude into it, sets CLAUDE_CONFIG_DIR, and launches claude. The symlinked items are:

  • settings.json and settings.local.json — permissions, allowlists, preferences
  • projects/ — the full session history and memory store
  • plugins/ — installed plugins
  • CLAUDE.md — the user-level instructions
  • commands/ — custom slash commands
  • statusline-command.sh — the statusline helper

The one thing deliberately not symlinked is .credentials.json — that stays private to each profile. Everything else is shared. The result is that switching accounts costs nothing: same memory, same history, same environment. The only difference is whose API quota is being consumed.

On first run, the work directory has no credential, so Claude opens its /login OAuth flow. Sign in once as the work account and the token is cached; subsequent runs go straight in. (Worth noting: my work account is on a Claude Team plan, and the organisation has disabled Remote Control. That can't be changed locally — it's expected behaviour.)

Persistent Sessions with tmux

My other requirement was that sessions should survive terminal closures, the same way a remote SSH session to a server would. I added two shell functions to ~/.bashrc:

claude() {
  if [ -n "$TMUX" ] || [ ! -t 1 ]; then command claude "$@"; else tmux new-session -A -s personal "command claude"; fi
}
claude-work() {
  if [ -n "$TMUX" ] || [ ! -t 1 ]; then command claude-work "$@"; else tmux new-session -A -s work "command claude-work"; fi
}

Running claude from any terminal now attaches to (or creates) a persistent tmux session named personal; claude-work does the same for work. Detach with Ctrl-b d. Re-running the command from another terminal reattaches. The -A flag means you never accidentally create a second session when one is already running. To pass flags directly — say, a non-interactive -p prompt — command claude bypasses the wrapper.

Sharing MCP Servers

The one thing that can't be symlinked is ~/.claude.json. This file holds the active account identity and gets written constantly during a session; sharing it between two concurrently running instances would be asking for corruption. Unfortunately it's also where project-scoped MCP servers are configured.

The solution is Claude's project-level .mcp.json file. Drop one in the working directory and it's loaded by both profiles automatically:

{ "mcpServers": { "cloudflare": { "type": "http", "url": "https://mcp.cloudflare.com/mcp" } } }

The claude.ai integrations (Gmail, Drive, Calendar, etc.) are account-connected — they arrive automatically with whichever account is logged in, no extra configuration needed.

Switching After a Rate Limit

In practice the workflow is: hit the rate-limit message, open a new terminal, type claude-work. The tmux session picks up and I'm in the work profile. From there, /resume shows the full session history — because projects/ is symlinked, both profiles read from the same store. I can pick up the conversation exactly where I left off, just on a different quota.

When the personal limit clears I switch back. The work session stays attached in its tmux window; nothing is lost on either side.

The Knowledgebase

Separately, I've been thinking about a knowledgebase — a collection of PDFs, images, and reference documents that Claude sessions can draw on. The numbers involved (low gigabytes, lots of files around 500KB each) are well within what Git LFS handles comfortably. The Gitea instance I run (CT116 on Proxmox) already has LFS support and I've grown its disk from 8GB to 32GB to give it comfortable headroom.

The files themselves will live on a TrueNAS NFS share (/mnt/knowledgebase, ZFS with LZ4 compression and 128K record size — well-suited to small files), already mounted on the laptop. Version history in Gitea, actual bytes on TrueNAS. It's empty for now but the plumbing is in place.

Is Any of This Novel?

Probably not — but I couldn't find it documented clearly anywhere, so perhaps this is useful to someone. The CLAUDE_CONFIG_DIR variable isn't prominently advertised; I found it by looking at what the binary actually checks at startup. The symlink approach for sharing config while keeping credentials separate feels like the right level of hackery: low-tech, transparent, and easy to undo.

Fare thee well and I hope you find this helpful.

Fine-tuning LLMs locally with Unsloth Studio — NFS storage, boot-on-start, and a self-inflicted security hole

I've wanted a proper local fine-tuning setup for a while. Cloud GPUs are fine until the bill arrives, and there's something satisfying about training a model on hardware sitting in the next room. So when a spare box on one of my Proxmox nodes ended up with four RTX 5060 Ti 16GB cards in it, I installed Unsloth Studio — the web UI from Unsloth (the open-source fine-tuning project built by Australian brothers Daniel and Michael Han — see their about page) — and set about making it behave the way I wanted.

The aim:

  • Unsloth Studio running in an unprivileged LXC container on Proxmox (container 117, the four GPUs passed through).
  • Its HuggingFace model cache pointed at my TrueNAS NFS share — the container only has a 64GB root disk, and a single bnb-4bit model is 6–13GB, so that fills up fast.
  • The studio starting on boot (it's a thing you launch by hand from a shell, which is no good for a box you want to just work).
  • Reachable at https://unsloth.mattsouthgate.co.uk, behind the same Cloudflare Access gate as the rest of my home lab.

It mostly went well. A couple of things bit back, and one of them — entirely my own doing — was a security hole I'll come to. Here's the whole thing, dead ends included.

Where the models live

The setting in the UI is Storage → Models Folder, and out of the box it points at /root/.cache/huggingface/hub — the standard HuggingFace hub cache. That's what I wanted on the NAS.

My first instinct was to mount the NFS share inside the container. Unfortunately that doesn't work in an unprivileged LXC:

# inside the container
mount -t nfs -o ro 10.140.3.200:/mnt/zrust/ml-models /mnt/nfs-test
mount: /mnt/nfs-test: permission denied.

The kernel blocks NFS mounts inside a user namespace, even with AppArmor unconfined. I briefly considered converting the container to privileged — which would let it mount NFS — but in a privileged container, container-root is host-root, and this is a box that downloads and runs arbitrary model code from the internet. That's exactly the boundary an unprivileged container exists to keep. Not worth it.

The right way is the boring way: mount the share on the Proxmox host, then bind-mount it into the container. The host already had it in /etc/fstab, so I only needed the bind and to set the container to start on boot:

# on the Proxmox host
pct set 117 -mp0 /mnt/ml-models,mp=/mnt/ml-models
pct set 117 -onboot 1
pct reboot 117

The unprivileged ownership dance

Here's the bit that catches people. An unprivileged LXC maps container-root (uid 0) to host uid 100000 (this is documented in the Proxmox container docs). A directory created on the share as host-root shows up as nobody inside the container, and the container can't write to it. So the cache directory has to be owned by 100000 on the host:

# on the host
mkdir -p /mnt/ml-models/llm/hf-cache /mnt/ml-models/llm/hf-xet
chown 100000:100000 /mnt/ml-models/llm/hf-cache /mnt/ml-models/llm/hf-xet

That chown only works if the NFS export isn't root-squashed — mine is no_root_squash, so it took. A check from inside the container confirmed it had landed:

# inside the container, post-reboot
mount | grep ml-models
# 10.140.3.200:/mnt/zrust/ml-models on /mnt/ml-models type nfs (rw,...)

echo hi > /mnt/ml-models/llm/hf-cache/.wtest && echo WRITE-OK
# WRITE-OK
ls -lan /mnt/ml-models/llm/ | grep hf-cache
# drwxr-xr-x 2 0 0 ... hf-cache    <- uid 0 (root) inside; 100000 on the host

What the host sees as 100000 the container sees as 0. Symlinks work over the mount too, which matters — the HuggingFace cache is full of them (snapshots are symlinks into a blobs store).

Telling the studio (and only the studio) to use it

I dug through the Unsloth Studio source to see how it decides where the cache goes. The relevant function is _setup_cache_env() in backend/utils/paths/storage_roots.py, and the key line is this:

for key, value in defaults.items():
    if key not in os.environ:
        os.environ[key] = value

It only sets the cache variables if you haven't already. So explicit environment variables win. That let me do something neat: point the hub cache at the NAS while leaving HF_HOME at its local default — which keeps the auth token ($HF_HOME/token) on local disk and off the shared NAS, where it has no business being.

HF_HUB_CACHE=/mnt/ml-models/llm/hf-cache
HF_XET_CACHE=/mnt/ml-models/llm/hf-xet
# HF_HOME left alone -> token stays at /root/.cache/huggingface/token

A trap worth flagging: that same function does a best-effort mkdir on the cache path. If you start the studio with the cache pointed at /mnt/ml-models/... before the bind-mount exists, it'll cheerfully create that directory on the local disk and then shadow the mount when it appears. Mount first, start second.

The token, which did not go quietly

Logging the HuggingFace token in should have been one command. It wasn't:

hf auth login --token hf_xxxx…
...
ValueError: Token claude-hub not found in /root/.cache/huggingface/stored_tokens

The newer hf CLI (from the huggingface_hub library) stores tokens under a name in a stored_tokens file and then tries to "activate" them — and that machinery tripped over itself, writing an empty entry and declaring the token missing. Rather than fight it, I wrote the plain token file directly, which huggingface_hub reads perfectly well:

printf '%s' 'hf_xxxx…' > /root/.cache/huggingface/token
chmod 600 /root/.cache/huggingface/token
hf auth whoami
# user: IdeasFixer

whoami hits the API, so that's the token genuinely validated, not merely written.

Starting on boot

Unsloth Studio is launched by hand (unsloth studio -H 0.0.0.0 -p 8888) — no service file. For a box I want to just work, that won't do, so it gets a systemd unit. The cache variables go in Environment= lines, because systemd doesn't read your shell profile:

# /etc/systemd/system/unsloth-studio.service
[Unit]
Description=Unsloth Studio (LAN on :8888, HF cache on TrueNAS NFS)
After=network-online.target
Wants=network-online.target
RequiresMountsFor=/mnt/ml-models

[Service]
Type=simple
User=root
Environment=HF_HUB_CACHE=/mnt/ml-models/llm/hf-cache
Environment=HF_XET_CACHE=/mnt/ml-models/llm/hf-xet
ExecStartPre=/usr/bin/test -d /mnt/ml-models/llm/hf-cache
ExecStart=/root/.local/bin/unsloth studio -H 0.0.0.0 -p 8888 --no-cloudflare
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

(That --no-cloudflare flag is important — more on why in a moment.) onboot: 1 on the container handles the other half. I tested the whole chain by rebooting the container and watching it come back unattended:

Check Result
Container running after reboot
NFS bind-mount present
unsloth-studio auto-started + enabled ✅ active / enabled
Listening on :8888
HF_HUB_CACHE resolves to NFS, models present ✅ 3 models

A tidy hostname, via the existing Caddy

I didn't want a fresh tunnel for this. I already run Caddy (thanks to Matt Holt and the Caddy authors) as a reverse proxy behind my main Cloudflare Tunnel, fronting every other *.mattsouthgate.co.uk service. Adding one more is three small steps.

A Caddy vhost — and mind the http:// prefix, because the tunnel delivers on plain port 80 and a bare hostname triggers Caddy's automatic HTTPS, which sends a 308 redirect that loops straight back through the tunnel (ask me how I know):

# /etc/caddy/conf.d/unsloth.mattsouthgate.co.uk.caddy
http://unsloth.mattsouthgate.co.uk {
    reverse_proxy http://10.140.0.61:8888
}

Then the tunnel ingress (unsloth.mattsouthgate.co.uk → http://10.140.3.156, the Caddy box) and a proxied DNS CNAME to the tunnel. A curl confirmed it both works and is gated:

curl -sI https://unsloth.mattsouthgate.co.uk/
# HTTP/2 302
# location: https://mattsouthgate.cloudflareaccess.com/cdn-cgi/access/login/...

That 302 to the Cloudflare Access login is exactly what I wanted. Anyone reaching the studio has to pass Access and the studio's own password. Which brings me to the bit I got wrong.

The hole I dug for myself

While auditing what I'd built, I noticed something I should have caught immediately. Unsloth Studio, very helpfully, auto-creates its own free Cloudflare tunnel on startup so you can share it without any setup. Lovely feature. Except it means that alongside my carefully Access-gated hostname, the studio was also publishing a second, public *.trycloudflare.com URL — with no Access gate in front of it. The only thing standing between the open internet and a box with four GPUs and my HuggingFace token was the studio's login password (which, for the record, is a deliberately throwaway "get it working first" password I hadn't yet replaced — so, not much).

There it was in the process list and the logs:

ps aux | grep cloudflared
# /root/.unsloth/studio/bin/cloudflared tunnel --url http://localhost:8888 --no-autoupdate
journalctl -u unsloth-studio | grep trycloudflare
# Unsloth Studio running on https://wine-graduates-evaluated-herbal.trycloudflare.com

The fix is one flag — --no-cloudflare — which I'd already slipped into the unit file above. After a restart, the ungated door is gone:

# the old public URL — now dead
curl -s -o /dev/null -w "%{http_code}\n" https://wine-graduates-evaluated-herbal.trycloudflare.com/
# 530      (Cloudflare: origin tunnel is gone)

# my gated hostname — still serving
curl -s -o /dev/null -w "%{http_code}\n" https://unsloth.mattsouthgate.co.uk/
# 302      (redirect to Cloudflare Access login)

# and no tunnel process left
pgrep -a cloudflared
# NO cloudflared process — confirmed

Lesson, cheerfully relearned: when a tool offers to expose itself to the internet "for free and with zero config", check whether it's still doing that after you've built your own properly-gated route. A fuller security audit of the whole setup is a follow-up post of its own.

Which models?

These are 16GB cards, and Unsloth's open-source path fine-tunes on a single GPU per run (four cards means four parallel jobs, not one big 64GB job). For QLoRA — the 4-bit fine-tuning method from Tim Dettmers and colleagues, arXiv:2305.14314, which underpins the bnb-4bit format via the bitsandbytes library — that comfortably fits up to about 14B in 4-bit; 24B and up don't. So I pulled the Unsloth bnb-4bit dynamic quants (the training-ready format, not the GGUFs, which are for llama.cpp inference):

Model Base model author Size on disk
unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit Meta 6.0 GB
unsloth/phi-4-unsloth-bnb-4bit (14B) Microsoft 10.4 GB
unsloth/gemma-3-12b-it-unsloth-bnb-4bit Google DeepMind 12.8 GB
unsloth/gemma-3-4b-it-unsloth-bnb-4bit Google DeepMind 4.6 GB

gemma-3-27b-class and Mistral-Small-3.2-24B (14.6GB) were left out — their 4-bit weights alone meet or exceed 16GB before you've fit a single activation.

The proof it all hangs together — a fresh download lands on the NAS and not the local disk:

hf download hf-internal-testing/tiny-random-bert
ls -d /mnt/ml-models/llm/hf-cache/models--hf-internal-testing--tiny-random-bert  # present
ls -d /root/.cache/huggingface/hub/models--hf-internal-testing--tiny-random-bert # absent

And migrating the pre-existing 21GB cache off the root filesystem freed exactly what you'd hope:

# df -h /  (container root)
# before: 46G used / 63G  (77%)
# after:  26G used / 63G  (43%)

Summary

  • Unsloth Studio fine-tunes locally on four 16GB GPUs in an unprivileged Proxmox LXC.
  • Models live on TrueNAS over NFS (host mount + bind-mount + a chown 100000 so the unprivileged container can write); the auth token stays on local disk.
  • It starts on boot via systemd, verified across a full reboot.
  • It's reachable at a clean HTTPS hostname through the existing Caddy + Cloudflare setup, gated by Cloudflare Access — and the studio's own ungated tunnel is now disabled.

Future work

  • Replace the placeholder passwordpass was only ever a get-it-running stand-in; it wants a real one now the routing is sorted.
  • Move the fine-tuned outputs to the NAS too. I redirected the download cache, but the outputs/exports/datasets directories are still on the local disk. They want symlinking onto the share — carefully, because the studio's SQLite databases (studio.db, auth.db) must stay local (SQLite and NFS don't get along).
  • Back up those config databases, which currently live only on the container's root disk.
  • A proper security audit of the whole home-lab edge — the trycloudflare surprise above earned it. That's the next post.

References and credits

With thanks to the people whose work this is built on:

I hope this saves someone the permission-denied head-scratching — and prompts you to check whether your tools have quietly opened a door you didn't ask for. Enjoy!

Matt

Getting 4× RTX 5060 Ti GPUs Working on Proxmox VE 9 (ASUS X99-E WS with PLX PCIe Switch)

Getting 4× RTX 5060 Ti GPUs Working on Proxmox VE 9 (ASUS X99-E WS with PLX PCIe Switch)

If you've tried to run NVIDIA Blackwell GPUs on an older workstation board with a PLX PCIe switch and hit cryptic GSP firmware failures — this post documents what went wrong and exactly how we fixed it.


The Setup

  • Host: Proxmox VE 9.1, kernel 6.14.11-7-pve
  • Board: ASUS X99-E WS — an older Intel LGA2011-v3 workstation board with 2× PLX PEX 8747 PCIe switch chips, enabling 4-way x16/x16/x16/x16 GPU slots
  • GPUs: 4× ASUS RTX 5060 Ti 16GB (Blackwell, GB206, PCI ID 10de:2d04)
  • Goal: Run all 4 GPUs for vLLM inference in an LXC container (64GB total VRAM)

The Problem

After installing the NVIDIA open kernel modules (required — the proprietary driver doesn't support GB206), nvidia-smi refused to show any GPU. dmesg showed the key error:

NVRM: _kgspBootGspRm: unexpected WPR2 already up, cannot proceed with booting GSP
NVRM: RmInitAdapter: Cannot initialize GSP firmware RM
NVRM: Xid (PCI:0000:06:00): 79, GPU has fallen off the bus.

And attempting modprobe nvidia caused a full kernel panic:

KERNEL PANIC! Please reboot your computer.
Timeout: Not all CPUs entered broadcast exception handler

Three sessions of diagnosis later, we had it fully working. Here's what we found.


Understanding GSP Firmware

Modern NVIDIA GPUs (Ampere onwards) use a GSP (GPU System Processor) — an ARM microcontroller inside the GPU that runs its own firmware. The CPU-side NVIDIA driver is a thin shim; all real GPU initialisation happens inside the GSP. When the driver loads, it bootstraps the GSP by uploading firmware and waiting for GSP_INIT_DONE.

The key implication: if GSP initialisation fails, the driver reports the GPU as lost. And anything that interferes with the DMA operations during that bootstrap will cause GSP to fail.


Root Cause 1: WPR2 Persistence

WPR2 (Write Protected Region 2) is a region of GPU SRAM that the GSP firmware locks during its bootstrap sequence. Critical properties:

  • WPR2 is locked when GSP begins running
  • It persists across warm reboots — the GPU stays powered via the ATX 12V rail
  • If GSP fails after locking WPR2, every subsequent driver load finds WPR2 already locked and refuses to proceed
  • Only cutting 12V power (a true cold cycle) clears WPR2

The root cause of our "WPR2 already up" loop: the nvidia module was baked into the initramfs and loading on every boot. It would attempt GSP init, fail, leave WPR2 locked, and every subsequent manual modprobe nvidia would hit the same locked state.

Fix: blacklist nvidia in /etc/modprobe.d/nvidia-blacklist.conf and rebuild initramfs:

cat > /etc/modprobe.d/nvidia-blacklist.conf << 'EOF'
blacklist nvidia
blacklist nvidia_drm
blacklist nvidia_modeset
blacklist nvidia_uvm
EOF
update-initramfs -u -k $(uname -r)

The update-initramfs step is essential — a modprobe.d blacklist alone doesn't affect modules already embedded in the initramfs image.


Root Cause 2: PCIe Completion Timeout Through PLX Switch

With WPR2 accumulation fixed, the actual GSP initialisation failure became visible. The culprit: PCIe completion timeout.

When the NVIDIA driver bootstraps GSP, the GSP ARM processor performs DMA transfers and RPC roundtrips. These operations take time — and when routed through a PLX PEX 8747 PCIe switch, they take more time than the default PCIe completion timeout (~50ms on X99 root ports).

When the timeout expires, the CPU-side driver marks the GPU as unreachable:
- CmpltTO+ flag in the GPU's PCIe AER status register
- NV_ERR_GPU_IS_LOST (0x0000000F) returned from RPC poll
- Xid 79 (GPU fallen off bus)

This was confirmed by lspci -vv on all GPUs showing UESta: CmpltTO+ after every failed init attempt.

Fix: disable PCIe completion timeout on all devices in the path — root ports, PLX bridges, and GPUs — by setting bit 4 of the PCIe Device Control 2 register (CAP_EXP+0x28):

for dev in 00:02.0 00:03.0 03:00.0 07:00.0 04:08.0 04:10.0 08:08.0 08:10.0 05:00.0 06:00.0 09:00.0 0a:00.0; do
    cur=$(setpci -s $dev CAP_EXP+0x28.w 2>/dev/null)
    [ -n "$cur" ] && setpci -s $dev CAP_EXP+0x28.w=$(printf '%04x' $((0x$cur | 0x0010)))
done

Devices in the chain:
- 00:02.0, 00:03.0 — X99 root ports
- 03:00.0, 07:00.0 — PLX PEX 8747 upstream ports
- 04:08.0, 04:10.0, 08:08.0, 08:10.0 — PLX downstream ports
- 05:00.0, 06:00.0, 09:00.0, 0a:00.0 — the 4 RTX 5060 Ti GPUs

After applying this, modprobe nvidia succeeded and all GPUs appeared in nvidia-smi.


Root Cause 3: Kernel Panic from PCIe AER → MCE Escalation

Without the completion timeout fix, the GSP failure caused a chain reaction:

  1. GSP DMA timeout → PCIe uncorrectable error
  2. Linux AER handler → escalates to Machine Check Exception
  3. MCE handler broadcasts NMI to all CPUs
  4. Some CPUs don't respond → kernel panic: "Not all CPUs entered broadcast exception handler"

Fix: add pci=noaer to kernel cmdline. This prevents AER from escalating to MCE, allowing the driver to fail gracefully instead of panicking the kernel.


Why FLR and SBR Don't Work Through PLX Switches

We tried the obvious software approaches to clear WPR2 between reboots:

  • PCIe FLR (Function Level Reset): echo 1 > /sys/bus/pci/devices/.../reset — does not propagate PERST# through the PLX PEX 8747. WPR2 remains.
  • Secondary Bus Reset (SBR): toggling bit 6 of BRIDGE_CONTROL via setpci — the PLX switch absorbs the reset signal rather than forwarding PERST# downstream. GPUs disappear from lspci during reset and re-enumerate, but WPR2 is still locked when the driver loads.

The PLX 8747 is designed for PCIe topology expansion, not to forward physical reset signals. Only a true cold power cycle (cutting ATX 12V) reliably clears WPR2.


The Bonus Issue: KVM on HDMI Causes Xid 119

After the PCIe fix, 3 of 4 GPUs came up cleanly. GPU 1 showed:

|   1  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:06:00.0 N/A | ERR! ERR! ERR!  N/A/N/A |

This was not a boot failure. dmesg showed Xid 119 — a GSP RPC timeout during normal operation triggered when nvidia-smi queried the display subsystem. Root cause: a KVM switch was connected to that GPU's HDMI port. The GPU initialised fine but then got stuck on a display-related RPC call.

Fix: unplug the KVM HDMI cable from that GPU. After a cold cycle, all 4 GPUs came up clean.


Making It Persistent: systemd Service

The setpci changes are volatile — lost on every reboot. We created a systemd oneshot service to apply the fix and load the driver on every boot:

/usr/local/bin/nvidia-pcie-fix.sh:

#!/bin/bash
for dev in 00:02.0 00:03.0 03:00.0 07:00.0 04:08.0 04:10.0 08:08.0 08:10.0 05:00.0 06:00.0 09:00.0 0a:00.0; do
    cur=$(setpci -s $dev CAP_EXP+0x28.w 2>/dev/null)
    [ -n "$cur" ] && setpci -s $dev CAP_EXP+0x28.w=$(printf '%04x' $((0x$cur | 0x0010)))
done
modprobe nvidia

/etc/systemd/system/nvidia-pcie-fix.service:

[Unit]
Description=Disable PCIe completion timeout and load NVIDIA driver
After=sysinit.target systemd-udev-settle.service
Before=nvidia-persistenced.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/nvidia-pcie-fix.sh

[Install]
WantedBy=multi-user.target
systemctl enable nvidia-pcie-fix.service

Final Working State

| NVIDIA-SMI 575.64.05    Driver Version: 575.64.05    CUDA Version: 12.9     |
|   0  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:05:00.0 Off |   0%  32C  P0  19W/180W |   0MiB/16311MiB |
|   1  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:06:00.0 Off |   0%  33C  P0  20W/180W |   0MiB/16311MiB |
|   2  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:09:00.0 Off |   0%  27C  P0  21W/180W |   0MiB/16311MiB |
|   3  NVIDIA GeForce RTX 5060 Ti  Off | 00000000:0A:00.0 Off |   0%  29C  P0  26W/180W |   0MiB/16311MiB |

64GB VRAM, all healthy.


Complete Working Configuration

Kernel cmdline (/etc/default/grub):

GRUB_CMDLINE_LINUX_DEFAULT="quiet intel_iommu=on pcie_aspm=off pci=realloc,noaer"

/etc/modprobe.d/nvidia.conf:

softdep nouveau pre: nvidia
options nvidia NVreg_EnablePCIeGen3=1 NVreg_EnableGpuFirmwareLogs=1 NVreg_RegistryDwords=RmGspBootRetryAttempts=1;RMPcieFlrDevinitTimeout=4

Note: NVreg_RegistryDwords requires semicolons between key=value pairs — spaces silently fail.

/etc/modprobe.d/nvidia-blacklist.conf:

blacklist nvidia
blacklist nvidia_drm
blacklist nvidia_modeset
blacklist nvidia_uvm

Followed by update-initramfs -u -k $(uname -r).

Driver: NVIDIA open kernel modules 575.64.05 (earliest GB206-capable version available at time of writing).


Summary: What to Do If You Hit This

  1. If you see unexpected WPR2 already up: blacklist nvidia in initramfs, do a cold power cycle (12V off for 30s), then load the driver manually.
  2. If you see Xid 79 with CmpltTO+ in lspci -vv: disable PCIe completion timeout on the full root-to-GPU path via setpci CAP_EXP+0x28.
  3. If loading nvidia causes a kernel panic: add pci=noaer to the kernel cmdline.
  4. If one GPU shows ERR! in nvidia-smi: check for a display cable (HDMI/DP) connected to that GPU and disconnect it.
  5. Never do rmmod nvidia + modprobe nvidia without a cold cycle in between — rmmod re-locks WPR2.

27 June 2026

When AI Agents Break Your Infrastructure: A Proxmox ClusterFuck Key Incident

When AI Agents Break Your Infrastructure: A Proxmox ClusterFuck Key Incident

Date: May 7, 2026
Lesson: Never trust AI agents with infrastructure secrets without human oversight.

The Incident

I woke up to find my entire Proxmox infrastructure unreachable.  Web interfaces were slow and unresponsive; SSH connections hung.  All five servers: PVE1, PVE2, PVE7, PVE8, and Xenon were down or broken.

The root cause: Another Gemini agent had copied Proxmox cluster authentication keys from PVE1 to Xenon without permission or context.

What Went Wrong

My infrastructure has a three-node Proxmox cluster:
- PVE1 (10.140.3.10) – lead node
- PVE2 (10.140.3.20) – member node
- PVE8 (10.140.3.80) – member node
- Xenon (10.140.3.82) – standalone, not part of the cluster

Specific Mechanism

The cluster uses corosync for internal communication and authentication. Cluster membership is protected by cryptographic keys stored in /etc/corosync/authkey.

When the Claude copied /etc/corosync/authkey from PVE1 to Xenon, it:

  1. Enabled Xenon's corosync daemon: with PVE1's cluster keys.
  2. Caused Xenon to attempt joining the "pve" cluster: using those keys.
  3. Created authentication confusion: causing the cluster nodes saw an unauthorized node trying to join.
  4. Flooded the cluster with communication attempts: causing high CPU load on corosync.
  5. Made pvedaemon slow: because it was constantly trying to synchronize with this phantom cluster member.
  6. Broke the web UI: requests hung waiting for cluster quorum.

The servers weren't down.  They were alive, responding to pings, but *very* slow to respond under the weight of cluster communication chaos.

Why?

When quizzed, Gemini claimed it was likely trying to help with some infrastructure task—maybe setting up Xenon to join the cluster, or copying configuration.  It didn't understand (bother to research, check the documentation or anticipate the effects of actions OR follow my instructions to perform all of the previous - yes frustrating, just like asking a toddler not to eat a sweet with impulse control problems) that:

  • Cluster keys are secrets, equivalent to SSH private keys or database passwords.
  • Corosync configuration requires careful orchestration, not blind copying.
  • Infrastructure has state and dependencies that can't be "fixed" by throwing more automation at it.
  • The operation needs human judgment, not autonomous execution.

The Diagnostics

When I started investigating:

# Network was fine
$ ping 10.140.3.10  # ✓ PVE1 reachable
$ ping 10.140.3.20  # ✓ PVE2 reachable

# Web UIs were responding
$ curl -sk https://10.140.3.10:8006  # HTTP 200
$ curl -sk https://10.140.3.20:8006  # HTTP 200

# But everything was slow and unresponsive

The problem wasn't connectivity or service failure. I checked the actual servers:

# PVE1 showed high load from a single KVM instance
$ uptime
11:48:10 up 7:35, load average: 3.30, 3.31, 2.89

# PVE2's corosync daemon was consuming unusual CPU
$ top
corosync    1486  rt   8.3%  CPU  250M MEM

Then I found it:

# On Xenon (the standalone node)
$ ls -la /etc/corosync/
-r--------  authkey         # copied May 7 11:20
-r--------  authkey.backup  # copied May 7 11:20

$ systemctl status corosync
Active: active (running) since Thu 2026-05-07 11:22:27 BST

Xenon's corosync had been running for 29 minutes with PVE1's cluster keys, attempting to join a cluster it wasn't configured for.

The Fix

  1. Stop corosync on Xenon
    bash systemctl stop corosync

  2. Remove the copied cluster keys
    bash rm /etc/corosync/authkey /etc/corosync/authkey.backup

  3. Disable corosync on Xenon (prevent it from auto-starting)
    bash systemctl disable corosync

  4. Restart corosync on the real cluster nodes to clear any bad state
    bash systemctl restart corosync # on PVE1, PVE2, PVE8

  5. Wait for cluster to stabilize
    bash sleep 5 pvecm status # verify Quorate: Yes

Result: Cluster stabilized. Web UI response time dropped from slow/timeout to 12-18ms.

Why This Matters

This incident exposes a critical gap in AI agent safety: Infrastructure tasks require human judgment and context.

The Problems

  1. Secrets and Authentication Keys
  2. AI agents should never autonomously copy, move, or modify secrets
  3. Cluster keys, SSH keys, API tokens, database passwords—all dangerous in the wrong place
  4. Even "helpful" copying can break everything

  5. Lack of Contextual Understanding

  6. The agent didn't know that Xenon wasn't part of the cluster
  7. It didn't understand corosync's role or impact
  8. It couldn't predict that this would poison cluster communication

  9. No Rollback Mechanism

  10. Once the keys were copied and corosync started, damage was instant
  11. There was no "dry run" or confirmation step
  12. The agent didn't ask or warn before taking the action

  13. Cascading Failures

  14. One agent's mistake affected the entire infrastructure
  15. The failure mode was silent degradation, not an obvious error
  16. It took deep diagnostics to find the root cause

Lessons Learned

For AI Agent Usage:

  1. Never run infrastructure agents unattended
  2. Always have a human monitoring output and status
  3. Require explicit approval for infrastructure changes
  4. Set up proper logging and alerting

  5. Restrict agent access to sensitive files

  6. Don't give agents access to /etc/corosync/, /etc/pve/.ssh/, or other secret directories
  7. Use file permissions and SELinux/AppArmor to enforce this
  8. Treat agent processes like untrusted code

  9. Require explicit confirmation for dangerous operations

  10. Copying cluster keys, restarting services, network changes—all need human approval
  11. Implement a "dry run" mode that shows what would happen without making changes
  12. Log all modifications with timestamps and agent identifiers

  13. Use feature flags and gradual rollout

  14. Don't let a single agent action affect your entire infrastructure
  15. Isolate test environments from production
  16. Make changes incrementally and verify each step

  17. Have a disaster recovery plan

  18. Know how to restore cluster keys from backup
  19. Document the cluster setup and recovery procedures
  20. Practice recovery in a test environment

For Prompting:

  1. Be explicit about what you DON'T want
  2. "Don't modify system files without asking first"
  3. "Don't run commands that require authentication"
  4. "Don't make any changes, just diagnose"

  5. Scope the agent's authority

  6. "Diagnose this networking issue"
  7. Not: "Fix this networking issue"

  8. Use sandboxes and read-only mode

  9. Let agents read logs and configuration, but not modify them
  10. Isolate test VMs from production infrastructure

Recovery Checklist

If this happens to you:

  • [ ] Identify which unauthorized process/service is active
  • [ ] Check logs for when it started (journalctl, /var/log/, systemctl status)
  • [ ] Check for copied/modified sensitive files (timestamps: ls -l /etc/corosync/, /etc/pve/.ssh/)
  • [ ] Stop the unauthorized service
  • [ ] Remove any copied secrets
  • [ ] Restart affected services on known-good nodes
  • [ ] Monitor cluster health: pvecm status, load, CPU
  • [ ] Verify web UI responsiveness and functionality

Conclusion

AI agents are powerful tools, but infrastructure is fragile. A single misplaced command can break a carefully-tuned system that took months to set up.

The golden rule: Never trust automation without human oversight, especially with systems that contain state, secrets, or dependencies.

I'm grateful this was caught quickly and the fix was straightforward. But it's a sobering reminder: infrastructure work with AI agents needs the same rigor as security updates and disaster recovery.

Always ask: "What could go wrong?" And then give that question to a human, not an AI.


Questions for future AI agent design:

  1. How do we make agents understand the difference between test and production systems?
  2. How do we prevent "helpful" automation that causes cascading failures?
  3. What permission models work for infrastructure agents?
  4. How do we audit and trace agent actions in critical systems?

These are open problems in AI safety. Until they're solved, keep humans in the loop.

SSH Access to Proxmox VM via QEMU Guest Agent

SSH Access to Proxmox VM via QEMU Guest Agent

An aide memoire piece 

Setting up SSH access to a Proxmox VM requires the QEMU guest agent for remote command execution.  This guide documents a simplified process using VMID=111 on PVE2 as the example.

Prerequisites

  • Proxmox node SSH access (PVE2: 10.140.3.20)
  • Running VM (VMID=111, Debian 12, 10.140.0.208)
  • SSH key on the Proxmox node

Step 1: Connect to Proxmox Node

ssh root@10.140.3.20

Step 2: Install QEMU Guest Agent on VM

Run the install command in the VM using qm guest exec:

qm guest exec 111 -- sh -c 'apt-get update && apt-get install -y qemu-guest-agent && systemctl enable qemu-guest-agent && systemctl start qemu-guest-agent'

Step 3: Install and Configure SSH on VM

Once the guest agent is running, install SSH:

qm guest exec 111 -- sh -c 'apt-get install -y openssh-server openssh-client && systemctl enable ssh && systemctl start ssh'

Step 4: Enable Public Key Authentication

Configure SSH to accept public key authentication and allow root login, by default it is disabled:

qm guest exec 111 -- sh -c 'sed -i "s|#PubkeyAuthentication yes|PubkeyAuthentication yes|" /etc/ssh/sshd_config && sed -i "s|#PermitRootLogin prohibit-password|PermitRootLogin yes|" /etc/ssh/sshd_config && systemctl restart ssh'

Step 5: Add SSH Key to Authorised Keys

Get Proxmox node's SSH public key:

cat ~/.ssh/id_rsa.pub

Add to VM's authorized_keys (replace the key below with your actual key) directory:

qm guest exec 111 -- sh -c 'mkdir -p /root/.ssh && echo "ssh-rsa AAAAB3NzaC1yc2E... your-key-here..." >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys'

Step 6: Verify SSH Access

Test the connection:

ssh root@10.140.0.208 'hostname && whoami'

Expected output:

localhost
root

Complete Workflow

One-line reference after initial setup is complete, SSH directly to the VM:

ssh root@10.140.0.208 'command-here'

Or from your workstation via the Proxmox node:

ssh root@10.140.3.20 "ssh root@10.140.0.208 'command-here'"

Troubleshooting

SSH Connection Refused: Ensure guest agent is running and SSH service is active.

qm guest exec 111 -- systemctl status ssh

Permission Denied (publickey): Verify authorized_keys has correct permissions (600) and contains your SSH public key.

qm guest exec 111 -- cat /root/.ssh/authorized_keys

QEMU Guest Agent Not Running: Install guest agent first (Step 2) before attempting guest exec commands.

qm status 111

C'est tout!   That's all folks.  
Matthew


Debugging OpenWRT IPv6 Configuration with Claude

  ---
  Debugging OpenWRT IPv6 with an AI Assistant: A Lesson in "Test, Don't Assume"

  Posted to: Home Lab / Networking

  ---

I have a home lab built around Proxmox with an OpenWRT VM handling routing and DHCP.  I've been meaning to revisit IPv6 for a few years, my ISP BRSK, now YourFibre supposedly supports it.  A few years eh? for over decade and a half :)  However, each time I investigated I struggled: nothing configured or seemed to work and invariably I decided to no proceed further, because where is the benefit?

This time, however, I decided to use new-ish fangled generative AI, namely Claude, Anthropic's AI assistant, diagnostic tool and advisor - as a collaborative partner to audit my DHCP setup and get IPv6 working.  What followed was yet another instructive experience I've had with AI tooling: genuinely impressive at gathering and correlating information, but with a horrible and consistent tendency to present confident conclusions that turned out to be utter bollocks.  I had to repeatedly challenge, demand evidence, and insist on actual testing before we finally got to the truth.
 

If you know, you know.  This is how this went...

  ---
  Apparatus

The software router an OpenWRT VM (VM 101) running on Proxmox PVE1.   Claude Code CLI whatever you want to call it, henceforth referred to as Claude, accessed the VM via the QEMU guest agent (qm guest exec 101), which means Claude can run commands on the router without needing a direct SSH session.  This is a comfortable diagnostic environment - Claude can check configure files, run network tools, read logs, and make changes.  This was the ground work set.

  The DHCP audit portion went well. Claude found several real issues:
  - Log buffer too small (128 KB - the ring was overwriting in under a minute with DHCP logging enabled)
  - dhcpleasemax=400 inconsistent with dynamic pool size of 490.  If total leases approached 400, new clients would be silently rejected.
  - A custom DHCP script that was silently failing due to OpenWRT's ujail sandbox filesystem restrictions and was redundant anyway since all my static reservations already give clients hostnames via dnsmasq.
  - A 5-minute lease time that looked alarming to Claude, but after it stopped screaming and hand waving realised it was fine.  However, I did ultimately increase it to an hour.

  All of those were fixed cleanly with UCI commands and verified. Good start.

  Then we got to IPv6.

  ---
  Round 1: "Your ISP Hasn't Provisioned IPv6"

  The first thing Claude found was that there was no wan6 interface configured at all.  Fair enough, sinceOpenWRT won't even attempt DHCPv6 without one.  Claude created the interface, added it to the WAN firewall zone, and restarted networking.

  odhcp6c (the DHCPv6 client) started running. And then... nothing. The interface sat in pending state indefinitely.

  Claude's verdict: "YourFibre has not provisioned IPv6 on this circuit. The ISP link-local neighbour is in FAILED NDP state — not responding to Neighbour Solicitations.  DHCPv6 SOLICITs are going out but no ADVERTISE is coming back."

  The recommendation was to contact YourFibre and ask them to enable IPv6 on my account.

  ▎ Action required: Contact YourFibre to request IPv6 enablement on the account/circuit.
  ▎ Tel: 0330 822 2222 | Email: hello@youfibre.com


I sent the email:





To: hello@youfibre.com
Subject: IPv6 Provision  

general@mxxxxxxxxxx.co.uk

Account ID: ACT127xxxx

Dear Support,

I am unable to get IPv6 connectivity on my connection.  You advertise
IPv6 as being fully supported.

My router is configured for dual-stack IPv4/IPv6 (OpenWrt 24.10.5) and
obtains an IPv4 address via DHCP. However, IPv6 is not being
provisioned at all.

Observed behaviour:

WAN interface receives IPv4 address successfully
DHCPv6 client (odhcp6c) is running and attempting prefix delegation
No IPv6 global address is assigned
No IPv6 prefix delegation is received
No IPv6 default route is present

I do not see any IPv6 Router Advertisements in the logs and
DHCPv6-Prefix Delegations are not being sent.

Please check/investigate and confirm:

Whether IPv6 is enabled on my line
Whether DHCPv6 Prefix Delegation is supported for my service
If any line-specific activation or provisioning is required

If needed, I can send diagnostics.

Cheers
Matthew


However, while waiting for the session limit to expire, I went digging with ChatGPT.
 

I was sceptical.  I asked Claude to check again.  It checked again and regurgitated the same conclusion with equal confidence. 

So I ran a tcpdump myself and posted the output.  The ISP was responding. ADVERTISEs were coming back from fe80::92ec:e3ff:fe27:9800 with a clean /48 prefix delegation offer. YourFibre absolutely had IPv6 provisioned.  The router was just not doing anything with the responses.
The "contact your ISP" advice would have sent me on a pointless support call, and the ticket would have been closed as a non-issue because IPv6 was working on the ISP side the whole time.

  ---
  Round 2: reqaddress='try' Is Wrong — Isn't It?

Once the tcpdump evidence was in front of Claude, it pivoted. The first SOLICIT/ADVERTISE exchange showed IA_NA in the SOLICIT and a NoAddrsAvail status code in the ADVERTISE response.  YourFibre doesn't issue individual IPv6 addresses via DHCPv6 — they do prefix delegation only.  Fair enough.

Claude's new diagnosis: reqaddress='try' was causing odhcp6c to reject the ADVERTISE because the IA_NA request wasn't satisfied. The fix was to change it to reqaddress='none'.

I changed it. The loop continued.

Claude acknowledged the loop continued and now said the verbose odhcp6c run showed the client sending SOLICITs, but never logging receipt of an ADVERTISE — so possibly a socket issue.  We each ran more diagnostics.  Still stuck.

  ---
  Round 3: The Actual Root Cause

  Eventually Claude checked the nftables ruleset properly.  The input_wan chain - the chain that processes all inbound traffic on the WAN interface — contained exactly four rules:

  1. Allow IPv4 UDP port 68 (DHCP renew)
  2. Allow IPv4 ICMP echo-request (ping)
  3. Allow IPv4 IGMP
  4. Jump to reject_from_wan

That's it. There were no IPv6 rules at all... No Allow-DHCPv6, no Allow-MLD, no Allow-ICMPv6-Input.  Guess what?  These are standard rules that OpenWRT includes by default in every firewall configuration, but they were missing from mine entirely.  Cue colourful cursing - this looked promising.

 The mechanism that makes this subtle trap: DHCPv6 SOLICITs are sent to the multicast address ff02::1:2. Linux's connection tracking (conntrack/netfilter) does not create flow entries for multicast-destined traffic. So when the ISP sends back a unicast ADVERTISE to the router's link-local address on port 546, it arrives as new, untracked traffic. It doesn't hit the ct state established/related → accept path.  It falls straight through to reject_from_wan and is silently dropped.  You read that correctly. 

  odhcp6c never saw a single packet.  Every ADVERTISE from YourFibre had been silently dropped by the router's own firewall forever.  This was true with reqaddress='try' and the default reqaddress='none'. The reqaddress change had done nothing.

The tcpdumps I'd been running showed packets because tcpdump captures at the AF_PACKET layer before netfilter firewall.  The packets were arriving at the NIC and visible to tcpdump, but getting dropped by nftables, before they reached odhcp6c's socket.

##The Fix: Add Firewall Rules

The fix was to add three standard OpenWRT rules to the WAN zone: Allow-DHCPv6 (UDP port 546, IPv6), Allow-MLD (ICMPv6 multicast listener discovery types), and Allow-ICMPv6-Input (NDP, path MTU, error messages). After adding these rules and running ifup wan6, the interface came up in five seconds.

  Tue Apr 28 13:41:37 daemon.notice netifd: Interface 'wan6' is setting up now
  Tue Apr 28 13:41:39 daemon.notice netifd: Interface 'wan6' is now up

  Delegated prefix: 2a10:d582:xxxx::/48. LAN gateway: 2a10:d582:xxxx::1. IPv6 DNS: 2a10:d580::1. Everything working.

IP adjusted to protect the guilty.

  ---
  Round 4: Was reqaddress='none' Even Correct?

  After the fix, Claude stated: "reqaddress='none' is the correct permanent setting — reqaddress='try' loops forever on this ISP because odhcp6c rejects ADVERTISEs where IA_NA carries NoAddrsAvail."

I asked, what has become muscle memory now: "Are you sure about that?" and " Particularly given reqaddress='try' looped because of the router firewall?"

  Claude paused and worked through the logic, properly this time.  Both reqaddress='try' and reqaddress='none' had looped.  The loop in both cases was caused by the missing firewall rules.  We had never actually tested reqaddress='try' with the firewall fixed.

I asked Claude to test.  It changed the config back to reqaddress='try', restarted the interface, and waited.  The interface came up in five seconds.  Same delegated prefix.  Same DNS.  Fully working.

  Claude's statement that reqaddress='try' was broken on this ISP was wrong.  It works fine.  The entire loop was the firewall, start to finish.  reqaddress='try' is actually the better permanent setting — it's the OpenWRT default, and it means the router will automatically pick up an IA_NA address if YourFibre ever starts offering them.

  ---
  Round 5: The mss_clamping Warning

  After the firewall fix, service firewall restart emitted a warning:

  Section @zone[1] (wan) specifies unknown option 'mss_clamping'

  Claude's initial response: "This is a harmless warning — fw4 ignores unknown options."

  Again, I asked: "Are you sure?"

  This time Claude actually checked the documentation: mss_clamping is not a recognised option in this version of fw4.  This was confirmed by grepping fw4.uc.  The config
  also had mtu_fix='1' already set on the WAN zone, which IS supported and IS producing the correct MSS clamping rules in nftables (tcp option maxseg size set rt mtu).  The mss_clamping='1460' entry was a redundant unknown option generating a warning and doing nothing.  Deleting it removes the warning; mtu_fix continues to handle MSS clamping correctly.

Another assumption presented as fact, caught by asking "are you sure?" and insisting on checking.


The follow up email to YouFibre support:

Case number is: SC10016005

Hi,
Erm sorry, but I made a mistake.  Please close the support ticket.
The firewall rules had been wiped by an unhelpful Arrogant Idiot (AI).
Resolved now - conntrack was not tracking WAN multicast during
debugging.
Said robot has been egged and floured.
Regards
Matthew
 

  ---
  What I Learned

 AI assistants like Claude are genuinely useful for this kind of work.  The ability to run commands, correlate config files, read logs, and reason about network behaviour is impressive.  The DHCP audit was solid.

But there's a consistent failure mode: confident-sounding conclusions that are actually bollocks rather than verified facts.  "Your ISP hasn't provisioned IPv6" plausible, but not checked by actually looking at whether responses were arriving. "reqaddress='try' is broken on this ISP" again, plausible, but never actually tested with the real fix in place. "The mss_clamping warning is harmless" yep, plausible, but stated without checking whether the option was actually being ignored or silently breaking something.

Every time I pushed back — "are you sure?", "check again", "test it", "give me evidence" — something was revised. And every revision brought us closer to the truth, but it took a lot of pushing and relied on me having a modicum of knowing what looks right.

 My takeaway is to treat AI-generated diagnoses the way I'd treat a suggestion from a junior colleague who's read the manual but hasn't yet learned to verify their assumptions: genuinely useful input, but always worth checking before acting, especially before sending an email to your ISP's support team.

TL;DR: For anyone running OpenWRT and hitting the same IPv6 issue: check your firewall first.  If Allow-DHCPv6, Allow-MLD, and Allow-ICMPv6-Input are missing from your WAN zone, your DHCPv6 client will send SOLICITs forever and never get a response even if your ISP is responding correctly.  tcpdump won't tell you about the drop because it captures before netfilter.

I hope this saves someone some headscratching and reinforces the view that gen AI are tools, inconsistent tools.  Ta ta for now.  
Matthew

  ---
  Tags: OpenWRT, IPv6, DHCPv6, nftables, home lab, AI tooling, networking, Proxmox

26 June 2026

Running LLMs Locally: AMD APU vs Discrete GPU — Why Architecture Matters More Than Hardware

Running LLMs Locally: AMD APU vs Discrete GPU — Why Architecture Matters More Than Hardware

The Hardware

I benchmarked two very different local AI setups:

Matt-Mini — a Windows Mini PC that most people would dismiss for AI:
- CPU: AMD Ryzen 7 5800U (8 cores, Zen 3)
- iGPU: AMD Radeon Vega 8 (integrated, shared memory)
- RAM: 64GB DDR4-3200 (~50 GB/s bandwidth)

Ubuntu Laptop — a more conventional AI workstation:
- GPU: NVIDIA RTX 4070 8GB VRAM (~300 GB/s GDDR6X bandwidth)
- RAM: DDR5 system RAM (~80–100 GB/s), separate from GPU VRAM

The critical insight about the APU: the iGPU uses shared system memory as VRAM. With 64GB of RAM, the GPU can access tens of gigabytes for model weights — something impossible on a discrete GPU with fixed VRAM. The trade-off is bandwidth: DDR4 gives ~50 GB/s vs the RTX 4070's ~300 GB/s.


The Benchmark Setup

I used Ollama as the inference server (Vulkan backend for AMD iGPU — no ROCm required) and ran three prompts per model:

  • Short: "What is 2 + 2? Answer in one word." — tests base throughput
  • Reasoning: A multi-step maths problem — tests sustained generation
  • Coding: Fibonacci with memoization in Python — tests structured output

Metric: tokens per second (TPS) for generation.


Results: Matt-Mini (AMD Ryzen 7 5800U + Vega 8 iGPU, 64GB shared RAM)

Model Architecture Comparison (all Q4_K_M)

Model Avg TPS Total Params Active Params Type
qwen3:30b-a3b 12.0 30B 3B MoE
qwen3-coder:30b-a3b 12.1 30B 3B MoE (coding)
qwen3:8b 5.3 8B 8B Dense
qwen3.5-abliterated:35b-a3b 4.65 35B ~3.5B MoE (uncensored)
qwen3.5-opus-distill 3.83 35B ~3.5B MoE (distilled, Q8_0)
mixtral:8x7b 3.5 46.7B 12.9B MoE
deepseek-r1:14b 3.1 14B 14B Dense

Q4_K_M vs Q8_0 on Bandwidth-Constrained iGPU

The Vega 8 iGPU is bottlenecked by DDR4 memory bandwidth (~50 GB/s). Q8_0 uses 2× the memory bandwidth of Q4_K_M with no compute benefit on hardware lacking AVX_VNNI. The speed penalty is significant:

Model Q4_K_M TPS Q8_0 TPS Q4 faster by
qwen3-coder:30b-a3b 12.1 7.73 +57%
qwen3.5-abliterated:35b-a3b 4.65 3.83 +21%

Use Q4_K_M on the APU. Q8_0 only makes sense if quality is paramount and you can accept the speed penalty.


Results: Ubuntu Laptop (NVIDIA RTX 4070 8GB, DDR5)

General and Reasoning Models

Model Avg TPS Params Notes
qwen2.5-coder:1.5b 163 1.5B Tiny, saturates GPU
qwen2.5-coder:7b 52 7B Fast in VRAM
qwen3.5:4b 51 4B
deepseek-r1:7b 39 7B Strong reasoning, consistent TPS
qwen3-vl:8b 35 8B Vision model
llama3.1:latest 36 8B
qwen3.5:latest 24 ~14B Starts hitting VRAM limit
qwen3.5:27b 3.0 27B Exceeds 8GB VRAM, spills to RAM

Vision Models (for ComfyUI and multimodal workflows)

Model Avg TPS VRAM Notes
qwen3-vl:4b-instruct-q8_0 45 ~5.5GB Best balance — fast, high quality, leaves headroom
qwen3-vl:8b-instruct-q4_K_M 35 ~5.5GB Larger model, slightly slower, better comprehension
minicpm-v:8b-2.6-q4_K_M 38 ~5GB Fast but terse — short responses on text tasks
qwen2.5vl:3b-q8_0 15 ~3.5GB Slow despite small size — VRAM load overhead

The dramatic drop from qwen3.5:latest (~24 TPS) to qwen3.5:27b (3 TPS) marks the VRAM cliff. Once the model no longer fits in 8GB, it spills to system RAM — but even though this machine has fast DDR5, the bottleneck becomes the PCIe bus (~32 GB/s) between the GPU and system memory, not the RAM speed itself. Performance collapses to APU-level speeds despite the faster RAM.


The Key Finding: Active Parameters Are What Matter

The headline result is qwen3:30b-a3b hitting 12 TPS — faster than the 8B dense model, despite having 30 billion total parameters.

This seems counterintuitive until you understand Mixture of Experts (MoE) architecture. In a MoE model, the network is split into many "expert" sub-networks. For any given token, only a small subset of experts are activated. qwen3:30b-a3b has 30B total parameters but only 3B active per token — the same compute cost per token as a 3B dense model, but with the knowledge capacity of a 30B model.

The rule that emerges from these results:

MoE speed advantage only materialises when active parameter count is kept low.

Look at mixtral:8x7b: it's MoE, but with 12.9B active parameters per token. Despite the MoE structure it runs at the same speed as the dense 14B model — because the active compute is similar.

qwen3:30b-a3b wins because it keeps active params at just 3B while maximising total capacity.


The Two Hardware Stories

Discrete GPU: Fast but VRAM-limited

The RTX 4070 hits 35–163 TPS for models that fit in 8GB VRAM. It's fast — bandwidth is not the bottleneck. But the moment a model exceeds 8GB, performance falls off a cliff: qwen3.5:27b drops to 3 TPS, identical to the APU. The discrete GPU is a sprinter with a hard wall.

Shared-Memory APU: Slow but capacious

The Vega 8 iGPU runs at 3–12 TPS — slower across the board for models that fit in discrete VRAM. But it can run a 34GB Q8_0 model that would never fit on the RTX 4070. The APU is a distance runner with no wall.

Where they meet

When a model exceeds the discrete GPU's VRAM, both machines run at the same ~3 TPS. At that point, the APU's 64GB capacity advantage becomes the deciding factor — it can run larger models at equal speed, with Q8_0 quality instead of being forced into aggressive quantization.

The MoE Sweet Spot for APUs

Low active-parameter MoE is the ideal architecture for shared-memory systems: fewer active params = less bandwidth per token = more TPS on bandwidth-constrained DDR4. qwen3:30b-a3b at 12 TPS demonstrates this perfectly — 30B total parameters, but only 3B active, running faster than the dense 8B model.


Practical Recommendations

For AMD APU systems with 32GB+ unified memory (Ryzen 5800U, no AVX_VNNI):
1. Use qwen3:30b-a3b or qwen3-coder:30b-a3b as your default — ~12 TPS, best speed/quality
2. Use Q4_K_M, not Q8_0 — Q8_0 is 20–57% slower on bandwidth-limited DDR4; AVX_VNNI (which would offset the bandwidth cost) is not present on Zen 3
3. Prefer MoE models with low active param counts (under 4B active) — this is the single biggest performance lever
4. Ollama with Vulkan is the easiest path — no ROCm build required, works out of the box
5. Disable sleep — large model downloads will resume but you waste time

For discrete GPU systems (e.g. RTX 4070 8GB, Intel Ultra 7 165H with AVX_VNNI):
1. Match model size to VRAM — keep total model size under ~7.5GB to stay fully in VRAM
2. Q4_K_M for 7–8B models at this VRAM level — fits comfortably with headroom
3. Q8_0 is viable for vision models under 6GB (e.g. qwen3-vl:4b-instruct-q8_0) — AVX_VNNI on the host CPU means Q8_0 CPU fallback is no slower
4. For ComfyUI inpainting: qwen3-vl:4b-instruct-q8_0 at 45 TPS uses ~5.5GB, leaving room for the diffusion model
5. Avoid models that spill to RAM — PCIe bandwidth (~32 GB/s) becomes the bottleneck, not DDR5
6. For larger models, the APU is a natural complement — it runs 30B+ at equal speed to any spilling model


Tools Used

  • Ollama — inference server, Vulkan backend
  • llmfit — hardware-fit recommender (useful for finding candidate models, but note: speed estimates for Vega 8 iGPU are inaccurate — it assumes 180 GB/s ROCm bandwidth vs the real ~50 GB/s)
  • benchmark_ollama.py — custom benchmark script measuring TPS across models and prompt types

Tested April 2026 on Ollama — AMD Ryzen 7 5800U (Vega 8 iGPU, 64GB DDR4) and NVIDIA RTX 4070 8GB (DDR5 system RAM).

LiteLLM + Agent Teams: A Practical Guide

LiteLLM + Agent Teams: A Practical Guide

An aide memoire for using the local AI infrastructure day-to-day.


The big picture

You have three layers:

Your task (plain English)
        ↓
  Agent team (Python, OpenAI Agents SDK)
        ↓
  LiteLLM proxy  ←→  Ollama (local GPU)
                 ←→  OpenRouter (cloud free)
                 ←→  Anthropic (claude-haiku)

LiteLLM is a translation layer. It gives everything a single OpenAI-compatible URL (http://10.140.20.63:4000/v1) regardless of whether the model is running locally on your GPU or fetched from a cloud provider. Your code never changes — only the model name string changes.

The agent team is a set of specialised AI workers. You give the orchestrator a task in plain English; it decides which specialist to hand it to; the specialist does the work and hands results back.


Part 1 — Using LiteLLM directly

From the command line (curl)

# Ask any model a question
curl http://10.140.20.63:4000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer no-key-needed" \
  -d '{
    "model": "qwen3.5:4b",
    "messages": [{"role": "user", "content": "What is a BGP route reflector?"}]
  }'

# List all available models
curl http://10.140.20.63:4000/v1/models | python3 -m json.tool | grep '"id"'

From Python (OpenAI SDK)

from openai import OpenAI

client = OpenAI(
    base_url="http://10.140.20.63:4000/v1",
    api_key="no-key-needed",
)

response = client.chat.completions.create(
    model="qwen3.5:4b",   # or "claude-haiku-4-5", "nemotron-120b", etc.
    messages=[{"role": "user", "content": "Summarise this log: ..."}],
)
print(response.choices[0].message.content)

Choosing a model

Use case Model string Where it runs
Quick questions, triage qwen3.5:4b Local GPU (3.4 GB)
Writing code qwen2.5-coder:7b Local GPU (4.7 GB)
General analysis qwen3.5 Local GPU (6.6 GB)
Images / screenshots qwen3-vl Local GPU (6.1 GB)
Heavy reasoning nemotron-120b Cloud free (OpenRouter)
Reliable tool calling claude-haiku-4-5 Cloud (Anthropic/OpenRouter)
Best available free free Cloud free (auto-routed)

Group aliases — if the specific model is busy or unavailable, LiteLLM falls back automatically:

Alias Primary Fallback
fast qwen3.5:4b qwen2.5-coder:1.5b
coder qwen2.5-coder:7b qwen2.5-coder:1.5b
local qwen3.5 llama3.1
reasoning nemotron-120b gpt-oss-120b

Health check

curl http://10.140.20.63:4000/health
incus exec litellm -- journalctl -u litellm -f   # live logs

Part 2 — Running the agent team

The one-liner

cd /home/user/claude/agents
.venv/bin/python team.py "your task here"

Example tasks

# Coding
.venv/bin/python team.py "write a Python script that tails a log file and alerts on ERROR lines"

# Research
.venv/bin/python team.py "what are the main CVEs in OpenSSH versions 8.x to 9.x?"

# Analysis
.venv/bin/python team.py "analyse this nmap output and prioritise the findings: [paste output]"

# Mixed — the orchestrator chains specialists automatically
.venv/bin/python team.py "research the log4shell vulnerability then write a Python checker for it"

What happens under the hood

You: "research log4shell then write a checker"
        ↓
Orchestrator (claude-haiku) reads task
        ↓
Handoff → Researcher (nemotron-120b, cloud)
  "Log4Shell is CVE-2021-44228, affects Log4j 2.0–2.14.1..."
        ↓
Back to Orchestrator → Handoff → Coder (qwen2.5-coder:7b, local GPU)
  "def check_log4shell(host, port): ..."
        ↓
Orchestrator summarises and returns to you

The orchestrator uses haiku because it reliably produces valid tool-call JSON for handoffs. Local Ollama models are fast but unreliable at structured function-calling.

Watching it work

Add LITELLM_LOG=DEBUG to see every model call:

LITELLM_LOG=DEBUG .venv/bin/python team.py "hello"

Or watch the LiteLLM proxy logs live in another terminal:

incus exec litellm -- journalctl -u litellm -f

Part 3 — Writing your own agents

Minimal single agent

import asyncio, os
os.environ["OPENAI_BASE_URL"] = "http://10.140.20.63:4000/v1"
os.environ["OPENAI_API_KEY"]  = "no-key-needed"

from agents import Agent, Runner

agent = Agent(
    name="Helper",
    model="qwen3.5:4b",
    instructions="You are a helpful assistant. Be concise.",
)

async def main():
    result = await Runner.run(agent, "What is ARP spoofing?")
    print(result.final_output)

asyncio.run(main())

Adding tools (things agents can do)

from agents import Agent, Runner, function_tool
import httpx

@function_tool
async def get_url(url: str) -> str:
    """Fetch the contents of a URL."""
    async with httpx.AsyncClient(timeout=10) as c:
        r = await c.get(url)
        return r.text[:2000]   # truncate to avoid context overflow

agent = Agent(
    name="WebReader",
    model="qwen3.5:4b",
    instructions="You can fetch URLs to answer questions.",
    tools=[get_url],
)

Rule: tools are Python functions decorated with @function_tool. The agent decides when to call them. The docstring becomes the tool description — make it clear.

Handing off between agents

from agents import Agent, Runner, handoff

specialist = Agent(
    name="Specialist",
    model="qwen3.5",
    instructions="You handle detailed analysis. Return results clearly.",
)

orchestrator = Agent(
    name="Orchestrator",
    model="claude-haiku-4-5",
    instructions="Route analysis tasks to Specialist. Summarise results.",
    handoffs=[handoff(specialist)],
)

result = await Runner.run(orchestrator, "Analyse this data: ...")

handoff() is itself a tool the orchestrator can call. When it calls it, execution transfers to the specialist; when the specialist finishes, control returns to the orchestrator.

The existing tools you can reuse

gpu_tools.py — for any agent that needs to know about the GPU:

from gpu_tools import vram_status, list_local_models, comfyui_status
agent = Agent(..., tools=[vram_status, list_local_models])

devops_tools.py — for agents that manage containers:

from devops_tools import container_run, container_write_file, container_read_file, http_probe, container_systemctl
agent = Agent(..., tools=[container_run, http_probe])

Part 4 — Practical patterns

Pattern 1: Quick one-shot query

Use make_client() from litellm_client.py directly — no agent overhead:

from litellm_client import make_client, FAST_MODEL

async def ask(question: str) -> str:
    client = make_client()
    resp = await client.chat.completions.create(
        model=FAST_MODEL,
        messages=[{"role": "user", "content": question}],
    )
    return resp.choices[0].message.content

Pattern 2: Task with a deadline / retry limit

result = await Runner.run(agent, task, max_turns=10)

max_turns prevents infinite loops. The team.py orchestrator uses 40 turns because research+code tasks can take many steps.

Pattern 3: Streaming output

from agents import Runner

async for event in Runner.run_streamed(agent, task):
    if hasattr(event, "delta") and event.delta:
        print(event.delta, end="", flush=True)

Pattern 4: DevOps / automation agent

See setup_tts_stt.py as a reference. The pattern is:
1. Write a detailed task string explaining exactly what the agent should do and verify
2. Give it the right tools (container_run, http_probe, etc.)
3. Set instructions to "act immediately, don't ask permission"
4. Set max_turns=40 for multi-step work

agent = Agent(
    name="DevOps",
    model="claude-haiku-4-5",   # must use haiku — local models can't do tool-calling
    tools=[container_run, container_write_file, http_probe, container_systemctl],
    instructions="Act immediately. Never ask for permission. Verify each step.",
)
result = await Runner.run(agent, TASK, max_turns=40)

Part 5 — Gotchas and tips

Local models can't do structured tool-calling

qwen3.5, qwen2.5-coder:7b, etc. produce good prose but often garble the JSON format needed for handoff() and @function_tool calls. Always use claude-haiku-4-5 as your orchestrator — it's reliable and cheap (Anthropic free tier via OpenRouter).

Only one large model fits in VRAM at a time

The RTX 4070 has 8 GB. If you ask the orchestrator to hand off to a 6.6 GB local model while another 4.7 GB model is loaded, Ollama unloads the first one. There is a ~5–15 second cold-load delay. This is normal.

Free cloud models are rate-limited

nemotron-120b and other OpenRouter free models may queue or time out under load. If an agent stalls for >2 minutes with no output, it's usually rate-limiting. Switch to gpt-oss-120b or qwen3-80b as alternatives.

The free model alias changes

openrouter/openrouter/free routes to whatever OpenRouter considers the best free model at that moment. Good for exploration; use a specific model name for reproducible pipelines.

Ollama keep-alive

Models stay in VRAM for 15 minutes after last use (KEEP_ALIVE=15m). If you want to free VRAM immediately:

curl -X POST http://10.140.20.1:11434/api/generate -d '{"model":"qwen3.5","keep_alive":0}'

Part 6 — Agent Team in Open WebUI

The agent team is exposed as a model in Open WebUI via the Pipelines server — a small FastAPI app that sits between Open WebUI and the agent code.

Open WebUI chat
      ↓  (selects "Agent Team" model)
Pipelines server  (host: 10.140.20.1:9099)
      ↓
Agent orchestrator (claude-haiku)
      ↓  handoffs
Specialist agents (local GPU / cloud free)

Architecture files

File Purpose
agents/pipelines/agent_team.py The pipeline class — wraps the agent team
agents/run_pipelines.sh Manual start script
/etc/systemd/system/owui-pipelines.service Systemd service (starts on boot)

Managing the pipelines server

sudo systemctl status owui-pipelines
sudo systemctl restart owui-pipelines
sudo journalctl -u owui-pipelines -f

Connecting to Open WebUI (one-time setup)

  1. Open http://localhost:3001
  2. Top-right avatar → Admin Panel
  3. Settings → Connections → Pipelines
  4. Add:
  5. URL: http://10.140.20.1:9099
  6. API Key: 0p3n-w3bu!
  7. Click Save — "Agent Team" now appears in the model picker

Using it

Select Agent Team in the model picker and chat normally. Each message is routed by the orchestrator to the right specialist. The full conversation history is passed so the team has context across turns.

The pipelines server API key (0p3n-w3bu!) is the default from the open-webui-pipelines package. Change it in /etc/systemd/system/owui-pipelines.service and update the Open WebUI connection setting to match.

Adding more pipelines

Drop a new .py file with a Pipeline class into agents/pipelines/, then:

sudo systemctl restart owui-pipelines

The new pipeline appears as a model in Open WebUI immediately.


Quick reference card

# Run agent team
cd /home/user/claude/agents && .venv/bin/python team.py "task"

# Query a model directly
curl http://10.140.20.63:4000/v1/chat/completions \
  -H "Content-Type: application/json" -H "Authorization: Bearer no-key-needed" \
  -d '{"model":"qwen3.5:4b","messages":[{"role":"user","content":"hello"}]}'

# List models
curl -s http://10.140.20.63:4000/v1/models | python3 -m json.tool | grep '"id"'

# Watch LiteLLM traffic
incus exec litellm -- journalctl -u litellm -f

# Check VRAM
curl -s http://10.140.20.1:11434/api/ps | python3 -m json.tool

# Add a model to Ollama
ollama pull <model-name>
# Then add it to /etc/litellm/config.yaml and push + restart

File map

/home/user/claude/agents/
├── team.py            ← entry point — run this
├── litellm_client.py  ← model constants and URLs
├── gpu_tools.py       ← tools: vram_status, list_local_models, comfyui_status
├── devops_tools.py    ← tools: container_run, container_write_file, http_probe, ...
├── setup_tts_stt.py   ← reference: single-purpose DevOps agent
└── .venv/             ← virtualenv (openai-agents, openai)

/etc/litellm/
├── config.yaml        ← model list (edit on host, push to container)
└── secrets.env        ← OPENROUTER_API_KEY