# 2026-07-12 — Wails desktop application

**Status:** Done — Phases 0.0–0.6 deployed to production (mac-mini, commit
`0c0f35a`, 2026-07-12). Phases 1.0–1.4 implemented and iterated (commits
through `eeb78ed`, 2026-07-14). App installed to `/Applications` on dev Mac,
end-to-end OIDC login via lokal HTTP server + system browser confirmed working.

**Production deploy (2026-07-12):** merged to `main`, picked up by the
2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild →
rolling restart → health check), `healthy after 1s`. Verified post-deploy:
unauthenticated `/api/v1/*` now 401s (the dev-open bypass was live in
production before this — `OIKOS_ENV=dev` with no token set — so this closed
a real, currently-exploitable hole, not just future prep); `/healthz` stayed
open; nomos reconnected its MCP session with the new
`OIKOS_MCP_BEARER_TOKEN` and a real tool call round-tripped end to end
(`get_health_summary` via `/query`). A real random token was generated and
added to mac-mini's `.env` (not committed — gitignored) before deploy, so
the `${OIKOS_MCP_BEARER_TOKEN:-dev-token}` fallback in `docker-compose.yml`
never activated with the weak literal default.

**Deliberately not done as part of this deploy** (out of scope — a different
host/repo than "mac-mini", not touched): the Caddy LXC (121) and
`dtoro/caddy-conf`. Checked the real production Caddyfile directly — there is
**no `oikos.hubris.network` site block at all yet**, so the Authentik-bypass
risk (gap 1 below) doesn't apply yet; there's no public UI exposed to break.
`mcp.hubris.network` exists but still reverse-proxies to the old
pre-consolidation service on LXC 105 (`192.168.8.205:9810`), unrelated to
this stack — stale, but pre-existing and out of scope here. Exposing
`oikos.hubris.network` publicly (with the `@api` bypass this plan's
Caddyfile.oikos reference copy already has) is unstarted follow-up work, not
a regression from this deploy.

## Plan review — gaps found before starting Phase 0

Reviewed against the current codebase and the live Caddy topology
(`compose/caddy/Caddyfile.oikos`) before writing any code. Six gaps, each
with the resolution taken:

1. **Authentik forward-auth vs. bearer-token clients.** The deployed
   `oikos.hubris.network` site gates every route (including `/agent/*` and,
   after this plan, `/api/v1/*`) with `import authentik` — a browser-session
   forward-auth check, not a header a non-browser client can supply. Closing
   the dev-open gate (0.4) makes every API route require a bearer token, but
   says nothing about how a bearer-token client (Wails, curl, a future mobile
   client) gets past Authentik's login redirect in front of it. Same shape as
   the existing `@enroll` bypass for `/api/v1/clients/enroll`.
   **Resolution:** updated the reference copy
   ([Caddyfile.oikos](compose/caddy/Caddyfile.oikos)) with an `@api path
   /api/v1/* /mcp /agent/*` bypass around `import authentik`, same pattern as
   `@enroll`, and moved static-SPA serving into the `handle {}` fallback
   (0.6). This repo's copy is not what's deployed — the real file lives in
   `dtoro/caddy-conf` and auto-deploys from there — so the equivalent change
   still needs to land there before a Wails client (or anything else that
   can't complete Authentik's browser login) can actually reach the API in
   production. Flagged explicitly as risk #6 below so it isn't discovered the
   hard way.
2. **Nomos's own gateway (C1) is a parallel, unauthenticated path to the same
   backend.** [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)'s
   C1 finding — nomos's port 8092 has zero auth of its own — is still open.
   Phase 0.3's CORS/auth work only touches `internal/httpapi` (the `api`
   process); `cmd/nomos` is untouched. The architecture diagram in this plan
   shows Caddy's `handle_path /agent/*` proxying straight to `:8092`,
   bypassing `api`'s `combinedAuth` entirely and relying solely on Authentik.
   Closing the API's dev-open gate does nothing for this path — nomos's
   direct mesh-published port (`docker-compose.yml:133`) and
   `nomos.hubris.network` remain reachable with no credential check at all.
   **Resolution:** not fixed by this plan — flagged as a pre-existing,
   independent gap (already tracked as C1) that the Wails desktop app
   inherits rather than introduces. Added as risk #6 below so it isn't
   mistaken for something Phase 0 closes.
3. **`github.com/go-chi/cors` isn't a dependency yet**, and the plan's sample
   CORS config (`AllowCredentials: true` with a default `"*"` origin) is
   spec-invalid — browsers and webviews reject a wildcard
   `Access-Control-Allow-Origin` when credentials are requested. This API
   authenticates via `Authorization: Bearer`, not cookies, so credentialed
   CORS mode isn't needed at all. **Resolution:** drop `AllowCredentials`
   from the middleware config in 0.3 rather than ship a setting that silently
   breaks the first time an origin other than `*` is configured.
4. **Closing dev-open (0.4) breaks local `docker compose --profile dev up`
   out of the box** — none of the compose services currently set a token, and
   today they rely entirely on `OIKOS_ENV=dev` + devOpen. Worse: `cmd/nomos`
   itself is an unauthenticated client of `api`'s `/mcp` endpoint and
   `/api/v1/approvals/{id}/decision` (chat-assent approvals) —
   `grep -rn "Authorization" cmd/nomos/*.go` returned nothing before this
   fix. Closing dev-open without touching nomos would have broken nomos's own
   connection to the API, not just local dev ergonomics; this wasn't called
   out anywhere in the original plan text. **Resolution:** added a `token`
   field threaded through `mcpClient`/`mcpClientPool` and `agent.apiToken`,
   both reading `OIKOS_MCP_BEARER_TOKEN` (the same shared secret `api`
   already validates static tokens against) and sent as `Authorization:
   Bearer ...` on every request nomos makes to `api`. `docker-compose.yml`
   sets `OIKOS_MCP_BEARER_TOKEN` (default `dev-token`) on both the `api` and
   `nomos` services so local dev keeps working.
5. **0.2's `const API = apiBase('/api/v1')` pattern bakes in a stale origin.**
   Module-level constants evaluate once, at import time — before
   `main.ts`'s `initConfig()` runs (ES module imports are hoisted ahead of a
   file's own top-level statements) and before `Config.svelte` or a
   Wails-injected `window.__OIKOS_CONFIG__` can set `apiUrl`. A first-launch
   Wails webview would resolve `API` to a relative path and try to fetch
   `wails://.../api/v1/...`, which doesn't exist. **Resolution:** `api.ts`
   keeps `BASE`/`API` as bare path prefixes (`/agent`, `/api/v1`, never
   resolved to a URL) and lets `fetchWithAuth` call `apiBase()` fresh on
   every request — the same fix pattern as gap 4's SSE snippet: resolve at
   call time, not at module-load time.
6. **`api`'s own `/agent` reverse-proxy mount (to nomos) was never behind
   `combinedAuth` — found while auditing every route for the dev-open
   removal.** [server.go](../internal/httpapi/server.go)'s
   `r.Mount("/agent", ...)` was registered directly on the base router,
   unlike every other custom route (`/mcp`, `/api/v1/knowledge/recent`,
   etc.), which all use `r.With(combinedAuth(cfg, false))`. Harmless while
   dev-open made the whole API open anyway; a real hole the moment 0.4 closes
   it — any request to `api`'s `/agent/*` would reach nomos with no
   credential check at all, independent of C1 (nomos's *own* gateway on
   :8092, still open) and independent of gap 1 (Caddy/Authentik). **Resolution:**
   wrapped the mount in `combinedAuth(cfg, false)`, matching every other
   route.

Also: 0.4's local-dev token delivery ended up simpler than described —
"Vite injects it into `window.__OIKOS_CONFIG__` at dev time" isn't needed at
all for the relative-path dev case. The Vite proxy (0.2) already injects
`Authorization: Bearer $OIKOS_API_TOKEN` server-side on every proxied
`/api`/`/agent` request, so relative-path fetches during `npm run dev` are
authenticated before they leave the dev server — no client-side config
needed. `window.__OIKOS_CONFIG__` injection is still exactly what Phase 1's
Wails shell needs (absolute URL, no dev proxy to lean on).

Also: 0.3's SSE-auth snippet checks `GetActor(r.Context()) == nil` *inside*
`serveSSE` and validates the query token there — but `serveSSE` only runs
after `combinedAuth` has already accepted or rejected the request, and
`combinedAuth` requires a header today, so `EventSource` requests (no custom
headers) never reach `serveSSE` at all; they 401 in the middleware first.
**Actual implementation:** `combinedAuth` itself takes an `allowQueryToken
bool`; when set (only for the `/api/v1/events/stream` route) it falls back to
`?token=` when the `Authorization` header is absent, before running the same
OIDC/static validation as every other route. This reuses all existing auth
logic instead of duplicating a static-token-only path inside `serveSSE`, and
keeps the gate at the middleware layer rather than half-open inside the
handler. The static-token comparison itself was extracted into
`staticTokenActor(cfg, raw)`, shared between the header and query-param
paths.

## 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<Response> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(opts?.headers as Record<string, string> ?? {}),
  }
  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),
              `<script>window.__OIKOS_CONFIG__ = {};</script>`,
              fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON),
              1)
          return "index.html", []byte(html), nil
      }
      b, _ := assets.ReadFile(name)
      return name, b, nil
  }
  ```
  `index.html` includes a placeholder `<script>` tag that gets replaced:
  ```html
  <script>window.__OIKOS_CONFIG__ = {};</script>
  <script type="module" src="/src/main.ts"></script>
  ```
- 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.

6. **Deploy-time Caddy changes this plan does not make.** Two changes are
   required outside this repo before Phase 0's auth tightening actually
   protects anything in production, both in `dtoro/caddy-conf`:
   - Add a bearer-token bypass around `import authentik` for `/api/v1/*` and
     `/mcp` on `oikos.hubris.network`, mirroring the existing `@enroll`
     bypass — otherwise closing the dev-open gate just adds a second,
     redundant auth layer behind Authentik's browser-session check, and
     non-browser clients (Wails, curl) can never get past the first one.
   - Nomos's gateway (port 8092) has no auth of its own (C1, tracked in
     [2026-07-11-nomos-agent-code-review.md](2026-07-11-nomos-agent-code-review.md)).
     Phase 0 does not fix this — the mesh-published port and
     `nomos.hubris.network` remain open regardless of anything done here.
     Treat C1 as a co-requisite for a production Wails rollout, not
     something this plan's auth work incidentally covers.
