19 July 2026

Building a Multi-Model Audio Transcription Pipeline on PVE2: Part 2 — What the Benchmarks Actually Measure

This is Part 2 of a series. Part 1 covered the initial model selection and design decisions. Part 3 will have the actual test results.

Part 1 ended with an ensemble of five models and a GPU layout. This part is a step back: before running anything, it's worth understanding what the benchmark numbers actually mean, whether the leaderboard rankings translate to real meeting recordings, and — most honestly — which model I'd missed entirely the first time around.


How the Open ASR Leaderboard works

The Open ASR Leaderboard is a collaboration between Hugging Face, NVIDIA, Mistral AI, and the University of Cambridge. It evaluates 86 models (open-source and commercial APIs) across 12 standardised datasets, producing reproducible results using open-source toolkits (ESPNet, NeMo, SpeechBrain, HuggingFace Transformers). The accompanying paper (arXiv:2510.06961) describes the methodology in detail.

The 12 datasets span several distinct conditions:

Dataset What it tests
LibriSpeech test-clean Read speech from audiobooks. Studio-quality, single speaker, no noise.
LibriSpeech test-other Same audiobook data but harder accents and recording conditions.
TED-LIUM 3 Conference talks. Scripted or semi-scripted, moderate noise.
AMI meeting corpus Real multi-speaker meetings. Overlapping speech, room acoustics, variable mic quality.
GigaSpeech Web audio: podcasts, YouTube, diverse conditions.
Earnings-22 Financial earnings calls. Domain-specific vocabulary, telephone audio quality.
CallHome Conversational telephone calls between family members.
VoxPopuli European Parliament speech (multilingual track).
Common Voice Crowdsourced speech across many languages.
FLEURS Read sentences across 102 languages.
MLS (Multilingual LibriSpeech) Audiobook data for European languages.
CV 16 Common Voice, specific to 16 languages.

The English average WER that headlines the leaderboard is a mean across a subset of these. LibriSpeech, TED-LIUM, AMI, GigaSpeech, Earnings-22, CallHome, and Common Voice all feed into it. A model's headline figure is therefore a blend of clean and noisy conditions — but the blend is dominated by the cleaner datasets, which makes the headline look better than meeting-only performance would suggest.


The gap between benchmarks and meeting recordings

This is the most important thing to understand before picking models for this use case.

LibriSpeech test-clean is read speech from audiobooks, recorded in quiet conditions, by a single speaker per file, with a professional microphone. It is the easiest English ASR benchmark by a considerable margin. NVIDIA Parakeet-TDT-0.6b-v2 achieves 1.69% WER on LibriSpeech test-clean. On the AMI meeting corpus — which captures real multi-speaker meetings in rooms — the same model scores 11.16% WER. That's a 6.6× degradation.

Whisper large-v3 degrades less sharply (around 3.5×) on noisy conditions because it was trained on noisier, more diverse data. But it starts from a higher WER on clean benchmarks, so it ends up in a similar place in practice. IBM Granite Speech 3.3 reports only 7.54% relative degradation from clean to noisy conditions — unusually low, suggesting deliberate noise-robustness training.

The upshot: for selecting models for poor-quality office recordings, the AMI score and any available noise-condition figures are more relevant than the LibriSpeech headline. The leaderboard presents both where available; it's worth checking rather than reading only the top-line average.


A more complete model comparison

Here's the picture when you include all the serious contenders, with the metrics that matter most for this use case:

Model HF repo Avg WER AMI WER Noise notes Timestamps Licence VRAM (float16)
Canary-Qwen-2.5B nvidia/canary-qwen-2.5b 5.63% 2.41% at 10 dB SNR forced align CC-BY-4.0 ~8 GB
IBM Granite 3.3 8B ibm-granite/granite-speech-3.3-8b 5.74% 7.54% relative degradation forced align Apache-2.0 ~18 GB
IBM Granite 3.3 2B ibm-granite/granite-speech-3.3-2b ~6.00% similar noise profile forced align Apache-2.0 ~5 GB
Qwen3-ASR-1.7B Qwen/Qwen3-ASR-1.7B ~6% 16.17% vs Whisper's 63.17% under extreme noise native Apache-2.0 ~4 GB
Phi-4-multimodal microsoft/Phi-4-multimodal-instruct 6.14% no native (40-sec cap) MIT ~12 GB
Parakeet-TDT-0.6b-v2 nvidia/parakeet-tdt-0.6b-v2 6.05% 11.16% native CC-BY-4.0 ~2 GB
CrisperWhisper nyrahealth/CrisperWhisper 6.67% disfluency-aware specialised ~6 GB
Kyutai STT-2.6b-en kyutai/stt-2.6b-en 6.4% 2-hr files, streaming token-level CC-BY-4.0 ~6 GB
Voxtral-Mini-3B mistralai/Voxtral-Mini-3B-2507 beats Whisper-v3 no native (30-min cap) Apache-2.0 ~9.5 GB
Whisper large-v3 (WhisperX) openai/whisper-large-v3 7.4% ~9% diverse training data wav2vec2 align MIT ~6 GB
Gemma 4 E4B google/gemma-4-E4B-it no native (30-sec cap) Apache-2.0 ~8 GB

The commercial-API leaders (ElevenLabs on long-form, RevAI, Speechmatics) don't appear here because there's no self-hosted option. For transcribing client-confidential recordings, sending audio to a third-party API isn't on the table regardless of the accuracy numbers.


The model I missed: Qwen3-ASR

Qwen3-ASR (GitHub: QwenLM/Qwen3-ASR) was released by Alibaba Cloud's Qwen team at the end of January 2026, under Apache-2.0, covering 52 languages. The technical report is arXiv:2601.21337. It has over 700 million downloads on HuggingFace, which suggests it's found a real audience.

The number that makes it relevant here: under extreme noise conditions, Qwen3-ASR-1.7B achieves 16.17% WER compared to Whisper large-v3's 63.17% — a 3.9× advantage. In moderate noise the gap narrows, but the model was specifically designed to handle acoustic environments that simpler models fall apart on: significant ambient noise, background music, overlapping voices, regional dialects.

For recordings made in server rooms, open-plan offices, and cafes — which describes several of the recordings I want to transcribe — this is the most relevant differentiator on the whole leaderboard.

Two variants: Qwen3-ASR-1.7B (flagship, maximum accuracy) and Qwen3-ASR-0.6B (lightweight, ~600M parameters). The 1.7B fits at ~4 GB in float16, small enough to join the dedicated ASR pack on GPU 2.

The reason it wasn't in Part 1: it surfaced only in the follow-up review pass, after the initial research agents had already completed. That's now corrected.


Some honest caveats about the plan

On the LLM reconciler. The GenSEC paper (arXiv:2409.09785) is unambiguous that zero-shot prompt-only LLMs frequently make ASR output worse — they're fluent but they hallucinate. The literature's wins on GER come from fine-tuned models (Whispering LLaMA, arXiv:2310.06434, being the canonical example). I'm using a general-purpose Qwen3-14B-AWQ with a carefully constrained prompt. That's a reasonable starting point but not the same thing, and I'll be watching the per-segment hypotheses in the JSON output early on to see whether the LLM is actually helping or just confidently rearranging the furniture.

On pyannote diarisation. Pyannote works well on clean two-speaker audio with good microphone separation. Meeting recordings — overlapping speech, varying acoustic distance, sometimes three or four speakers — are harder. Diarisation error rates on real meetings can be 20–30% even with good tooling. The timestamps will be accurate (wav2vec2 forced alignment is robust); the speaker attribution will be best-effort. The auditable transcript.json with per-segment hypotheses lets you check and correct speaker assignments manually if needed.

On the short audio caps. Phi-4-multimodal handles 40 seconds per ASR call; Voxtral handles 30 minutes; Gemma 4 handles 30 seconds. All three need the audio chunked with overlapping boundaries before submission. The preprocessing step handles this — but it's worth noting that the "just POST the file" simplicity only applies to Parakeet, Canary-Qwen, WhisperX, and Qwen3-ASR.


The revised ensemble

With Qwen3-ASR added and whisper-large-v3-turbo (which was in the GPU table in Part 1 but never properly introduced) replaced:

GPU Role Models
GPU 0 Voice LLM transcription Voxtral-Mini-3B-2507 (~9.5 GB bf16)
GPU 1 Voice LLM transcription Phi-4-multimodal-instruct (~12 GB)
GPU 2 Dedicated ASR pack WhisperX (large-v3) + Parakeet-TDT-0.6b-v2 + Qwen3-ASR-1.7B
GPU 3 Reconciliation + summary Qwen3-14B-AWQ via vLLM

WhisperX remains the diarisation and timing backbone. Parakeet contributes native word timestamps and extraordinary throughput. Qwen3-ASR adds the noise-robustness the ensemble was previously missing. Voxtral and Phi-4 give two architecturally distinct voice-LLM hypotheses. Kyutai STT, Canary-Qwen, and IBM Granite 3.3 2B are on deck as optional additions if GPU 2 VRAM headroom allows — at build time I'll measure rather than guess.


What we're about to test

The recordings I'm working with include:

  • 60–90 minute meetings in university offices (reasonable quiet, 2–3 speakers, formal vocabulary)
  • Site visits at client premises (background machinery, 2–4 speakers, more variable)
  • A couple of recordings from what I can only describe as "enthusiastically air-conditioned corridors"

For each, the pipeline will produce five hypotheses per segment. Part 3 will show where the models agreed, where they disagreed, how the LLM reconciler resolved it, and whether the final transcript reads better or worse than any individual engine's output. I'll also have the diarisation accuracy to report — at least for the recordings where I know who was speaking when.

The architecture is built. Time to find out if the benchmarks mean anything on real audio.


References


Part 3 will have the actual numbers. Et volia — or not. We'll see.

17 July 2026

Caddy and dnsmasq: HTTPS .lan Hostnames for Every Home Lab Service

The .lan suffix is a perfectly reasonable convention for home lab services, but getting it to work cleanly — proper HTTPS, no certificate warnings, short names that resolve everywhere on the machine — takes a few moving parts. This is the setup I've landed on (Ubuntu 24.04, as of June 2026), plus the DNS priority conflict I ran into when trying to add services that the router already knew about.

The pieces

Three components work together:

  1. dnsmasq at 127.0.0.2 — resolves every *.lan name to 127.0.0.1 via a single wildcard rule
  2. systemd-resolved — routes .lan queries to dnsmasq, everything else upstream
  3. Caddy v2 — listens on 127.0.0.1:443, terminates TLS, proxies to backends

The result: any .lan name resolves locally, Caddy picks it up and proxies to the right container or port, and the browser sees a valid HTTPS certificate (from Caddy's built-in CA).

dnsmasq

Install and configure:

sudo apt install dnsmasq
# /etc/dnsmasq.d/lan.conf
address=/.lan/127.0.0.1
listen-address=127.0.0.2

The listen-address=127.0.0.2 keeps dnsmasq off port 53 on the main loopback (which systemd-resolved's stub resolver already owns at 127.0.0.53). The wildcard address=/.lan/127.0.0.1 answers every *.lan query with 127.0.0.1 — no per-service DNS entries needed.

sudo systemctl enable --now dnsmasq

systemd-resolved routing domain

# /etc/systemd/resolved.conf.d/lan.conf
[Resolve]
DNS=127.0.0.2
Domains=~lan

The ~ prefix makes lan a routing domain: systemd-resolved sends .lan queries to 127.0.0.2 (dnsmasq) and nothing else. Queries for everything else go to whichever upstream DNS the network provides.

sudo systemctl restart systemd-resolved
resolvectl status | grep -A3 "Global"

You should see 127.0.0.2 listed and ~lan as the domain.

Caddy

Install from the official Cloudsmith apt repo rather than the Ubuntu package — the Ubuntu version lags significantly:

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

Trust Caddy's internal CA so the browser accepts .lan certs without a warning:

sudo caddy trust

(You'll need to import the CA into Firefox manually if you use it — the caddy trust command handles Chrome/system trust stores.)

Organising the config

Rather than one large Caddyfile, a snippet import keeps things tidy:

# /etc/caddy/Caddyfile
import /etc/caddy/sites-enabled/*.caddy

Each service gets its own file:

# /etc/caddy/sites-enabled/open-webui.caddy
open-webui.lan {
    tls internal
    reverse_proxy 10.140.20.61:8080
}

For a service with a self-signed upstream certificate (Proxmox web UI being the obvious example):

# /etc/caddy/sites-enabled/xenon.caddy
xenon.lan {
    tls internal
    reverse_proxy https://10.140.3.82:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}

Add a service, reload:

sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

The DNS priority conflict

This works perfectly for services the router doesn't know about — typically anything on a local container subnet (10.140.20.x in my setup, managed by the Incus bridge's DHCP). For services elsewhere on the network whose hostnames appear in the router's DHCP lease table, there's a conflict.

systemd-resolved receives .lan queries and has two servers claiming authority: the global ~lan → 127.0.0.2 entry from resolved.conf, and the per-link entry pushed by DHCP (domain=lan, DNS=<router IP>). Per-link entries win over global entries in systemd-resolved's priority ordering. So for a hostname the router knows — say gitea.lan for a container it gave a lease to — the router answers first with the real IP, Caddy is bypassed, and you get a connection refused on port 443 (or wherever Caddy is listening).

The fix in NetworkManager is to set a positive dns-priority on the affected connections, which makes them lower priority than the global server:

sudo nmcli con mod "Wired connection 3" ipv4.dns-priority 100
sudo nmcli con mod "Wired connection 3" ipv6.dns-priority 100
sudo nmcli con up "Wired connection 3"

In my session this didn't propagate immediately (the priority change may need a full reconnect or systemctl restart systemd-resolved). The pragmatic workaround is an /etc/hosts entry for any hostname the router already knows:

echo "127.0.0.1 gitea.lan" | sudo tee -a /etc/hosts
echo "127.0.0.1 xenon.lan" | sudo tee -a /etc/hosts

Not elegant, but unambiguous — /etc/hosts is checked before DNS by glibc, so it wins regardless of what the router says.

Current service map

For reference, the full set of .lan entries currently active on this machine:

Hostname Backend Notes
open-webui.lan 10.140.20.61:8080 Open WebUI (Incus container)
litellm.lan 10.140.20.63:4000 LiteLLM proxy
searxng.lan 10.140.20.15:8080 SearXNG search
ollama.lan localhost:11434 Ollama LLM inference
comfyui.lan localhost:8188 ComfyUI image generation
gitea.lan 10.140.3.116:3000 Gitea (via /etc/hosts)
xenon.lan 10.140.3.82:8006 Proxmox UI, TLS skip (via /etc/hosts)
vaultwarden.lan 10.140.20.16:8080 Vaultwarden
wake.lan 10.140.20.45:8080 Wake-on-LAN service
notes.lan /home/user/claude/notes Static file server (Caddy file_server)
cockpit.lan https://localhost:9090 Cockpit, TLS skip verify

Adding a new service is one file and a reload — the DNS side takes care of itself.

References

  • Caddy v2 — reverse proxy with automatic TLS; tls internal generates certs from Caddy's built-in CA
  • Caddy Cloudsmith apt repo — official packages, more current than Ubuntu's
  • dnsmasqaddress=/ directive for wildcard DNS answers
  • systemd-resolved — stub resolver; Domains=~lan routing domain syntax
  • resolvectl(1)resolvectl query <name> shows which DNS server answered and from which link
  • NetworkManager / nmcliipv4.dns-priority for per-link DNS priority (positive value = lower priority)
  • Incus — LXC/VM container manager; containers on 10.140.20.0/24 in this setup
  • Proxmox VE — hypervisor; web UI on port 8006 with self-signed cert

I hope you find this helpful.

15 July 2026

Giving a Multi-Agent Framework Shell Access — What We Fixed and Why It Matters

Background

jiuwenclaw is a Chinese-origin multi-agent framework built on top of the openjiuwen agent-core. It supports a "team mode" where a leader agent coordinates a group of specialist teammates, each with their own skills, tools, and workspace.

This post documents three connected bugs we found and fixed, all triggered by a single user request: "Can the agent run shell commands the way Gemini CLI does?"


Problem 1: The React Loop (Agent Generates Text Instead of Tool Calls)

Symptom

After asking a question that required inspecting the machine (e.g. "what WiFi networks can you see?"), the agent entered an infinite loop. Each iteration the LLM produced a natural-language response describing how it would run a command — but never actually ran one. The runner log showed finish_reason: stop on every turn, meaning no tool was ever called.

Root Cause

The react loop in the openjiuwen harness re-invokes the LLM when the response has no tool call (finish_reason != tool_calls). The LLM was generating text because no shell tool existed in the agent's tool card list.

The mcp_exec_command tool was already implemented in command_tools.py and wired into the SkillDevService, but the team/agent mode uses a different tool registration path. In interface_deep.py, _get_tool_cards() built the list of available tools — and mcp_exec_command was never added to it.

Fix

Register mcp_exec_command in _get_tool_cards():

# jiuwenclaw/agentserver/deep_agent/interface_deep.py
from jiuwenclaw.agentserver.tools.command_tools import mcp_exec_command

def _get_tool_cards(self, ...) -> list[ToolCard]:
    ...
    # After wiki tools:
    if not Runner.resource_mgr.get_tool(mcp_exec_command.card.id):
        Runner.resource_mgr.add_tool(mcp_exec_command)
    tool_cards.append(mcp_exec_command.card)
    return tool_cards

This is the pattern used by all other tools in that method. Once registered, the LLM can actually call the tool instead of narrating about it.


Problem 2: The Sandbox Restriction Blocked Useful Commands

Symptom

After fixing the registration, agents could call mcp_exec_command — but any workdir outside the agent workspace raised a ValueError, producing [ERROR]: workdir is outside project workspace. This prevented running commands like ip addr in /tmp or inspecting files anywhere outside the agent's sandbox.

Root Cause

_resolve_command_workdir unconditionally called:

candidate.relative_to(project_root)  # raises ValueError if outside workspace

This was appropriate for a sandboxed skill dev environment but too restrictive for an agent with system-level access.

Fix

Introduce a MCP_EXEC_COMMAND_SANDBOX environment variable (default false) that opts into the restriction rather than enforcing it always:

def _is_workdir_sandbox_enabled() -> bool:
    raw = os.getenv("MCP_EXEC_COMMAND_SANDBOX", "false").strip().lower()
    return raw in ("1", "true", "yes", "on")

def _resolve_command_workdir(workdir: str) -> Path:
    project_root = get_agent_workspace_dir()
    candidate = Path(workdir) if workdir else project_root
    if not candidate.is_absolute():
        candidate = project_root / candidate
    candidate = candidate.resolve()
    if _is_workdir_sandbox_enabled():
        candidate.relative_to(project_root)  # raises ValueError if outside workspace
    return candidate

The switch is documented in .env:

# mcp_exec_command: restrict workdir to agent workspace (true) or allow any path (false)
MCP_EXEC_COMMAND_SANDBOX=false

Problem 3: Workspace Files Written in Chinese Despite preferred_language: en

Symptom

After the agent's workspace was initialised, the generated files (AGENT.md, SOUL.md, HEARTBEAT.md, IDENTITY.md) were written in Chinese, even though the config contained preferred_language: en.

Root Cause — Tracing the Data Flow

The language setting travels through three layers before it reaches the template selector:

  1. Configpreferred_language: en
  2. load_team_spec_dict → builds per-agent WorkspaceSpec dicts
  3. WorkspaceSpec.language → passed to get_workspace_schema(language) → selects EN or CN template

The bug was in step 2. _DEFAULT_AGENT_WORKSPACE was defined as:

_DEFAULT_AGENT_WORKSPACE = {"stable_base": True}

No language key. When this dict was used to construct each agent's workspace spec via merged.setdefault("workspace", deepcopy(default_workspace)), the resulting spec had no language, so WorkspaceSpec.language fell back to its model default — "cn".

The preferred_language value was read in load_team_spec_dict but only assigned to spec_dict["language"] (the top-level field) — it was never propagated into the per-agent workspace dicts.

Fix

Thread preferred_language as a language parameter through _build_agents_config, then inject it into every workspace spec via setdefault (respecting any explicit per-agent override):

def _build_agents_config(
    team_raw: dict[str, Any], config_base: dict[str, Any], language: str = "zh"
) -> dict[str, Any]:
    default_model = _build_default_model_dict(config_base)
    default_workspace, max_iterations, completion_timeout = _build_agent_defaults()
    default_workspace.setdefault("language", language)  # ← inject here

    for agent_key, raw_agent_config in agents_raw.items():
        agent_config = dict(raw_agent_config) if isinstance(raw_agent_config, dict) else {}
        if "workspace" in agent_config and isinstance(agent_config["workspace"], dict):
            agent_config["workspace"].setdefault("language", language)  # ← and per-agent
        ...

Also propagate to the team-level workspace spec:

# in load_team_spec_dict:
resolved_lang = str(config_base.get("preferred_language", "zh")).strip().lower()
agents = _build_agents_config(team_raw, config_base, language=resolved_lang)
...
workspace_spec = _build_workspace_spec(team_raw)
if workspace_spec is not None:
    workspace_spec.setdefault("language", resolved_lang)
    spec_dict["workspace"] = workspace_spec

Using setdefault throughout means an agent that explicitly configures workspace: {language: zh} won't have it overridden.


Skill File

To guide the agent on what commands are available and how to use them, a skill was created at:

~/.jiuwenclaw/agent/jiuwenclaw_workspace/skills/system-commands/SKILL.md

The allowed_tools frontmatter field in jiuwenclaw's skill system is advisory — it hints to the LLM which tools the skill uses. Actual tool availability is determined by what's registered in _get_tool_cards(). The skill body includes common command patterns for networking, hardware inspection, process listing, and log tailing.


Unit Tests

test_command_tools.py — 19 tests

TestSandboxSwitch (3 tests): verifies _is_workdir_sandbox_enabled() reads the env var correctly, including case-insensitive truthy/falsy parsing for true/True/TRUE/1/yes/on and false/False/FALSE/0/no/off.

TestResolveCommandWorkdir (6 tests): verifies that:
- Empty workdir falls back to agent workspace
- Relative paths are resolved under the workspace
- Sandbox off: absolute paths outside workspace are allowed
- Sandbox on: absolute paths outside workspace raise ValueError
- Sandbox on: paths inside workspace (including root) are allowed

test_team_config_loader.py — 8 new tests in TestLanguagePropagation

  • preferred_language: en sets spec["language"] == "en" at the top level
  • Absent preferred_language defaults to "zh"
  • "en" propagates to each agent's workspace spec
  • "en" propagates to all agents when multiple agents are configured
  • "en" is injected into explicit per-agent workspace dicts (that set other keys)
  • An explicit workspace: {language: zh} on an agent is not overridden
  • "en" propagates to the team-level workspace spec
  • An explicit language: zh in the team workspace spec is not overridden

Key Architectural Learnings

  1. _get_tool_cards() is the source of truth for tool availability in team/agent mode — not get_mcp_tools(), not skill allowed_tools. Any tool that should be callable must be explicitly added here.

  2. The react harness loops on finish_reason != tool_calls — if the model can't call a tool (because none are registered for the task), it will narrate endlessly. The fix is always to provide the right tool, not to adjust the loop logic.

  3. setdefault is the right pattern for propagating config defaults — it lets outer layers provide defaults while inner (explicit) config takes precedence.

  4. MCP_EXEC_COMMAND_SANDBOX follows the same bool-string convention as other env vars1/true/yes/on enable, everything else (including absence) disables.


Files Changed

File Change
jiuwenclaw/agentserver/deep_agent/interface_deep.py Register mcp_exec_command in _get_tool_cards()
jiuwenclaw/agentserver/tools/command_tools.py Add _is_workdir_sandbox_enabled(), make sandbox conditional
jiuwenclaw/agentserver/team/config_loader.py Propagate preferred_language into all workspace specs
tests/unit_tests/agentserver/test_command_tools.py New — 19 tests for sandbox switch and workdir resolution
tests/unit_tests/agentserver/test_team_config_loader.py 8 new TestLanguagePropagation tests + updated 1 existing assertion
~/.jiuwenclaw/agent/.../skills/system-commands/SKILL.md New skill providing shell command guidance
~/.jiuwenclaw/config/.env Document MCP_EXEC_COMMAND_SANDBOX=false

Installing JiuwenClaw from Source: The Errors You Will Hit and How to Fix Them

JiuwenClaw is an open-source Python AI agent platform with a multi-agent team mode, a web UI, and support for local LLM backends via Ollama. Installing it from source is straightforward — until it isn't. This post documents every error I hit setting it up on Ubuntu and exactly how I resolved each one.

The Setup

  • Ubuntu, Intel Ultra 7 165H, RTX 4070 8GB
  • Ollama running locally with qwen3:8b
  • JiuwenClaw 0.1.11 installed from source via uv

Error 1: GitHub Rejects Your Password

fatal: could not read Username for 'https://github.com': No such device or address

GitHub dropped password authentication for git operations in 2021. You need a Personal Access Token (PAT).

Fix: Generate a PAT at https://github.com/settings/tokens (tick the repo scope), then embed it in the clone URL:

git clone https://USERNAME:TOKEN@github.com/openJiuwen-ai/jiuwenclaw /path/to/dest

If your username is an email address containing @, URL-encode it as %40 — otherwise git misparses the URL and rejects it with a cryptic port number error:

URL rejected: Port number was not a decimal number between 0 and 65535
# Correct for email usernames
git clone https://user%40domain.com:TOKEN@github.com/openJiuwen-ai/jiuwenclaw /path/to/dest

Also configure the credential store so you only do this once:

git config --global credential.helper store

Error 2: jiuwenclaw-init Hangs or Crashes When Run Non-Interactively

EOFError: EOF when reading a line

The init script prompts for language preference interactively. When run via a non-interactive shell (or via Claude Code's ! command prefix), there is no TTY and it crashes immediately.

Fix: Run jiuwenclaw-init directly in your terminal with the virtual environment activated — not via any automation layer:

source .venv/bin/activate
jiuwenclaw-init

Error 3: Startup Fails with dist directory not found

dist directory not found: /home/user/.jiuwenclaw/web/dist
[start_services] web exited with code 1

Source installs do not ship the pre-built frontend. The web/dist directory is in .gitignore and must be built manually.

Fix:

cd jiuwenclaw/web
npm install
npm run build
cp -r dist ~/.jiuwenclaw/web/dist

The build takes about 3 seconds. You only need to redo this if the frontend source changes (e.g. after a git pull).


Error 4: team must be a non-empty array

ValueError: team must be a non-empty array

This is thrown by the config parser when modes.team in config.yaml is written as a named mapping (dict) instead of a list. The web UI expects an array.

Wrong:

modes:
  team:
    my_team:          # dict key — causes the error
      team_name: my_team

Correct:

modes:
  team:
    - team_name: my_team    # list item — works
      lifecycle: persistent

This error also appears if the UI saves an empty team list after you delete all teams. Always ensure at least one team entry exists before saving.


Error 5: Team Configuration Shows Stale or Empty Data After Editing config.yaml

You edit config.yaml directly, restart the server, refresh the browser — and the Team Configuration panel still shows old data or nothing at all.

Root cause: The web UI does not read team configuration back from the server. The config.get WebSocket call only returns environment variable values. Team data is stored exclusively in browser localStorage under the key jiuwenclaw_agents_teams_cache. Direct edits to config.yaml are invisible to the UI until you also update localStorage.

Fix: Seed localStorage via the browser console. Because the JiuwenClaw frontend runs on localhost:5173, a plain fetch() from the same origin works — but loading a file from a different port requires CORS headers.

Start a CORS-enabled local file server:

python3 -c "
from http.server import HTTPServer, SimpleHTTPRequestHandler
import os

class CORSHandler(SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        super().end_headers()
    def log_message(self, *a): pass

os.chdir('/tmp')
HTTPServer(('127.0.0.1', 8765), CORSHandler).serve_forever()
" &

Place your team JSON in /tmp/jw_setup.js as a single localStorage.setItem(...) call, then run in the browser console on http://localhost:5173:

fetch('http://localhost:8765/jw_setup.js').then(r=>r.text()).then(code=>eval(code))

Refresh the page. The team config will appear. Click Save to write it back to config.yaml.

Why not just paste the JSON directly in the console? A multi-line paste causes SyntaxError: string literal contains an unescaped line break because the browser console treats each line as a separate statement. The file-serving approach sidesteps this completely.

Why not use Python's built-in http.server? It works for serving files but does not add Access-Control-Allow-Origin headers, so the browser blocks the fetch with a CORS error even though the status code is 200. You need the custom subclass shown above.


Error 6: kill Exit Code 1 After Stopping Orphaned Processes

kill 80613 80615 && ss -tlnp | grep 19001 || echo "Ports clear"
# Ports clear   ← correct, but exit code was 1

The ss | grep returns exit code 1 when it finds nothing (standard grep behaviour), which causes the && chain to short-circuit. The ports were actually clear — the message was correct.

Fix: Use ; echo rather than || echo if you want to see the grep output regardless, or check the port state with a separate command after killing.


Summary

Error Cause Fix
GitHub auth failure Password auth removed 2021 Use PAT in clone URL
@ in username breaks URL Git misparses the host URL-encode @ as %40
jiuwenclaw-init EOFError No interactive TTY Run directly in terminal
dist directory not found Frontend not built npm install && npm run build && cp -r dist ~/.jiuwenclaw/web/dist
team must be a non-empty array modes.team is a dict not a list Use - team_name: list syntax
Team config not visible in UI UI reads localStorage, not config.yaml Seed localStorage via CORS fetch
CORS error on fetch Default http.server adds no CORS headers Use custom handler with Access-Control-Allow-Origin: *
kill exit code 1 grep exits 1 on no match Separate the kill and verify commands

JiuwenClaw is a capable platform once it's running — the team mode with local Ollama models works well for multi-agent engineering workflows. The setup friction is mostly undocumented edge cases that are easy to fix once you understand the architecture.

13 July 2026

I thought Cloudflare Access was guarding my Proxmox box. It wasn't.

I'd just put a Proxmox server behind a Cloudflare Tunnel — two public hostnames, one for SSH and one for the web UI — and I was quietly pleased with myself. I already run a Zero Trust setup on the domain, a wildcard application that demands an email one-time-code before it lets anyone near anything. So the new box was protected too. Obviously.

It was not protected. And the way I found out is worth writing down, because the failure is silent and the test for it is genuinely easy once you know what you're looking at.

The niggle

I was testing the new web UI hostname and ran a plain curl at it — no browser, no cookies, nothing. It cheerfully handed me back the Proxmox login page. HTTP 200.

That shouldn't happen. If Cloudflare Access were sitting in front, an unauthenticated request should never reach the origin — it should be bounced to the login screen. Getting the actual Proxmox page from a credential-free curl is the sound of a stable door swinging in the wind.

My first instinct was to explain it away — "this machine's already authenticated, it's passing through." Plausible! And wrong, as it turned out, which is the useful bit.

The insight: where Access actually sits

Cloudflare Access runs at the edge, before your request is proxied down the tunnel to your origin. So for a hostname an Access policy actually covers, an unauthenticated request gets a 302 redirect to your-team.cloudflareaccess.com and never touches the tunnel. You can test it without even involving the origin:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -i https://your-host.example.com
  • 302 → *.cloudflareaccess.com — Access is enforcing. Good.
  • 200 (origin), or 530 (tunnel down) — your request reached the tunnel layer, which means Access did not stop it. Not protected.

That last point is the neat one: even with my origin offline later (tunnel down, returning 530), the test still worked — because a 530 only happens after a request has cleared the edge. If Access were guarding the hostname I'd have got the login redirect regardless of whether the origin was up. A 530 to an unauthenticated caller is itself a finding.

The confounder: don't test from your own machine

Here's the trap I nearly fell into. If you test from a device that's enrolled in WARP, or that's already completed the email-code dance in a browser, your requests carry a valid Access session — so they sail straight through and everything looks protected. False negative.

Two things to check on your testing machine first:

curl -s https://www.cloudflare.com/cdn-cgi/trace | grep -E 'warp=|gateway='   # want warp=off gateway=off
ls ~/.cloudflared/                                                            # cached *-token files = you hold sessions

In my case the laptop wasn't on WARP — but ~/.cloudflared/ held an org-level Access token and a token for a different host entirely. So my "I'm already authenticated, it's fine" hunch was half right: I held a session, but for other applications. The new box had no application at all. Test from somewhere with an empty ~/.cloudflared, no WARP, and no browser cookies.

The actual cause

This is the part worth tattooing somewhere. A wildcard Access application does not automatically protect every subdomain you later create. And creating a tunnel route — cloudflared tunnel route dns <tunnel> <hostname> — only writes the CNAME. It does not create an Access application. So my two new hostnames had a working tunnel, a working DNS record, and precisely nothing standing between the public internet and a Proxmox login (and, for the SSH hostname, only the root password). The wildcard app I was relying on simply didn't match them in the way I'd assumed.

The fix

Add a real Access application for each hostname (Zero Trust → Access → Applications → Add → Self-hosted):

  • One application per hostname (or a wildcard you've verified matches), Allow policy, with an identity requirement — email OTP at minimum, ideally device posture or a group.
  • Keep the session short.
  • Re-run the curl test from a clean machine afterwards and confirm you get the 302 to *.cloudflareaccess.com.

For SSH hostnames specifically, the Access app is the front door but it isn't the only lock — make sure the origin itself isn't accepting password root logins behind it. Belt and braces.

How to audit the whole lot

If you've made this mistake once, you've probably made it more than once, so audit everything rather than spot-fixing. Briefly:

  1. List your DNS records and flag every CNAME pointing at *.cfargotunnel.com — those are tunnel-backed hostnames.
  2. List your tunnels and their ingress rules (note: locally-managed tunnels keep their ingress in /etc/cloudflared/config.yml on the origin, not in the dashboard API — you have to read the box).
  3. List your Access applications and their policies — note any bypass decisions, which are effectively "no protection" (sometimes that's deliberate; document why).
  4. Cross-reference: for every tunnel-backed hostname, is there an Access app, what's the policy, and does it require MFA? Anything fronting SSH, RDP or a hypervisor UI without an Allow+MFA policy is exposed.
  5. Verify each one empirically with the clean-machine curl test. Config and reality don't always agree.

The Cloudflare API (read-only token) makes steps 1–3 scriptable; I'll spare you the JSON here.

The lesson

The uncomfortable bit isn't that I'd misconfigured something — it's that I'd assumed a protection existed and never tested the assumption, because the dashboard looked tidy and the tunnel worked. A tunnel coming up is not the same as a door being locked. Test the lock, from outside the building, with no keys in your pocket.

Ta ta for now, and do go and curl your own hostnames — you might get a surprise.

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!

12 July 2026

Building a Triggerable Home Lab Health Check with Python and systemd

The Claude Code sessions on my laptop dropped out mid-conversation. The internet felt fine — pages loaded, DNS resolved — but I couldn't be certain whether something had shifted on the LAN. What I wanted was a single command that would sweep through everything: public internet reachability, DNS resolution, gateway, every Incus container with a .lan address, and a handful of remote hosts. One invocation, one exit code.

What I discovered while building it made the effort worthwhile almost immediately.

The DHCP drift problem

My home lab includes a TP-Link Kasa HS110 smart plug (running on python-kasa) that controls mains power to a Proxmox node with no IPMI — it's the only remote kill switch available. That plug's IP was documented everywhere as 10.140.1.235. The health check tried it and got silence.

A quick nmap sweep by MAC address found it immediately at 10.140.1.236:

sudo nmap -sn 10.140.0.0/22 | grep -B2 D8:47:32:A3:FF:CA
Nmap scan report for HS110.lan (10.140.1.236)
Host is up (0.075s latency).
MAC Address: D8:47:32:A3:FF:CA (TP-Link Technologies)

The DHCP lease had quietly shifted by one. The plug was on, PVE2 was drawing 126 W and happily running — I just wouldn't have been able to reach it from any documented procedure. The lesson is obvious in hindsight: for anything critical, find it by MAC, not by IP. The health check script now tries the known IP first and falls back to an nmap sweep if it doesn't respond, reporting the new address so you can update your docs.

Adding missing .lan entries

While auditing the LAN, I found two services without .lan entries: Gitea (running in an LXC container on a second Proxmox node at 10.140.3.116:3000) and the Xenon Proxmox node itself (at 10.140.3.82:8006). Both get Caddy reverse proxy entries — Xenon needs a tls_insecure_skip_verify because Proxmox uses a self-signed certificate:

# /etc/caddy/sites-enabled/gitea.caddy
gitea.lan {
    tls internal
    reverse_proxy http://10.140.3.116:3000
}
# /etc/caddy/sites-enabled/xenon.caddy
xenon.lan {
    tls internal
    reverse_proxy https://10.140.3.82:8006 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy

The DNS priority conflict

Here's where it got interesting. After reloading Caddy, curl https://gitea.lan/ returned a connection refused — but not because of Caddy. Running resolvectl query gitea.lan showed it resolving to 10.140.3.116 directly rather than 127.0.0.1 (which is what the dnsmasq wildcard should return).

The setup uses dnsmasq at 127.0.0.2 with address=/.lan/127.0.0.1 to catch all .lan names and send them to Caddy. systemd-resolved is configured in /etc/systemd/resolved.conf.d/lan.conf to route .lan to that dnsmasq instance:

[Resolve]
DNS=127.0.0.2
Domains=~lan

The problem: the wired ethernet DHCP lease (from the router at 10.140.2.6) also pushes domain=lan, which means systemd-resolved registers the router as a per-link routing DNS for .lan as well. Since the router has a genuine DHCP record for gitea.lan (it knows about the Gitea container's lease), it answers first — and systemd-resolved never falls back to dnsmasq.

Setting dns-priority=100 on the NetworkManager connection should tell systemd-resolved to prefer the global server, but the change didn't propagate cleanly in this session. The reliable workaround is an /etc/hosts entry:

printf '127.0.0.1 gitea.lan\n127.0.0.1 xenon.lan\n' | sudo tee -a /etc/hosts

Inelegant, but unambiguous. getent hosts gitea.lan immediately returned 127.0.0.1 and Caddy took over correctly. The underlying dns-priority issue is worth a proper investigation another day — the nmcli setting is:

sudo nmcli con mod "Wired connection 3" ipv4.dns-priority 100
sudo nmcli con mod "MiHA-2G 2" ipv4.dns-priority 100

The health check script

The script lives at agents/lan_health.py and covers four categories: internet (ICMP to 1.1.1.1 and 8.8.8.8, pass if either responds), DNS resolution, LAN host pings, and HTTPS checks against every .lan service. The HS110 section is the most useful part:

HS110_MAC = "D8:47:32:A3:FF:CA"
HS110_IP  = "10.140.1.236"   # DHCP — may drift

def find_hs110():
    r = subprocess.run(
        ["sudo", "nmap", "-sn", "--open", "10.140.1.0/24"],
        capture_output=True, text=True, timeout=30
    )
    lines = r.stdout.splitlines()
    for i, line in enumerate(lines):
        if HS110_MAC.upper() in line.upper():
            for j in range(max(0, i-3), i):
                if "report for" in lines[j]:
                    return lines[j].split()[-1].strip("()")
    return None

If the known IP doesn't respond, find_hs110() runs the sweep and returns the new IP (or None if the plug genuinely isn't on the network). Output is colour-coded; exit code is non-zero on any failure, which makes it composable with scripts or monitoring.

The systemd service

The whole point is to trigger this on demand rather than poll continuously. A oneshot unit is the right shape:

# /etc/systemd/system/lan-health.service
[Unit]
Description=Home LAN health check
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/home/user/claude/.venv/bin/python3 /home/user/claude/agents/lan_health.py
User=user
StandardOutput=journal+console
StandardError=journal+console
sudo systemctl daemon-reload
systemctl start lan-health.service
journalctl -u lan-health.service -n 50 --no-pager

No persistent daemon, no polling — it runs when asked, logs to the journal, and exits. StandardOutput=journal+console means you see the output immediately if you're watching, and journalctl -u lan-health.service gives you the last run any time you want it.

First invocation caught the HS110 drift, three services returning 502 (backends stopped, proxies still up), and confirmed everything else was healthy. Not a bad return for an afternoon's work.

References

  • nmap — network discovery; -sn ping scan, MAC address lookup in sweep output
  • Caddy v2 — automatic HTTPS reverse proxy; tls internal for self-signed .lan certs, tls_insecure_skip_verify for upstream self-signed
  • dnsmasq — lightweight DNS/DHCP; address=/.lan/127.0.0.1 wildcard used here
  • systemd-resolved — stub resolver; Domains=~lan routing domain config
  • resolvectl(1)resolvectl query for diagnosing which DNS server answered
  • NetworkManager / nmcliipv4.dns-priority for per-link DNS priority
  • systemd.service(5)Type=oneshot for run-and-exit services
  • python-kasa — TP-Link Kasa smart device control (used separately to read HS110 wattage)
  • Python stdlib: subprocess, socket, urllib.request, ssl — all used in the health check script

Ta ta for now, Matt

Getting a Proxmox box online — and remotely reachable — through a corporate network that really didn't want me to

I had a Proxmox server sitting on a desk at work (let's call it NERIC), no IPMI, no remote anything, and a narrow window in which I was physically next to it. The goal was simple to state and, as it turned out, a mite tricky to achieve: get NERIC permanently reachable from home, over a university network that is locked down by a corporate firewall and has no obvious route in or out.

This is the story of how that went — including the several dead ends, because the dead ends are where the actual learning lives.

A note on the clock: I was at work with maybe an hour, and my laptop — the only thing on the same wired segment as NERIC — was going home with me at the end of it. So a recurring theme below is "make the right decision given that I'm about to lose my only local access." That pressure shaped a lot of the choices.


Step 0: I couldn't even reach it

First problem: NERIC (192.168.100.2) wouldn't answer a ping or SSH from my laptop, despite both being plugged into the same physical network. ARP resolved fine, though — which is the tell.

arp -n | grep 192.168.100.2
192.168.100.2  ether  04:d4:c4:xx:xx:xx  C  enx1ce192b3ec51

ARP works at layer 2, so the wire is fine. ICMP and TCP timing out while ARP succeeds is the classic signature of asymmetric routing: my packets reach NERIC, but NERIC can't get its replies back. A quick tcpdump confirmed it — NERIC received each ping and immediately ARPed for its gateway (192.168.100.1) trying to route the reply, got no answer, and gave up. My laptop was sourcing from 192.168.0.143 (a /16), which isn't on NERIC's local /24, so NERIC needed a gateway that was dead.

The fix is local and reversible — give my laptop a second IP on NERIC's subnet so the replies come straight back over ARP, no gateway required:

sudo ip addr add 192.168.100.50/24 dev enx1ce192b3ec51
ssh -b 192.168.100.50 root@192.168.100.2

And I was in. (Interestingly, ICMP stayed firewalled even after this — but TCP worked, which is all I needed. Don't let a failed ping convince you the path is dead.)


Step 1: a rabbit hole — "why does NERIC have no internet?"

Inside, the picture was bleak: vmbr0 statically set to 192.168.100.2/24, gateway 192.168.100.1, and that gateway stone dead. No internet, DNS broken, nothing.

I spotted that the live router on the wire was actually 192.168.0.1 (my laptop's own gateway, healthy at ~2ms), on a different subnet to NERIC entirely. So I did the clever thing — an onlink default route so NERIC would ARP for an off-subnet gateway directly:

ip route replace default via 192.168.0.1 dev vmbr0 onlink

NERIC could now reach 192.168.0.1... and traceroute died at exactly one hop. The router took the packets and refused to forward them. I burned a little time giving NERIC a temporary in-subnet IP to make it NAT-eligible — still nothing.

Then the penny dropped, and it's embarrassing in hindsight: my laptop's own internet was coming over wifi, not that wired segment at all (the wired default route had a much higher metric). I'd never actually established that 192.168.0.1 had a path to the internet — and it didn't. The whole wired segment was an island. I'd been trying to route through a door that opened onto a wall.

Lesson re-learned: verify your assumptions about the upstream before building anything on top of them. curl to a real host beats a ping to the gateway every time.

At this point the owner of the network kindly reconfigured the router to actually serve 192.168.100.0/24 with a working gateway — but testing from both NERIC and the laptop showed the gateway answered on the LAN yet still wouldn't pass DNS or TCP to the outside. The segment simply had no usable WAN. Dead end, cleanly confirmed.


Step 2: Tailscale — and why it was never going to work here

The plan had been Tailscale. I installed it, ran tailscale up... and it sat there refusing to produce a login URL. The daemon log told me exactly why, and it's worth quoting because Tailscale ships a detector for precisely this:

tlsdial: server cert seen while dialing "controlplane.tailscale.com"
         looks like "Fortinet" equipment (could be blocking Tailscale)
Received error: fetch control key: ... x509: certificate signed by unknown authority

A quick look at the certificate confirmed it:

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

There's a FortiGate doing SSL deep-inspection, and it's specifically intercepting Tailscale's coordination server with its own (untrusted) certificate. login.tailscale.com and tailscale.com passed through untouched — this is targeted. Tailscale validates that control-plane cert against the system trust store, the Fortinet CA isn't in it, the handshake fails, and you never get a login URL. There is no client-side fix; only the network team can exempt it.

So Tailscale was out. Not misconfigured — blocked. Good to know definitively rather than fiddle with it for an hour.

The useful question then became: what isn't blocked? I checked the certificates on a few endpoints:

Endpoint Cert issuer Verdict
controlplane.tailscale.com Fortinet intercepted
api.cloudflare.com Google Trust Services clean
update.argotunnel.com Google Trust Services clean
github.com Sectigo clean

And region1.v2.argotunnel.com:7844 (Cloudflare's tunnel port) connected fine. Cloudflare tunnels sail straight through this firewall. I already run cloudflared for another Proxmox box at home, so the decision made itself: cloudflared, not Tailscale.


Step 3: but first, NERIC needs its own internet — enter the wifi dongle

A tunnel is no use if the box can't get online once I've left with the laptop. The owner plugged in a USB wifi dongle, and this turned into its own little saga.

It enumerated as a TP-Link "802.11ac WLAN Adapter", USB ID 2357:0120 — which a quick search identifies as a TP-Link Archer T2U Plus, chipset Realtek RTL8821AU. No driver bound, no wl* interface, and (naturally) no in-kernel support on the PVE 6.8 kernel. The mainline rtl8xxxu lists 0107/0108/...0126 but not 0120; rtw88 has the 8821cu/8822bu families but, again, not this ID.

So it's the out-of-tree 8821au DKMS driver (morrownr's maintained fork). That needs kernel headers, which on a no-subscription PVE host means adding the no-subscription repo to get pve-headers-6.8.12-4-pve. Build tools and the driver went in fine:

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. The reason was entirely self-inflicted: while experimenting earlier I'd modprobe'd rtw88_8822bu and it had grabbed the device via a runtime new_id override, so the correct driver couldn't claim it. Unbinding it hung — the unbind write blocked in uninterruptible (D) state, which is about as stuck as Linux gets.

A reboot was the clean way out. Except the reboot also hung — on the very same wedged process. The console showed systemd-shutdown waiting 90 seconds for it, failing to remount root read-only ("Device or resource busy"), and finally forcing the reboot. A hard power-cycle finished the job. (No data loss — ext4 journalled-recovered cleanly on the way back up.)

On the next boot, with the conflicting drivers blacklisted:

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

...the right driver won, and wlxXXXXXXXXXXXX appeared. Lesson: when you've been poking at new_id overrides, a reboot clears the runtime cruft and a blacklist makes the result stick.


Step 4: the captive portal — authenticating a headless server

The dongle associated to the open WiFi-Guest SSID and got a DHCP lease immediately. But any HTTP request bounced to a familiar wall:

Location: https://wifi.sky.com/login?client_mac=XXXXXXXXXXXX&reason=Un-Auth-Captive...

It's Sky WiFi / "The Cloud" — a per-MAC captive portal, which is precisely the wrong thing to put in front of a headless server. There's no browser to click "Continue", and the portal authorises the MAC that hits it — so I couldn't just log in from my laptop's browser, because my laptop was on a different network (eduroam, as it happens) with a different MAC.

The trick that solved it is the bit I'm most pleased with. I turned NERIC into a SOCKS proxy over the SSH connection I already had:

ssh -D 1080 -N -b 192.168.100.50 root@192.168.100.2

The SSH control channel rides the stable wired path; anything sent into that proxy is originated by NERIC and exits its wifi connection. So I pointed a headless Playwright browser at the proxy, and from the portal's point of view it was NERIC knocking — correct MAC, correct session. I accepted the terms, registered a Cloud account against a disposable mailbox, signed in, and:

(The throwaway address, in case it's handy: neric1780499778@wshu.net, from the rather useful mail.tm. It has an API, which is what makes it scriptable for catching a verification email — though The Cloud didn't end up demanding one. It'll lapse before long, so do generate your own rather than relying on mine.)

generate_204 -> 204      (no longer walled)
github.com   -> 200
api.cloudflare.com -> 200

We were online. (The authorisation lives at the gateway keyed to NERIC's MAC, so once done I could tear the browser and proxy down — the box stays connected on its own.)


Step 5: making it survive a reboot (and me leaving)

A working setup that evaporates on reboot is not a setup. Two services make this persistent.

A wifi watchdog (neric-wifi.service) that re-associates, re-DHCPs, and — crucially — forces the default route over wifi, overriding the dead wired gateway that /etc/network/interfaces still installs at boot. I deliberately did not edit interfaces (no appetite for breaking PVE's bridge config remotely); the watchdog just out-prioritises the dead route at runtime, every boot, and re-checks every 30 seconds.

cloudflared, installed as a service, publishing two hostnames off my existing tunnel zone:

ingress:
  - hostname: neric-pve.example.com      # SSH
    service: ssh://localhost:22
  - hostname: neric-pve-ui.example.com   # Proxmox web UI
    service: https://localhost:8006
    originRequest:
      noTLSVerify: true
      disableChunkedEncoding: true
  - service: http_status:404

(noTLSVerify and disableChunkedEncoding because Proxmox uses a self-signed cert internally and doesn't play nicely with chunked encoding through a tunnel — learned that on the previous box.)

And because The Cloud's session can eventually expire, the watchdog calls a headless Playwright re-auth script on NERIC when it detects a walled state — replaying the same "accept terms, sign in" flow that worked manually.


Does it actually work?

Tested from the public internet, with the laptop's scaffolding (NAT, SOCKS, the IP alias) all torn down — so this is genuinely NERIC standing on its own:

Test Result
Internet on NERIC (generate_204) 204
Default route via 10.223.88.1 dev wlxXXXXXXXXXXXX (wifi)
neric-wifi.service active, enabled
cloudflared.service active, enabled (4 QUIC conns to LHR edge)
SSH via cloudflared access ssh --hostname neric-pve.example.com returns hostname pve
PVE UI https://neric-pve-ui.example.com HTTP 200, "pve - Proxmox Virtual Environment" ✅
Re-auth script dry run already online, exit 0 ✅

So: a Proxmox box, online via a USB wifi dongle, behind a captive portal, past a FortiGate that blocks Tailscale — and reachable from anywhere over SSH and the web UI. Et voilà.


A word on decisions under time pressure

A few calls were made specifically because the clock was running:

  • Pre-stage over perfect. I got cloudflared up and tested before the laptop left, because cloudflared is my only way back in afterwards. Everything else can be refined remotely.
  • Watchdog over editing interfaces. Forcing the route at runtime is uglier than a clean config edit, but it can't brick the host's networking — and I wouldn't be there to fix it if it did.
  • Re-auth on the host for now. The neater home for the headless browser is an LXC (keep the hypervisor clean), but downloading a template and wiring its networking through the host's wifi was too much to finish in the window. Host now, container later — over the tunnel.

Not the choices I'd make with unlimited time, but the right ones for the time I had.


Next steps

  • Migrate the portal re-auth browser into an LXC and strip Playwright/Chromium off the hypervisor.
  • Tidy the now-unused DNS record left over from a rename.
  • The dormant Tailscale install stays put — it costs nothing, and if the network team ever exempts *.tailscale.com from inspection, it'll just connect.

If you're ever trying to do something similar — a headless box on a hostile corporate network — the two ideas worth stealing are the SOCKS-proxy-through-the-server trick for satisfying a per-MAC captive portal, and checking certificate issuers to find out what your firewall is and isn't tampering with before you commit to a tunnelling strategy.

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

Building remote access against the clock

A slightly different sort of post — less "here's the fix", more "here's what an hour of pressure does to your decision-making", because I think the decisions were more interesting than any single command.

The situation: a Proxmox box on a desk at work, no remote management of any kind, and me standing next to it with maybe an hour before I had to leave — with the only laptop that was on the same network as the box. The job was to make it permanently reachable from home. So every choice was shaped by one looming fact: I was about to lose my only way in, and anything left half-finished would be left half-finished for good.

You'd think that means rush. It actually means the opposite — it means being ruthless about order.

Get the way back in working first, and test it, before anything else. The temptation is to do the satisfying engineering — the clean config, the tidy persistent setup — and leave "remote access" as the bow on top. Backwards. The remote path is the thing that lets you fix everything else later, so it's the thing that has to exist before you lose local access. I got a tunnel up and SSH'd into the box through it — from the public internet, not the local wire — while I still had the laptop as a safety net. Only once that worked did I relax. Everything after that, I could finish from my sofa.

Prefer the ugly-but-safe option when you won't be there to catch it. At one point the right-looking fix was to edit the box's network config file. The runtime-only version — a little watchdog that forced the correct route every 30 seconds — was uglier, and a purist would wince. But a typo in a network config file, on a host you're about to be unable to reach, is how you brick the whole exercise. The watchdog couldn't take the box off the network; a bad config edit could. Ugly and recoverable beats elegant and fragile, every time you won't be in the room.

"Good enough now, neat later" is a real plan, not an excuse. There was a heavier component (a headless browser, of all things) that really belonged in a container rather than on the hypervisor. Building the container properly would have eaten the rest of my window. So: on the host now, into a container later — over the remote path I'd just built. Writing down the "later" so it actually happens is the difference between pragmatism and a mess.

It didn't all end perfectly, by the way — the one piece I couldn't fully test before leaving (an automatic re-login for the captive portal the box sits behind) is exactly the piece that later went wrong, and took the box offline. Which is its own lesson, and an honest one: the thing you couldn't test under pressure is the thing that'll fail. If I'd had ten more minutes, I'd have spent them forcing that failure on purpose rather than hoping.

None of these are choices I'd make with unlimited time. They were the right ones for the time I had — and knowing the difference is most of the job.

Until next time, Matt.

Running a second Claude subscription without unpicking the first

I have two Claude subscriptions. One is mine, the other belongs to a work account, and like most people who end up in this position I'd already spent a good while getting the first one comfortable — settings just so, a memory directory full of hard-won notes, a working tree I actually trust. The last thing I wanted was to log out, log in as the other account, and discover I'd quietly traded all of that away.

So the goal was narrow and a little stubborn: use the second subscription's login, keep absolutely everything else. Here's how that turned out, in case you're staring at the same problem.

Where Claude Code keeps the thing that matters

Claude Code stores its subscription login as an OAuth token in a single file:

$CLAUDE_CONFIG_DIR/.credentials.json

Peek inside (carefully — it's mode 0600 for a reason) and you'll find one key, claudeAiOauth, holding the access token, a refresh token, the expiry, and your subscription tier. That's the whole identity. Anthropic's own authentication docs spell out the location: on Linux, credentials live in ~/.claude/.credentials.json at mode 0600, and Claude Code manages that file through /login and /logout. There is exactly one active credential per config directory, and that directory defaults to ~/.claude.

The crucial line in those same docs is this: "If you've set the CLAUDE_CONFIG_DIR environment variable on Linux or Windows, the .credentials.json file lives under that directory instead." (authentication docs) That's the entire trick. The account isn't pinned to your machine or your home directory — it's pinned to that one file inside whichever config directory is active. Point Claude Code at a different config directory and it's a clean slate: no token, so it asks you to log in.

(I'll note in passing that I confirmed all of the above on my own machine before trusting it — the file really does hold just claudeAiOauth, and a fresh config dir really does start tokenless. Documentation is lovely, but I like to see it work.)

The obvious approach, and why it's not quite right

The tidy answer is "just set CLAUDE_CONFIG_DIR to a second folder and log in there." And it works — you get a completely separate profile for the work account. (Josh Grossman wrote up this very approach for two accounts on one Windows PC, which is worth a read if you're on Windows and happy with full separation.)

The trouble is completely separate is too much. That second folder gets its own settings, its own plugins, and — the one that actually stings — its own memory and project history. All the notes I'd accumulated would be sitting back in ~/.claude, invisible to the work account. I'd have swapped one annoyance for another.

What I actually wanted was a profile that shares everything except the credential.

The fix: share the environment, isolate only the login

The pieces inside ~/.claude split cleanly into two groups. The credential is account-specific. Almost everything else — settings.json, your plugins, and the projects/ directory where the per-project memory and history live — is account-agnostic. There's no reason the work account can't read the very same files.

So the script gives the work account its own config dir, then symlinks the shared pieces back to my personal one. Only .credentials.json is left to stand on its own.

I called mine claude-work (it launches the work subscription — name yours whatever makes sense):

#!/usr/bin/env bash
set -euo pipefail

CLAUDE_WORK_ACCOUNT="${CLAUDE_WORK_ACCOUNT:-m.j.southgate@salford.ac.uk}"
PERSONAL_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
WORK_DIR="${CLAUDE_WORK_DIR:-$HOME/.claude-work}"

mkdir -p "$WORK_DIR"; chmod 700 "$WORK_DIR"

# Account-agnostic config to SHARE. Note .credentials.json is NOT here.
SHARE=(settings.json settings.local.json projects plugins CLAUDE.md)
for item in "${SHARE[@]}"; do
  src="$PERSONAL_DIR/$item"; dst="$WORK_DIR/$item"
  [[ -e "$src" || -L "$src" ]] || continue
  [[ -L "$dst" ]] && continue
  [[ -e "$dst" ]] && { echo "claude-work: $dst exists, leaving as-is" >&2; continue; }
  ln -s "$src" "$dst"
done

export CLAUDE_CONFIG_DIR="$WORK_DIR"
exec claude "$@"

(The real version has a bit more belt-and-braces — it finds the claude binary if it isn't on PATH, and prints a reminder to log in on first run — but that's the heart of it.)

Run it, and on the very first launch the work config dir has no token, so Claude Code drops you straight into the login flow. Run /login, sign in as the work account, and you're done — the token lands in the work dir's own .credentials.json and is refreshed automatically from then on. Your personal login back in ~/.claude is never so much as opened.

A quick ls of the new directory is reassuring: nothing but symlinks pointing home, and (after you log in) one real .credentials.json that belongs to the work account alone.

One small convenience while you're here: drop a symlink somewhere on your PATH and you can run it as a bare command, exactly like claude itself:

ln -s ~/path/to/claude-work ~/.local/bin/claude-work

Skip this and you'll be typing ./claude-work from the right directory forever, and wondering why claude-work on its own says "command not found" (don't ask me how I know).

One caveat worth knowing

Because projects/ is shared, both accounts write their session history and memory into the same place. That's exactly what you want for continuity — but it does mean you shouldn't run both accounts at the same time in the same project, or they'll be scribbling in the same notebook at once. Run them one after the other and it's a non-issue. If you'd rather keep sessions fully separate, just drop projects from that SHARE list and it reverts to an isolated history (you'll keep shared settings and plugins).

There's also a third thing in the config directory that I glossed over, and it's worth being honest about: a file called .claude.json. Unlike settings.json, it's not in the share list — each account gets its own — and it holds account-local state like your workspace-trust decisions, some per-project bits, and any user-scoped MCP servers you've configured. So the work account won't inherit those: it'll ask you to trust a workspace the first time, and user-level MCP servers won't follow it across. (In my case there were none configured at that level, so nothing was lost — but check yours before you assume.) Project-scoped MCP servers, the ones in a repo's own .mcp.json, live in your working directory and are available to both, because they were never in the config directory to begin with.

One more thing the docs are worth reading for: there's an authentication precedence order, and ANTHROPIC_API_KEY sits above your subscription login. So if you've got that variable exported somewhere (a leftover from some API experiment, say), it'll quietly override the /login you just did and you'll wonder why nothing took. If /status shows the wrong account, unset ANTHROPIC_API_KEY is the first thing to try.

A wrinkle if the second account is a work one

This one caught me out, and it's worth flagging precisely because it's the situation this whole post is about: your two subscriptions may not have the same features, even though one script logs into both. My personal account can drive a running session from my phone with Remote Control; my work account — signed in by the very same script, authenticating cleanly — cannot. Claude Code reports it's disabled by my organisation's policy.

That's not the script misbehaving, and it's not a bug. Remote Control is off by default on Team and Enterprise plans until an admin enables the toggle, and some data-retention or compliance configurations grey that toggle out entirely. The difference between my two accounts isn't on my machine at all — it's a server-side entitlement attached to the credential. If you live in a terminal (I reach this box over SSH), you'll never notice. If you were hoping to pick the work session up on your phone, that's the one thing that won't come across. A quick /status tells you which account and login method are actually active.

Worth knowing if you do want Remote Control on every session (on the account that's allowed it): the toggle is in /config under "Enable Remote Control for all sessions", which persists as a settings.json key, remoteControlAtStartup: true. (That key isn't in the published settings reference — I dug it out of the binary — but it's read straight from your settings.) Here's the sharp edge that follows from the design above: because settings.json is shared between both accounts, flipping it on means the work account will also try to start Remote Control every session and hit the policy wall each time. Harmless — a one-line notice — but if it nags, move that one key into the personal-only project-local settings rather than the shared file.

And the bit that pleasantly surprised me: your actual repos and notes don't enter into any of this. They live in your working directory, not the config dir, so they're available no matter which account you're running. The whole exercise really does come down to that one credential file.

Ta ta for now, and I hope this saves you from unpicking a setup you were happy with. Enjoy!


References

A Triggerable Home LAN Health Check with Systemd

The Claude sessions on my laptop dropped out. Internet felt fine, but I wasn't sure — was it the connection, a specific service, or something deeper on the LAN? I wanted a single command that would tell me everything in one pass: internet reachability, DNS, gateway, key hosts, and all the local services I rely on.

I also had a slightly awkward discovery to handle. I run a TP-Link Kasa HS110 smart plug to control power to one of my Proxmox servers (it has no IPMI, so this is the only remote power switch). The HS110 is on DHCP, and during the check I found it had silently moved from 10.140.1.235 to 10.140.1.236 — which explained why my documented procedures would have failed at the worst possible moment.

The script

A single Python file in agents/lan_health.py:

import subprocess, socket, sys, time, urllib.request, urllib.error, ssl

INTERNET_HOSTS = [("1.1.1.1", "Cloudflare"), ("8.8.8.8", "Google DNS")]
DNS_NAMES      = ["google.com", "cloudflare.com"]

LAN_HOSTS = [
    ("10.140.2.6",   "Gateway"),
    ("10.140.3.82",  "Xenon (PVE)"),
    ("10.140.3.202", "claude-hub"),
    ("10.140.20.1",  "Incus bridge"),
]

HTTP_SERVICES = [
    ("https://open-webui.lan",  "open-webui"),
    ("https://litellm.lan",     "litellm"),
    ("https://searxng.lan",     "searxng"),
    ("https://xenon.lan",       "xenon (Proxmox UI)"),
    # ... add your own
]

HS110_MAC = "D8:47:32:A3:FF:CA"
HS110_IP  = "10.140.1.236"  # DHCP — may drift

It pings the internet hosts (any one responding counts as internet up), resolves a couple of DNS names, hits an HTTPS endpoint to confirm real connectivity, then pings key LAN hosts and does a quick HTTP check against each .lan service. For the smart plug, it tries the known IP first and if that fails it runs an nmap sweep to find the MAC — so it catches a DHCP lease change and tells you the new address.

Output is colour-coded and exits non-zero if anything fails, which plays nicely with scripts and monitoring.

The service

The whole point is to be triggerable on demand rather than always-on. A systemd oneshot unit is the right shape for this:

# /etc/systemd/system/lan-health.service
[Unit]
Description=Home LAN health check
After=network-online.target

[Service]
Type=oneshot
ExecStart=/path/to/venv/python3 /path/to/agents/lan_health.py
User=your-user
StandardOutput=journal+console
StandardError=journal+console

Run it with:

systemctl start lan-health.service

Output goes to the journal (so journalctl -u lan-health.service shows the last run), and also to the console if you're watching. No persistent daemon, no polling — just runs when you ask it to.

What it found

Running this for the first time caught the HS110 DHCP drift immediately. It also surfaced three services returning 502 (the backend processes had stopped but the reverse proxy was still up), which I'd otherwise not have noticed. It's become my first step whenever anything feels off on the LAN.

If you're running any kind of home lab with more than a handful of services, a ten-minute investment in something like this pays off fairly quickly. Adjust the host list to match your setup, add it to systemd, and you're done.

Ta ta for now, Matt

Sharing your laptop's wifi to another box with two iptables lines

Now and then you need to get a second machine online through your laptop — a headless box on a bench, a server with no working uplink yet, a device on a little isolated LAN. You don't need a fancy connection-sharing GUI; on Linux it's two firewall rules and a sysctl. Here's the lot, plus the one gotcha that catches people out.

The setup

Say your laptop has internet on wlp... (wifi) and the other box is reachable on a wired interface where your laptop is 192.168.50.50 and the box is 192.168.50.2.

Enable forwarding and NAT the box's subnet out through your wifi:

sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 192.168.50.0/24 -o wlp... -j MASQUERADE
sudo iptables -I FORWARD 1 -s 192.168.50.0/24 -o wlp... -j ACCEPT
sudo iptables -I FORWARD 1 -d 192.168.50.0/24 -i wlp... -m state --state RELATED,ESTABLISHED -j ACCEPT

Then on the other box, point its default route at your laptop:

ip route replace default via 192.168.50.50 dev <its-iface>

That's it — the box now reaches the internet, NATed behind your laptop's wifi address. Set its DNS to something sensible (1.1.1.1 will do) if it doesn't have one.

The gotcha: captive portals authorise you

Here's the bit worth knowing. If your laptop's wifi is behind a captive portal or an enterprise login (eduroam, a guest portal, a hotel), the upstream only knows your laptop. The other box's traffic is NATed, so it appears to the network as your laptop — which means it rides your already-authenticated session and works. Conversely, if your laptop isn't through the portal yet, sharing won't magically get the other box online either. The shared box inherits your laptop's auth state, for better or worse. (This is genuinely handy: it's how you give internet to a device that could never click through a portal itself.)

Tidy up after yourself

These are runtime-only and vanish on reboot, but remove them when you're done — and leave ip_forward alone if you run containers (Docker/Podman/Incus/libvirt all need it; don't set it back to 0 reflexively):

sudo iptables -t nat -D POSTROUTING -s 192.168.50.0/24 -o wlp... -j MASQUERADE
sudo iptables -D FORWARD -s 192.168.50.0/24 -o wlp... -j ACCEPT
sudo iptables -D FORWARD -d 192.168.50.0/24 -i wlp... -m state --state RELATED,ESTABLISHED -j ACCEPT

Two lines of NAT and you've turned your laptop into a router. I hope you find this helpful!