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.