# EnginAIer — Deployment Architecture (internal)

This document explains **what we host** versus **what runs on a customer machine**. End users must never install MongoDB, Node.js, or run a server.

---

## Responsibility split

| Layer | Runs where | Provided by | User sees it? |
|-------|-----------|-------------|---------------|
| MongoDB (accounts, sessions, usage, billing) | Our cloud (MongoDB Atlas) | **Us** | No |
| Backend API (`index.js`, `server/*`) — auth, chat, Anthropic calls, Stripe, ACC OAuth | Our cloud (VM / container) | **Us** | No |
| Anthropic API key | Server-side env only | **Us** | No |
| Desktop app (Electron shell + renderer) | Customer PC | **Us** (installer) | Yes — the app |
| Node runtime | Bundled inside Electron | **Us** (installer) | No |
| MCP bridge servers (`civil3d/mcp/*/dist`, `mcp/*/dist`) | Customer PC, inside the app bundle | **Us** (installer) | No |
| CAD plugins (`.dll`, `.rhp`, `.gha`) | Customer PC | **Us** (installer / plugin download) | Yes — install cards |
| CAD software (Civil 3D, Revit, …) | Customer PC | **Customer** | Yes |

### Why MCP bridges must stay local

They speak to Civil 3D / Revit over **Windows named pipes** (`\\.\pipe\EnginAIer_C3D`). A pipe cannot cross machines, so the bridge process has to run beside the CAD application. It ships inside the installer, so this is invisible to the user.

### Why the same code runs in two places

`server/mcp-pool.js` **spawns** the MCP bridges, and it is part of the backend. A purely hosted backend therefore cannot reach a customer's named pipes. The resolution is that the backend runs in both places, in two different modes:

| | Local connector (customer PC) | Cloud (our host) |
|---|---|---|
| Anthropic key | **none** | yes |
| Runs the agent loop | yes | — |
| Executes tool calls against CAD | yes, over named pipes | never |
| Authenticates the user | no — delegates | yes |
| Checks credit and records usage | no — delegates | yes |
| Mode flag | `ENGAIIER_CLOUD_API_URL` set → `proxy` | unset → `direct` |

The connector keeps the agent loop local so tool calls never cross the network. Only model turns leave the machine, and they go to `POST /api/llm/v1/messages` on the cloud, which is the single place that holds the key and meters usage.

**Metering is not client-reported.** `server/llm-proxy.js` bills from the token counts in Anthropic's own response, so a tampered client cannot under-report what it consumed. Credit is checked **once per model turn**, never per tool call, so a long tool-heavy answer costs one extra round trip in total.

### Measured latency cost

```
node scripts/test-llm-proxy.js
```

With both instances on one machine the relay adds no measurable overhead (within run-to-run noise on a ~1.3s model turn). In production the added cost is one network hop to our host, roughly 20–60ms per model turn, against a turn that already takes a second or more. Tool calls are unchanged.

### Why Node.js is not a user requirement

Electron embeds Node. MCP bridges are spawned with the app's own executable in Node mode:

```
process.execPath  +  ELECTRON_RUN_AS_NODE=1
```

See `resolveNodeCommand()` and `mcpSpawnEnv()` in `server/mcp-pool.js`.

---

## The two setups

`desktop/config.js` resolves a mode, and everything else follows from it.

| | `dev` | `customer` |
|---|---|---|
| Chosen when | running from the repo (`electron .`) | packaged build |
| Connector port | `PORT` from `.env` | `43110` |
| Cloud API | none | `CLOUD_API_URL` |
| Anthropic key | developer's, from `.env` | none on the machine |
| Billing | not exercised | full path |

Force either one with `ENGAIIER_MODE=dev|customer`.

### Developer workflows

There are two, and they are deliberately different:

1. **Cursor → MCP → CAD.** Straight to the plugins over named pipes. No backend, no auth, no billing. This is for building and testing tools and is unaffected by everything above.
2. **The app.** Runs the full stack. By default in `dev` mode, which uses your own key and skips billing.

To verify what a customer will actually hit, including credit checks and metering, run the app against the customer path:

```powershell
$env:ENGAIIER_MODE = "customer"
$env:ENGAIIER_CLOUD_API_URL = "https://api.enginaier.com"   # or a staging host
cd desktop; npm start
```

> **Before the first public release:** set `CLOUD_API_URL` in `desktop/config.js` to the real production API domain.

### Testing the customer path locally

Run one instance as the cloud and one as the connector, then exercise the relay:

```powershell
# terminal 1 — acts as the cloud (has the key, has Mongo)
$env:PORT="4300"; node index.js

# terminal 2 — acts as the customer's connector (no key)
$env:PORT="4301"; $env:ENGAIIER_CLOUD_API_URL="http://127.0.0.1:4300"; node index.js

# terminal 3 — sign in, chat through the connector, confirm the cloud metered it
node scripts/test-llm-proxy.js
```

The test account needs credit, so either top it up or add its address to `ADMIN_EMAILS` on both instances.

---

## Plugin auto-load registration

Installing a plugin writes it into the folder the host application scans on startup, so the user never runs `NETLOAD` or `APPLOAD`.

| Platform | Target | Mechanism |
|----------|--------|-----------|
| Civil 3D / AutoCAD | `%APPDATA%\Autodesk\ApplicationPlugins\EnginAIer.C3D.<year>.bundle\` | Generated `PackageContents.xml` with `LoadOnAutoCADStartup="True"`; `SeriesMin`/`SeriesMax` pin each bundle to one release so parallel year installs do not double-load |
| Revit | `%APPDATA%\Autodesk\Revit\Addins\<year>\` | The payload's `.addin` is copied to the Addins root with its `<Assembly>` paths repointed at the `EnginAIer\` subfolder |
| Navisworks | `%APPDATA%\Autodesk Navisworks Manage <year>\Plugins\<AssemblyName>\` | Folder name must equal the assembly name. The roaming root is one folder (`Autodesk Navisworks Manage <year>`), not `Autodesk\Navisworks Manage <year>` |
| Grasshopper | `%APPDATA%\Grasshopper\Libraries\EnginAIer\` | `.gha` is loaded from Libraries |
| Rhino | `%APPDATA%\McNeel\Rhinoceros\packages\<ver>\EnginAIer\<version>\` | Yak package layout |
| Dynamo | `<Dynamo Revit\|Dynamo Core>\<ver>\packages\EnginAIer\` | `bin/` plus `pkg.json` (the build's own `pkg.json` is preferred) |
| Tekla | `%LOCALAPPDATA%\Trimble\Tekla Structures\<ver>.0\Extensions\EnginAIer\` | Extensions folder |
| OpenRoads | `%LOCALAPPDATA%\EnginAIer\ord\UnifiedMcp\` | ORD resolves add-ins from configuration variables, not a drop folder, so the payload goes to a folder we own and `<ORD>\config\appl\EnginAIerOrdUnifiedMcp.cfg` points `MS_ADDINPATH` and `MS_DGNAPPS` at it. That folder is under Program Files, so registration needs elevation once — it is the only place ORD honours `MS_DGNAPPS`. The Organization `standards.cfg` is never included by the shipped configuration, and `Personal.ucf` is read but ignores `MS_DGNAPPS`. Add-ins load on DGN open, not at launch |

Code: `desktop/installer/autoload-targets.js` (where), `payload-source.js` (what), `plugin-manager.js` (install/uninstall).

The Civil 3D payload includes `automation-latest.txt` **and** the one `automation-<stamp>/` folder it names — the shell resolves that marker relative to its own directory. Historical timestamped DLLs in `deploy/latest` are filtered out so the host never loads duplicate assemblies.

Revit and Navisworks each ship a companion handler assembly in a subfolder whose name the stub resolves at run time: `handler/` for Revit (`HandlerHost.ResolveHandlerDir()` → `{stubDir}\handler`) and `Dependencies/` for Navisworks (`{stubDir}\Dependencies`, also the probe path for `PluginAssemblyResolver`). Shipping the stub without that folder produces a deceptive failure: `ping` is answered inline by the pipe listener so the app reports "connected", while every real action returns `Handler not loaded`. `plugin-manager.js` now attaches an install note when the folder is absent instead of letting it pass silently.

The OpenRoads payload carries a fixed `automation/` subfolder instead. Its shell loads that assembly from bytes rather than by path, so the file is never locked and there is only ever one copy to ship.

Every install also keeps a reference copy in `%LOCALAPPDATA%\EnginAIer\plugins\<pluginId>\`. Uninstall removes the owned auto-load folders, every tracked file, and the store copy, then prunes empty parents.

### Verifying without touching real folders

```powershell
node scripts/test-autoload-plan.js                 # dry run: payload + target paths for all plugins
node scripts/test-autoload-install.js civil3d-2025  # install into a temp APPDATA, print tree, uninstall
```

---

## Building the installer

```powershell
# 1. Build MCP bridges (must run before packaging — dist/ is bundled)
npm run build:mcps

# 2. Build CAD plugins
powershell -File civil3d/build.ps1
powershell -File civil3d/build-automation.ps1

# 3. Package the desktop app (stages plugin payloads, then runs electron-builder)
cd desktop
npm install
npm run dist
```

Output: `dist-desktop/EnginAIer-Setup-<version>.exe`

`npm run dist` first runs `scripts/stage-plugin-payloads.js`. For Civil 3D, Revit and Navisworks it stages **one payload per year** into `dist-payloads/<platform>-<year>/`, because those artifacts are not interchangeable across years; every other product still gets a single `dist-payloads/<platform>/`. `electron-builder` ships that as `resources/plugin-payloads/`, and `payload-source.js` prefers it over repo `deploy/` folders at runtime.

<!-- verified-support:begin deployment-years -->
Do not ship a year to customers that `docs/verified-support.json` does not mark `verified`. Current state: **Autodesk Civil 3D** — no verified year; **Autodesk Revit** — no verified year; **Autodesk Navisworks Manage** — no verified year. Navisworks is **Manage** edition only. See `docs/PLUGIN-YEAR-MATRIX.md` for the per-year evidence, and for why each year needs its own build.
<!-- verified-support:end deployment-years -->

### Packaging checklist

- [ ] `CLOUD_API_URL` points at production
- [ ] Connector delegates auth/credit to the cloud (no local Mongo needed)
- [ ] `npm run build:mcps` succeeded (9 packages)
- [ ] Plugin `deploy/<year>/latest/` folders contain current DLLs for every year you ship
- [ ] `node scripts/stage-plugin-payloads.js` reports no missing platforms **and no per-year warnings** (a warning means a year-scoped product fell back to the unversioned tree, or shipped without its handler folder)
- [ ] Code-signing certificate configured (avoids SmartScreen warning)
- [ ] Auto-update feed configured (`electron-updater`)

---

## Server deployment (our side)

Required environment variables on the host:

| Variable | Purpose |
|----------|---------|
| `PORT` | HTTP port behind the reverse proxy |
| `MONGODB_URI` | Atlas connection string |
| `MONGODB_DB` | Database name (`enginaier`) |
| `ANTHROPIC_API_KEY` | AI chat |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | Billing |
| `APS_CLIENT_ID`, `APS_CLIENT_SECRET`, `APS_CALLBACK_URL` | Autodesk ACC OAuth |
| `APP_BASE_URL` | Public base URL for callbacks |
| `ADMIN_EMAILS` | Unlimited-usage accounts |

Never ship these values inside the desktop installer.

---

## Remaining work to reach a true zero-dependency install

| Item | Status |
|------|--------|
| Hosted-backend URL resolution + no local spawn | **Done** (`desktop/config.js`, `backend-manager.js`) |
| Node bundled via Electron for MCP spawning | **Done** (`server/mcp-pool.js`) |
| `electron-builder` NSIS installer config | **Done** (`desktop/package.json`) |
| Resolve MCP paths from `process.resourcesPath` when packaged | **Done** (`server/app-paths.js`) |
| Auto-load plugins into CAD products — no `NETLOAD` | **Done** (`desktop/installer/autoload-targets.js`) |
| Ship plugin payloads inside the installer | **Done** (`scripts/stage-plugin-payloads.js` → `resources/plugin-payloads`) |
| Local connector runs the agent loop; cloud holds the key and meters usage | **Done** (`server/llm-client.js`, `server/llm-proxy.js`) |
| Connector bundled into the installer | **Done** (`desktop/package.json` extraResources) |
| Connector delegates auth + credit to the cloud instead of needing local Mongo | **Blocking for a real customer install** — the chat route still authenticates locally |
| Deploy backend + Atlas to production host | Pending |
| Code signing + auto-update | Pending |
| Serve plugin payloads from a signed download endpoint (updates between app releases) | Pending — `payload-source.js` has the lookup seam |
