AI-assisted HR letter drafting for the UK and US. Wizard-driven, legally aware, with a full-height right-rail AI agent that proposes section-level edits and inline floating comment threads anchored to the document text. Built on Vite + React 19 + TypeScript, AI SDK v6, and Anthropic Claude (claude-sonnet-4-6 by default).
Three product surfaces, three user types:
- Employee mode (
/me) — single-letter wizard for an individual handling their own grievance / resignation / flexible-working / tribunal claim. - HR/Management mode (
/hr) — portfolio workbench: Employees directory, Matters with risk register, bulk CSV operations, workspace-saved Custom Templates, today/risk/compliance dashboard. - Consultant mode (
/c) — marketplace specialists who review letters for HR teams: profile/listing, booking inbox, matter-scoped share-token draft review, per-booking messaging, ratings.
Current version: 0.21.1 — billing + monetisation live on prod in Stripe test mode: subscriptions (Starter/Pro/Business + Employee Plus), one-off letters, case packs, and AI credit packs, with an AI-critique feature that spends credits and a plan credit drip that grants them. The hard paywall is built and airtight but env-gated OFF (DRAFTHR_ENFORCE_PAYWALL) until the test→live cutover. Built on the 0.18 security-hardening wave (per-route role guards, fail-closed auth, build-enforced tenant isolation; see SECURITY.md) and the 0.17 consultant-marketplace GA. Open backlog in ROADMAP.md.
→ Live design system: /public/design-system.html
→ Regenerate UX options: /public/regenerate-options.html
→ Onboarding funnel mockups (parked, decision pending): /public/mockups/onboarding-index.html
| Doc | Status | What |
|---|---|---|
CHANGELOG.md |
live | Release history, newest first |
ROADMAP.md |
live | Open backlog — build / protect / upgrade |
SECURITY.md |
live | Auth model, access-control matrix, residual risks |
PROVISIONING.md |
reference | Vercel/Neon/WorkOS env provisioning |
HR-LETTER-KNOWLEDGE-BASE.md |
reference | Legal scaffolding behind the letter templates |
PLAN.md, SUNDAY.md, EMPLOYEE-SIDE-DESIGN.md, RADIX-THEMES-MIGRATION.md, QWEN-API-REFERENCE.md |
historical | Original specs / dated inventories / superseded migration notes — kept for provenance, not current truth |
- Portfolio dashboard (HR mode) — Today / Risk / Compliance triptych: open tasks + matter next-actions due in 7d, risk register with £ exposure across active matters + a low/moderate/high/critical heatmap (auto-derived from health-check signals across each matter's drafts when no manual tagging exists), rolled-up compliance health. Recent-activity feed across the whole workspace.
- Employees directory —
/employees, sortable + searchable, with status filter (active / notice / on-leave / left), one-click import from existing drafts (derives unique people by normalised legal name), per-employee profile with Active matters / Letter history / Notes tabs. - Matters as case files — Matters carry type (investigation / grievance / disciplinary / performance / absence / redundancy / restructure / settlement / tribunal / onboarding / offboarding), risk band, participants (subject / manager / accused / complainant / witness / representative), tasks, calendar events, owner, next-action banner, and £ exposure. Auto-derived risk band falls back from health checks when not manually set.
- Bulk operations —
/bulk, 4-step CSV → batch drafts flow with mail-merge economics: one AI call drafts a template with{{placeholder}}tokens for every CSV column, then client-side substitution produces N personalised drafts. 50-row batch ≈ £0.20 instead of ≈£10. - Custom templates —
BrowseLetters(/hr/new,/me/new) has a tab strip: DraftHR (the built-in 35 letter types) and My Templates (workspace-saved house phrasings). File → Save as template… in the editor captures per-section HTML + answers; starting a draft from a custom template spawns the base letter type's sections pre-filled. - Wizard → draft — 35 letter types (14 HR-side, 21 employee-side), each with a UK and US template authored as a structured scaffold (statutory anchors, hard constraints, gold-standard examples). The model fills the scaffold using the user's wizard answers; output is HTML.
- Per-section editor — every letter is broken into sections (recipient, subject, opening, body, terms, closing, signature…). Each section regenerates independently, has version history, and can be refined with a freeform instruction ("more conciliatory; cite s.95(1)(c) ERA explicitly").
- Two editing surfaces — section-by-section editing for structured drafting, plus edit-on-page mode that renders real paged.js A4 cards and lets the user type directly on the paginated letter. Preview and edit-on-page share the same A4 print geometry and responsive mobile page scaling.
- Top File / Tools / View menubar on desktop — File (project), Tools (AI actions: regen / refine / comments / timeline), View (mode + display toggles). Mobile keeps the previous icon toolbar + ⋯ overflow.
- Inline floating comments — selection-triggered "+ Comment" (Tiptap BubbleMenu in the editor, floating button in the preview) opens a
FloatingCommentPanelportalled to<body>and anchored to the highlighted text mark. Self-positions below the anchor, repositions on scroll, dismisses on Esc / outside-click. An All Comments dialog (Tools menu) lists every unresolved thread for sweep-before-export. Comments are persisted via a customCommentMarkTiptap extension. - Right-rail AI agent — full-rail-height, no tabs. Typed AI SDK v6 tool-calling: the agent can propose section rewrites, add named clauses, tighten a section to a word-count target, or produce a counsel-grade summary. Mutating calls stay human-in-the-loop: the user reviews each proposal with an Apply button before it commits, and successful applies push the outcome back to the agent. Empty state surfaces a starter grid (one card per typed tool) and a recent-prompts row backed by localStorage.
- Evidence & Attachments step — after drafting, add typed notes or uploaded files, link each item to the letter sections it supports, then reference, quote, or list those items in the letter before review/export.
- Workspace collaborators + invites — invite people by email + role, redeem invite links through WorkOS sign-in, and land directly in the accepted workspace. Workspace member management supports owner/admin/drafter/reviewer/viewer roles with owner-only controls for owner privileges.
- Consultant marketplace — HR books a vetted specialist to review a letter. Consultants enrol (
/c), publish a marketplace card (specialties, rate, SLA, availability), and receive bookings in an inbox. Full lifecycle: request → accept → deliver → complete, with a matter-scoped share token, minted once the booking is paid and withdrawn on refund or dispute, that grants the consultant read-only access to the specific draft (and nothing else) until it expires, per-booking messaging (server-derived author, no spoofing), and ratings that aggregate onto the profile. Persisted to Neon (cross-device) via a single dispatched function (/api/marketplace/*). Payments run via Stripe Connect (destination charges to the consultant's connected account; account + connect webhooks reconcile payout state). - Billing & monetisation (
/pricing) — direct-charge Stripe (DraftHR is the merchant), separate from the marketplace's Connect flow. Stripe-hosted Checkout →billing-webhookmirrors the subscription/purchase intosubscriptions/entitlement_purchases/credit_ledger(idempotent, exactly-once grants). HR plans bill the workspace, Employee Plus + one-offs bill the user (a one-off letter is bought for a tier, routine to tribunal, and only covers letters of that tier or below); shared tenant credit pool. AI critique (Improve tab) spends a credit for a lens-based specialist review via Claude Opus 4.8 (refunded on failure); plan credits drip per billing cycle; case packs are metered per-letter against a fixed allowance. The hard paywall (assertCanCreateLetterserver gate +<PaywallGate>client gate) is airtight but env-gated OFF until go-live. - Compliance + template quality inspector — live per-clause pattern matches plus section-by-section template requirements, including hard "MUST" checks and guardrails. Tied to the counsel-review status pill in the topbar.
- Letterhead + signature image uploads — Vercel Blob-backed when configured, with data-URL fallback for local development. Uploads are same-origin/rate-limited, production-session guarded, and restricted to PNG/JPEG/WEBP/GIF byte signatures.
- Export — PDF (hardened server Puppeteer with client
html2canvas+jspdffallback), DOCX (in-repo OOXML writer), and a ZIP export pack bundling the letter, evidence, manifest, audit JSON, timeline, and review checklist. High-risk letter types gate behind a counsel-review attestation dialog. - Security posture — production fails closed without WorkOS auth unless
VITE_ALLOW_PUBLIC_APP=trueis explicitly set; API routes enforce JSON/size/origin/rate-limit/session guards; rendered HTML is sanitized; embedded images are restricted to PNG/JPEG/WEBP/GIF. - Cryptographic signing (not yet shipped in exports) — the Ed25519 signer (AWS KMS in production via
SIGNING_KMS_KEY_ID, required there; in-process key in dev), the RFC 3161 timestamping over the signature value (FreeTSA) and the PAdES-B-T-style CMS writer and verifier exist, but the export pack does not yet writemanifest.sig./api/sign/manifestis off unlessDRAFTHR_MANIFEST_SIGNING=enabled, because it signs caller-supplied bytes; it needs a server-built manifest before it can be turned on. Details indocs/signing.md. - Cmd+K search — indexes drafts, letter catalog, section content (with snippets), comments, and route shortcuts.
| Layer | What |
|---|---|
| Build | Vite 6, React 19, TypeScript |
| Styling | Tailwind v4, Radix UI Themes (gray / slate), Source Serif 4 for marquee, Geist for sans |
| State | Zustand stores (useDraftStore, useProfileStore, useThemeStore) persisted to localStorage |
| Editor | Tiptap section editor plus paged.js edit-on-page; native toolbar commands, undo/redo, cursor restore across reflow, Word-paste cleanup, oversized-block normalization, responsive A4 scaling, custom CommentMark |
| AI | AI SDK v6 (ai@^6, @ai-sdk/react@^3) + Anthropic Claude via @ai-sdk/anthropic@^3 — Sonnet 4.6 default, Opus 4.7 / Haiku 4.5 selectable in Settings (Opus needs a paid plan; viewers can't generate). Anthropic native prompt caching (cacheControl: { type: "ephemeral" }) on /api/draft + /api/agent stable system prefixes — ~90% input-token discount on repeat calls inside the 5-min TTL |
| Motion | Framer Motion 12 — bottom-sheet drag/snap on mobile, route cross-fade transitions |
| Toasts | Sonner (theme-aware, swipe-dismiss, Undo affordance on accept/reject pending changes) |
| Auth/DB | WorkOS-gated production by default; localStorage primary persistence with optional Neon/Postgres dual-write and workspace APIs |
| Export | Hardened Puppeteer PDF route; client PDF fallback via html2canvas + jsPDF; in-repo DOCX writer; ZIP export packs |
# Install
npm install
# LLM key — copy and fill in ANTHROPIC_API_KEY
cp .env.example .env.local
# Dev server (Vite on port 5273 by default)
npm run devOpen http://localhost:5273.
Heads-up —
npm run devdoes NOT serve/api/auth/*. The Vite dev plugin only mounts/api/draftand/api/agent(seedev/qwen-proxy-plugin.ts). Every other/api/*route — auth, workspace, sign-up, password reset, verify-email — only runs under the real Vercel runtime. If you need to test those locally, install the Vercel CLI and runvercel devinstead. Symptom of using the wrong dev server: the SPA's sign-in form throws "Sign-in API didn't respond with JSON" because the auth endpoint fell through to the SPA's 404 HTML.
# Required
ANTHROPIC_API_KEY=<your-anthropic-api-key>
# Optional
LLM_MODEL=claude-sonnet-4-6 # allow-listed: claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5
VITE_WORKOS_CLIENT_ID=
WORKOS_API_KEY=
WORKOS_CLIENT_ID=
WORKOS_REDIRECT_URI=http://localhost:5273/auth/callback
WORKOS_COOKIE_PASSWORD=<32+ chars>
VITE_ALLOW_PUBLIC_APP=false
BLOB_READ_WRITE_TOKEN= # Vercel Blob — letterhead/signature/photo uploads
DRAFTHR_DUAL_WRITE=false # mirror drafts to Postgres (server side)
VITE_DRAFTHR_DUAL_WRITE=false # client twin — Vite inlines VITE_ vars only; both must match
DATABASE_URL= # Neon — enables dual-write + workspaces + marketplace
WORKOS_WEBHOOK_SECRET= # verifies WorkOS Directory Sync webhook signatures
VETTING_AUTO_PUBLISH= # '1' auto-publishes consultant profiles; leave UNSET in prod once admin vetting exists
VITE_ADMIN_EMAILS= # comma-list allowlist for /admin/consultants client gate (cosmetic until a server admin role lands)
# DRAFTHR_DEV_NO_AUTH=1 # LOCAL DEV ONLY — bypasses session on /api/draft,/api/agent,/api/upload. NEVER set on Vercel.A user can also pick the model at runtime via Settings → "Generation model". The choice is sent in the request body, validated by the server allow-list, and takes precedence over the env default. ANTHROPIC_API_KEY is required server-side for AI generation; do not commit provider keys or expose them through VITE_* variables.
Local dev can run without WorkOS. Production fails closed if WorkOS is not configured; set VITE_ALLOW_PUBLIC_APP=true only for an intentional public demo.
- Rotate any provider key that was ever committed or shared in git history.
- Production API routes use JSON-only request parsing, size limits, same-origin checks, no-store/nosniff responses, lightweight per-IP rate limits, and session/workspace guards for tenant-scoped mutations, AI generation, PDF export, and configured image uploads.
- Server-side PDF export disables JavaScript, blocks external network requests during render, injects a restrictive CSP, and sanitizes output filenames.
- User-controlled HTML is sanitized before preview/export; accepted AI proposals are sanitized before they become canonical section HTML.
- Image embeds are limited to PNG/JPEG/WEBP/GIF. SVG is rejected; configured uploads validate image byte signatures before storage.
api/ # Vercel functions
draft.ts # /api/draft — section-streaming SSE
agent.ts # /api/agent — AI SDK UIMessage stream for the right-rail chat
_utils/ # JSON guards, same-origin checks, rate limits
server/
proxy.ts # Vite dev handlers for /api/draft and /api/agent
llm-model.ts # shared model allow-listing + Anthropic key requirement
agent-tools.ts # server-side AI SDK tool schemas
dev/qwen-proxy-plugin.ts # Vite middleware that mounts /api/* in dev
src/
components/
ds/ # design-system primitives (BrandMark, Avatar, PageHeader,
# DataTable, StatusDot, ImageUploader, EmptyState, Wordmark)
editor/ # section editor, AIPanel (full-rail), FloatingCommentPanel,
# CommentsPanel, TimelinePanel, MobileBottomSheet,
# refine popover, edit-details sheet, RightRail wrapper
matter/ # MatterRiskBadge, NextActionBanner, MatterParticipants,
# MatterTasks, MatterCalendar
employees/ # EmployeeForm modal, ImportSuggestions panel
templates/ # CustomTemplateCard, MyTemplatesGrid,
# SaveAsTemplateModal, RenameCustomTemplateModal
ui/ # shadcn primitives (kept where useful)
lib/
letters/
catalog/ # 35 letter-type definitions, employer + employee
templates/ # 70 (letterType × UK|US) gold-standard scaffolds
questions/ # wizard schemas
llm/
client.ts # browser → /api/draft helper (SSE)
draft.ts # generate / regenerate orchestration
agent.ts # agent system prompt
tools.ts # client-side clause template metadata
bulk/
mailmerge.ts # {{placeholder}} substitution + ONE-AI-call orchestration
matters/
risk.ts # auto-derive RiskBand from health-check signals
finalize/
healthChecks.ts # static + AI-ready letter health checks per draft
prompts/ # buildSystemPrompt / buildSectionPrompt
evidence.ts # evidence filtering + prompt rendering
employees.ts # deriveEmployeesFromDrafts + matchEmployeeForDraft
template-quality.ts # section requirement and compliance quality report
timeline.ts # derived + stored draft activity feed
store.ts # drafts + matters + employees + tasks + events +
# customTemplates (Zustand persist, sanitizes AI HTML)
profile.ts # user profile (Zustand persist, safe image defaults)
safe-data-url.ts # shared base64 image data URL validation
auth/ # WorkOS-backed SPA auth provider + route guards
tiptap/comment-mark.ts # custom Tiptap mark for inline comments
routes/ # Home (HR triptych + Employee single-letter), BrowseLetters,
# Wizard, Editor, EmployeesIndex, EmployeeDetail,
# MattersIndex, MatterDetail, BulkOperations, Settings,
# Calendar, Finalize, ReviewStep, EvidenceStep, etc.
public/
design-system.html # canonical design tokens + page templates (static)
regenerate-options.html # UX proposal for refine + AI chat patterns (static)
- Letter generation = template + LLM.
buildSectionPromptprefers a curated template (statutory anchors, structural scaffold, MUST / MUST NOT constraints, worked example) when available, falling back to a generic instruction. The template addendum is appended to the master system prompt. This is what makes the output consistent across sessions and legally fenced. - AI agent uses SDK-native tool-calling. Server-side tool schemas live in
server/agent-tools.ts. The right-rail agent can proposeregenerateSection,addClause,tightenSection, andsummariseForCounsel. Mutating tools are human-in-the-loop: no server-sideexecute, so the model's call streams to the client as atool-*part. The user reviews and clicks Apply; the client runs the change and callsaddToolResultso the model sees the outcome and can continue. - Evidence is scoped before prompting. Evidence items live on the draft, can be linked to specific section ids, and are filtered into the system, section, and agent prompts so the model sees the right supporting facts without dumping unrelated attachments into every section.
- Model selection is centralised.
server/llm-model.tsowns the allow-list, env default, andcreateAnthropicconstruction. Browser settings still send a preferred model, but the server validates it against the allow-list.ANTHROPIC_API_KEYis required server-side and missing keys fail closed. - Persistence is local-first. Drafts, comments, collaborators, AI chat history all live in
localStoragevia Zustandpersist; server dual-write can sync drafts into Postgres when enabled. Production route access is WorkOS-gated by default, but true multi-device collaborative editing is still future work. - Rendering boundaries are defensive. Paged preview/edit, PDF export, DOCX export, Markdown rendering, and pending AI proposals all pass through sanitization or safe data-URL checks before content becomes visible or canonical.
- Dual audit chains. Every mutation appends a row to
audit_events(hash-chained, per-tenant, tamper-evident — seesrc/db/audit.ts). When a tenant hasworkos_org_idset andWORKOS_AUDIT_LOGS_ENABLED=true, the same row is mirrored fire-and-forget to WorkOS Audit Logs so the tenant's SOC team can subscribe a SIEM. The local chain is the source of truth; the WorkOS mirror is a downstream sink. Seedocs/audit-logs.md.
DraftHR keeps two audit trails for every state change:
-
Local hash-chained
audit_events(src/db/audit.ts). Per-tenant chain, per-row SHA-256 overprev_hash || canonicalJson(payload) || kind || subject_id || created_at_epoch_ms. Rows are ordered by a per-tenantseq, and a database trigger (migration 0021) rejects UPDATE, DELETE and TRUNCATE onaudit_eventsapart from the tenant-erasure cascade and the actor SET NULL on user deletion. Any retroactive edit breaks the chain at the edit point and every row after it — seesrc/db/audit-verify.tsfor the walker. -
WorkOS Audit Logs mirror (
src/lib/audit/workos-sink.ts). Fired fire-and-forget after the local row commits. Off by default; flip on per environment withWORKOS_AUDIT_LOGS_ENABLED=true. Per-tenant gate: only tenants withworkos_org_idset are mirrored. Cross-tenant safety: the writer resolves the org from the audit row's tenant id — clients never pass an org id.
DraftHR audit_events |
WorkOS Audit Logs |
|---|---|
kind (e.g. draft.exported) |
event.action (e.g. letters.draft.exported) — see mapKindToWorkOSAction |
actor_user_id + joined users.email / legal_name |
event.actor (type: "user", id, name, metadata.email); falls back to a system actor when null |
subject_type / subject_id |
event.targets[0] (type, id) |
created_at |
event.occurred_at |
payload_json |
event.metadata (clamped: 50 keys, 40-char keys, 500-char values) |
id |
Idempotency-Key header (24h dedupe window) |
Request IP / UA are passed through when the caller has them; absent IPs default to 0.0.0.0 because the WorkOS schema requires context.location.
# Per-deployment (all tenants):
vercel env add WORKOS_AUDIT_LOGS_ENABLED # set to "true"
# Per-tenant (only mirror enterprise tenants):
update tenants set workos_org_id = 'org_xxx' where id = '...';When both gates are off, the local chain still writes; nothing is sent to WorkOS.
After enabling the mirror, replay older rows with the CLI:
tsx scripts/backfill-workos-audit-logs.ts --since 2026-01-01Safe to re-run — the Idempotency-Key is the local row id, so retries within 24h dedupe server-side. The script streams per-tenant for every tenant with workos_org_id set and logs progress every 25 rows.
Settings → Audit Logs (admin-only) renders a status pill, the last successful mirror timestamp, an "Open WorkOS Admin Portal" link (intent: audit_logs), and a "Send test event" button that emits audit.test_event through appendAuditEvent end-to-end.
Enterprise tenants can auto-provision DraftHR seats from Okta, Entra ID, Google Workspace, or any SCIM 2.0 directory. Webhook events from WorkOS land at POST /api/workos/directory-webhook (signature-verified via WORKOS_WEBHOOK_SECRET); each event upserts users + tenant_members and writes a directory.user.* row to the hash-chained audit log. Soft-delete on deprovisioning preserves audit history.
Admins see status + a one-shot "Open WorkOS Admin Portal" link at Settings → Directory Sync. Full operations guide in docs/directory-sync.md.
This tool drafts HR correspondence with an LLM. The generated content is not legal advice and should be reviewed by qualified HR or employment-law counsel before issuance — especially for high-risk letters (termination, redundancy, settlement, immigration). Tribunal-bound letters carry the additional risk that anything in the draft may later be disclosed in proceedings.
See CHANGELOG.md for the release history.