Packaging a Python AI Service Inside an Electron App
Packaging a Python AI Service Inside an Electron App
How to bundle a FastAPI-based AI backend into a standalone Electron desktop app using PyInstaller and electron-builder.
Step 1: Package the Python Service with PyInstaller
Bundle your FastAPI Python service into a standalone executable.
- Identify the correct entry point (e.g.,
uvicorn main:app) - Audit dependencies — PyInstaller often misses hidden imports (
--hidden-import) - Include non-Python data files (model weights, prompt templates) via
--add-data - Test the resulting binary in isolation before integrating with Electron
Watch out for: hidden imports, missing data files, and native C-extension libraries that PyInstaller doesn't auto-detect.
Step 2: Modify the Electron Main Process
Update electron/main.ts to spawn the PyInstaller binary in production while keeping the raw python command for development.
import { app } from "electron";
import { spawn } from "child_process";
import path from "path";
function startBackend() {
const isProd = app.isPackaged;
if (isProd) {
// Packaged: run the PyInstaller executable
const binaryPath = path.join(process.resourcesPath, "backend", "aiservice.exe");
return spawn(binaryPath, [], { stdio: "pipe" });
} else {
// Development: run Python directly
return spawn("python", ["-m", "uvicorn", "main:app", "--port", "8000"], {
cwd: path.join(__dirname, "..", "aiservice"),
stdio: "pipe",
});
}
}
Step 3: Configure electron-builder
Add electron-builder as a dev dependency and configure it in package.json:
// package.json
{
"build": {
"appId": "com.yourapp.id",
"files": [
"dist-electron/**/*",
"frontend/out/**/*"
],
"extraResources": [
{ "from": "backend/dist/aiservice", "to": "backend" },
{ "from": "models/", "to": "models" }
],
"win": {
"target": "nsis"
}
}
}
Key bundles: dist-electron/, frontend/out/, the PyInstaller binary, and AI model files.
Step 4: Test the Build
- Run
npx electron-builder --win - Install the output
.exeon your own machine as a regular user would - Verify the full stack works end-to-end:
- Frontend loads
- Backend starts and is reachable
- AI service responds to requests
- Model files are accessible
Step 1: Path Resolution Module (paths.py)
When the app is packaged, file paths change completely. This utility module handles both execution modes transparently.
| Mode | Read-only data (prompts, models) | Writable user data (DB, config) |
|---|---|---|
| Development | aiservice/ directory on disk |
Same as app root |
| Frozen (PyInstaller) | sys._MEIPASS (temp extraction folder) |
OS app-data directory |
"""
Central path resolution for the AI Service.
Handles two execution modes:
- Development: paths resolve relative to the aiservice/ directory on disk
- Frozen (PyInstaller): bundled read-only data resolves via sys._MEIPASS,
while user-generated data resolves to the OS app-data directory
"""
import os
import sys
import platform
from pathlib import Path
def is_frozen() -> bool:
"""Check if running as a PyInstaller bundle."""
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
def get_app_root() -> Path:
"""
Root for bundled, read-only application data.
Used for: prompts/, models/, jre/, Guides/
- Dev: aiservice/ directory
- Frozen: sys._MEIPASS (PyInstaller extraction folder)
"""
if is_frozen():
return Path(sys._MEIPASS)
return Path(__file__).parent.parent
def get_user_data_root() -> Path:
"""
Root for writable, persistent user data that survives app updates.
Used for: data/chroma/, .env.user, .env.defaults
- Dev: aiservice/ directory (same as app root)
- Frozen: OS-specific app data folder
Creates the directory if it doesn't exist.
"""
if is_frozen():
system = platform.system()
if system == "Windows":
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
elif system == "Darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
user_dir = base / "EPIC"
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir
return Path(__file__).parent.parent
def get_project_root() -> Path:
"""
Project root directory (parent of aiservice/).
Used for: Guides/ folder
- Dev: two levels up from this file
- Frozen: same as app root (Guides/ bundled inside)
"""
if is_frozen():
return get_app_root()
return Path(__file__).parent.parent.parent
Step 2: Migrate Existing Files to Use paths.py
Every file that uses Path(__file__).parent.parent to find paths needs to switch to the centralized helpers. The pattern is always the same: remove the manual Path(__file__) chain, import the right function from api.paths, and use it instead.
File 1: aiservice/api/server.py
Imports and path setup (top of file):
# BEFORE
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from dotenv import load_dotenv
AISERVICE_DIR = Path(__file__).parent.parent
load_dotenv(AISERVICE_DIR / ".env.defaults", override=False)
load_dotenv(AISERVICE_DIR / ".env.user", override=True)
# AFTER
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from api.paths import get_app_root, get_user_data_root
APP_ROOT = get_app_root()
USER_DATA = get_user_data_root()
load_dotenv(USER_DATA / ".env.defaults", override=False)
load_dotenv(USER_DATA / ".env.user", override=True)
Engine path config:
# BEFORE
set_prompts_path(AISERVICE_DIR / "prompts")
set_models_dir(AISERVICE_DIR / "models")
set_persist_directory(str(AISERVICE_DIR / "data" / "chroma"))
# AFTER
set_prompts_path(APP_ROOT / "prompts")
set_models_dir(APP_ROOT / "models")
set_persist_directory(str(USER_DATA / "data" / "chroma"))
Lifespan print statements:
# BEFORE
print(f" Prompts: {AISERVICE_DIR / 'prompts'}")
print(f" Models: {AISERVICE_DIR / 'models'}")
print(f" ChromaDB: {AISERVICE_DIR / 'data' / 'chroma'}")
# AFTER
print(f" Prompts: {APP_ROOT / 'prompts'}")
print(f" Models: {APP_ROOT / 'models'}")
print(f" ChromaDB: {USER_DATA / 'data' / 'chroma'}")
File 2: aiservice/api/routes.py
_load_strict_mode_addendum function (~line 290):
# BEFORE
import yaml
from pathlib import Path
aiservice_dir = Path(__file__).parent.parent
prompt_file = aiservice_dir / "prompts" / f"{prompt_name}.yaml"
# AFTER
import yaml
from api.paths import get_app_root
prompt_file = get_app_root() / "prompts" / f"{prompt_name}.yaml"
File 3: aiservice/services/guide_retriever.py
get_guides_path function (~line 146):
# BEFORE
def get_guides_path() -> Path:
"""Get the path to the Guides folder."""
current_dir = Path(__file__).parent
aiservice_dir = current_dir.parent
project_root = aiservice_dir.parent
return project_root / "Guides"
# AFTER
def get_guides_path() -> Path:
"""Get the path to the Guides folder."""
from api.paths import get_project_root
return get_project_root() / "Guides"
File 4: aiservice/services/library_grammar.py
JRE path setup (~line 10):
# BEFORE
import os
from pathlib import Path
_bundled_jre = Path(__file__).parent.parent / "jre"
# AFTER
import os
from api.paths import get_app_root
_bundled_jre = get_app_root() / "jre"
Step 3: Disable Uvicorn Reload When Frozen
PyInstaller bundles compiled .pyc bytecode, not raw .py source files. Uvicorn's reload=True watches for source file changes — in a frozen binary those files don't exist, so it crashes.
File: aiservice/api/main.py
# BEFORE
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"api.server:app",
host="0.0.0.0",
port=8000,
reload=True # Auto-reload on code changes (dev mode)
)
# AFTER
import uvicorn
from api.paths import is_frozen
if __name__ == "__main__":
if is_frozen():
# Frozen mode: import app directly (string import doesn't work in PyInstaller)
from api.server import app
uvicorn.run(app, host="0.0.0.0", port=8000)
else:
# Dev mode: use string import so reload=True works
uvicorn.run("api.server:app", host="0.0.0.0", port=8000, reload=True)
What changed:
- Added
from api.paths import is_frozen - Changed
reload=Truetoreload=not is_frozen()- Dev:
is_frozen()returnsFalse→reload=True(same as before) - Packaged:
is_frozen()returnsTrue→reload=False(prevents crash)
- Dev:
Step 4: Build Script (build.py)
Automates the full PyInstaller build pipeline: creates a clean virtual environment, installs all dependencies (including local packages as non-editable), and runs PyInstaller against the spec file.
Usage: Run from the aiservice/ directory.
python build.py
File: aiservice/build.py
"""
Build script for EPIC AI Service.
Creates a clean virtual environment, installs all dependencies (including
epic-engine as a non-editable package), and runs PyInstaller.
"""
import subprocess
import sys
import shutil
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────
AISERVICE_DIR = Path(__file__).parent
BUILD_ENV_DIR = AISERVICE_DIR / ".build_env"
EPIC_ENGINE_DIR = (AISERVICE_DIR / "../../package/epic_engine").resolve()
SPEC_FILE = AISERVICE_DIR / "aiservice.spec"
# Platform-specific venv python path
if sys.platform == "win32":
VENV_PYTHON = BUILD_ENV_DIR / "Scripts" / "python.exe"
else:
VENV_PYTHON = BUILD_ENV_DIR / "bin" / "python"
# Use python -m pip instead of pip directly (Windows can't overwrite pip.exe while running)
PIP = [str(VENV_PYTHON), "-m", "pip"]
def run(cmd, description):
"""Run a command, print what's happening, and exit on failure."""
print(f"\n{'='*60}")
print(f" {description}")
print(f"{'='*60}")
result = subprocess.run(cmd, cwd=str(AISERVICE_DIR))
if result.returncode != 0:
print(f"\nFAILED: {description}")
sys.exit(1)
def main():
print("EPIC AI Service — Build Script")
print(f" aiservice dir: {AISERVICE_DIR}")
print(f" epic-engine: {EPIC_ENGINE_DIR}")
print(f" build env: {BUILD_ENV_DIR}")
# Verify epic-engine exists
if not EPIC_ENGINE_DIR.exists():
print(f"\nERROR: epic-engine not found at {EPIC_ENGINE_DIR}")
sys.exit(1)
# Verify spec file exists
if not SPEC_FILE.exists():
print(f"\nERROR: PyInstaller spec file not found at {SPEC_FILE}")
print("Create aiservice.spec first (Step 5).")
sys.exit(1)
# Step 1: Create fresh build virtualenv
if BUILD_ENV_DIR.exists():
print(f"\nRemoving old build env: {BUILD_ENV_DIR}")
shutil.rmtree(BUILD_ENV_DIR)
run(
[sys.executable, "-m", "venv", str(BUILD_ENV_DIR)],
"Creating fresh build virtualenv"
)
# Step 2: Upgrade pip
run(
PIP + ["install", "--upgrade", "pip"],
"Upgrading pip"
)
# Step 3: Install aiservice dependencies (from pyproject.toml)
# Uses --no-deps first to avoid pulling epic-engine from the editable source
run(
PIP + ["install", "--no-deps", "."],
"Installing aiservice package (no deps)"
)
# Step 4: Install epic-engine as a regular (non-editable) package
run(
PIP + ["install", str(EPIC_ENGINE_DIR)],
"Installing epic-engine (non-editable)"
)
# Step 5: Install remaining dependencies
run(
PIP + ["install", "."],
"Installing all remaining dependencies"
)
# Step 6: Install PyInstaller
run(
PIP + ["install", "pyinstaller"],
"Installing PyInstaller"
)
# Step 7: Run PyInstaller with the spec file
run(
[str(VENV_PYTHON), "-m", "PyInstaller", str(SPEC_FILE), "--noconfirm"],
"Running PyInstaller"
)
print(f"\n{'='*60}")
print(" BUILD COMPLETE")
print(f"{'='*60}")
print(f"Output: {AISERVICE_DIR / 'dist' / 'aiservice'}")
print("Test it by running: dist/aiservice/aiservice.exe")
if __name__ == "__main__":
main()
Don't forget: add to .gitignore
# PyInstaller build env
aiservice/.build_env/
Step 5: PyInstaller Spec File (aiservice/aiservice.spec)
The spec file tells PyInstaller exactly what to bundle: entry point, data files (prompts, models, JRE, guides), and all the hidden imports that dynamic loading makes invisible to the tracer.
Usage: Run via build.py (recommended) or directly:
pyinstaller aiservice.spec --noconfirm
File: aiservice/aiservice.spec
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec file for EPIC AI Service.
Run via build.py (recommended) or directly:
pyinstaller aiservice.spec --noconfirm
"""
import sys
from pathlib import Path
# ── Paths ──────────────────────────────────────────────────────
AISERVICE_DIR = Path(SPECPATH)
PROJECT_ROOT = AISERVICE_DIR.parent
GUIDES_DIR = PROJECT_ROOT / "Guides"
# ── Analysis — trace imports from entry point ──────────────────
a = Analysis(
[str(AISERVICE_DIR / "api" / "main.py")],
pathex=[str(AISERVICE_DIR)],
datas=[
(str(AISERVICE_DIR / "prompts"), "prompts"), # Prompt YAML configs
(str(AISERVICE_DIR / "models"), "models"), # Qwen model (~4.6 GB)
(str(AISERVICE_DIR / "jre"), "jre"), # Bundled Java Runtime (LanguageTool)
(str(GUIDES_DIR), "Guides"), # User-facing help guides
],
hiddenimports=[
# FastAPI / Pydantic / Uvicorn internals
"uvicorn.logging",
"uvicorn.loops", "uvicorn.loops.auto", "uvicorn.loops.asyncio",
"uvicorn.protocols",
"uvicorn.protocols.http", "uvicorn.protocols.http.auto",
"uvicorn.protocols.http.h11_impl", "uvicorn.protocols.http.httptools_impl",
"uvicorn.protocols.websockets", "uvicorn.protocols.websockets.auto",
"uvicorn.protocols.websockets.wsproto_impl",
"uvicorn.lifespan", "uvicorn.lifespan.on", "uvicorn.lifespan.off",
"fastapi", "fastapi.middleware", "fastapi.middleware.cors",
"pydantic", "pydantic.deprecated", "pydantic.deprecated.decorator",
# epic-engine submodules
"epic_engine", "epic_engine.rag",
"epic_engine.providers", "epic_engine.providers.local",
"epic_engine.vectors", "epic_engine.prompts", "epic_engine.retrieval",
"epic_engine.core", "epic_engine.core.config", "epic_engine.core.types",
"epic_engine.consistency", "epic_engine.knowledge",
"epic_engine.evaluation", "epic_engine.generation",
"epic_engine.adapters", "epic_engine.agents", "epic_engine.utils",
# LLM providers (dynamically loaded by litellm / epic-engine)
"litellm", "litellm.llms",
"litellm.llms.openai", "litellm.llms.anthropic", "litellm.llms.custom_httpx",
"openai", "anthropic", "google.generativeai",
# llama-cpp-python (local Qwen model)
"llama_cpp",
# ChromaDB
"chromadb", "chromadb.config", "chromadb.api",
"chromadb.db", "chromadb.db.impl", "chromadb.db.impl.sqlite",
# Sentence Transformers / Torch
"sentence_transformers", "torch",
# Tiktoken (compiled tokenizer data)
"tiktoken", "tiktoken_ext", "tiktoken_ext.openai_public",
# Other
"httpx", "dotenv", "yaml", "language_tool_python",
"sklearn", "sklearn.utils", "sklearn.neighbors",
"umap", "numpy", "requests",
],
binaries=[],
noarchive=False,
)
# ── Bundle into a folder (onedir mode) ─────────────────────────
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="aiservice",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # Don't compress — faster startup, avoids AV false positives
console=True, # Show console for logging (hide later if desired)
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="aiservice",
)
Step 6: Build & Test the Python Binary
Preparing the toml file:
If you are using the Flat Layout for python aiservice, modify the toml file to include this:
[tool.setuptools.packages.find]
include = ["api*", "services*", "test_interface*"]
6a. Run the build
cd aiservice/
python build.py
The first run takes a long time — installing torch, sentence-transformers, etc. into the build env, then PyInstaller copying ~5+ GB of files.
6b. Check if the build succeeded
BUILD COMPLETEprinted — binary created atdist/aiservice/aiservice.exe- Fails during virtualenv/install — dependency issue (missing package, incompatible version). Read the pip error and fix it.
- Fails during PyInstaller — usually a missing module or path issue. Read the error and adjust
aiservice.spec.
6c. Test the binary
Test it by running:
dist/aiservice/aiservice.exe
Expected startup sequence:
- A console window opens
- Python interpreter (bundled inside the
.exe) boots up main.pyruns → callsuvicorn.run("api.server:app", ...)server.pyloads → sets up epic-engine paths, loads.envfiles- The lifespan starts → you should see:
Starting AI service...
Prompts: <path>\prompts
Models: <path>\models
ChromaDB: <path>\data\chroma
Loading embedding model...
- Embedding model loads (takes a few seconds)
- Local Qwen model initializes (takes a few more seconds)
- You see:
AI service ready! - Uvicorn prints:
Uvicorn running on http://0.0.0.0:8000
A python popup window might open asking you to allow or cancel. Press cancel, since the electron app is going to read from it.
Now to see if it is working, open http://localhost:8000/ in a browser — you should see {"message": "Epic AI Service is running"}.
More likely on the first try: it crashes somewhere in steps 3–5 with a ModuleNotFoundError or FileNotFoundError. That's the debug loop (see 6e) — read the error, add the missing hidden import or fix the path, rebuild, and try again.
Common outcomes:
| What you see | What it means | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'xyz' |
PyInstaller missed a dynamic import | Add 'xyz' to hiddenimports in aiservice.spec, rebuild |
FileNotFoundError: prompts/ghostwriter.yaml |
Data files not found in bundle | Check the datas section in aiservice.spec |
ERROR: JAVA_HOME is not set |
JRE path not resolving in frozen mode | Check that paths.py → get_app_root() finds the bundled jre/ |
| Server starts, prints AI service ready! | It works | Move to endpoint testing |
6d. Test endpoints
Once the server starts, verify each layer:
http://localhost:8000/— Should return{"message": "Epic AI Service is running"}http://localhost:8000/api/health— API health checkhttp://localhost:8000/api/grammar/health— Grammar service (tests JRE)- Try a generation or RAG call from your frontend (
npm run devin a separate terminal) — it will hit the binary since it's on the same port
6e. The debug loop
Realistically, you'll cycle through this several times:
- Run the binary → crashes
- Read the error
- Add missing hidden import to
aiservice.specOR fix a path issue - Run
python build.pyagain (faster after the first time since the virtualenv already exists — consider modifyingbuild.pyto skip recreating it) - Repeat
Tip: The first few errors are usually obvious missing modules. The last few can be tricky (deeply nested dynamic imports inside libraries like chromadb or sentence-transformers). Stack Overflow and PyInstaller's GitHub issues are your friends for those.
6f. Done when
aiservice.exestarts without errorshttp://localhost:8000/responds- Grammar check works (proves JRE bundling works)
- A generation call works (proves model + epic-engine work)
- ChromaDB data gets written to
%APPDATA%/EPIC/data/chroma/(not inside thedist/folder)