Paul's Programming Notes PostsRSSGithub

Switching my local LLM to Qwen 3.6, a 35B Mixture-of-Experts model, on a 16 GB GPU

A few months ago I wrote about switching Open WebUI from Ollama to llama.cpp for Qwen 3.5. I’m still using the same RTX 5060 Ti with 16 GB of VRAM, but I swapped the dense 9B for Qwen3.6-35B-A3B, a Mixture-of-Experts (MoE) model. A 35B model usually wouldn’t fit on a 16 GB card without MoE.

A dense model uses every parameter for every token, so the weights have to fit in VRAM or throughput tanks. On this card that capped me around 14B. MoE models have many expert sub-networks but only use a few per token. Qwen3.6-35B-A3B is 35B total but only ~3B active. The idle experts can sit in system RAM instead of VRAM, pulled onto the GPU when needed for a small speed hit. llama.cpp does this with --n-cpu-moe, which keeps the top N layers’ experts on the CPU.

Staying within the 16 GB budget took some more tuning. I set LLAMA_ARG_N_CPU_MOE=8, which freed about 2 GB of VRAM for context. Raise it if the model OOMs on load, lower it if you have VRAM to spare. I used a dynamic UD-Q3_K_M quant (about 15 GB) and lowered BATCH_SIZE and UBATCH from the 9B’s values to leave room for a 64K KV cache. Flash attention and a q8_0 KV cache save the rest, same as before.

Turning thinking mode off took two flags. LLAMA_ARG_THINK_BUDGET=0 (reasoning-budget 0) alone didn’t stop it, a known issue on the hybrid Qwen models. I was still watching it think in circles for several minutes without getting anything done. The other was enable_thinking=false in the chat template (LLAMA_ARG_CHAT_TEMPLATE_KWARGS), with jinja on. I also set the sampling to Qwen3.6’s non-thinking defaults (temp 0.7, top_k 20, top_p 0.8).

For serious work I still use a hosted frontier model, but this is handy for local jobs like redacting or cleaning text before it goes to the cloud.

Minimal two-container compose to get mostly set up:

services:
  llama-server:
    image: ghcr.io/ggml-org/llama.cpp:server-cuda-b9592
    restart: unless-stopped
    environment:
      # Pin the model cache to the mounted volume so a container recreate
      # doesn't re-download the weights.
      - LLAMA_CACHE=/root/.cache/llama.cpp
      # Auto-downloads from HuggingFace on first run.
      - LLAMA_ARG_HF_REPO=unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q3_K_M
      - LLAMA_ARG_N_GPU_LAYERS=99      # all layers to GPU...
      - LLAMA_ARG_N_CPU_MOE=8          # ...except the top 8 layers' experts, kept in system RAM
      - LLAMA_ARG_CTX_SIZE=65536       # 64K context, realistic for a 35B MoE in 16 GB
      - LLAMA_ARG_N_PARALLEL=1         # single user, one KV cache slot
      - LLAMA_ARG_FLASH_ATTN=1         # big KV cache VRAM savings
      - LLAMA_ARG_CACHE_TYPE_K=q8_0    # halve KV cache memory vs FP16
      - LLAMA_ARG_CACHE_TYPE_V=q8_0
      - LLAMA_ARG_BATCH_SIZE=2048      # lowered from 4096 to leave VRAM for the bigger model
      - LLAMA_ARG_UBATCH=512           # lowered from 2048 for the same reason
      - LLAMA_ARG_JINJA=1              # correct chat template + tool calling
      # Non-thinking mode. On the Qwen3 hybrid models, reasoning-budget 0 alone kept
      # emitting think blocks; enable_thinking=false is what stopped it, so set both.
      - 'LLAMA_ARG_CHAT_TEMPLATE_KWARGS={"enable_thinking":false}'
      - LLAMA_ARG_THINK_BUDGET=0       # reasoning-budget 0
      - LLAMA_ARG_TEMP=0.7             # Qwen3.6 non-thinking sampling defaults
      - LLAMA_ARG_TOP_K=20
      - LLAMA_ARG_TOP_P=0.8
      - LLAMA_ARG_MIN_P=0
      - LLAMA_ARG_PORT=11434
      - LLAMA_ARG_HOST=0.0.0.0
    volumes:
      - ./models:/root/.cache/llama.cpp
    ports:
      - "11434:11434"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  open-webui:
    image: ghcr.io/open-webui/open-webui:v0.9.6
    restart: unless-stopped
    ports:
      - "3000:8080"
    environment:
      - ENABLE_OLLAMA_API=false
      - OPENAI_API_BASE_URLS=http://llama-server:11434/v1
      - OPENAI_API_KEYS=no-key
    volumes:
      - ./open-webui:/app/backend/data
    depends_on:
      - llama-server

The best-value thin clients with a PCIe slot in 2026

I was picking hardware for an OPNsense router and needed a real PCIe slot, which most cheap low-power boxes don’t have. A slot lets you add a network card with SFP+ or more ports, and swap it later for something else (like a GPU).

The thin clients I wrote about last time don’t have one. The Wyse 5070 and OptiPlex 3000 both have an M.2 A/E key slot, good for a 2.5GbE adapter or a Coral TPU but not an SFP+ or a quad-port NIC.

A few corporate thin clients do have a full-size slot:

ModelCPUPCIe slotUsed price
HP t730AMD RX-427BB (4C)half-height x16 mechanical, x8 electrical~$100-120
HP t740AMD Ryzen V1756B (4C/8T)half-height x16 mechanical, x8 electrical, Gen3~$150-170
Dell Wyse 5070 ExtendedCeleron J4105 / Pentium J5005half-height slot in the thicker “Extended” chassis~$90-130

The HP t730 and t740 are the well-known ones, both popular pfSense/OPNsense boxes with a half-height x16-mechanical, x8-electrical slot. The Wyse 5070 comes in two sizes. The slim one has only the M.2 slot. The thicker Extended chassis adds a half-height PCIe slot.

The slot is half-height, so you’ll want a low-profile card, and its power is limited (roughly 35W on the t740), enough for a NIC but only a low-power GPU.

The other route is a Topton or CWWK mini PC, which usually skips the slot and solders on the NICs instead, often four 2.5GbE ports or a couple of SFP+ cages. Handy if those ports are what you need, but you’re stuck with them, with no way to swap in a specific card.

A dead man's switch for a single-host monitoring stack

I run Prometheus, Alertmanager, and Grafana on a single mini PC, which has an obvious blind spot: if that box goes down, nothing can alert me, because the thing that sends alerts is the thing that’s down. Prometheus once sat dead for two days before I noticed, and only because a dashboard had gone blank.

The fix is a dead man’s switch: an alert that fires constantly, routed to an outside service that complains when it stops arriving. Every other alert fires when something breaks; this one fires all the time, and silence is the failure signal.

The rule is just vector(1), which is always true:

# alerts/meta.yml
- alert: Watchdog
  expr: vector(1)
  labels:
    severity: none

kube-prometheus-stack ships the same alert under the same name. Then route that one alert away from Telegram and into a webhook:

routes:
  - matchers:
      - alertname = "Watchdog"
    receiver: deadmansswitch
    group_wait: 0s
    repeat_interval: 5m   # re-ping every 5 minutes

receivers:
  - name: deadmansswitch
    webhook_configs:
      - url: https://hc-ping.com/<uuid>
        send_resolved: false

The webhook target is a healthchecks.io check. Alertmanager pings it every five minutes while the pipeline is healthy; I set the check to a ~10 minute period with a ~5 minute grace, so a real outage pages me within about 15 minutes, from a service that isn’t on my network.

Two details that bit me: the ping URL lives in my git-ignored alertmanager.yml (low-sensitivity, like the Telegram chat ID), and send_resolved: false so the “resolved” call doesn’t muddy the heartbeat.

The same trick works for any scheduled job: have the script curl a healthchecks.io ping URL on success, and let the absence page you.

Replacing my Raspberry Pis with used thin clients

Raspberry Pis have gotten expensive enough that I’ve started replacing them with used corporate thin clients. These two came off eBay: a Dell Wyse 5070 (Celeron J4105) for $43 (+$15 for a power adapter it didn’t include), and a Dell OptiPlex 3000 thin client (Pentium Silver N6005) for $84. Both are fanless, both idle at 5W or less, and both cost a fraction of a Pi 5.

A Pi 5 16GB lists at $299.99 these days, and adding a power supply, a case, an SD card, and an NVMe HAT pushes it well past what I paid for either box. The OptiPlex came with 16GB of RAM and its power adapter for $84, with an M.2 slot for a real NVMe drive. Jeff Geerling called the hobbyist SBC market “dying, or at least on life support” in April 2026, and for a low-power box that sits in a closet the thin client is just better value now. However, the OptiPlex’s N6005 is slower than a Pi 5 in Geekbench 6, 544/1421 to the Pi’s 764/1604.

These boxes are old (the Wyse 5070 can be 8+ years old, the OptiPlex 3000 4+), so run memtest86+ before you trust one. The Wyse is why I say that: both its 4GB SK Hynix sticks failed memtest, so I swapped in one of the OptiPlex’s 8GB sticks.

My favorite thing about the Wyse 5070 is the USB-C port. It normally runs headless, but for setup and troubleshooting I can plug it into a USB-C hub and get video and USB over one cable.

The OptiPlex 3000 is the more annoying of the two. No USB-C, and its M.2 slot officially only takes a short 2230 SSD. A full-length 2280 fits, but I had to desolder a little standoff to make room. I tried to dodge the soldering iron with a Sintech M.2 extender cable first, and that was a mistake: the extra length wrecked PCIe signal integrity and dmesg filled with correctable errors:

AER: Correctable error message received
PCIe Bus Error: severity=Correctable, type=Physical Layer

What the OptiPlex has going for it is the newer processor. These make a capable little web server: with a low-latency home connection you can expose Docker containers to the public internet through a Cloudflare Tunnel with no port forwarding or static IP. (Other people run the 5070 as a fanless Linux home server too.)

Why I moved from Hubitat to Home Assistant

I ran a Hubitat hub for years and liked it. Somewhere along the way I stood up Home Assistant too, because it handled a few things better (some Aqara sensors, mostly), and for a long time I was happy running both.

Running two hubs stayed painless thanks to the Hubitat integration for Home Assistant, a HACS add-on that pulls Hubitat devices into HA over Hubitat’s Maker API. Expose a device in the Maker API and it shows up in Home Assistant automatically, state and all, so HA could see everything while Hubitat still ran the radios.

What changed recently is the tooling. LLMs are genuinely good at writing and fixing Home Assistant automations, because an HA automation is plain YAML in a file and there are years of community examples the models have already learned from. Debugging one used to be an afternoon; now it’s a quick back-and-forth. So I’ve been repairing automations I’d let sit broken for years, and getting more ambitious with new ones now that the cost of getting them working has dropped.

That’s the real reason Home Assistant wins for me now: the config lives in code. I keep automations.yaml in git, hand the whole file to an assistant to review or extend, and get back a diff I can read and commit. The one wrinkle is that HA’s GUI editor rewrites the whole file on every save, so I match its serializer (PyYAML’s CSafeDumper, block style, keys left unsorted) to keep a save down to a one-line diff, and I put rationale in each automation’s description field since comments don’t survive. On Hubitat the logic lived behind a UI I couldn’t diff or point a tool at.

Two more things pushed me to go all-in. Monitoring Home Assistant itself with Grafana is great, and I’d rather run one system than keep two in sync: renaming an entity across both hubs is clunky, and one source of truth is simpler. I picked up the Home Assistant Connect ZWA-2, Home Assistant’s official Z-Wave adapter with Z-Wave Long Range support, to move the last Z-Wave devices over, a real range upgrade from the old Hubitat box.

A few sensors are still on Hubitat, and it served me well for years. But with everything in code and one hub to manage, I’m not going back.

Switching from pre-commit to prek

I moved my repos from pre-commit to prek, a reimplementation of the same framework in Rust. It reads your existing .pre-commit-config.yaml unchanged, so the switch is mostly a no-op. Two things sold me: it installs as a single binary with no Python of its own (uv tool install prek, or a standalone script), and for language: python hooks it builds the hook environments with uv instead of pip and virtualenv, which is a lot faster on a cold cache.

“Drop-in” came with one wrinkle. prek’s YAML parser is stricter than pre-commit’s, and it rejected two hook manifests that pre-commit had been quietly accepting for years. Both turned out to be real bugs in the upstream hooks.

The first was a duplicate key. The gruntwork-io/pre-commit version I was pinned to, v0.1.20, shipped a golangci-lint hook that declared language twice:

- id: golangci-lint
  entry: hooks/golangci-lint.sh
  language: script
  language: script   # duplicate
  files: \.go$

pre-commit parses with PyYAML, which silently keeps the last of two duplicate keys and moves on. prek’s Rust parser treats a duplicate mapping key as an error and refuses to run. It’s fixed upstream in v0.1.30 (gruntwork PR #134), so bumping the hook version cleared it.

The second was deprecated stage names. The pre-commit-hooks version I was pinned to, 4.4.0, still used the old git-stage spelling:

stages: [commit, push]           # deprecated
stages: [pre-commit, pre-push]   # current

pre-commit renamed the commit stage to pre-commit and push to pre-push a while back and started warning on the old names; newer tooling rejects them outright. pre-commit-hooks uses the modern names in 6.0.0, so upgrading to it sorted it.

Neither was prek’s fault. It just stopped tolerating manifests that were already wrong.

Once everyone on the repo is on prek, you might not need the pre-commit-hooks repo at all. prek ships Rust-native versions of the common checks (trailing whitespace, end-of-file fixer, YAML and JSON validation) that you pull in with repo: builtin instead of cloning and pinning the upstream hooks. Just note that repo: builtin only works under prek, so it’s for repos where you’ve moved the whole team over and don’t need the config to keep running under stock pre-commit.

The reason I bother with any of this: I run the exact same hooks locally and in CI, through j178/prek-action. If a formatter or a lint rule is going to fail, I want it fixed in the commit, not after a five-minute CI round-trip and a “fix lint” follow-up commit. Enforcing it across a whole team kills a lot of that churn.

The catch is that hooks have to stay fast, because they run on every commit. Formatters, linters, a YAML syntax check: fine. The full test suite or Playwright browser tests: no. Those go in CI, where slow is acceptable and the same prek config runs --all-files anyway.

The Zyxel XGS1210-12 is the best cheap OpenWrt 2.5G switch right now

I run OpenWrt as the firmware on my core switch, a Zyxel XGS1210-12: eight gigabit ports, two 2.5GbE copper ports, and two SFP+ cages that do 10G, for around $120. If you want a cheap, multi-gig managed switch that runs open-source firmware and still does VLANs and the rest, I think it’s the best option right now. It’s on the hardware list in the OpenWrt Beginner’s Guide, which is where I’d check for current recommendations since this moves quickly.

For me the appeal is owning the hardware outright, more than any feature list. With open firmware I can see exactly what the switch runs, test a fix myself when I hit a bug, and send a patch upstream if it comes to that. Closed gear leaves you one update away from changes that suit the vendor more than you, and for something as tied to privacy and security as networking equipment, the transparency is worth it.

UniFi is the obvious alternative, and credit where it’s due: its UI and single-pane-of-glass view of a whole network are genuinely nicer, so going the OpenWrt route is hard mode. But it costs a lot more. A comparable UniFi switch with 2.5G and a 10G SFP+ uplink, the Pro Max 16, runs a few hundred dollars ($399 with PoE, less without) versus $120 here, and that adds up fast across several switches. The open-source enterprise options don’t help on price either: SONiC, DENT, and Cumulus all need $1,000-plus whitebox gear.

Under the hood it’s a Realtek RTL9302, and getting the 2.5G copper ports working was genuinely hard. The PHY spoke a proprietary Realtek protocol the Linux kernel couldn’t drive, and the fix took getting proper support upstream so it could switch modes by link speed. The whole saga is in issue #19640; the breakthrough landed in PR #19843 in August 2025, and the rev B1 board got its fix in PR #21605 in January 2026. That work came from Birger Koblitz (who started OpenWrt’s Realtek port), Tobias Schramm, Markus Stockhausen, Jan Hoffmann, Jonas Jelonek, and Sander van Heule. I’m surely forgetting people, so check the linked PRs and issue thread for the full list.

None of it came with a datasheet. Realtek doesn’t publish one, so the whole thing was reverse-engineered from GPL source dumps and a lot of register poking. Sander van Heule documents the internals at svanheule.net and even built a web tool for browsing these chips’ registers. People doing that for free, with no documentation, is the reason a $120 switch can run mainline Linux at all. I appreciate it a lot.

OpenWrt itself is worth appreciating too. It’s a remarkably flexible Linux build, and with all sorts of tricks to squeeze it down, it’ll run a full, configurable network OS on devices with only a few megabytes of flash. Getting real Linux into that little space is the cool part.

A couple of things to flag. There’s no PoE on this model, so APs and cameras need injectors or a separate PoE switch. And the 2.5G work is on the snapshot builds rather than the stable release for now, so flash a recent snapshot that matches your board revision (mine’s the B1). As of writing that’s r35109-f5d928e52a. Beyond that it’s been solid for me, and for a cheap, open, multi-gig managed switch it’s the one I’d get.

Claude Code - Experimenting With Dev Containers and Permission Allowlists

Updated 2026-07-11: reworked around workspace trust, which was the real reason my allowlist wasn’t taking effect.

I run Claude Code, and its VS Code extension, inside dev containers. The reason is isolation. Editor extensions and the toolchains a project pulls in have been a real supply-chain vector lately, and a dev container keeps a compromised one off my host: the extension, the agent, and everything npm or uv installs all live inside the container, not on my machine.

That isolation changes how I think about permissions. Because the blast radius is the container and not my laptop, I’m comfortable letting the agent do far more inside it than I would on bare metal.

The container is a small file. The Claude Code dev container feature installs the CLI and the extension into the container, and I run as a non-root user. I also mount a named volume at ~/.claude so plugins and login state persist across rebuilds:

{
  "name": "project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  "mounts": [
    "source=claude-code-shared,target=/home/vscode/.claude,type=volume"
  ],
  "remoteUser": "vscode",
  "postCreateCommand": "bash .devcontainer/post-create.sh"
}

That volume isn’t the whole story. Claude Code keeps your login in ~/.claude.json, a $HOME file that sits outside the mounted .claude directory, so a rebuild wipes it and forces a re-login. I fixed that a while back by symlinking the file into the volume:

if [ ! -L "$HOME/.claude.json" ]; then
  [ -f "$HOME/.claude.json" ] && mv -f "$HOME/.claude.json" "$HOME/.claude/.claude.json"
  ln -sf "$HOME/.claude/.claude.json" "$HOME/.claude.json"
fi

The same file holds your per-repo workspace trust, and that turned out to matter more than the login. Claude Code won’t apply a repo’s committed allow rules until you accept its workspace-trust dialog (docs); until then it reads the rules and ignores them, so an allowlisted make test still prompts. Since trust lives in the same ~/.claude.json, the symlink already persists it, so I pre-accept it for the repo and skip the dialog. Do it right after the symlink so the write lands in the volume, and key on the exact git repo root, since trusting a parent like /workspaces doesn’t count (anthropics/claude-code#72896):

REPO_ROOT="$(git -C "$PWD" rev-parse --show-toplevel)"
node -e '
  const fs = require("fs"), f = process.env.HOME + "/.claude.json", p = process.argv[1];
  const j = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8") || "{}") : {};
  (j.projects ||= {})[p] ||= {};
  j.projects[p].hasTrustDialogAccepted = true;
  fs.writeFileSync(f, JSON.stringify(j, null, 2));
' "$REPO_ROOT"

With trust settled, the committed allowlist takes effect. I commit a wide allowlist to .claude/settings.json and turn on acceptEdits so routine file writes don’t prompt either:

{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(make test:*)",
      "Bash(make lint:*)",
      "Bash(uv run pytest:*)",
      "Bash(npm run build:*)",
      "Bash(tail:*)",
      "Bash(head:*)",
      "Bash(grep:*)",
      "Bash(git add:*)",
      "Bash(git commit:*)"
    ],
    "deny": ["Read(.env)", "Read(**/.env)"]
  }
}

It goes in .claude/settings.json, not .claude/settings.local.json, on purpose. When you click “yes, don’t ask again”, Claude Code writes the rule to the local file, and that file gets wiped on rebuild too. Commit the allowlist and it survives rebuilds and travels with the repo. tail, head, and grep are in there because Claude appends | tail or | head to a lot of commands to keep output short, and Claude Code matches each side of a pipe on its own, so the pipe target needs its own rule.

The container isn’t a free-for-all, so I keep two guardrails inside it. It still holds my code and a token, so I don’t blanket-allow uv run or npm install: uv run runs whatever follows it, and npm install <pkg> runs package install scripts, which is how a lot of npm supply-chain attacks land. I pin the inner command (uv run pytest) and leave the runners prompting. I also keep the container’s GitHub token read-only and push from the host, so a bad suggestion can’t push or open a PR on its own.

A committed allowlist still won’t cover every command, and a new one puts you back at a prompt. The option that fits the VS Code extension is auto mode, which approves routine commands with a classifier instead of a static list. The built-in Bash sandbox is the other documented path, isolating commands at the OS level, but it’s CLI-only today (anthropics/claude-code#64061), so it doesn’t reach the extension.

None of this is a hard sandbox on its own. Permission rules are enforced by Claude Code, not the OS. I’ve always been more worried about approving the one thing I shouldn’t than about the prompts themselves, and I’m still working out how to shrink the blast radius further. This is early, and I expect parts of it to look risky in hindsight.

Macvlan private mode for container L2 isolation

Macvlan is a Docker networking mode that gives each container its own MAC and IP on the network the host port is plugged into. The container shows up on the subnet as its own device, gets a DHCP lease from the router, and talks to the LAN directly instead of going through the Docker bridge.

I have a box with multiple Ethernet ports, each port on a different VLAN. Some containers sit on the port assigned to a guest-style VLAN that can only reach the internet, not other hosts on the LAN. That worked fine in the default bridge macvlan mode while each port only had one service on it. Once I added more services to the same port, I hit a problem I hadn’t thought about: containers sharing a macvlan parent in bridge mode can freely ARP and talk to each other inside the kernel, which defeats the VLAN-level isolation.

Macvlan has a private mode for exactly this. The kernel drops frames going between siblings on the same parent. Outbound traffic via the gateway still works. No switch cooperation, no extra VLANs.

docker network create -d macvlan \
  --subnet=192.0.2.0/24 \
  --gateway=192.0.2.1 \
  -o parent=eth0 \
  -o macvlan_mode=private \
  guest_private

Attach a container to it with a static IP:

services:
  app:
    networks:
      guest_private:
        ipv4_address: 192.0.2.16
networks:
  guest_private:
    external: true

One caveat: the host itself can’t reach its own containers over that interface. That’s true for any macvlan setup, not specific to private mode. For my case it’s fine since the containers just need internet access and to be reachable from other subnets through the router.

More on the modes underneath: ip-link(8) documents macvlan’s private, vepa, bridge, and passthru modes. Docker’s guide: Docker macvlan driver.

Python - Inline Script Dependencies With PEP 723

Distributing one-off Python scripts has gotten a lot better recently. PEP 723 added a standard way to declare dependencies and a minimum Python version inside a single .py file, and uv makes running them painless. I used this to share a script with the team and it just worked across everyone’s machines.

Before this, you’d end up writing some wrapper shell script that creates a virtualenv, makes sure the right Python version is being used, installs the dependencies, and tries to keep everything in sync. Brew-installed Python was especially bad for this because brew upgrade can silently swap your Python version and break every virtualenv pointing at it. Even version managers like asdf or pyenv don’t really help because the recipient still needs the right version installed through their manager. A lot of ceremony for a single file.

PEP 723 lets you declare all of that inline:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "requests",
# ]
# ///

import requests

resp = requests.get("https://example.com/api/health", timeout=10)
print(resp.json())

The # /// script block is TOML. You don’t have to write it by hand either, uv add --script example.py 'requests' will add it for you.

The recipient just needs uv installed:

uv run check_status.py

uv reads the inline metadata and installs the dependencies in an isolated environment. If the required Python version isn’t on their machine, uv downloads it automatically. No virtualenv, no version manager, no wrapper script.

You can also lock dependencies with uv lock --script example.py for reproducible runs. For bigger tools that span multiple files, uv tool install is the next step up and can install from a private PyPI index.