# 2026-07-12 — Wails desktop application **Status:** Planned — not started ## Goal Transform the Oikos control room into a native desktop application using [Wails](https://wails.io), built on top of a clean client/server split. The server (API, MCP, scheduler, notifier, Nomos) stays on the homelab as a long-running service. The client (SPA) is separated from the server binary and deployed independently — any browser talks to the server over HTTP. The Wails app is a thin native client: it loads the same SPA in a webview, configured with the server URL and auth token, and adds system tray, native notifications, auto-start, and auto-update. --- ## Architecture ``` ┌────────────────────────────────────────────────────────────┐ │ Server (homelab, permanent) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ oikos api │ │ oikos sched │ │ oikos notif │ │ │ │ :8090 │ │ (observe) │ │ (Matrix) │ │ │ │ REST + SSE │ └──────────────┘ └──────────────┘ │ │ │ MCP /mcp │ │ │ └──────┬───────┘ ┌──────────────┐ │ │ │ │ nomos serve │ │ │ ├──────────┤ :8092 │ │ │ │ MCP │ /agent/* │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ └────────┬────────┘ │ │ ┌──────▼──────┐ │ │ │ Postgres │ │ │ └─────────────┘ │ │ │ │ Caddy: /api/* → :8090 /agent/* → :8092 /mcp → :8090 │ │ / → static SPA (web/dist/) │ └───────────────────────┬────────────────────────────────────┘ │ HTTPS (bearer auth or OIDC) ┌───────────────┼───────────────┐ │ │ │ ┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ Browser │ │ Wails app │ │ CLI/mobile │ │ (SPA at /) │ │ (SPA in │ │ (future) │ │ │ │ webview) │ │ │ └──────────────┘ └─────────────┘ └─────────────┘ ``` ### Server The existing server roles (`oikos api`, `oikos scheduler`, `oikos notifier`, `nomos serve`, Postgres) run on the homelab mac-mini as systemd services — unchanged. The SPA is no longer embedded in the `oikos` binary; it's a standalone static build served by Caddy at `/`. The API routes (`/api/v1/*`, `/mcp`, `/healthz`) don't conflict with root, and the old root redirect is removed, so no path prefix is needed. ### Clients Any HTTP client that speaks the REST API + bearer auth. The SPA is the canonical client, deployed as static files. The Wails app wraps the same SPA in a native webview. Future clients (CLI, mobile) use the same API. --- ## Stack **Server:** existing Go code in `internal/` — no changes. `cmd/oikos` removes the SPA embed and `/ui/*` routes. Caddy serves `web/dist/` at `/` with SPA fallback. **SPA:** existing Svelte 5 + Vite + Tailwind 4 + shadcn-svelte in `web/`. API base URL and auth token become runtime-configurable. `base: '/'` — no path prefix needed since the SPA is served at root. **Desktop:** Wails v3 (Go + webview). The Wails app is a thin shell: - Embeds the SPA as static assets (Wails's `go:embed`-based asset system) - Reads server URL + token from OS keychain at startup, injects into webview - SPA talks to the remote server over HTTPS — same as the browser - No Go backend, no Postgres connection, no bundled sidecars - Native shell: system tray, notifications, auto-start, auto-update, window persistence --- ## Phase 0 — Client/server split This phase separates the SPA from the `oikos` binary and makes it a standalone client. The Wails app depends on this split being done first. ### 0.1 — Remove SPA embed from the server - **Delete `web/embed.go`** — the server no longer embeds `web/dist/`. - **`cmd/oikos/main.go`** — remove `uiHandler()` (~35 lines). The `httpapi.ListenAndServe()` signature no longer takes a `uiHandler` param; pass `nil` and handle nil in `server.go`. - **`internal/httpapi/server.go`** — remove the `/ui/*` and `/ui` routes (~15 lines at `server.go:175-182`), and the root redirect to `/ui/` (`server.go:183-185`). - **`web/dist/.gitkeep`** — delete (no longer needed to keep backend-only builds green). - **Dockerfile** — remove the node/ui-builder stage and `COPY --from=` of `web/dist/`. The 3-stage Dockerfile (node → go → runtime) becomes a 2-stage build (go → runtime). ~20 lines deleted. ~80 lines deleted. The `oikos api` binary is now API-only: REST, SSE, MCP, healthz. ### 0.2 — Make SPA API base URL configurable and add auth interceptor The SPA currently hardcodes relative paths and has no auth headers: ```ts // web/src/lib/api.ts const BASE = '/agent' const API = '/api/v1' ``` Replace with a runtime-configuration module (`web/src/lib/config.ts`): ```ts // web/src/lib/config.ts interface OikosConfig { apiUrl: string // e.g. "https://oikos.hubris.network" token?: string // bearer token for auth } declare global { interface Window { __OIKOS_CONFIG__?: OikosConfig } } let cfg: OikosConfig | undefined export function initConfig(override?: OikosConfig) { cfg = override ?? window.__OIKOS_CONFIG__ if (cfg?.token) { localStorage.setItem('oikos_token', cfg.token) if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl) } } export function getConfig(): OikosConfig { if (!cfg) { const token = localStorage.getItem('oikos_token') const apiUrl = localStorage.getItem('oikos_api_url') if (token || apiUrl) { cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined } } } return cfg ?? { apiUrl: '' } } export function isConfigured(): boolean { const c = getConfig() return !!c.apiUrl && !!c.token } // Relative paths are used in dev (Vite proxy) and when the SPA shares an // origin with the API server (Caddy reverse proxy). Absolute paths are used // when the API server is on a different origin (Wails webview, remote access). function apiBase(path: string): string { const c = getConfig() if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy return `${c.apiUrl}${path}` } // ---- Auth fetch wrapper ---- // Replaces every raw fetch() call in api.ts. Prepends the API base URL // (absolute when configured, relative when unset for Vite dev proxy) and // adds the Authorization header. async function fetchWithAuth(path: string, opts?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(opts?.headers as Record ?? {}), } const c = getConfig() if (c.token) { headers['Authorization'] = `Bearer ${c.token}` } return fetch(apiBase(path), { ...opts, headers }) } // SSE path builder — EventSource doesn't take headers, so pass the token // as a query parameter (the SSE handler in server.go checks it alongside // the Authorization header). export function sseUrl(path: string): string { const c = getConfig() const url = apiBase(path) if (!c.token) return url const sep = url.includes('?') ? '&' : '?' return `${url}${sep}token=${encodeURIComponent(c.token)}` } // Export for api.ts to use throughout export { fetchWithAuth, apiBase } ``` Then `web/src/lib/api.ts` — replace every `fetch(...)` call with `fetchWithAuth(...)`. Example: ```ts // Before: // const res = await fetch(`${API}/entities?${params}`) // After: import { fetchWithAuth, apiBase } from './config' const API = apiBase('/api/v1') const BASE = apiBase('/agent') // ... const res = await fetchWithAuth(`/api/v1/entities?${params}`) ``` **`web/src/lib/stores/events.ts`** — replace `new EventSource(...)` with `new EventSource(sseUrl(...))`: ```ts import { sseUrl } from '$lib/config' // Before: // source = new EventSource('/api/v1/events/stream') // After: source = new EventSource(sseUrl('/api/v1/events/stream')) ``` **`web/src/lib/stores/chat.ts`** — the chat SSE is POST + ReadableStream via `fetch()`, which already goes through `streamChat` in `api.ts`. When the plan says "replace every fetch(...) call", `streamChat` is included — the POST to `/agent/chat` becomes `fetchWithAuth('/agent/chat', ...)`. **`web/src/lib/stores/context.ts`** — `refreshContext()` calls `fetchDashboardSummary()` and `fetchApprovals()` from `api.ts`. Those already go through `fetchWithAuth`. No change needed here. **`web/src/lib/stores/workspace.ts`** — calls `fetchPlan()` and `fetchQuestions()` from `api.ts`. No change needed. **`web/src/main.ts`** — call `initConfig()` before mounting the app: ```ts import { initConfig, isConfigured } from '$lib/config' initConfig() const app = mount(isConfigured() ? App : Setup, { target: document.getElementById('app')! }) export default app ``` This is the single largest frontend change: ~40 `fetch()` calls spread across `api.ts` (all routes), `stores/events.ts` (EventSource), and the chat stream. Each gets replaced with `fetchWithAuth()` or `sseUrl()`. **`web/vite.config.ts`** — `base: '/'` (remove `/ui/` prefix, since the SPA is served at root after the split). The dev proxy stays — same origin in dev means relative paths work. After 0.4 closes the dev-open auth gate, inject the token via a `configure` hook: ```ts import { svelte } from '@sveltejs/vite-plugin-svelte' import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [tailwindcss(), svelte()], base: '/', resolve: { alias: { $lib: '/src/lib' } }, build: { outDir: 'dist', emptyOutDir: true }, server: { proxy: { '/api': { target: 'http://localhost:8090', configure: (proxy) => { proxy.on('proxyReq', (proxyReq) => { const token = process.env.OIKOS_API_TOKEN if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) }) } }, '/agent': { target: 'http://localhost:8092', rewrite: (path) => path.replace(/^\/agent/, ''), configure: (proxy) => { proxy.on('proxyReq', (proxyReq) => { const token = process.env.OIKOS_API_TOKEN if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) }) } } } } }) ``` ### 0.3 — Add SSE query-param auth and CORS to the API server **SSE auth.** The SSE handler at `/api/v1/events/stream` currently relies on `combinedAuth` middleware for bearer token validation. `EventSource` can't send custom headers, so the SPA passes the token as a query param (`?token=...`). The SSE handler needs to extract and validate it. **`internal/httpapi/sse.go`** — in `serveSSE`, before using the context's actor, check for a query-param token: ```go func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) { // If combinedAuth didn't set an actor (no Authorization header — // EventSource can't send one), try the query param. if GetActor(r.Context()) == nil { token := r.URL.Query().Get("token") if token != "" { validateStaticToken(s.cfg, r, token) } } // ... rest of SSE handler } ``` Extract the static-token validation from `combinedAuth` into a shared helper so both the middleware and the SSE handler use the same logic. **CORS middleware.** Add CORS to the chi router. This is needed for Wails (webview origin differs from the remote server) and local dev (Vite on `:5173` vs server on `:8090`). For the browser production deployment (Caddy serves both SPA and API from the same origin), it's a no-op. **`internal/httpapi/server.go`** — add before the auth middleware: ```go r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{cfg.CORSAllowedOrigin}, AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"}, AllowCredentials: true, MaxAge: 86400, })) ``` **`internal/config/config.go`** — add `CORSAllowedOrigin string`, populated from `OIKOS_CORS_ORIGIN`. Default: `"*"` in dev, the Caddy site URL in prod. ~30 lines added. ### 0.4 — Auth: close the dev-open gate Currently `combinedAuth` opens the gate when `OIKOS_ENV=dev` and no tokens are set (`server.go:228`). After the split, a client from any origin can hit the API — the dev-open path is a security hole. - **Remove the `devOpen` path** from `combinedAuth` — every request must carry a valid bearer token (via `Authorization` header or `?token=` query param for SSE). - **For local dev:** set `OIKOS_API_TOKEN=dev-token` and the SPA reads it from `OIKOS_API_TOKEN` env var (Vite injects it into `window.__OIKOS_CONFIG__` at dev time, and the Vite proxy forwards it). - **Browser (production):** the SPA's `Config.svelte` page accepts a static token (stored in `localStorage`). OIDC login flows are a follow-on milestone. - **Desktop (production):** the Wails app reads the token from the OS keychain and injects it into `window.__OIKOS_CONFIG__` before the webview loads. ### 0.5 — SPA config page (first-launch / setup) The SPA needs a page for entering the server URL and auth token on first launch. This page also serves as the foundation for future OIDC login. New file `web/src/pages/Config.svelte`: - Two fields: "Server URL" (text input) and "Token" (password input) - "Connect" button: calls `fetchWithAuth('/api/v1/dashboard/summary')` to validate, stores in `localStorage` on success, calls `initConfig()` to refresh runtime config, navigates to `#/overview` - Tabs placeholder for future OIDC flow: "Token", "Login with Authentik" (the second tab is disabled with "coming soon") - Error state: connection failed, wrong token, server unreachable `web/src/App.svelte` — check `isConfigured()` at mount. If false, render `Config.svelte` instead of the sidebar. On successful config, transition to the full app. `web/src/main.ts` — simplify to always mount `App.svelte` (the config check lives in App.svelte's mount hook, not in main.ts): ```ts import { mount } from 'svelte' import App from './App.svelte' import './app.css' import { initConfig } from '$lib/config' initConfig() mount(App, { target: document.getElementById('app')! }) ``` ### 0.6 — Deploy SPA as standalone static files The SPA is built with `base: '/'` and served by Caddy at `/` with SPA fallback. API routes take priority (explicit `handle_path` blocks in Caddy). **Caddy config** (add to the existing `compose/caddy/Caddyfile.oikos`): ``` handle { root * /var/www/oikos-ui file_server try_files {path} /index.html } ``` The `index.html` doesn't need a placeholder (`__OIKOS_API_URL__`) in the Caddy deployment case — the SPA and API share an origin, so relative paths work and `__OIKOS_CONFIG__` only needs `apiUrl` unset. The token is entered by the user on the Config page and stored in `localStorage`. **Build + deploy:** ```makefile ui: ## Build the SPA for standalone deployment cd web && npm run build deploy-ui: ui ## Deploy SPA to the Caddy host scp -r web/dist/* mac-mini:/var/www/oikos-ui/ ssh mac-mini sudo systemctl reload caddy ``` **Dockerfile** (server image, no Node required): ```dockerfile # Stage 1: Build Go binary FROM golang:1.26 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o oikos -tags timetzdata ./cmd/oikos # Stage 2: Runtime FROM debian:bookworm-slim COPY --from=builder /app/oikos /usr/local/bin/oikos EXPOSE 8090 ENTRYPOINT ["oikos"] ``` Drops the node builder stage entirely. The UI is built and deployed separately. ### 0.7 — Verification ``` # Server oikos api # API + SSE + MCP, no UI curl localhost:8090/healthz # {"status":"ok"} curl -H "Authorization: Bearer dev-token" \ localhost:8090/api/v1/dashboard/summary # data # SPA (dev) OIKOS_API_TOKEN=dev-token npm run dev # Vite at :5173, proxy to :8090 open http://localhost:5173/ # Config page (enter URL + token) # → Overview with live data # SPA (production test) cd web && npm run build caddy file-server -root dist --listen :3000 # serve SPA locally # Run oikos api separately, open http://localhost:3000, # enter apiUrl=http://localhost:8090 + token on Config page ``` --- ## Phase 1 — Wails desktop app Built on top of the split. The Wails app is a thin native wrapper around the same SPA, configured to talk to the deployed server over HTTPS. No bundled Go server, no Postgres connection, no nomos sidecar. ### 1.0 — Scaffold and window (1 session) Create the Wails project with a native window loading the built SPA. **`cmd/desktop/main.go`** — Wails v3 app: - On startup: read config from OS keychain (`keyring` package or Wails secrets plugin). Keys: `oikos_server_url`, `oikos_token`. - If no config in keychain: load the SPA anyway — `Config.svelte` handles first-launch setup. - If config exists: inject `window.__OIKOS_CONFIG__` before the webview mounts. Wails v3's `AssetsHandler` can mutate `index.html` before serving: ```go assetsHandler: func(ctx context.Context, name string) (string, []byte, error) { if name == "index.html" { b, _ := assets.ReadFile("index.html") html := strings.Replace(string(b), ``, fmt.Sprintf(``, configJSON), 1) return "index.html", []byte(html), nil } b, _ := assets.ReadFile(name) return name, b, nil } ``` `index.html` includes a placeholder ` ``` - Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark title bar (`mac.TitleBarStyleHiddenInset` or equivalent dark mode). - Wails embeds `web/dist/` into the binary (via `//go:embed all:dist` in the Wails project or the standard Wails asset system). **`cmd/desktop/wails.json`** — Wails project config: ```json { "name": "oikos-desktop", "frontend:dir": "../../web", "frontend:build": "npm run build", "frontend:dev:watcher": "npm run dev" } ``` **`Makefile`**: ```makefile desktop: ui ## Build the Wails desktop app wails build -clean -o oikos-desktop ``` **Dev loop for the desktop app:** ```sh # Terminal 1: run the server locally (or point at remote) OIKOS_API_TOKEN=dev-token oikos api # Terminal 2: start Wails in dev mode (hot-reload, connects to Vite) cd cmd/desktop && wails dev ``` **`web/vite.config.ts`** — add `base: '/'` (already done in 0.2). No Wails-specific Vite config needed since Wails v3 uses the standard Vite dev server. **Verify:** ```sh OIKOS_SERVER_URL=https://oikos.hubris.network OIKOS_DESKTOP_TOKEN=... make desktop ./oikos-desktop # Window opens → Config page (if no keychain entry) or Overview with live data ``` ### 1.1 — Native shell features (1 session) - **System tray** (Wails v3 `application.NewSystemTray`): - Oikos logo icon (from `web/public/favicon.svg`) - Menu: "Open Control Room" (focus/restore window), "Pending Approvals: N" (fetched via backchannel HTTP call from Go, not the SPA), separator, "Quit" - When window is closed: minimize to tray instead of quitting (set `HideOnClose`) - **Native notifications** (`application.Notification`): - A backchannel goroutine polls `GET /api/v1/dashboard/summary` every 30s (separate HTTPS client in Go, independent of the webview) - Fires OS notification when `approvals_pending` or `signals_by_severity.critical` increases since last poll - Click notification → `Window.Restore()` + send a message to the SPA via Wails events to navigate to the relevant page - **Window persistence**: remember size/position via Wails v3 `window.PersistState` or a JSON file in `~/.config/oikos/window.json` - **Auto-start on login** (macOS): - During setup flow, offer a checkbox: "Start automatically on login" - Writes a LaunchAgent plist to `~/Library/LaunchAgents/com.hubris.oikos-desktop.plist` that runs the binary on login - Linux equivalent: `~/.config/autostart/oikos-desktop.desktop` **Verify:** Close window → app stays in tray. New approval arrives → OS notification appears with count. Click notification → window opens to Ops page. Restart machine → app opens automatically on login. ### 1.2 — Token management (1 session) - **First launch**: `Config.svelte` prompts for server URL + token (same page as the browser SPA's setup) - On "Connect" success, `Config.svelte` calls a Wails binding `SaveConfig(apiUrl, token)` that stores in the OS keychain: ```go func (a *App) SaveConfig(apiUrl string, token string) error { keyring.Set("oikos_server_url", apiUrl) keyring.Set("oikos_token", token) return nil } ``` - **Subsequent launches**: Wails reads keychain, injects config, SPA skips Config page - **Logout**: "Log out" menu item in system tray clears keychain and refreshes the webview → `Config.svelte` appears **Verify:** Enter URL + token on first launch, quit, reopen → skips setup and loads Overview. ### 1.3 — Auto-update (1 session) - Check Gitea releases (or a configured update URL) on startup and every 6 hours - Wails v3 update plugin or a custom Go goroutine: `GET /releases/latest` → compare semver → download binary → verify checksum → prompt restart - Update manifest published alongside each release: `oikos-desktop-darwin-arm64.json` with `version`, `url`, `sha256` **Verify:** Build v1.0.0, publish v1.0.1 → app detects update, downloads, prompts restart. After restart, version is 1.0.1. ### 1.4 — Distribution and packaging (1 session) - **macOS**: `.app` bundle via `wails build`, code-sign with Apple Developer ID, notarize via `xcrun notarytool` - Bundle ID: `com.hubris.oikos-desktop` - Entitlements: network client, keychain access - **Linux**: `.deb` and AppImage via `wails build` + packaging scripts - **CI**: `.gitea/workflows/desktop.yml` — builds all targets on tag push, uploads artifacts to Gitea releases - **AGENTS.md** update: document the desktop app as a first-class client **Verify:** Download `.app` on a fresh Mac, open → first-launch setup → connect to the homelab → full app works with zero dev tools. --- ## What does NOT change - `internal/` — every package imported as-is. Zero modifications. - `cmd/oikos/` — minus the SPA embed (0.1), the `oikos` binary is unchanged. - `cmd/nomos/` — unchanged. The desktop app talks to nomos through the server's `/agent` reverse proxy — same as the browser. - `web/` — SPA source shared between browser and desktop builds. Gains `config.ts` (auth interceptor), `Config.svelte` (setup page), and `vite.config.ts` drops `/ui/` prefix + adds proxy token injection. All existing pages, components, stores, hooks reused. - `api/openapi.yaml` — unchanged. - `seeds/` — unchanged. - `docker-compose.yml` — server deployment unchanged (minus the Dockerfile losing the UI build stage). --- ## Risks and open questions 1. **Wails v3 maturity.** v3 is newer than v2. Fallback: Wails v2 — same architecture (Go + webview + embedded assets), different Go APIs. Scope of impact: one file (`cmd/desktop/main.go`). The thin-wrapper approach means the Wails API surface is ~50 lines of Go — trivially portable. 2. **OIDC login flow.** The browser SPA needs an OIDC redirect flow via Authentik for production use (static tokens are fine for homelab dev but not for external access). The `Config.svelte` page has a tab placeholder for this. It's a separate milestone — for now, both browser and desktop clients use a static bearer token configured at first launch. 3. **SSE query-param token in logs.** The token in `?token=...` appears in Caddy access logs and server request logs. Mitigation: log redaction in Caddy (`log { format filter { wrap json { fields { request>uri replace "token=[^&]*" "token=***" } } } }`) and strip the query param from the request logger in `server.go`. 4. **Webview CORS for embedded assets.** Wails loads the SPA from `wails://` or `asset://` origin, making cross-origin requests to the remote server. CORS middleware (0.3) handles this. The `Access-Control-Allow-Origin` must match the webview's origin, which may change between Wails versions. Mitigation: allow the configured origin explicitly; fall back to `*` for dev; Wails v3 document its asset origin. 5. **Multiple clients hitting the same SSE broker.** Browser, Wails app, and Nomos all connect to `/api/v1/events/stream`. The SSE broker already handles multiple subscribers (fan-out via the subscriber list in `sse.go`). Each client gets its own connection and replay. No change needed.