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

  1. Run npx electron-builder --win
  2. Install the output .exe on your own machine as a regular user would
  3. 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=True to reload=not is_frozen()
    • Dev: is_frozen() returns Falsereload=True (same as before)
    • Packaged: is_frozen() returns Truereload=False (prevents crash)

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 COMPLETE printed — binary created at dist/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:

  1. A console window opens
  2. Python interpreter (bundled inside the .exe) boots up
  3. main.py runs → calls uvicorn.run("api.server:app", ...)
  4. server.py loads → sets up epic-engine paths, loads .env files
  5. The lifespan starts → you should see:
Starting AI service...
  Prompts: <path>\prompts
  Models: <path>\models
  ChromaDB: <path>\data\chroma
Loading embedding model...
  1. Embedding model loads (takes a few seconds)
  2. Local Qwen model initializes (takes a few more seconds)
  3. You see: AI service ready!
  4. 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.pyget_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 check
  • http://localhost:8000/api/grammar/health — Grammar service (tests JRE)
  • Try a generation or RAG call from your frontend (npm run dev in 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:

  1. Run the binary → crashes
  2. Read the error
  3. Add missing hidden import to aiservice.spec OR fix a path issue
  4. Run python build.py again (faster after the first time since the virtualenv already exists — consider modifying build.py to skip recreating it)
  5. 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.exe starts without errors
  • http://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 the dist/ folder)