A portable, offline AI stack on a USB stick. One binary launches Ollama (local LLM server) and Open-WebUI (chat UI) inside an Electron desktop app that supervises both services and embeds the chat UI — no system Python, Node, or internet required at runtime.
Everything is built on NixOS (the dev box), including the Windows and macOS
artifacts. The dashboard is styled with
plan-ai-design (consumed as a Tailwind
layer; a git submodule).
┌──────────── single artifact (AppImage / win .zip / mac .app.zip) ───────────┐
│ Electron shell │
│ ├─ dashboard (service status · start/stop · logs) ← plan-ai-design CSS │
│ ├─ embedded <webview> → http://127.0.0.1:8080 (Open-WebUI) │
│ └─ supervises: ollama serve + uvicorn open_webui.main:app │
└─────────────────────────────────────────────────────────────────────────────┘
<usb>/models → OLLAMA_MODELS <usb>/data → Open-WebUI DATA_DIR
- Concepts · Prerequisites · usb.lock
- Flow A — Dev (run on NixOS)
- Flow B — Prod (build shippable artifacts)
- Flow C — Ready-to-burn USB image
- Models · Codesigning · Testing · CI
- Platform support · Scripts · NixOS notes
There are two python runtime strategies, by design:
| how it's built | runs on | used by | |
|---|---|---|---|
| dev runtime | a venv from the nixpkgs Python | NixOS (native) | make dev / run-nixos.sh |
| prod runtime | python-build-standalone + uv pip install --python-platform (cross) |
the target OS (generic Linux / Windows / macOS) | bundle.sh artifacts |
The prod runtime is cross-installed (it never runs the target interpreter), so a single NixOS host can build all three OS artifacts. The dev runtime exists because those generic binaries can't run on NixOS (the bare nix-ld stub) — for local iteration you use nix-native tooling instead.
Nix with flakes. Everything else is in the devshell:
git clone --recurse-submodules <repo> && cd plan-ai-usb-minimal
nix develop # node22, python312, uv, electron, rcodesign, wine, nsis,
# osslsigncode, mtools, dosfstools, qemu, … + submodule initThe devshell is the only supported environment for every command below.
Single source of truth every script reads (usb.lock):
Fastest inner loop. Builds a nix-native runtime and launches the real stack via the nixpkgs Electron. Idempotent — re-runs skip completed steps.
nix develop
make dev # = scripts/dev.shWhat it does: init the design submodule → download the linux-amd64 ollama
flavour → build the Open-WebUI wheel + prefetch offline assets → create a
nix-native venv with Open-WebUI → build Tailwind CSS → stage into dist/ →
launch the dashboard.
UI-only iteration (after make dev once):
cd app && npm start # electron with hot-ish reload of renderer
npm run css:watch # rebuild tailwind on design/markup changesThe dev launcher is scripts/run-nixos.sh (staging + nixpkgs electron). Models
and data default to app/.run/ in dev; override with PLANAI_PORTABLE_ROOT.
Run the REAL bundled launcher (mounted components, control plane, update flow)
with one locally-built piece swapped in via --with-* flags (each feeds
an env override: PLANAI_APP_DIR / PLANAI_SPA_DIR / PLANAI_USBD_BIN).
Needs a built bundle once (make components bundle TARGET=linux-x64).
make dev-spa # build the SPA (scripts/build-spa.sh) + launch with --with-spa
make dev-electron # launch with --with-electron app (needs: cd app && npm i)
make dev-usbd # cargo build usbd + launch with --with-usbd
# or by hand, mixing overrides:
dist/bundle/plan-ai.linux-x64.exe --with-spa launcher/spa --with-usbd usbd/target/release/usbdOther inner loops:
make ui # SPA against the mock backend (dx hot reload + mock API)
cd usbd && cargo build # the USB daemon alone (run: target/debug/usbd usbd --offline)
nix develop .#usbd-win # cross-compile usbd for windows (cargo-zigbuild shell)
nix develop .#usbd-mac # …and for mac (Apple SDK via SDKROOT)
scripts/build-spa.sh # SPA via nix (reproducible) into launcher/spa/The usbd daemon's heavy machinery comes from mac-mgmt-agent in the
third_party/mac-mgmt submodule; its reduced config types live in
crates/usb-config (shared with the mock-server). Editing either rebuilds
with plain cargo build in usbd/.
Each artifact = the Electron app + the chosen ollama flavour + a relocatable python runtime for the target, packaged as a single file.
nix develop
make download # ollama (all flavours) + open-webui source
make wheel # open-webui frontend + wheel + offline assets
# per target (TARGET = linux-x64 | win-x64 | mac-arm64 | mac-x64):
make runtime TARGET=linux-x64 # python-build-standalone + cross-installed deps
make app # npm ci + tailwind
make bundle TARGET=linux-x64 # → dist/bundle/plan-ai-<ver>-linux-x64.AppImageOr the whole linux pipeline at once: make all (defaults to host target).
Outputs in dist/bundle/:
| TARGET | artifact | packaged with |
|---|---|---|
linux-x64 |
plan-ai-<ver>-linux-x64.AppImage |
electron-builder |
win-x64 |
plan-ai-<ver>-win-x64.zip |
electron-builder (zip) |
mac-arm64 / mac-x64 |
plan-ai-<ver>-mac-<arch>.zip (signed .app) |
@electron/packager + rcodesign |
Minimal builds: fetch just one ollama flavour, e.g.
./scripts/download-ollama.sh ollama-linux-amd64.tar.zst. Pick a GPU flavour for
a target with ./scripts/bundle.sh linux-x64 --flavour ollama-linux-amd64-rocm.tar.zst.
Collect the per-platform artifacts + models into one FAT32 image with all three
bundles at the root (so each binary finds the shared models/ + data/):
make bundle TARGET=linux-x64
make bundle TARGET=win-x64
make bundle TARGET=mac-arm64
make image # → dist/plan-ai-usb.img (FAT32, no root needed)
sudo dd if=dist/plan-ai-usb.img of=/dev/sdX bs=4M status=progress conv=fsyncFilesystem choice (--fs fat32|exfat|auto, default fat32):
-
FAT32 (default) — universal (reads everywhere). The linux AppImage exceeds FAT32's 4 GiB/file limit, so it is auto-split into
<name>.AppImage.partNN(<4 GiB each) plus a<name>.run.shlauncher that reassembles + verifies (sha256) + runs it:make image # FS=fat32 (default); splits the AppImage # on the stick: ./plan-ai-<ver>-linux-x86_64.run.sh (joins parts -> ~/.cache, launches)
Note: reassembly needs ~5.3 GiB free off-USB (
~/.cache/plan-ai, override withPLANAI_CACHE) since FAT32 can't hold the whole file. Split a standalone AppImage yourself with./scripts/split-appimage.sh. -
exFAT (
make image FS=exfat) — stores the AppImage whole; it runs directly (no reassembly, no off-USB scratch), via a loop mount (needs sudo). Less universal than FAT32.
# add to usb.lock: "models": ["smollm2:1.7b", "qwen2.5:7b"]
make seed # pulls them into ./models (shared on the USB)- macOS —
bundle.sh mac-*signs the.appwithrcodesign. Ad-hoc by default; for a real identity:MAC_P12=cert.p12 MAC_P12_PASS=… ./scripts/bundle.sh mac-arm64. - Windows — the
ziptarget is unsigned. To Authenticode-sign the innerplan.ai.exe, sign withosslsigncode(provideWIN_PFX/WIN_PFX_PASS); the hook inbundle.shcovers the exe-target case.
make test # lint + open_webui import + live ollama/open-webui /health (NixOS)
make test-usb # FAT32 loop image: models/data on FAT32, launch, screenshot (needs sudo)
make test-vm # run the AppImage in an Ubuntu 26.04 incus VM (needs KVM)
make test-clean # wipe build outputs and rebuild + verify (TARGET=linux-x64)
make test-clean -- --full # also wipe the vendor/ download cacheAll are runnable on the NixOS dev box (sudo for loop mount; KVM + incus for the
VM). They produce screenshots under /tmp/*.png.
.gitlab-ci.yml runs every job in the nix devshell:
lint→build:linux/build:win/build:mac(matrix) →usb-imagetest:usb-fat32andtest:ubuntu-vmneed a privileged/KVM runner (tagged).
Windows/macOS builds run from the Linux runner (cross). For nsis/dmg installers or notarization, add native Windows/macOS runners.
| target | builds on NixOS | verified runnable | notes |
|---|---|---|---|
| linux-x64 (AppImage) | ✅ | ✅ NixOS dev + Ubuntu 26.04 VM | the reference path |
| win-x64 (zip) | ✅ | extract + run plan.ai.exe; nsis/portable need a real wine prefix or Windows runner |
|
| mac-arm64 / mac-x64 (.app.zip) | ✅ | rcodesign ad-hoc; .dmg + notarization need macOS |
| script | purpose |
|---|---|
lib.sh |
shared: usb.lock accessors, GitHub API, sha256, pbs/triple maps |
download-ollama.sh [names…] |
fetch ollama flavours (filter by substring), verify, manifest |
download-openwebui.sh |
fetch + verify open-webui source |
build-openwebui.sh |
npm build + uv build wheel + prefetch embedding/nltk assets |
make-runtime.sh [target] |
python-build-standalone + cross-installed Open-WebUI |
bundle.sh [target] [--flavour] |
package the single-file artifact (+ codesign) |
make-usb-image.sh [out] [--fs auto|fat32|exfat] |
USB image with all bundles + models (fat32 auto-splits the AppImage) |
split-appimage.sh [file] [--chunk-mb N] |
split a >4 GiB artifact into FAT32-sized parts + a reassembly launcher |
seed-models.sh [dir] |
pre-pull usb.lock models |
dev.sh / run-nixos.sh |
NixOS dev build + launcher |
test-*.sh |
build/health, FAT32, ubuntu-vm, clean-build tests |
NixOS can't run generic FHS binaries (bare nix-ld stub), which shapes several choices:
- electron-builder's prebuilt helpers (
mksquashfs,appimagetool,makensis) arepatchelf'd to the nix loader at pack time (the embedded AppImage runtime is left generic);USE_SYSTEM_7ZAuses the nix 7za. - The prod python runtime is cross-installed (never executed at build).
- The dev stack passes nix libs to child processes via
PLANAI_CHILD_LD_LIBRARY_PATHonly — never to electron (globalLD_LIBRARY_PATHSIGILLs nixpkgs electron). - Windows
portable/nsistargets execute the exe under wine (needs a full prefix); theziptarget avoids this.
{ "ollama": { "repo": "ollama/ollama", "version": "v0.30.6" }, "openwebui": { "repo": "open-webui/open-webui", "version": "v0.9.6" }, "python": "3.12.13", // pinned CPython (nix + python-build-standalone) "pbs_release": "20260602", // python-build-standalone release tag "targets": ["linux-x64", "win-x64", "mac-arm64", "mac-x64"], "models": ["smollm2:1.7b"] // pre-seeded onto the USB }