krow-employee
The KROW Employer console — the hiring surface for platform.krowforce.com.
A standalone React frontend over the existing KROW Go backend. It has no backend
and no database of its own: every figure on every page is read from
/api/v1, and every write goes back through the same API.
Running it
npm run dev starts the API for you. It checks the host that vite.config.js
proxies /api to, and if nothing is listening there it launches
../krow-backend, waits for /health, and only then starts Vite — so the dev
server never comes up pointing at an API that does not exist.
# One-time backend setup (separate repo, ../krow-backend)
cd ../krow-backend
make migrate-up && make seed
make import-agents ORG=krow-dev # required, or Owliver 404s
make ingest ORG=krow-dev # required, or retrieval finds nothing
# This app — starts the API too
cd ../Krow-employee
npm install
cp .env.example .env
npm run dev # http://localhost:5173
PostgreSQL still has to be running; the API needs it and will not start without
it. pg_isready should say accepting connections.
| Variable | Meaning |
|---|---|
KROW_BACKEND_DIR |
the API repo, if it is not ../krow-backend |
KROW_SKIP_API_CHECK=1 |
skip the preflight |
npm run dev:vite runs Vite alone, for frontend work with no API.
502 Bad Gateway on /api/v1/...
This means nothing is listening on the proxy target — the API is a separate process, and a frontend that looks perfectly healthy will still 502 on every call without it. It is not a backend bug and not a CORS problem.
npm run dev now prevents it at startup. If you see it anyway, the API exited
mid-session; the terminal running npm run dev prints the target and the fix,
and the response body carries code: "api_not_running" rather than a generic
status-502 failure. Restart with npm run dev, or cd ../krow-backend && make run.
GET /api/v1/version reports the endpoint count. Owliver's agent routes are
registered only when ANTHROPIC_API_KEY is set on the backend; without it the
assistant panel says so plainly instead of failing at request time.
Signing in
The seed ships one user, demo@krow.app, with role admin and no password —
passwords never travel in the seed. Set one, and add an employer to test the role
gate against:
-- only if employer@krow.app does not already exist
INSERT INTO users (org_id, email, full_name, role, account_type, status)
SELECT id, 'employer@krow.app', 'Employer Demo', 'employer', 'employer', 'active'
FROM organizations WHERE slug = 'krow-dev';
cd ../krow-backend/go-api
go run ./cmd/setpassword -email employer@krow.app
Without an employer user every local session runs as admin, and the role gate
in routes/EmployerRoute.jsx is never actually exercised.
Why the dev server proxies the API
VITE_API_BASE_URL must stay a same-origin path (/api/v1) in development.
The session is an HttpOnly, SameSite=Lax cookie. A Lax cookie is not sent on a
cross-site request, and a browser treats localhost:5173 and 127.0.0.1:8080 as
different sites — so an absolute URL there means every call after login arrives
without the cookie and is answered 401. vite.config.js proxies /api and
/health instead, which makes the cookie first-party. Nothing in client code
names a backend host.
Architecture
Employer UI → lib/krowHooks.js → api/krowClient.js → api/httpClient.js
(TanStack Query) (entities, auth, (fetch, credentials:
workflows) 'include')
↓
Existing KROW Go API → PostgreSQL
| Directory | What lives there |
|---|---|
src/api/ |
httpClient.js (fetch, {data, meta} unwrapping, KrowApiError), krowClient.js (entities, auth, workflows, Owliver suggestions), aiEngine.js |
src/components/ds/ |
The KROW design system. One import surface: @/components/ds |
src/components/ui/ |
Radix primitives, restyled to the tokens |
src/components/ai-assistant/ |
Owliver: the panel, the SSE transport, the block vocabulary |
src/components/krow/ |
Domain components — candidate cards, talent modals, analytics blocks |
src/lib/ |
Hooks, scoring, the hiring ladder, the skill and agent registries |
src/agents/, src/skills/ |
Markdown definitions, loaded by import.meta.glob at build time |
src/pages/hiring/ |
The hiring pages |
Routes
/login · /dashboard · /positions · /positions/new · /positions/:id ·
/candidates · /candidates/:id · /hired · /talent-pool · /analytics ·
/activity · /profile · /settings
Canonical paths — read this before editing a route
Route literals in the source are written in canonical form (/admin/positions),
not the addresses this app serves (/positions). That is deliberate.
This console was lifted out of krow-demo, where the same hiring pages are
mounted twice — once under /admin and once under /employer. Well over a
hundred route literals are written against /admin, and not only in components:
lib/skills/registry.js, lib/skills/actions.js and components/ai-assistant/
emit and match canonical routes too, and pageKeyForRoute derives from them the
page id the backend's GET /owliver/suggestions?page= accepts — a closed
vocabulary this app does not own (control-center, positions, hired-history…).
So lib/product.js translates in one place instead:
rebase('/admin/positions')→/positions— on the way out, at every linkcanonicalize('/positions')→/admin/positions— on the way in, for the placement, skill and agent tables
useProduct.js is the hook layer. A page still says
navigate('/admin/positions/123'); the address bar says /positions/123.
What this console deliberately does not have
The backend has no endpoint for any of these, so none of them are built. A control that looks live and does nothing is worse than an absent one.
- Interview scheduling and candidate messaging. No table, no endpoint. The
card offers a real
tel:dialler and real stage transitions instead. - Notifications. Nothing in the backend sends any. The Activity page and Owliver's suggestions carry "what needs attention".
- Password change, 2FA, session management. Passwords are set by the
setpasswordCLI on the server. - Organisation settings, team members, billing. No
/organizationsor/usersroute exists. - File and avatar upload.
selfie_urland friends are plain text columns. - Deleting a position.
DELETE /job-postings/{id}answers 405 by design.
Analytics is computed in the browser from raw list endpoints, because the
backend has no aggregate routes and no date-range filters. The SQL for those
aggregations already exists in krow-backend/go-api/internal/tools/*.go and
would be the natural extension when data volume outgrows the 1000-row cap.
AI screening, job-description generation and talent matching run through
api/aiEngine.js, a deterministic local stand-in, and persist to the real
score columns. It sits behind krow.integrations.Core.InvokeLLM, so replacing
it with a server endpoint is a one-file change.
Data traps
These come from the backend's own handover notes and have produced wrong numbers
before. The ladder lives once, in src/lib/hiringRecords.js, and everything
counts through it.
screened_atis vestigial. Nothing writes it. Screened means the application is at or beyondai_screened— useatOrBeyond.- A score of
0means "not rated", never "rated zero" —ai_score,client_rating,krow_score,reliability_score,attendance_score,performance_score,experience_years. Average with a> 0filter and state the basis count. - Genuine zeros, do not filter:
overtime_hours,minutes_late,xp,profile_completion,actual_hours. rejectedstill counts as screened. It is terminal from any stage and overwrites the stage it was reached from;rankOfputs it back at the screened rung.assignedcounts as hired — a hire who has been rostered.HIRED_STATUSES.attendance_scoredefaults to 100 and must never be a scoring input.- Array columns are not server-filterable —
skills,certifications,availability. Filter them client-side. - Most resources have no
GET /{id}. Onlyjob-postingsandcoursesdo; everything else answers 405.createEntityuses?id=<uuid>on the list route, so pages do not have to know.
Checks
npm run build # production bundle
npm run lint # eslint, must be clean
npm run typecheck # tsc over JSDoc; see the note below
typecheck reports pre-existing errors inherited from krow-demo (mostly
InvokeLLM call shapes and import.meta.glob, which tsc does not model). It
is a signal to read, not a gate that currently passes.