This document covers building Outpost from source: project structure, the three ways to run it, packaging the backend as a standalone executable, wiring it into Tauri, and debugging the issues you're most likely to hit.
outpost/
├── README.md
├── DEVELOPMENT.md ← you are here
├── LICENSE
├── .gitignore
├── outpost-backend.spec ← PyInstaller build spec
│
├── backend/
│ ├── main.py ← PyInstaller entry point (imports app directly)
│ ├── server.py ← FastAPI app, all routes
│ ├── rag.py ← document ingestion + retrieval (ChromaDB)
│ ├── config.py ← persisted settings (~/.outpost/config.json)
│ └── requirements.txt
│
├── cli/
│ └── cli.py ← Typer CLI, talks to the same backend API
│
├── frontend/
│ ├── package.json
│ ├── vite.config.js
│ ├── index.html
│ ├── src/
│ │ ├── App.jsx
│ │ ├── store/ ← Zustand state, all API calls
│ │ ├── pages/
│ │ │ ├── ChatPage.jsx
│ │ │ ├── ModelsPage.jsx
│ │ │ ├── DocumentsPage.jsx
│ │ │ └── SettingsPage.jsx
│ │ └── components/
│ │ └── Sidebar.jsx
│ ├── public/
│ └── src-tauri/
│ ├── tauri.conf.json
│ ├── Cargo.toml
│ ├── binaries/ ← compiled backend exe goes here
│ └── src/main.rs
│
├── scripts/
│ ├── install.sh
│ └── install.ps1
│
├── docs/
│
└── data/
├── documents/ ← uploaded files (gitignored)
├── embeddings/ ← ChromaDB storage (gitignored)
└── models/ (gitignored - models live in Ollama's own store)
| Tool | Check with | Install |
|---|---|---|
| Python 3.10+ | python --version |
python.org |
| Node.js 18+ | node --version |
nodejs.org |
| Rust (stable) | rustc --version |
rustup.rs or winget install Rustlang.Rustup |
| Tauri CLI | cargo tauri --version |
cargo install tauri-cli |
| Ollama | ollama --version |
ollama.com |
On Windows, after installing Rust, close and reopen your terminal before checking the version - PATH won't update in an already-open shell.
Fastest iteration loop. The frontend is just a browser tab, not a real desktop window yet.
# Terminal 1 - backend
python -m uvicorn backend.server:app --host 127.0.0.1 --port 8765
# Terminal 2 - frontend
cd frontend
npm install
npm run devOpens at http://localhost:5173.
Real desktop window, still hot-reloading.
cd frontend
npm install
npm run tauri devThis requires frontend/src-tauri/ to exist. If it doesn't yet:
cd frontend
npm install @tauri-apps/cli @tauri-apps/api
cargo tauri initWhen prompted:
App name: Outpost
Window title: Outpost
Web assets location: ../dist
Dev server URL: http://localhost:5173
Frontend dev command: npm run dev
Frontend build command: npm run build
You still need the backend running in a separate terminal for Method 2 until you wire up the sidecar (see below).
The real deliverable. Backend gets bundled as a native executable and Tauri manages its lifecycle - the user double-clicks one app, nothing else required.
cd frontend
npm run tauri buildProduces a .exe/.msi (Windows), .AppImage/.deb (Linux), or .dmg (Mac) in frontend/src-tauri/target/release/bundle/.
This is the part with the most moving pieces. Here's the full pipeline end to end.
Don't build from your main dev env (conda, etc) - it can pull in extra packages you don't actually need, bloating the binary or causing version mismatches.
python -m venv .venv-build
# Windows
.venv-build\Scripts\activate
# Linux/Mac
source .venv-build/bin/activate
pip install -r backend/requirements.txt
pip install pyinstallerpython -m uvicorn backend.server:app --host 127.0.0.1 --port 8765Confirm chat and RAG work before bundling - if something's broken here, PyInstaller will just bundle the same broken thing.
uvicorn.run("backend.server:app") works fine when Python can resolve the module string at runtime. It fails inside a frozen PyInstaller binary because there's no real package structure on disk anymore - you'll see:
ModuleNotFoundError: No module named 'backend'
The fix is to import the app object directly instead of passing a string:
# backend/main.py
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import uvicorn
from backend.server import app
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8765)ChromaDB (used for RAG) relies heavily on dynamic imports that PyInstaller's static analyzer can't trace automatically. Rather than chasing missing-module errors one at a time, use collect_all() plus an explicit hidden-imports list:
# outpost-backend.spec
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
chroma_datas, chroma_binaries, chroma_hiddenimports = collect_all('chromadb')
a = Analysis(
['backend\\main.py'],
pathex=['.'],
binaries=chroma_binaries,
datas=chroma_datas,
hiddenimports=chroma_hiddenimports + [
"chromadb.telemetry.product.posthog",
"chromadb.telemetry.product",
"chromadb.telemetry",
"chromadb.api.segment",
"chromadb.segment.impl.manager.local",
"chromadb.segment.impl.vector.local_persistent_hnsw",
"chromadb.segment.impl.metadata.sqlite",
"chromadb.db.impl.sqlite",
"anyio._backends._asyncio",
"uvicorn.loops.asyncio",
"uvicorn.protocols.http.auto",
"uvicorn.protocols.websockets.auto",
"uvicorn.lifespan.on",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='outpost-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)Build with:
pyinstaller outpost-backend.specSome warnings are expected and harmless:
ERROR: Hidden import 'posthog' not found ← fine, telemetry stub
WARNING: Hidden import "tzdata" not found ← fine, timezone fallback
WARNING: Hidden import "_cffi_backend" not found ← fine
What matters is the final line:
INFO: Build complete! The results are available in: .../dist
.\dist\outpost-backend.exeYou're looking for:
INFO: Started server process [...]
[RAG] ChromaDB ready at C:\Users\you\.outpost\chromadb, collection 'outpost_docs'
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8765
If you instead see [RAG] ... disabled, a hidden import is still missing - check the spec's hiddenimports list against whatever module the traceback names.
Tauri's sidecar mechanism expects the binary name to include the target triple. Find yours:
rustc -vVLook for the host: line, e.g. x86_64-pc-windows-msvc.
Copy and rename the built exe accordingly:
# Windows example
copy .\dist\outpost-backend.exe .\frontend\src-tauri\binaries\outpost-backend-x86_64-pc-windows-msvc.exeIn frontend/src-tauri/tauri.conf.json, reference it under bundle.externalBin without the target suffix or extension - Tauri appends that automatically per-platform:
{
"bundle": {
"externalBin": ["binaries/outpost-backend"]
}
}Then start/stop it from Rust using the shell plugin's sidecar API (in src-tauri/src/main.rs), so the backend launches when the app opens and is killed when the app closes.
Once wired, run Method 2 again without manually starting the backend - Tauri should spawn it for you.
cd frontend
npm run tauri dev| You changed... | You need to... |
|---|---|
backend/*.py |
Rebuild with PyInstaller, recopy exe into src-tauri/binaries/ |
cli/cli.py |
Nothing - runs directly via python cli/cli.py, no rebuild |
frontend/src/** |
Nothing - Vite hot-reloads automatically in tauri dev |
frontend/src-tauri/tauri.conf.json |
Restart tauri dev |
outpost-backend.spec |
Rebuild with PyInstaller |
Backend exe won't die between runs (port already in use):
# Windows
taskkill /IM outpost-backend.exe /F
# Linux/Mac
pkill -f outpost-backendnpm run tauri dev fails with "Missing script: tauri":
Your package.json is missing the script and Tauri deps. Add:
"scripts": { "tauri": "tauri" },
"devDependencies": { "@tauri-apps/cli": "^2" },
"dependencies": { "@tauri-apps/api": "^2" }ModuleNotFoundError: No module named 'backend' when running the exe:
You're calling uvicorn.run("backend.server:app") (string form) instead of importing the app object directly. See the main.py fix above.
[RAG] ChromaDB failed: No module named 'chromadb.telemetry...':
Missing hidden import. Add it to the spec's hiddenimports list, or use collect_all('chromadb') to grab everything at once, then rebuild.
CLI picks an embedding model for chat instead of a real chat model: Filter embedding models out of auto-selection:
chat_models = [m for m in models if "embed" not in m["name"].lower()]Document size always shows "-" in docs list:
The size string computed at ingest time isn't being persisted into the metadata that list_documents reads back from ChromaDB. Store it in each chunk's metadata at ingest:
metadatas = [
{"doc_id": doc_id, "filename": filename, "chunk_idx": i, "size": size_str}
for i in range(len(chunks))
]And read it back in list_documents:
docs[doc_id] = {"id": doc_id, "name": m.get("filename"), "chunks": 0, "size": m.get("size", "-")}Backend works in dev but RAG says "disabled" after PyInstaller build:
Test the bare exe directly first (./dist/outpost-backend.exe) before blaming Tauri - isolates whether it's a packaging issue or a sidecar wiring issue.
Run through this after any significant change:
Chat
- Multi-turn conversation retains context
- Streaming renders smoothly, no stutter
- New chat / switch chat / delete chat all work
- Behaves sanely with no model selected
Models
- Pull a model from the UI, progress bar updates
- Switch models mid-conversation
- Custom model name field works
- Delete a model
RAG / Documents
- Upload CSV, PDF, Markdown - all chunk successfully
- Ask a question that requires the uploaded data - answer reflects it
- Delete a document - it no longer affects future answers
-
docs listshows correct chunk count and size
CLI
python cli/cli.py status
python cli/cli.py models list
python cli/cli.py models pull llama3.2:3b
python cli/cli.py chat
python cli/cli.py docs add ./somefile.csv
python cli/cli.py docs list
python cli/cli.py docs delete <id>Packaged build
-
outpost-backend.exeruns standalone, RAG initializes without errors -
npm run tauri devstarts the backend automatically (no manual terminal) -
npm run tauri buildproduces an installer - Fresh machine (or VM) with no Python installed can run the installed app