# Krow frontend — API audit Every API this repository needs, and where each one stands against the running Go backend at `../krow-backend`. **The backend's `docs/api-contract.md` is normative.** This document does not restate its semantics — it inventories what *this* repo calls, and marks each against what the server actually registers. Nothing here proposes a route, request field, response field or table that is not traced to a frontend call site or an existing backend requirement. ## Status labels | Label | Meaning | | --- | --- | | **Served** | The frontend calls it, and the backend registers it. Working. | | **Adopt** | The backend registers it; the frontend does not use it yet, and should. No new work on the server. | | **Local stub** | Implemented in-browser. No network, no server, no key. | | **Browser-only** | State lives in `localStorage` / `sessionStorage` and nowhere else. | | **External** | Third-party host, hotlinked, no auth, no contract. | | **Dead code** | Frontend code exists with no consumer and no route. Not a backend gap. | --- ## 1. Route counts, derived from source The figure "38 endpoints" is the backend contract's **§2 table** (34 Live + 4 "unreachable today"). It is not the count of what the server registers. Counted from source rather than from any README: | Group | Routes | Where counted | | --- | --- | --- | | Entity CRUD | **34** | `Ops` bits across 14 resources in `internal/domain/resources_gen.go` | | `/me` | 4 | `internal/httpserver/me.go:83-86` | | `/auth` | 2 | `internal/httpserver/auth.go:129-130` | | Agent + skill definitions | 10 | `internal/httpserver/definitions.go:11-21` | | Workflows | 2 | `internal/httpserver/workflows.go:87-88` | | Owliver suggestions | 1 | `internal/httpserver/owliver.go` | | Health | 1 | `internal/httpserver/server.go:146` | | **Total registered** | **54** | | `Server.Endpoints()` returns 53 — the sum of the six `route*` helpers, excluding `GET /health`, which is registered before the sum. The backend README used to say "51 registered routes", omitting the two workflow routes; it now enumerates all 54 and is back in step with its router. --- ## 2. Configuration and transport | Variable | Default | Read by | | --- | --- | --- | | `VITE_API_BASE_URL` | `/api/v1` | `src/api/httpClient.js:50` — client code | | `VITE_API_PROXY_TARGET` | `https://mcp.krowforce.com` | `vite.config.js:9` — dev server only | | `VITE_ASSISTANT_ENDPOINT` | *(unset)* | `src/components/ai-assistant/provider.js:205` | - **Development.** `vite.config.js` proxies `/api` and `/health` to `VITE_API_PROXY_TARGET`, so the browser only ever talks to one origin. - **Production.** `Dockerfile:33` bakes `ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1` at build time — `.dockerignore` excludes `.env*`, so no env file reaches the image, and `nginx.conf` has **no** `location /api/`. A relative base URL in production would answer a login POST with 405. - **Session cookie.** HttpOnly, SameSite=Lax. `httpClient.js` sends `credentials: 'include'` and warns in DEV if the base URL is cross-origin (`httpClient.js:72-84`). - **Envelope.** `{ data, meta }` in, bare records out — `request()` returns `payload.data` and drops `meta` entirely (`httpClient.js:226`). - **Errors.** `{ error: { code, message, details } }` becomes a `KrowApiError` carrying `.status`, `.code`, `.details`. A transport failure becomes `status: 0, code: 'unreachable'` with a message naming the URL. --- ## 3. Entity CRUD — Served `RESOURCE_PATHS` (`src/api/httpClient.js:96`) against `Ops` (`internal/domain/resources_gen.go`). **Every method the frontend actually calls has a route. Zero new endpoints are needed here.** | Entity | Path | Frontend calls | Backend `Ops` | | | --- | --- | --- | --- | --- | | JobPosting | `job-postings` | list, get, create, update | List·Get·Create·Update | Served | | JobApplication | `job-applications` | list, filter, create, update, delete | List·Create·Update·Delete | Served | | AIInterview | `ai-interviews` | list, create | List·Create | Served | | Staff | `staff` | list, create, update | List·Create·Update | Served | | WorkerProfile | `worker-profiles` | list, filter, create, update | List·Create·Update | Served | | Course | `courses` | list, get, create, update | List·Get·Create·Update | Served | | LearningPath | `learning-paths` | list | List | Served | | RoleCategory | `role-categories` | list, create | List·Create | Served | | Certification | `certifications` | list, create, delete | List·Create·Delete | Served | | UserActivity | `user-activity` | list, filter, create | List·Create | Served | | Evidence | `evidence` | list, filter, create, update | List·Create·Update | Served | | Assignment | `assignments` | list, create | List·Create | Served | | ShiftRecord | `shift-records` | list | List | Served | | Badge | `badges` | `Badge.list` via `useBadges` | **`Ops: 0` — no routes** | Dead code | | User | `users` | *(none)* | **no resource** | Dead code | `filter()` is not a separate endpoint — it is `GET /{resource}` with field params. Arrays become repeated params (`?status=applied&status=hired`); `undefined` is omitted; `sort` is always sent, because `?sort=` means "no ordering" while omitting it applies the endpoint default. ### The two dead-code rows Neither is a backend gap, and neither warrants a new endpoint: - **Badge.** `useBadges` (`src/lib/krowHooks.js:537`) has **zero consumers**. The backend contract §2 records why: every badge the UI renders comes from `worker_profiles.earned_badges`. `GET /api/v1/badges` would 404 if this hook were ever wired. Fix in this repo by deleting the hook, or accept it as unreachable. - **User.** `'User'` is in `ENTITY_NAMES` and `RESOURCE_PATHS`, so `createEntity('User')` builds a client for `/api/v1/users` — a path with no backend resource. **No call site exists.** Authentication goes through `/me`. ### Defaults line up The backend's `DefaultSort` / `DefaultLimit` were derived from these call sites, and match them: | Resource | Frontend call | Backend default | | --- | --- | --- | | job-applications | `list('-ai_score', 200)` | `-ai_score` / 200 | | worker-profiles | `list('-krow_score', 500)` | `-krow_score` / 500 | | user-activity, assignments, shift-records | `list('-created_date', 500)` | `-created_date` / 500 | | courses, certifications, evidence, badges | `list('-created_date', 200)` | `-created_date` / 200 | | job-postings, ai-interviews, staff, learning-paths, role-categories | `list('-created_date', 100)` | `-created_date` / 100 | Filters actually sent: `job_posting_id` (applications), `email` (worker profiles, `limit 1`), `worker_email` (evidence), `user_email` (activity, `limit 20` — `src/pages/Profile.jsx:28`). --- ## 4. Auth and preferences — Served | Method | Path | Frontend | | --- | --- | --- | | `GET` | `/api/v1/me` | `auth.me()`, and a module-load hydration promise | | `PATCH` | `/api/v1/me` | `auth.updateMe(patch)` | | `PATCH` | `/api/v1/me/preferences` | `auth.updatePreferences(patch)` | | `POST` | `/api/v1/auth/login` | `{ email, password, remember_me }` | | `POST` | `/api/v1/auth/logout` | Redirects locally even if the call fails | - `GET /me` is fired once at module load and shared, so the eleven `me()` call sites in the first render make one request between them (`src/api/base44Client.js:130-143`). - A 401 is the ordinary signed-out state, not a failure. Every credential failure returns the same 401 with the same message, by design. - `auth.preferences()` is **synchronous** and must stay so — `AssistantPanelContext` decides whether Owliver starts open in a `useState` initialiser. That is why the last user is mirrored to `localStorage` under `krow_demo_user`. ### Preference keys in use | Key | Written by | Note | | --- | --- | --- | | `owliverDefault` | Settings | Seeded (`src/api/seed.js:1891`) | | `compactDensity` | Settings | Seeded; read by `ds/DataTable.jsx:67` | | `emailDigest` | Settings | Seeded | | `customSkills` | Skill editors, `WorkspaceSkills.jsx` | Array of `{ path: 'custom/.md', raw: }` | | `customAgents` | `lib/agents/useAgents.js:68,78` | Same shape, for agents | | `disabledSkills` | `WorkspaceSkills.jsx`, `AgentDetail.jsx` | Array of skill ids | | `removedSkills` | `WorkspaceSkills.jsx:331,353` | Array of skill ids | > **Drift:** the backend contract §1 still says *"Auth: None in v1. Every > endpoint is unauthenticated."* That is stale — sessions, argon2id and cookie > middleware are implemented (`migrations/000004_auth_sessions.up.sql`, > `internal/auth/`, `internal/httpserver/auth.go`). --- ## 5. Adopt — 12 endpoints the backend already has and the frontend ignores None of these needs server work. All exist and are registered today. ### 5.1 Workflow endpoints (2) — **Served** The backend built these specifically to replace frontend call sequences, and says so in the source. Both are now adopted. | Endpoint | Frontend call site | Replaced | | --- | --- | --- | | `POST /api/v1/job-applications/{id}/hire` | `base44.workflows.hire` ← `useHireCandidate` | `PATCH /job-applications/{id}` + `POST /staff` + `POST /user-activity` | | `POST /api/v1/job-postings/{id}/assignments` | `base44.workflows.assign` ← `useAssignWorkers` | per worker: `POST /assignments` + `POST`/`PATCH` `/job-applications` + `POST /user-activity` | `internal/httpserver/workflows.go:85` reads verbatim: > *"handleHire moves an application to `hired` and creates the staff record in > one transaction. Replaces the two-call sequence at krowHooks.js:302-303."* That sequence is gone. `useHireCandidate` now issues one request and sends only what a hiring decision chooses — `role`, `profile_tier`, `hire_date`, `status`; name, email, phone, score, posting and application id are carried across from the application by the server. `useAssignWorkers` sends the whole batch in one body, naming each worker's application by `application_id` where the page already holds it and by an `application` payload where it does not, so a worker with no application is found-or-filed inside the same transaction rather than in a second, untransacted request. Both handlers also write their own `user-activity` entry inside the transaction — `hire_candidate` and `assign_employee` — so the frontend no longer logs either. It invalidates `['userActivity']` instead. The server-side handlers authorize on the operations they perform — hire needs UPDATE on job-applications, CREATE on staff, CREATE on user-activity — checked all-or-nothing before any transaction opens. ### 5.1a Interview completion — **Served** `POST /api/v1/ai-interviews` is routed through the same workflow service (`httpserver/api.go:156`): creating an interview also moves its application to `status: 'interview'`, sets `interview_id`, and copies `overall_interview_score` onto `ai_score` when the request supplied one — all in one transaction. `AIInterviewModal.finishInterview` no longer patches the application afterwards. That second call was not merely redundant: for a talent user sitting their own interview it was a 403, because `ai-interviews:Create` is open to everyone and `job-applications:Update` is operators only — the interview existed, the application still read `applied`, and nothing that counts `status === 'interview' || interview_id` could see it. ### 5.2 Definition endpoints (10) `agent-definitions` and `skill-definitions`, five methods each: `GET`, `POST` on the collection; `GET`, `PATCH`, `DELETE` on `/{id}`. **This is where account-authored Owliver skills are supposed to live.** `migrations/000005_agent_skill_definitions.up.sql` states the intent verbatim: > *"Before this migration the last two lived in `user_preferences.extra`, a > jsonb blob with no owner, no tenancy, no size bound, no server-side > validation and no query surface — and returned in full by GET /api/v1/me on > every page load. This migration is that move."* **The frontend has not moved.** It still writes `customSkills` (121 references across the repo) and `customAgents` through `PATCH /me/preferences`. What the tables give that the preferences blob does not: | Column | Purpose | | --- | --- | | `markdown` | The definition verbatim — the authoritative artefact | | `definition_id` | Author-facing id, `^[a-z0-9][a-z0-9-]*$`, unique per owner or per org | | `visibility` | `personal` \| `organization` — shadow-by-id is deliberate | | `owner_user_id` / `org_id` | Tenancy and ownership; personal definitions cascade with their owner | | `status` | agents: `draft`/`published`/`archived`; skills: `active`/`inactive` | | `version` | Agents only, monotonic. **Skills have no version** — none is invented | | `name`, `description`, `pages` | Server-parsed projections, never accepted from a request body | Plus a `length(markdown) BETWEEN 1 AND 65536` bound the jsonb blob has no equivalent of. **`disabledSkills` and `removedSkills` stay in preferences** — the same migration says so. They are per-account arrays of skill *ids*, mostly shipped ids, so they are suppression preferences over a namespace, not definitions. ### 5.3 Owliver suggestions (1) `GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in the backend contract's §2A. **Called, registered in backend source, and NOT deployed on every host.** This is the one row in this document where those three come apart, so it is worth stating plainly: | | | | --- | --- | | Frontend calls it | `src/api/base44Client.js` (`base44.owliver.suggestions`) → `src/lib/krowHooks.js` (`fetchOwliverSuggestions`, `useOwliverSuggestions`) → `src/lib/skills/serverSuggestions.js` → `KrowAssistant.jsx` | | Backend source registers it | `go-api/internal/httpserver/owliver.go:22` | | `https://mcp.krowforce.com` serves it | **No — 404 as of this writing** | The endpoint now also builds real context: with no `query`, the backend counts the organization's positions, applications, interviews, staff, profiles, courses and activity in one statement and ranks the page's readings against them, so a suggestion changes when the data does. See the backend contract's §2A. So a signed-in dev server pointed at `mcp.krowforce.com` answers this route `404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a frontend bug, and no change in this repository fixes it.** The two ways out are to deploy `b6f8655` to that host, or to point `VITE_API_PROXY_TARGET` at a backend built from current source. Two things make this expensive to diagnose, and both are worth knowing before reaching for the frontend code again: - **An unauthenticated probe cannot tell you anything.** Authentication wraps the whole mux (`server.go:153`), so *every* path under `/api/v1` answers `401` when the session cookie is absent — a registered route and a nonexistent one alike. `curl` against the host proves nothing on its own. The `404` only appears once a request is authenticated and reaches the router, which is why it shows up in the browser and not in a terminal. - **`401` versus `404` is the whole diagnosis.** Through the dev proxy, `401` means the session is not signed in; `404` means the session is fine and the route is not on that backend. The panel treats the two differently, in `fetchOwliverSuggestions` (`src/lib/krowHooks.js`): a `404` closes a circuit breaker and it stops asking for the rest of the page load, because no further keystroke can change the answer. A `401`, a `500` and an unreachable API are all still retried. Against a host that does not serve the route the chip row is simply empty — the panel, the composer and every answer are unaffected. The endpoint replaces the *selection*, not the answering: it returns at most three `{ text, intent }` pairs, where `intent` is the frontend capability id verbatim — `position-strength`, `hiring-operations` — so a chosen suggestion dispatches through the capability manifests that already exist. An optional `capability` names the section type when the query asked for one ("as a flow"), from `OWLIVER_CAPABILITIES`. `page` is a `SKILL_SURFACES` id, not a route and not a context id, so the call site is `surfaceForRoute()` / the page key the skill registry already derives — not `ASSISTANT_CONTEXTS`. Aliases resolve, so `hired` and `hired-history` both work. What the server adds over computing this in the browser is the permission filter: each intent declares what it reads, checked against the policy table, so a caller is never offered a reading their role cannot perform. That check cannot be done client-side, which is the reason this one is worth adopting rather than keeping local. No table and no migration — the catalogue is derived from the capability manifests and the closed Owliver vocabulary. §6's `owliver:` suggestions are a different thing and unaffected: those are what an *authored skill* declares, and the backend still treats the block as opaque. ### 5.4 Registered, deliberately not adopted (1) `GET /api/v1/me/preferences` exists. The frontend reads preferences off `GET /me` plus the `krow_demo_user` mirror instead, because `auth.preferences()` must answer synchronously on first paint. Not a gap. --- ## 6. Owliver skill response API — Local stub What a skill definition may declare about what Owliver can be *asked for*. The `owliver:` block, normalized by `src/lib/skills/owliverConfig.js` against the closed vocabulary in `src/lib/skills/surfaces.js`. ```yaml owliver: enabled: true suggestions: - Show hiring activity - label: Summarize it prompt: Summarize hiring activity capability: summary capabilities: - summary - flow responses: flow: title: Hiring Activity Flow source: position.activity steps: [today, yesterday, last-week] ``` ### 6.1 Resolution rules - **A response is a section.** It normalizes through the same `normalizeSection()` the page's `ui:` sections go through, so a capability resolves to the same record, the same data source and the same renderer. There is no second shape for "the chat version". - **The block is optional.** No `owliver:` normalizes to `NO_OWLIVER` — disabled, and behaviourally identical to before the feature existed. - `enabled: false` switches it off without deleting what was written. - **`steps:` ≡ `periods:`** — the same list under two names. - **Inheritance.** A response with no `source` takes the first declared `ui:` section's `source`, `periods`, `limit` and `editable`, in declaration order. - **A capability that resolves no source is dropped**, with a named error — otherwise it would register as something Owliver offers and then have nothing to answer with. - **Nothing unknown survives.** Capabilities, sources, periods and shapes are checked against the tables below; an unrecognised value is a named error, not a dropped key. - **`editable` needs both halves.** The source must be `writable` *and* the page must publish a handler (`usePublishPageActions`). Neither side can enable editing alone. ### 6.2 Capabilities (10) `shape` is the section type the answer is drawn with. `terms` are how a question is recognised as asking for that *shape* — never for a subject; a subject belongs in the definition's `triggers`. | id | shape | Reads as | | --- | --- | --- | | `summary` | *(none — prose)* | The figures read back as sentences | | `flow` | `flow` | Stages or periods as a sequence | | `stats` | `stats` | A row of counted figures | | `list` | `list` | A ranked or plain list of records | | `table` | `table` | Rows and columns | | `timeline` | `timeline` | Dated events, most recent first | | `progress` | `progress` | Bars against a total | | `weights` | `weights` | Weighted criteria, adjustable where the page accepts the write | | `insight` | `insight` | One finding, stated plainly | | `card` | `card` | A titled panel of figures | `summary` is the one capability with no component, so it is compatible with every source. ### 6.3 Data sources (25) `context` is what the page must supply for the reading to be possible. `options` are the knobs a definition may set. Only `position.vetting` is writable. | id | context | options | | --- | --- | --- | | `position.activity` | positionId | periods | | `position.pipeline` | positionId | — | | `position.candidates` | positionId | limit | | `position.matches` | positionId | limit | | `position.requirements` | positionId | — | | `position.vetting` | positionId | — · **writable** | | `candidate.readiness` | candidateId | — | | `candidate.activity` | candidateId | — | | `candidates.pipeline` | — | — | | `candidates.activity` | — | periods | | `candidates.quality` | — | periods, limit | | `positions.demand` | — | limit | | `positions.risk` | — | limit | | `talent.pool` | — | limit | | `hires.recent` | — | limit | | `hires.performance` | — | — | | `workforce.training` | — | limit | | `workforce.coverage` | — | limit | | `workforce.attendance` | — | periods, limit | | `workforce.overtime` | — | periods, limit | | `activity.signals` | — | limit | | `activity.breakdown` | — | periods, limit | | `activity.events` | — | limit | | `operations.risk` | — | — | | `workspace.summary` | — | — | Each source declares which `shapes` it can be drawn as; `sourceSupportsShape()` is the single resolver all three consumers use (the normalizer, the Board editor's picker, the Owliver editor's per-capability picker). ### 6.4 Section types (9) and periods (6) `card`, `stats`, `list`, `timeline`, `flow`, `table`, `progress`, `insight`, `weights`. `today`, `yesterday`, `last-7-days`, `last-week`, `this-month`, `previous-month` — each a window over `created_date`, resolved at read time. Never a stored figure, never a hard-coded date. ### 6.5 Surfaces (18) `control-center`, `positions`, `create-position` *(alias `new-position`)*, `candidates`, `hired-history` *(alias `hired`)*, `talent-pool`, `krow-forge` *(aliases `university`, `forge`)*, `analytics`, `activity`, `workspace-agent-configure`, `settings`, `workspace`, `workspace-agents`, `workspace-skills`, `workspace-skill-configure`, `skill-development`, `profile`, `candidates-analysis`. The nine with placements are domain surfaces; the rest declare `placements: []` — configuration screens holding no workforce records, where a `ui:` skill is refused at validation rather than validating and drawing nothing. `placementProvides()` says which placement supplies a `positionId` or a `candidateId`, so a section reading `position.activity` cannot be attached where no position is in context. ### 6.6 Where this is enforced **Client-side only.** The backend mirrors `SKILL_SURFACES` exactly — all 18 ids and the same aliases, in `internal/definition/vocabulary.go:18-40` — and validates a definition's `pages:` against it. It treats `ui:` and `owliver:` as **opaque** (`internal/definition/skill.go:237`). So if the definition endpoints in §5.2 are adopted, the server will store and round-trip an `owliver:` block verbatim and check its `pages:`, but will not reject an unknown capability or data source. That validation stays in `owliverConfig.js`. --- ## 7. Assistant streaming API — Local stub (wired, unused) `src/components/ai-assistant/provider.js`. The UI never generates an answer and never knows where one came from; it calls `provider.stream(request)` and renders snapshots. ``` provider.id: string provider.stream(request): AsyncIterable request = { contextId, capability, question, facts, signal } ``` Each yielded value is the response **so far** as a full block array — snapshots, not deltas, because a table or a KPI row has no meaningful half-state. `createHttpProvider({ endpoint })` sends: ```http POST Content-Type: application/json { "contextId": "admin.positions", "capability": "flow", "question": "...", "agent": {...}, "owliverContext": {...} } ``` and reads SSE-style lines: - `data: {"block": {...}}` — appended as a whole block - `data: {"delta": "..."}` — appended to the trailing text block - `data: [DONE]` — ignored - unparseable payload — appended as text Requires a streaming `response.body`; a non-2xx throws `Assistant request failed: `. **The fact sheet is deliberately not sent.** Dashboard data is to be read server-side from the caller's own session, so the client cannot ask about records it is not entitled to see — and an agent cannot widen that by being named in the body. **Status.** `createAssistantProvider()` returns `createHttpProvider` only when `VITE_ASSISTANT_ENDPOINT` is set. It is unset, so the app runs `createLocalProvider()` — deterministic answers computed from the dashboard's own data. **No backend route exists for this.** Related, and honest about itself: an agent with `webSearch` set gets a note saying no search provider is configured in this deployment, rather than an answer that quietly came from nowhere. --- ## 8. AI integrations — Local stub `src/api/aiEngine.js`. No network, no key, no server route anywhere in the backend. ### `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })` Recognises each workflow by a stable phrase its prompt opens with, reads the structured fields the prompt already carries, and scores deterministically — same input, same output. An unrecognised prompt logs a warning and returns `{}` (or `''` with no schema) rather than a shape the caller cannot use. Consumers — `src/lib/krowAi.js`: `generateJobDescription`, `buildResumeFromText`, `screenCandidate`, `generateInterviewQuestion`, `matchTalentForJob`, `evaluateInterview`, `owliverQuestion`, `buildProfileFromConversation`. And `src/lib/provingGround.js`: `challengeFollowUp`, `evaluateChallenge`. ### `integrations.Core.UploadFile({ file })` Returns `{ file_url, file_name, file_size }` where `file_url` is a `blob:` URL from `URL.createObjectURL`. **There is no file storage backend.** The URL is valid for the rest of the session and dies on reload — anything persisted through it (a selfie on a worker profile, challenge media) is a dangling reference afterwards. Consumers: `src/pages/KrowIdentity.jsx`, `src/components/krow/proving/MediaChallenge.jsx`, `src/components/krow/proving/VideoRecorder.jsx`. ### `analytics.track({ eventName, properties })` A DEV-only `console.debug` (`src/api/base44Client.js:287-293`). No telemetry endpoint exists. Note that real activity logging is separate and *is* served — `logActivity` writes through `POST /api/v1/user-activity`. ### `GET /health` Registered on the backend and public. `vite.config.js` proxies it. **No frontend code calls it.** --- ## 9. Browser-only state | Key | Store | Holds | | --- | --- | --- | | `krow_demo_user` | localStorage | Mirror of the last `GET /me`, so `auth.preferences()` can be synchronous | | `krow_assistant:history` | localStorage | Owliver conversation history across sessions | | `krow_assistant_open` | sessionStorage | Whether the panel is open | | `krow_assistant_expanded` | sessionStorage | Expanded state | | `krow_assistant_width` | sessionStorage | Panel width, throttled on drag | | `krow_assistant:agent` | sessionStorage | Selected agent | | `` / `:flow` / `:id` | sessionStorage | Current thread, in-flight flow and conversation id, per page context | Other platform APIs: `window.matchMedia` and `window.visualViewport` (§11), `URL.createObjectURL` (§8), `navigator.mediaDevices.getUserMedia` and `MediaRecorder` (`components/krow/proving/VideoRecorder.jsx:21,37`), and `html2canvas` (`components/krow/talent/IdentityCareerCard.jsx`). --- ## 10. External hotlinks — External No auth, no versioning, no contract. Each is a live dependency on a third party. | Host | Used for | Where | | --- | --- | --- | | `fonts.googleapis.com` | Sora webfont | `src/index.css:1` | | `api.qrserver.com` | vCard QR for the KROW ID | `KrowIdPreview.jsx:52`, `talent/IdentityCareerCard.jsx:132` | | `images.unsplash.com` | Portrait fallback when `selfie_url` is empty | `SuggestedTalent.jsx`, `CandidateCard.jsx`, `TalentPoolCard.jsx` | | `i.pravatar.cc` | Seed avatars | `src/api/seed.js` (15 records) | | `static.wixstatic.com` | Image host allowlist | `src/components/ui/image.jsx:6` | --- ## 11. Owliver panel responsiveness **This section is UI layout, not an API contract.** It is here because it was asked for alongside the API surface, not because anything below crosses a network boundary. `src/components/ai-assistant/viewport.js`: - **`STACK_BREAKPOINT = 768`**, i.e. Tailwind's `md`, exported so the panel and the provider switch on the same number. Below it Owliver stops being a column beside the dashboard and becomes an overlay — a 380px track on a 375px phone collapses `main` to nothing. Deliberately `md` and not `lg`: tablets lay the inline column out acceptably, so only phones change presentation. - **`useIsPhone()` uses `matchMedia`, not `innerWidth`.** When anything on the page overflows, mobile browsers widen the layout viewport to fit it, and `innerWidth` reports the widened value — so a width comparison reads the viewport as a tablet and hands it a sidebar, defeating the very layout that exists to rescue the page. A media query is evaluated against the media, not the content. - **`useVisualViewport()`** returns the region left over with the on-screen keyboard up. `100dvh` follows the address bar but knows nothing about the keyboard, so a `100dvh` sheet puts its own composer underneath the keys. Returns `null` where the API is missing, and the caller falls back to the `100dvh` class. No fixed pixel heights anywhere. - **Width** is clamped against `useViewportWidth()` and persisted to `krow_assistant_width` on a throttle rather than sixty times a second. --- ## 12. Drift found Recorded, not fixed — each belongs in its own repo. | Where | Issue | | --- | --- | | `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. | | ~~`krow-demo/src/api/store.js`~~ | **Fixed.** The file is deleted. It was 173 lines and imported by nothing. | | ~~`krow-demo/src/api/seed.js`~~ | **Fixed.** The default user shape moved to `src/api/demoUser.js`, so no production module imports the fixture and its 1,928 lines are out of the bundle. `seed.js` remains, serving `scripts/skill-check.mjs` and `scripts/owliver-capture.mjs` as the test fixture it now is. | | `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. | | `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. | ## 13. Summary - **Nothing the frontend calls is missing from the backend.** All 14 live entities and all 5 auth/preference routes are served. - **The two workflow endpoints are adopted.** Hire, assign and interview completion each go over one request and one transaction; the frontend no longer sequences the writes itself, and no longer writes the `hire_candidate` or `assign_employee` audit entries the server now writes inside those transactions. - **12 registered endpoints still go unused**, of which 11 are worth adopting: 10 definition endpoints that are where Owliver's authored skills and agents were meant to move, and the Owliver suggestion endpoint, which is the only one that cannot be done correctly in the browser — it is permission filtered. - **Four surfaces have no server at all** — `InvokeLLM`, `UploadFile`, the assistant streaming endpoint, and web search. They are local stubs today, and the app is honest about each. - **The Owliver skill vocabulary is enforced in the browser only.** The backend validates `pages:` and treats the rest of a definition as opaque.