diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index a998625..6dcbe52 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -122,8 +122,12 @@ Plans:
2. Each conversation automatically includes a pre-loaded NetBox inventory context summary
3. Conversation history persists across browser sessions and is viewable in the chat UI
4. Model can be switched from Opus to any OpenRouter-compatible model via the dropdown without restarting the server
-**Plans**: TBD
-**UI hint**: yes
+**Plans**: 3 plans
+
+Plans:
+- [ ] 06-01-PLAN.md — PostgreSQL store: pgx/v5 dep, conversations + messages tables, RunMigrations, typed CRUD methods
+- [ ] 06-02-PLAN.md — Advisor backend: InventoryContextBuilder (60s cache), AdvisorHandler SSE streaming, router wiring, main.go wiring
+- [ ] 06-03-PLAN.md — Frontend: AdvisorPage at /advisor, conversation sidebar, streaming chat UI, model dropdown, TopBar link
### Phase 7: Research Agent & Search
**Goal**: Items flagged needs_research are automatically enriched by a SearXNG research agent, and any inventory question can be answered via natural language search
@@ -149,5 +153,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
| 3. Dashboard & Intake UI | 5/5 | Complete | 2026-04-10 |
| 4. USB Manager & Label Printing | 5/5 | Complete | 2026-04-10 |
| 5. Cable Test Integration | 3/3 | Complete | 2026-04-10 |
-| 6. Lab Advisor | 0/TBD | Not started | - |
+| 6. Lab Advisor | 0/3 | Not started | - |
| 7. Research Agent & Search | 0/TBD | Not started | - |
diff --git a/.planning/phases/06-lab-advisor/06-01-PLAN.md b/.planning/phases/06-lab-advisor/06-01-PLAN.md
new file mode 100644
index 0000000..1d3a743
--- /dev/null
+++ b/.planning/phases/06-lab-advisor/06-01-PLAN.md
@@ -0,0 +1,195 @@
+---
+phase: 06-lab-advisor
+plan: "01"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - internal/store/store.go
+ - internal/store/migrations.go
+ - internal/store/conversations.go
+ - internal/store/store_test.go
+ - go.mod
+ - go.sum
+autonomous: true
+requirements: [ADV-03]
+
+must_haves:
+ truths:
+ - "PostgreSQL connection is established and verified at startup"
+ - "conversations table exists with id, started_at, model columns"
+ - "messages table exists with id, conversation_id, role, content, created_at columns"
+ - "Store can create a conversation, append messages, and retrieve full thread"
+ - "Store can list all conversations ordered by started_at DESC"
+ artifacts:
+ - path: "internal/store/store.go"
+ provides: "Store struct, NewStore constructor, DB connection via pgx/v5"
+ exports: [Store, NewStore]
+ - path: "internal/store/migrations.go"
+ provides: "RunMigrations — idempotent CREATE TABLE IF NOT EXISTS for conversations + messages"
+ exports: [RunMigrations]
+ - path: "internal/store/conversations.go"
+ provides: "CreateConversation, AddMessage, GetConversation, ListConversations"
+ exports: [CreateConversation, AddMessage, GetConversation, ListConversations]
+ - path: "internal/store/store_test.go"
+ provides: "Integration tests against real PostgreSQL"
+ key_links:
+ - from: "internal/store/conversations.go"
+ to: "PostgreSQL homelabby DB"
+ via: "pgx/v5 pgxpool"
+ pattern: "pgxpool.New"
+ - from: "internal/store/migrations.go"
+ to: "conversations, messages tables"
+ via: "CREATE TABLE IF NOT EXISTS"
+ pattern: "CREATE TABLE IF NOT EXISTS conversations"
+---
+
+
+Create the internal/store package that manages chat persistence in PostgreSQL.
+RunMigrations creates conversations and messages tables idempotently; the Store
+struct exposes typed methods for the advisor handler to use.
+
+Purpose: ADV-03 requires conversation history to persist across browser sessions.
+SQLite is already used for other local state but PostgreSQL is the designated store
+here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env).
+
+Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods,
+integration tests.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+
+# Module path: git.georgsen.dk/hwlab
+# PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env):
+# postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby
+# pgx/v5 driver: github.com/jackc/pgx/v5
+# Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn
+# Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly
+# (pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3)
+
+
+
+
+
+ Task 1: Add pgx/v5 dep and create Store with RunMigrations
+ internal/store/store.go, internal/store/migrations.go, go.mod, go.sum
+
+ - NewStore("invalid-dsn") returns non-nil error
+ - NewStore(validDSN) returns *Store with open pool, no error
+ - Store.Close() does not panic
+ - RunMigrations(ctx, pool) on empty DB creates conversations and messages tables
+ - RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables)
+ - conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT ''
+ - messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+
+
+Run `go get github.com/jackc/pgx/v5` to add the dependency.
+
+Create internal/store/store.go:
+- Package store
+- Import pgxpool "github.com/jackc/pgx/v5/pgxpool"
+- Store struct holds *pgxpool.Pool
+- NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure
+- Close() — calls pool.Close()
+- Expose Pool() *pgxpool.Pool accessor for use by repository methods
+
+Create internal/store/migrations.go:
+- RunMigrations(ctx context.Context, pool *pgxpool.Pool) error
+- Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec
+- conversations first (messages references it)
+- Return first non-nil error encountered
+
+Write tests first (RED), confirm they fail, then implement (GREEN).
+
+
+ cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v
+
+ TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod
+
+
+
+ Task 2: Conversation and message CRUD methods
+ internal/store/conversations.go, internal/store/store_test.go
+
+ - CreateConversation(ctx, model string) returns (conversationID string, err error)
+ - AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error)
+ - AddMessage with invalid role returns error (CHECK constraint violation from DB)
+ - GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message
+ - GetConversation on unknown ID returns nil, ErrNotFound (sentinel)
+ - ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count)
+ - Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order
+
+
+Create internal/store/conversations.go:
+
+Types:
+- Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time }
+- Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message }
+- ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int }
+- var ErrNotFound = errors.New("store: not found")
+
+Methods on *Store:
+- CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id
+- AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id
+- GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound
+- ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC
+
+Write tests in internal/store/store_test.go using build tag `//go:build integration`
+so they run only with -tags integration. Tests connect to the real PostgreSQL.
+Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1).
+
+Write tests first (RED), confirm compile error or test failure, then implement (GREEN).
+
+
+ cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v
+
+ All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| App → PostgreSQL | DSN from env; pool executes parameterized queries |
+| Messages table | role column has CHECK constraint; content is free text |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL |
+| T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file |
+| T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error |
+| T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard |
+
+
+
+- `go build ./...` passes
+- `go test ./internal/store/...` passes (non-integration tests)
+- `go test ./internal/store/... -tags integration` passes against live PostgreSQL
+- `psql $HWLAB_DATABASE_URL -c "\d conversations"` shows the schema
+- `psql $HWLAB_DATABASE_URL -c "\d messages"` shows the schema with FK to conversations
+
+
+
+- pgx/v5 added to go.mod
+- internal/store package compiles with no errors
+- RunMigrations creates both tables idempotently
+- Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests
+- ErrNotFound returned for unknown conversation ID
+
+
+
diff --git a/.planning/phases/06-lab-advisor/06-02-PLAN.md b/.planning/phases/06-lab-advisor/06-02-PLAN.md
new file mode 100644
index 0000000..0af9f8a
--- /dev/null
+++ b/.planning/phases/06-lab-advisor/06-02-PLAN.md
@@ -0,0 +1,246 @@
+---
+phase: 06-lab-advisor
+plan: "02"
+type: execute
+wave: 2
+depends_on: [06-01]
+files_modified:
+ - internal/advisor/handler.go
+ - internal/advisor/context.go
+ - internal/api/handlers/advisor.go
+ - internal/api/router.go
+ - cmd/hwlab/main.go
+autonomous: true
+requirements: [ADV-01, ADV-02, ADV-03, ADV-05]
+
+must_haves:
+ truths:
+ - "POST /api/advisor/chat streams tokens from Claude Opus via SSE"
+ - "Each chat request includes a pre-assembled NetBox inventory summary in the system prompt"
+ - "Inventory context is cached 60s to avoid hammering NetBox on every message"
+ - "Every message and conversation is persisted in PostgreSQL via the store package"
+ - "GET /api/advisor/conversations returns list of past conversations"
+ - "GET /api/advisor/conversations/:id returns full message thread"
+ - "Model is configurable per-request via model field; defaults to anthropic/claude-opus-4"
+ - "Model switch takes effect on the next request without restarting the server"
+ artifacts:
+ - path: "internal/advisor/context.go"
+ provides: "InventoryContextBuilder — assembles compact NetBox summary with 60s cache"
+ exports: [InventoryContextBuilder, NewInventoryContextBuilder, BuildContext]
+ - path: "internal/advisor/handler.go"
+ provides: "AdvisorHandler — POST /chat streaming SSE, GET /conversations, GET /conversations/:id"
+ exports: [AdvisorHandler, NewAdvisorHandler]
+ - path: "internal/api/handlers/advisor.go"
+ provides: "Thin glue wiring AdvisorHandler to chi routes (or inline in router.go)"
+ - path: "internal/api/router.go"
+ provides: "Routes for /api/advisor/chat, /api/advisor/conversations, /api/advisor/conversations/:id"
+ key_links:
+ - from: "internal/advisor/handler.go"
+ to: "go-openai CreateChatCompletionStream"
+ via: "TierClient extended with StreamChat method or direct openai.Client use"
+ pattern: "CreateChatCompletionStream"
+ - from: "internal/advisor/context.go"
+ to: "internal/netbox.Client.ListDevices"
+ via: "sync.Mutex + time.Time expiry for 60s cache"
+ pattern: "ListDevices"
+ - from: "internal/advisor/handler.go"
+ to: "internal/store.Store"
+ via: "CreateConversation + AddMessage on each turn"
+ pattern: "store.CreateConversation"
+---
+
+
+Build the advisor backend: context assembly with NetBox inventory, SSE streaming chat
+endpoint backed by Claude Opus via OpenRouter, and PostgreSQL persistence of every
+conversation and message.
+
+Purpose: ADV-01 (streaming chat), ADV-02 (inventory context), ADV-03 (persistence),
+ADV-05 (model switch without restart).
+
+Output: internal/advisor package + router wiring exposing three endpoints.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/06-lab-advisor/06-01-SUMMARY.md
+
+# Key interfaces from prior plans (no exploration needed):
+
+# internal/ai/client.go — AIClient + TierClient
+# TierClient has: client *openai.Client, model string, timeout time.Duration
+# NewTierClient(cfg TierConfig) *TierClient
+# TierConfig: BaseURL, APIKey, Model, TimeoutSeconds
+# go-openai streaming: client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{Stream: true, ...})
+# returns (openai.ChatCompletionStream, error) — call .Recv() in loop until io.EOF
+
+# internal/netbox/client.go
+# ListDevices(ctx, limit int) ([]Device, error)
+# Device struct has: ID int, Name string, AssetTag string, CustomFields map
+
+# internal/store (from Plan 01):
+# NewStore(ctx, dsn) (*Store, error)
+# Store.CreateConversation(ctx, model) (string, error)
+# Store.AddMessage(ctx, conversationID, role, content) (string, error)
+# Store.GetConversation(ctx, id) (*Conversation, error) — returns ErrNotFound if missing
+# Store.ListConversations(ctx) ([]ConversationSummary, error)
+
+# ai_config.json tier3 config (OpenRouter):
+# BaseURL: https://openrouter.ai/api/v1
+# Default model: anthropic/claude-opus-4
+# APIKey: from ai_config.json (loaded by config package)
+
+# SSE pattern (from Phase 4/5 USBEventsHandler):
+# w.Header().Set("Content-Type", "text/event-stream")
+# w.Header().Set("Cache-Control", "no-cache")
+# w.Header().Set("Connection", "keep-alive")
+# fmt.Fprintf(w, "data: %s\n\n", token)
+# flusher.Flush()
+
+# Module path: git.georgsen.dk/hwlab
+# Router uses chi, parameter extraction: chi.URLParam(r, "id")
+
+
+
+
+
+ Task 1: InventoryContextBuilder with 60s cache
+ internal/advisor/context.go
+
+Create internal/advisor/context.go.
+
+InventoryContextBuilder struct:
+- nb *netbox.Client
+- mu sync.Mutex
+- cached string
+- cachedAt time.Time
+- ttl time.Duration (default 60s)
+
+NewInventoryContextBuilder(nb *netbox.Client) *InventoryContextBuilder
+
+BuildContext(ctx context.Context) (string, error):
+- Under mutex: if time.Since(cachedAt) < ttl, return cached
+- Call nb.ListDevices(ctx, 200)
+- Build compact text summary:
+ - First line: "Inventory: N items total"
+ - Count by category (use CustomFields["category"] if present)
+ - List recent 20 items: "- HW-ID name (category)" from device.AssetTag + device.Name + CustomFields["category"]
+ - Aim for < 2000 chars; truncate item list if needed
+- Store in cached + set cachedAt = time.Now()
+- Return the summary string
+
+The system prompt prefix for each chat: "You are a homelab advisor. Here is the current inventory:\n\n" + context
+
+
+ cd /home/mikkel/homelabby && go build ./internal/advisor/...
+
+ internal/advisor/context.go compiles; BuildContext returns a non-empty string when given a mock netbox client returning sample devices
+
+
+
+ Task 2: AdvisorHandler (SSE streaming + persistence) and router wiring
+ internal/advisor/handler.go, internal/api/router.go, cmd/hwlab/main.go
+
+Create internal/advisor/handler.go.
+
+ChatRequest struct (JSON):
+- ConversationID string `json:"conversation_id"` — empty = new conversation
+- Message string `json:"message"`
+- Model string `json:"model"` — empty = "anthropic/claude-opus-4"
+
+AdvisorHandler struct:
+- store *store.Store
+- ctx *InventoryContextBuilder
+- aiCfg ai.AIConfig (for Tier3 config: BaseURL + APIKey)
+
+NewAdvisorHandler(s *store.Store, ctxBuilder *InventoryContextBuilder, aiCfg ai.AIConfig) *AdvisorHandler
+
+StreamChat(w http.ResponseWriter, r *http.Request):
+1. Decode ChatRequest from body
+2. If ConversationID empty: call store.CreateConversation(r.Context(), model) to get new ID
+3. Call store.AddMessage(r.Context(), convID, "user", req.Message) to persist user turn
+4. Call ctxBuilder.BuildContext to get inventory summary
+5. Build []openai.ChatCompletionMessage: system (inventory summary), user (req.Message)
+6. Build openai.ClientConfig from aiCfg.Tier3 (BaseURL, APIKey) but override Model to req.Model
+ — use openai.DefaultConfig(apiKey); cfg.BaseURL = baseURL; openai.NewClientWithConfig(cfg)
+7. Call client.CreateChatCompletionStream(ctx, req{Model: model, Stream: true, Messages: msgs})
+8. Set SSE headers; write "data: {"conversation_id":"...","token":"..."}\n\n" for each Recv() token
+9. Flush after each write (cast w to http.Flusher)
+10. On io.EOF: write final "data: [DONE]\n\n"; collect full response text from accumulated tokens
+11. Call store.AddMessage(r.Context(), convID, "assistant", fullContent) to persist assistant turn
+12. Error during stream: write "data: {"error":"..."}\n\n" and return
+
+GetConversations(w http.ResponseWriter, r *http.Request):
+- Call store.ListConversations(r.Context()); JSON encode list; 200
+
+GetConversation(w http.ResponseWriter, r *http.Request):
+- id := chi.URLParam(r, "id")
+- Call store.GetConversation; if errors.Is(err, store.ErrNotFound) → 404; else JSON 200
+
+Update internal/api/router.go:
+- Add *advisor.AdvisorHandler parameter to NewRouter signature
+- Add routes under r.Route("/api/advisor", ...):
+ - POST /chat → advisorHandler.StreamChat
+ - GET /conversations → advisorHandler.GetConversations
+ - GET /conversations/{id} → advisorHandler.GetConversation
+
+Update cmd/hwlab/main.go:
+- Read HWLAB_DATABASE_URL from env (already loaded via godotenv)
+- Call store.NewStore(ctx, os.Getenv("HWLAB_DATABASE_URL"))
+- Call store.RunMigrations(ctx, s.Pool())
+- Create InventoryContextBuilder with netboxClient
+- Create AdvisorHandler
+- Pass to NewRouter
+
+
+ cd /home/mikkel/homelabby && go build ./... && curl -s -N -X POST http://localhost:8080/api/advisor/chat -H "Content-Type: application/json" -d '{"message":"hello"}' | head -5
+
+ go build ./... passes; POST /api/advisor/chat returns SSE stream with data: lines; GET /api/advisor/conversations returns JSON array; GET /api/advisor/conversations/:id returns 404 for unknown ID
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| HTTP client → POST /api/advisor/chat | Untrusted JSON body; message content forwarded to OpenRouter |
+| AdvisorHandler → OpenRouter API | API key in memory from ai_config.json; never echoed to client |
+| SSE stream → browser | Token data flows back; no user data echoed except conversation_id |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-06-02-01 | Injection | StreamChat — message forwarded to OpenRouter | mitigate | Message is passed as user role content — OpenRouter's model, not our SQL. No SQL built from message content. |
+| T-06-02-02 | Information Disclosure | StreamChat — OpenRouter APIKey | mitigate | Key read from ai_config.json / ai_config.local.json; never logged, never written to SSE stream |
+| T-06-02-03 | Denial of Service | StreamChat — unbounded message length | mitigate | Truncate req.Message to 8000 chars before sending to OpenRouter; return 400 if body > 64KB |
+| T-06-02-04 | Information Disclosure | SSE stream — conversation_id exposed | accept | Single-operator homelab; no multi-user auth; all conversations are the operator's own |
+| T-06-02-05 | Spoofing | Model field in ChatRequest — caller can specify arbitrary model | accept | Single-operator tool; operator controls their OpenRouter account/spend; no model allowlist needed for homelab |
+
+
+
+- `go build ./...` passes
+- POST /api/advisor/chat with `{"message":"ping"}` returns SSE with at least one `data:` line
+- GET /api/advisor/conversations returns `[]` or list of prior conversations
+- GET /api/advisor/conversations/nonexistent returns HTTP 404
+- psql confirms a row in conversations and rows in messages after a chat request
+
+
+
+- Three /api/advisor/* endpoints registered and responding
+- Streaming response delivers tokens as SSE `data:` events
+- NetBox inventory context appears in the system prompt (verify via log or test)
+- Conversation and messages rows created in PostgreSQL on each chat
+- Model override works: pass "anthropic/claude-3-5-sonnet" and it uses that model without restart
+
+
+
diff --git a/.planning/phases/06-lab-advisor/06-03-PLAN.md b/.planning/phases/06-lab-advisor/06-03-PLAN.md
new file mode 100644
index 0000000..87a94ce
--- /dev/null
+++ b/.planning/phases/06-lab-advisor/06-03-PLAN.md
@@ -0,0 +1,256 @@
+---
+phase: 06-lab-advisor
+plan: "03"
+type: execute
+wave: 3
+depends_on: [06-02]
+files_modified:
+ - web/src/pages/AdvisorPage.tsx
+ - web/src/api/advisor.ts
+ - web/src/router.tsx
+ - web/src/components/layout/TopBar.tsx
+autonomous: true
+requirements: [ADV-01, ADV-03, ADV-04]
+
+must_haves:
+ truths:
+ - "User can navigate to /advisor from the TopBar"
+ - "Left sidebar lists all past conversations ordered newest-first"
+ - "User can start a new conversation or select a prior one from the sidebar"
+ - "User types a message, presses Send, and sees streaming tokens appear in real time"
+ - "Conversation history (prior messages) is displayed above the input"
+ - "Model dropdown shows current model; changing it takes effect on the next message"
+ - "conversation_id is tracked across messages so all turns land in the same thread"
+ artifacts:
+ - path: "web/src/api/advisor.ts"
+ provides: "Typed API wrappers: streamChat (SSE EventSource), fetchConversations, fetchConversation"
+ exports: [streamChat, fetchConversations, fetchConversation, Conversation, ConversationSummary, ChatMessage]
+ - path: "web/src/pages/AdvisorPage.tsx"
+ provides: "AdvisorPage component: two-panel layout (sidebar + chat)"
+ - path: "web/src/router.tsx"
+ provides: "/advisor route added"
+ - path: "web/src/components/layout/TopBar.tsx"
+ provides: "Advisor nav link added to header"
+ key_links:
+ - from: "AdvisorPage.tsx — Send button"
+ to: "POST /api/advisor/chat via EventSource/fetch SSE"
+ via: "streamChat() in api/advisor.ts"
+ pattern: "EventSource|fetch.*text/event-stream"
+ - from: "AdvisorPage.tsx — sidebar"
+ to: "GET /api/advisor/conversations"
+ via: "useQuery + fetchConversations"
+ pattern: "fetchConversations"
+ - from: "AdvisorPage.tsx — conversation selection"
+ to: "GET /api/advisor/conversations/:id"
+ via: "useQuery + fetchConversation keyed by selected ID"
+ pattern: "fetchConversation"
+---
+
+
+Build the AdvisorPage React component at /advisor: two-panel chat UI with a conversation
+sidebar, streaming message display, and model selection dropdown.
+
+Purpose: ADV-01 (streaming chat UI), ADV-03 (history viewable in UI), ADV-04 (model
+dropdown, conversation list).
+
+Output: AdvisorPage.tsx, api/advisor.ts, router + TopBar updates.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/phases/06-lab-advisor/06-02-SUMMARY.md
+
+# Design system: ClickHouse-inspired dark theme (see ~/.claude/DESIGN.md)
+# Canvas: #000000, accent/volt: #faff69, charcoal border: #222, muted text: #888
+# Tailwind tokens already in project: bg-canvas, text-volt, border-charcoal
+# All existing pages use: AppShell wrapping, lucide-react icons, shadcn/ui Button/Input
+# Router: TanStack Router v1 — lazy import pattern from router.tsx (see below)
+# API pattern: useQuery from @tanstack/react-query for GET, fetch/EventSource for SSE
+
+# Backend endpoints (from Plan 02):
+# POST /api/advisor/chat
+# Body: { conversation_id?: string, message: string, model?: string }
+# Response: SSE stream, each event: data: {"conversation_id":"...","token":"..."}\n\n
+# Final event: data: [DONE]\n\n
+# GET /api/advisor/conversations
+# Response: [{id, started_at, model, message_count}]
+# GET /api/advisor/conversations/:id
+# Response: {id, started_at, model, messages: [{id, role, content, created_at}]}
+
+# Router lazy import pattern (match existing pages):
+# const AdvisorPage = lazy(() => import('./pages/AdvisorPage').then((m) => ({ default: m.AdvisorPage })))
+# createRoute({ path: '/advisor', component: () => }> })
+
+# TopBar pattern: Button variant="outline" size="sm" asChild wrapping Link to="/advisor"
+# Icon to use: MessageSquare from lucide-react
+
+# SSE streaming: use fetch() with ReadableStream (not EventSource — POST body needed)
+# Pattern: const res = await fetch('/api/advisor/chat', {method:'POST', body:JSON.stringify(...)})
+# const reader = res.body!.getReader(); const decoder = new TextDecoder()
+# loop: read chunk, decode, split on \n\n, parse data: lines
+
+# Available OpenRouter models for dropdown (hardcoded list, no API call needed):
+# ["anthropic/claude-opus-4", "anthropic/claude-sonnet-4-5", "anthropic/claude-3-5-haiku", "openai/gpt-4o"]
+# Default: "anthropic/claude-opus-4"
+
+
+
+
+
+ Task 1: API wrappers for advisor endpoints
+ web/src/api/advisor.ts
+
+Create web/src/api/advisor.ts with typed wrappers.
+
+Types:
+```typescript
+export interface ChatMessage {
+ id: string
+ role: 'user' | 'assistant' | 'system'
+ content: string
+ created_at: string
+}
+
+export interface ConversationSummary {
+ id: string
+ started_at: string
+ model: string
+ message_count: number
+}
+
+export interface Conversation {
+ id: string
+ started_at: string
+ model: string
+ messages: ChatMessage[]
+}
+```
+
+fetchConversations(): Promise
+— GET /api/advisor/conversations; returns [] on 404
+
+fetchConversation(id: string): Promise
+— GET /api/advisor/conversations/${id}
+
+streamChat(params: { conversationId?: string; message: string; model: string },
+ onToken: (token: string, conversationId: string) => void,
+ onDone: () => void,
+ onError: (err: string) => void): Promise
+— POST /api/advisor/chat with Content-Type: application/json
+— Read response.body via getReader() + TextDecoder
+— Split chunks by '\n\n'; each 'data: ' prefixed line is an event
+— If data === '[DONE]' call onDone() and return
+— Else parse JSON { token, conversation_id }; call onToken(token, conversation_id)
+— On JSON parse error or fetch error call onError(message)
+
+
+ cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | grep advisor
+
+ web/src/api/advisor.ts compiles with no TypeScript errors; all three exported functions present
+
+
+
+ Task 2: AdvisorPage component, route, and TopBar link
+ web/src/pages/AdvisorPage.tsx, web/src/router.tsx, web/src/components/layout/TopBar.tsx
+
+Create web/src/pages/AdvisorPage.tsx.
+
+Layout: AppShell > two-panel flex (lg: sidebar 280px fixed + main flex-1; mobile: main only, sidebar hidden):
+- Left sidebar (bg-[#0a0a0a] border-r border-charcoal/60):
+ - "Lab Advisor" heading (text-volt font-bold)
+ - "New Chat" button (full width, variant outline)
+ - Conversation list from useQuery(['conversations'], fetchConversations, { refetchInterval: 5000 })
+ Each item: truncated first message time + model name; selected item highlighted with bg-[#1a1a1a]
+ Click: set selectedConversationId, load messages via useQuery(['conversation', id], ...)
+- Main chat panel (flex-col):
+ - Messages area (flex-1 overflow-y-auto, scroll to bottom on new message via useEffect + ref):
+ - Empty state: centered text "Ask anything about your homelab"
+ - User messages: right-aligned, bg-[#1a1a1a] rounded-lg p-3 max-w-[80%]
+ - Assistant messages: left-aligned, bg-[#111] rounded-lg p-3 max-w-[80%], text-[#e0e0e0]
+ - Streaming assistant message shown inline as tokens arrive (append to streamingContent state)
+ - Input row (border-t border-charcoal/60 p-4 flex gap-2):
+ - Model select (shadcn/ui Select or native
+
+ cd /home/mikkel/homelabby/web && npx tsc --noEmit 2>&1 | head -20
+
+ npx tsc --noEmit passes with no errors; /advisor route registered in router; TopBar shows Advisor link; AdvisorPage renders without runtime errors
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| User input → POST /api/advisor/chat | Free-text message; sanitized by backend before OpenRouter |
+| SSE token stream → DOM | Tokens rendered as text content, not innerHTML |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-06-03-01 | Injection (XSS) | AdvisorPage — assistant message rendering | mitigate | Render message content as React text node (JSX children), never dangerouslySetInnerHTML; streaming tokens appended to string state, displayed as text |
+| T-06-03-02 | Information Disclosure | advisor.ts — fetch error details | accept | Error messages from backend shown in toast; backend returns generic messages, not stack traces; single-operator tool |
+| T-06-03-03 | Spoofing | model dropdown — arbitrary model string | accept | Value sent to backend which passes to OpenRouter; single-operator tool controls their own spend; no impact beyond the operator |
+
+
+
+- `cd web && npx tsc --noEmit` exits 0
+- Visit http://localhost:8080/advisor — page renders with sidebar + chat panel
+- TopBar shows Advisor link (MessageSquare icon)
+- "New Chat" → type message → Send → streaming tokens appear token by token
+- After stream completes, message appears in history
+- Reload page, select prior conversation from sidebar → messages load
+- Change model in dropdown → next message uses selected model (verify in psql: SELECT model FROM conversations ORDER BY started_at DESC LIMIT 1)
+
+
+
+- TypeScript compiles cleanly
+- /advisor route accessible from TopBar
+- Streaming works: tokens appear progressively, not all at once
+- Conversation persists: prior conversations visible in sidebar on page reload
+- Model dropdown changes the model used for next message
+- No XSS vector: assistant content rendered as text, not HTML
+
+
+