Files
krow_talent_app/docs/api-audit.md
2026-08-25 18:56:07 +05:30

662 lines
30 KiB
Markdown

# 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/<id>.md', raw: <markdown> }` |
| `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.** 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:300`, via `useSuggestions.js` |
| Backend source registers it | `go-api/internal/httpserver/owliver.go:22`, commit `b6f8655`, pushed to `origin/main` |
| `https://mcp.krowforce.com` serves it | **No — 404** |
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 as of `useSuggestions.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.
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<Block[]>
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 <endpoint>
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: <status>`.
**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 |
| `<contextKey>` / `: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` | 173 lines, **imported by nothing**. Superseded by `httpClient.js`. Dead. |
| `krow-demo/src/api/seed.js` | 1,928 lines, retained for one export — `DEMO_USER.preferences`, the default shape the synchronous accessor needs. Entity data lives in PostgreSQL. |
| `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.