Every LLM tool call re-sends your entire conversation

When it comes to LLM chats, I know long context costs more, so I try to keep mine short. What I couldn’t explain was why a single prompt still billed for many times more tokens than the context window holds.

Every time an AI agent calls a tool, it re-sends the entire conversation. The Messages API is stateless, so the model remembers nothing between requests. When it calls a tool, the request ends, your client runs the tool, appends the result to the history, and POSTs the whole thing back as a new request. You pay input tokens to remind it every time.

So a chat near the 1M-token context limit that makes 20 tool calls pays for that 1M about 20 times over, turning it into 20M billed tokens and around $20 on today’s frontier models. That is 20 times the context window, from a single prompt.

Prompt caching helps keep the cost down. Normally the model reprocesses the whole conversation on every request, but a cache lets it reuse the unchanged prefix, so re-sending that part costs about a tenth of full price. You pay a small premium to store it the first time, then read it back cheap after, the same way OpenAI (automatic caching) and Gemini (implicit caching) do it. The reuse only holds while the start of the prompt stays byte-for-byte identical, so a changing timestamp in your system prompt breaks the cache and puts you back at full price.

Home Assistant Leak Notification Automation

The Sonoff SNZB-05P seems like the best Zigbee leak sensor right now. It’s around $20, and its snap-on WLDC200 sensing cable detects water anywhere along the cord, so it isn’t just watching a single spot the water can spread around. I have them in appliance drip pans and under sinks, all feeding one Home Assistant automation that alerts my phone when a sensor trips and sends an all-clear when it dries.

automations.yaml

- id: leak_notifications
  alias: Leak Notifications
  triggers:
  # Wet: the state must hold 15s to filter momentary blips, and not_from
  # stops a sensor coming back from unavailable from re-alerting.
  - trigger: state
    entity_id:
    - binary_sensor.kitchen_sink_leak_sensor
    - binary_sensor.water_heater_leak_sensor
    not_from:
    - unavailable
    - unknown
    to: 'on'
    for:
      seconds: 15
    id: wet
  # Dry: same sensors, opposite transition.
  - trigger: state
    entity_id:
    - binary_sensor.kitchen_sink_leak_sensor
    - binary_sensor.water_heater_leak_sensor
    from: 'on'
    to: 'off'
    id: dry
  actions:
  - if:
    - condition: trigger
      id: wet
    then:
    - action: notify.mobile_app_your_phone
      data:
        title: Leak Detected
        message: 'Device: {{ trigger.to_state.attributes.friendly_name | default(trigger.entity_id) }} has detected water.'
        data:
          # Both branches share a per-sensor tag, so the dry notification
          # overwrites the wet one and your phone shows a single
          # up-to-date entry per sensor instead of a stack.
          tag: leak_{{ trigger.entity_id }}
          # Android-specific: alarm_stream plays at alarm volume even
          # during Do Not Disturb (a leak at 3am is worth waking up for),
          # and priority high + ttl 0 deliver it immediately.
          priority: high
          ttl: 0
          channel: alarm_stream
    else:
    # The all-clear is a normal-priority push.
    - action: notify.mobile_app_your_phone
      data:
        title: Leak Dry
        message: 'Device: {{ trigger.to_state.attributes.friendly_name | default(trigger.entity_id) }} is dry.'
        data:
          tag: leak_{{ trigger.entity_id }}
  # Parallel so one burst pipe tripping sensors in two rooms alerts for both.
  mode: parallel

To adapt

Swap in your own sensors under both entity_id lists and point the notify actions at your phone’s service. The Android keys are documented in the critical notifications docs and the tag behavior in the basic notification docs. The iOS equivalent of the alarm-stream keys is a critical push:

data:
  push:
    sound:
      name: default
      critical: 1
      volume: 1.0

Is CloudNativePG ready to replace Aurora or AlloyDB?

CloudNativePG is a Kubernetes operator for running PostgreSQL. It joined the CNCF Sandbox in January 2025, applied for Incubation and added PostgreSQL 18 support later that year, and shipped v1.30 in June 2026. It’s used by some big companies like IBM, Google Cloud, and Microsoft Azure, and it looks solid, though I haven’t run it myself. I’ve been weighing whether it’s worth advocating a swap from a plain Aurora or AlloyDB database onto it, and how much work you’d be taking on.

The pitch for Aurora and AlloyDB is that they take storage off your plate and let you scale compute on its own. Storage grows automatically with no downtime, up to 128 TiB, and is replicated six ways across three availability zones, so a write survives losing an entire zone and you never plan capacity or schedule a resize. AlloyDB takes the same approach on its own disaggregated storage and adds a columnar engine for analytics. CloudNativePG doesn’t work that way. It runs classic shared-nothing streaming replication, where each instance is a full Postgres node with its own PersistentVolumeClaim, and durability comes from replicating between instances rather than from a shared storage layer.

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg
spec:
  instances: 3                 # one primary, two streaming replicas
  storage:
    storageClass: local-nvme   # benchmark this before you trust it
    size: 200Gi
  walStorage:                  # WAL on its own volume
    storageClass: local-nvme
    size: 40Gi
  replicationSlots:
    highAvailability:
      enabled: true
  postgresql:
    parameters:
      shared_buffers: "4GB"    # tuning is yours now
    synchronous:               # opt in for RPO=0 (replication is async by default)
      method: any              # any = quorum-based
      number: 1                # standbys that must confirm each commit

That model works, but the volume is now your job. The docs recommend local NVMe for serious workloads and tell you to benchmark with fio and pgbench first, because network-attached storage can wreck Postgres latency. And there’s no auto-scaling, so you size the PVC up front and grow it later only if your CSI driver supports expansion.

Each instance keeps its own copy of the data, so with local volumes a failed node takes its data with it and the operator re-creates that instance by cloning a fresh copy from the primary, a slow rebuild for a large database. That only protects against a zone outage if the replicas are in other zones, which takes pod anti-affinity and topologySpreadConstraints to arrange, with the usual advice to run nodes in multiples of three, one per zone. The operator promotes a replica automatically when the primary fails, but replication is asynchronous by default, so that promotion can drop the last few committed transactions. Synchronous replication (method: any quorum, above) gets you back to RPO=0, and it makes the CAP tradeoff explicit. With the default dataDurability: required, writes pause when the operator can’t reach enough synchronous standbys, so a bad enough zone outage costs availability to preserve consistency. Aurora and AlloyDB make that same call at the storage layer and keep taking writes through an AZ failure. Cross-region means running a separate replica cluster fed by streaming or WAL shipping, with no automatic failover between clusters.

The operational pieces are all yours too. Continuous backup and point-in-time recovery go to object storage through the barman-cloud plugin, but you configure it, set the retention, and test that a restore actually works. Connection pooling is a Pooler resource running PgBouncer that you deploy and size. Monitoring is Prometheus metrics the operator exposes, wired into whatever stack you already run. Minor-version upgrades are rolling pod restarts you trigger.

So is it worth it? If you’re paying for Aurora mainly to avoid thinking about Postgres, self-hosting probably isn’t worth it. If you already have the Kubernetes and Postgres chops, live in GitOps, want off the managed-service markup, or need something the managed services won’t give you (specific extensions, exact versions, multi-cloud portability, no lock-in), it looks worth a serious trial. Either way I’d start on something smaller than a tier-1 database and prove out storage, backups, and failover first.

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

Update 2026-08-23: I have since swapped this for Qwen3.8-27B, a dense 27B that fits the same 16 GB card. I now recommend the setup in that post instead.

I wanted a bigger model than the Qwen3.5-9B I was running on my 16 GB RTX 5060 Ti (the setup). A larger dense model is hard to fit, since it uses every parameter for every token and any weights outside VRAM tank throughput. A Mixture-of-Experts (MoE) model uses only a few of its expert sub-networks per token, so the idle ones can sit in system RAM instead. Qwen3.6-35B-A3B is 35B total with about 3B active per token. Even quantized to 3-bit (Unsloth’s UD-Q3_K_M), its weights are about 15 GB, so some experts have to move off the card to leave room for context.

llama.cpp does this with --n-cpu-moe, which keeps the expert weights of the first N layers in system RAM for a small speed hit. LLAMA_ARG_N_CPU_MOE=8 freed about 2 GB of VRAM, enough for 64K of context with flash attention and a q8_0 KV cache. Raise it if the model OOMs on load, and lower it if you have VRAM to spare.

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. The compose file runs it behind Open WebUI:

services:
  llama-server:
    image: ghcr.io/ggml-org/llama.cpp:server-cuda-v0.5.0
    restart: unless-stopped
    environment:
      # Pin downloads to the mounted volume, or a container recreate re-fetches 15 GB.
      - LLAMA_CACHE=/root/.cache/llama.cpp
      - 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 first 8 layers' experts, kept in system RAM
      - LLAMA_ARG_CTX_SIZE=65536
      - 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_JINJA=1              # correct chat template + tool calling
      - LLAMA_ARG_REASONING=off        # thinking off, see below
      - LLAMA_ARG_TEMPERATURE=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.11.4
    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 thinking switch

--reasoning off (LLAMA_ARG_REASONING=off above, with LLAMA_ARG_JINJA=1) turns thinking off. LLAMA_ARG_THINK_BUDGET=0 alone didn’t stop it, and I watched it think in circles for several minutes without getting anything done. Passing enable_thinking=false through LLAMA_ARG_CHAT_TEMPLATE_KWARGS also works, but llama.cpp logs a deprecation warning for it.

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

Updated 2026-10-07: I’ve since upgraded from these to used HP Desktop Minis at around $100 each, which are much faster for a little more money.

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

If you’re choosing between Hubitat and Home Assistant, weigh how easily an LLM can work with each one above anything else right now. Home Assistant wins on that. An automation is plain YAML in a file, so I keep the config in git, an agent edits it, and I review the diff. On Hubitat the logic lives behind a UI you can’t diff or point a tool at, and its newer AI rule builder turns a typed request into a rule you then adjust in that same UI.

The models are good at Home Assistant automations too, because they’ve learned from years of community examples. Debugging one used to take an afternoon, and now it’s a quick back-and-forth. I’ve been fixing automations I’d let sit broken for years and writing more ambitious new ones, like my leak alerts.

Running both

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.

The Hubitat integration for Home Assistant made that easy. It’s a HACS integration that pulls Hubitat devices into Home Assistant over Hubitat’s Maker API. Any device I exposed there showed up in Home Assistant with its state, so Home Assistant could see everything while Hubitat still ran the radios.

If you’re already on Hubitat, running both is a fine way to migrate a device at a time. Two hubs give you more to keep in sync, though. Renaming an entity across both is clunky, and removing a device from the Maker API leaves its Home Assistant entities behind as unavailable until you remove the integration. That, and how well Grafana monitors Home Assistant itself, pushed me to go all-in.

Config in git

I run Home Assistant Container with Docker Compose on a box I SSH into. The compose file and Home Assistant’s config directory sit in the same repo, so deploying a change starts with a git pull on the box:

homelab/
  .gitignore                # ignores every service's data/ except this one
  home-assistant/
    docker-compose.yml      # mounts ./data as /config
    data/                   # Home Assistant's config directory
      .gitignore            # the allowlist below
      configuration.yaml
      automations.yaml      # the automation editor saves here
      scripts.yaml
      scenes.yaml
      dashboards/
      secrets.yaml          # ignored
      .storage/             # ignored
      home-assistant_v2.db  # ignored

The YAML is what you edit, and it’s the context an agent needs. The rest is state Home Assistant writes itself, like the device registry and auth tokens in .storage/ and the history database. Home Assistant’s own backups cover that, so git only gets the YAML.

My repo uses two .gitignore files for that. The one at the repo root ignores every service’s data/ directory, since those are Docker volumes full of state, and re-includes Home Assistant’s. Git can’t re-include a file if its parent directory is excluded, so without !home-assistant/data/ nothing under home-assistant/data/ would be tracked:

**/data/
!home-assistant/data/

The allowlist is home-assistant/data/.gitignore, and its patterns only apply inside that directory. It ignores everything and allows the YAML back in, so a new file Home Assistant drops there stays out of git by default. If the config directory is your whole repo, this is the only file you need:

*
# * matches directories too, and git won't look inside an ignored one
!*/
!*.yaml
!.gitignore

# passwords and API keys, referenced from other files with !secret
secrets.yaml
# code installed through HACS
custom_components/
# ESPHome device configs, which can hold Wi-Fi passwords and device keys
esphome/

Dashboards and helpers you create in the UI land in .storage/, so I define mine in configuration.yaml instead. They end up in git and come back with a fresh clone, though the UI can’t edit them after that.

After a pull, most changes only need a reload. For an automations.yaml change I use Quick reload (Settings, the three-dot menu, Restart Home Assistant, Quick reload), which checks the config first and applies the YAML that supports reloading without a restart. A change it can’t apply, like adding an integration to configuration.yaml, needs a restart, and Home Assistant’s docs suggest the full check from the container before that:

docker exec homeassistant python -m homeassistant --script check_config --config /config

A Green or Yellow runs Home Assistant OS, the other supported install type. It doesn’t give you a regular shell, so pulling from git takes extra setup. I haven’t tried it, but the official Git pull app pulls the config from a repo (it can’t push), and the Advanced SSH & Web Terminal app ships git if you’d rather commit from the box.

Guardrails for agent edits

Home Assistant’s GUI editor rewrites automations.yaml on every save, so the top of the file tells the agent how to edit it without churning the next save:

# The automation editor rewrites this file on every save and strips comments.
# Write it back in the editor's format so a save only changes what it touched:
#
#   yaml.dump(data, Dumper=yaml.CSafeDumper, sort_keys=False,
#             default_flow_style=False, allow_unicode=True)
#
# Put an automation's reasoning in its description:, which survives a save.

A GUI save strips this header too, so I restore it from git afterward.

The repo’s AGENTS.md points the agent at that header and tells it to run make check before committing, the same setup I describe in Grounding a coding agent in reality. Here make check runs prek’s check-yaml hook, which catches a YAML syntax error before Home Assistant does. It needs --unsafe to accept Home Assistant’s !include and !secret tags:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: check-yaml
        args: [--allow-multiple-documents, --unsafe]
      - id: detect-private-key  # the config is in git, so block pasted keys

An agent can also skip the files. The unofficial ha-mcp server creates and edits automations over Home Assistant’s API, while the official MCP Server integration only controls the entities you expose to Assist. I stick with files in git for the diffs.

Migrating off Hubitat

I moved Z-Wave to the Home Assistant Connect ZWA-2, Home Assistant’s official Z-Wave adapter. It supports Z-Wave Long Range and is a real range upgrade over my Hubitat C-7’s internal antennas. The Model C-8 added external antennas and an 800-series Z-Wave radio, so a current Hubitat may close some of that gap.

The C-7’s Z-Wave network couldn’t come with me. Hubitat’s Z-Wave backup needs a subscription and only restores to another Hubitat, and Home Assistant can’t use the hub’s internal radio, so every Z-Wave device needed a fresh inclusion on the ZWA-2. A few things made that easier:

  • A general exclusion from the ZWA-2 freed a device that was still joined to Hubitat, so I didn’t need the old hub near it.
  • Each inclusion creates new entity IDs, so check automations that hard-code them. An action aimed at a missing entity only logs a warning, so a broken reference is easy to miss.
  • Removing a Zigbee device cleanly from Hubitat sends a Leave, and an open ZHA join window often catches it right away. A device removed while unreachable keeps its old network key and needs its own reset before it can join.

prek vs pre-commit: what changed when I switched

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 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 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.

You get prek’s Rust hooks without touching your config. When a repo in .pre-commit-config.yaml is https://github.com/pre-commit/pre-commit-hooks, prek runs its own implementation of any hook ID it recognizes instead of shelling out to Python. This fast path is on by default and ignores the pinned rev, so the config still works under stock pre-commit. prek util list-builtins prints the 26 hooks it implements in 0.4.11, a few of which are opt-in only. It still clones the repo and builds the environment, so it only saves the Python process spawn on each run. If a hook starts behaving differently after the switch, PREK_NO_FAST_PATH=1 prek run forces the Python originals.

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, skipping the setup as well as the Python. 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 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 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. 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.