Visaroy
codebase handbook · pre-beta review · may 2026
A Schengen visa preparation assistant. Chat-driven data collection → PDF form generation → submission pack. This handbook maps every module, seam, and decision for a senior frontend engineer reviewing agent-written code before beta.
What Visaroy Does
Helps applicants prepare Schengen visa applications so they're accepted at the VFS/BLS counter on the first attempt. It is not a visa approval predictor or submission agent.
Chat collects trip data, not a form
Fills official blank PDFs per-country
Predicts counter acceptance (0-100)
Domain Vocabulary
Chat Topic Flow
The chat currently collects ~30 of ~60 harmonised Schengen fields. Missing fields render blank (acceptable for beta). Tier 2 defaults are wired.
Module Map
packages/schemas
All Zod schemas + TypeScript types. Country catalog lives here. 9 schema files, 3 with tests.
packages/chat-core
LangGraph conversational engine. Handles chat turns, tool calling, field extraction, validation. Uses OpenRouter → Gemini 3.
packages/form-engine
PDF generation. Two strategies: flat overlay (calibrated rects) and AcroForm fill (native fields). 15 country modules.
packages/rules
Pure deterministic business rules. Document validation, cross-doc consistency, confidence scoring. No framework deps.
Next.js 15 + Supabase Auth + Tailwind v4
Thin API routes delegating to packages. Chat UI, doc upload, calibration tool at /internal/.
Developer tooling CLI
Form preview, form review, chat replay, red-team testing. Not user-facing.
Seams (Package Boundaries)
API routes→@visaroy/rulesvalidate-documents, check-consistency, confidence-score routes are thin wrappers around pure rule functions.
API routes→@visaroy/schemasAll request/response validation uses Zod schemas. No inline ad-hoc validation.
API routes→@visaroy/form-enginegenerate-form and generate-pack delegate to form-engine for PDF rendering.
chat-turn route→@visaroy/chat-coreThin wrapper around runChatTurn().
Ctrl/Cmd + wheel to zoom · Drag to pan · Double-click to fit
packages/schemas/src/ — The single source of truth for all API contracts. Every other package imports types from here.
| File | Key Exports | Tests | Used By |
|---|---|---|---|
chat.schema.ts | ChatTopic, TopicData, ChatMessage, CHAT_MESSAGE_MAX_LENGTH | yes | chat-core, web |
form.schema.ts | SchengenFormData request/response schemas | form-engine, web | |
profile.schema.ts | Profile, ExtractedProfile schemas | yes | web, chat-core |
extraction.schema.ts | Document extraction schemas | yes | web |
validation.schema.ts | ValidationResult, DocumentValidation | rules, web | |
consistency.schema.ts | ConsistencyCheckResult | rules, web | |
confidence.schema.ts | ConfidenceIssue, ConfidenceLevel | rules, web | |
pack.schema.ts | Pack generation request/response | form-engine, web | |
application.schema.ts | Application state schemas | web | |
country-catalog.ts | COUNTRY_CATALOG, CountryCode, RenderStrategy, helpers | everywhere |
chat.schema.ts to understand TopicData shape (what fields the chat collects per topic), then form.schema.ts for what goes into SchengenFormData (the ~60 harmonised fields). The gap between those two is intentional for beta.
LangGraph State Machine
runChatTurn(input: RunChatTurnInput): Promise<RunChatTurnResult>
Takes topic, message, history, current fields → returns reply, extracted fields, isComplete flag, execution mode.
Input
topic— current ChatTopicmessage— user's messagehistory— previous turnscurrentFields— already-collected datafirstName— for personalizationsignal— AbortSignal for cancellation
Output
reply— assistant messageextractedFields— data parsed from user inputisComplete— topic done?executionMode— "deterministic" | "llm" | "fallback"toolCalls— trace of LLM tool usagefallbackReason— if mode is fallback
Execution Modes
No LLM needed. Pattern-matched input (e.g. "yes", country names).
Full LangGraph pipeline: prompt → LLM → structured output → tools.
LLM failed/timed out. Generic safe response via buildFallbackReply.
LangGraph Graph Shape
State = messages[] + structuredResponse + toolCalls[] + usedAccommodationLookup. Single tool available: accommodation_address_lookup (searches hotel names → structured address).
Model: Gemini 3 via OpenRouter. Temperature 0.2. Structured JSON output enforced via Zod schema.
Assertions (Quality Guards)
The assertions.ts module exports guards that validate LLM behavior. Used in tests and can be run in production.
assertOnlyCurrentTopicFieldsExtractedLLM doesn't leak fields from other topics
assertRequiredFieldsBeforeCompletionCan't mark topic done with missing required fields
assertSupportedCountryOnlyBlocks non-Schengen/online-only countries
assertNoOffTopicAdvanceLLM stays on current topic
assertNoCrashFallbackDetects crash-to-fallback patterns
assertAccommodationLookupOnlyInAccommodationTool only fires in right topic
Two Rendering Strategies
Draws text/checkboxes onto blank PDFs using calibrated rectangle maps.
Process: Load blank PDF from R2 → look up country module (rect map) → fillFlatOverlay() draws onto pages.
Calibrated via: Internal tool at /internal/calibration. Drafts saved to data/calibration-drafts/{code}.json.
Countries:
Fills native PDF form fields programmatically. No manual calibration needed.
Process: Load PDF → get PDFForm → map SchengenFormData keys to country-specific AcroForm field names → fill text/radio/checkbox.
Shared renderer: renderers-acroform.ts with per-country field name maps.
Countries:
Numbers = AcroForm field count per country.
SchengenFormData Model
The ~60-field harmonised data model in packages/form-engine/src/model.ts:
- surname, firstNames, dateOfBirth
- placeOfBirth, countryOfBirth
- nationality, sex, civilStatus
- nationalIdNumber
- type, number
- issueDate, expiryDate
- issuedBy
- address, email, phone
- residencePermitType/Number
- residencePermitValidUntil
- occupation, employerName
- employerAddress, employerPhone
- mainDestination, firstEntry
- arrivalDate, departureDate
- purpose, entries
- fingerprints, previous visa
- surname, firstNames
- dateOfBirth, nationality
- travelDocumentNumber
- relationship
Key Files
| File | Purpose | Review Priority |
|---|---|---|
renderer.ts | Unified flat overlay renderer. renderFlatOverlayForm() | high |
renderers-acroform.ts | Shared AcroForm renderer + per-country field maps | high |
fill.ts | fillFlatOverlay() — draws onto PDF pages | high |
draw.ts | Low-level text/checkbox drawing primitives | medium |
model.ts | SchengenFormData type definition | high |
country-catalog.ts | Re-exports from schemas (compat shim) | low |
resolve-country-strategy.ts | Returns render strategy for a country code | medium |
source-pdfs.ts | Loads blank PDFs from R2 | medium |
countries/*.ts | 15 flat-overlay country modules (rect maps) | low |
calibration/ | Internal calibration tooling (7 files) | low |
review.ts | Form review logic (imports from deprecated renderer) | medium |
current-pack-renderer.ts is deprecated — no countries route through it. Kept only for constants imported by review.ts. renderers-sweden-base.ts is also legacy — SE migrated to shared AcroForm renderer.
Pure functions with no framework dependencies. 3 files, each with full test coverage.
Per-document checks: type identified, format correct, not expired, name matching, readable quality.
validation.ts + validation.test.ts
Cross-document checks: flight dates vs form, insurance dates vs trip, hotel country vs entry, salary vs bank.
consistency.ts + consistency.test.ts
Helpers: parseDate, namesMatch, isDateWithinTolerance
Scoring from 100 down. Blocking = 0, High deduction = -25, Medium = -15, Low = -5. Consistency: -10 per fail, -5 per warn.
confidence.ts + confidence.test.ts
Confidence Scoring Rules (detailed)
| Category | Condition | Effect |
|---|---|---|
| BLOCKING | Insurance rejected, Flight rejected, Passport <3mo expiry, BRP rejected/expired | score → 0 |
| HIGH | Bank statements rejected/missing, Employment letter rejected/not addressed to consulate | −25 |
| MEDIUM | Accommodation warning (no guest name), missing passport/BRP copy | −15 |
| LOW | Any other document warning | −5 |
| CONSISTENCY | Per failed check / per warning | −10 / −5 |
API Routes
| Route | Method | Delegates To | Rate Limit |
|---|---|---|---|
/api/chat-turn | POST | chat-core → runChatTurn | 30/min |
/api/extract-profile | POST | Anthropic LLM (PDF extraction) | 5/min |
/api/extract-document | POST | LLM doc extraction | 60/min |
/api/correct-extraction | POST | Manual extraction correction | 60/min |
/api/validate-documents | POST | rules → validateDocuments | 60/min |
/api/check-consistency | POST | rules → runConsistencyChecks | 60/min |
/api/confidence-score | POST | rules → calculateConfidenceScore | 60/min |
/api/generate-form | POST | form-engine | 5/min |
/api/generate-pack | POST | form-engine (full pack) | 5/min |
/api/generate-cover-letter | POST | LLM | 10/min |
/api/applications | GET/POST | Supabase | 60/min |
/api/profiles | GET/POST | Supabase | 60/min |
/api/health | GET | — | 60/min |
/api/health/auth | GET | Supabase auth check | 60/min |
/api/health/db | GET | Supabase DB check | 60/min |
Middleware
In-memory rate limiter in src/middleware.ts. Buckets keyed by JWT user ID (when present) or IP fallback.
- Active only in production (or when
RATE_LIMIT_FORCE=1) - Stale bucket cleanup every 5 minutes
- Per-instance only — needs Redis/KV before handling multi-instance hostile traffic
Frontend Components
app/apply/page.tsx — Main user flow. useReducer-based state machine managing topics, profile, trip, accommodation, employment, documents, cover letter.
Supporting files: _reducer.ts, _types.ts, _utils.tsx, _greetings.ts, _icons.tsx, _file-upload.tsx, _checkpoint-summary.tsx, topic-mapping.ts
components/chat/ — ApplyChat, ChatBubble, plus document-specific components: AttachSlot, DocCard, ExtractionEditor, ExtractionSummary, CorrectionInput.
app/internal/calibration/ — Developer-only tool for calibrating flat overlay field positions. Canvas rendering, inspector, sidebar, preview panel.
shadcn/ui components: Button, Card, Input, Label, Progress. In components/ui/.
Supabase Schema
Ctrl/Cmd + wheel to zoom · Drag to pan · Double-click to fit
RLS Policies
- profiles: SELECT/INSERT/UPDATE restricted to
auth.uid() = user_id - applications: SELECT/INSERT/UPDATE restricted to
auth.uid() = user_id - documents: SELECT restricted via join to application's
user_id
| Country | Code | Strategy | PDF Supported | Portal Preferred |
|---|---|---|---|---|
| Austria | AT | flat_overlay | yes | |
| Belgium | BE | flat_overlay | yes | |
| Bulgaria | BG | flat_overlay | yes | |
| Croatia | HR | flat_overlay | yes | |
| Czech Republic | CZ | flat_overlay | yes | |
| Denmark | DK | flat_overlay | yes | |
| France | FR | flat_overlay | yes | |
| Germany | DE | flat_overlay | yes | |
| Hungary | HU | flat_overlay | yes | |
| Iceland | IS | acroform | yes | |
| Italy | IT | flat_overlay | yes | |
| Liechtenstein | LI | acroform | yes | |
| Luxembourg | LU | flat_overlay | yes | |
| Malta | MT | flat_overlay | yes | |
| Norway | NO | acroform | yes | |
| Portugal | PT | flat_overlay | yes | |
| Slovakia | SK | flat_overlay | yes | |
| Slovenia | SI | flat_overlay | yes | |
| Spain | ES | acroform | yes | |
| Sweden | SE | acroform | yes | |
| Switzerland | CH | acroform | yes | |
| Estonia | EE | online_only | portal | |
| Finland | FI | online_only | portal | |
| Greece | GR | online_only | portal | |
| Latvia | LV | online_only | portal | |
| Lithuania | LT | online_only | portal | |
| Netherlands | NL | online_only | portal | |
| Poland | PL | online_only | portal | |
| Romania | RO | online_only | portal |
Test Distribution
| Package | Test Files | Key Tests |
|---|---|---|
packages/schemas | 3 | profile, chat, extraction schema validation |
packages/chat-core | 5 | helpers, validation, assertions, chat turn (unit + agent-level) |
packages/form-engine | 5 | draw, AcroForm renderer, rects, signing place, source PDFs |
packages/rules | 3 | validation, consistency, confidence (full coverage) |
apps/web | 5 | middleware, chat helpers, doc extraction/retry/completion |
apps/cli | 5 | args, replay fixture, replay, topic-state conversion, workspace |
e2e/ | 9 specs | apply flow, profile extraction, form smoke, auth, health, API |
CI Pipeline
.github/workflows/ci.yml — Runs on push to main + PRs:
Separate job: gitleaks secret scanning (full history).
pnpm exec tsc --noEmit && pnpm build — verify before shippingpnpm test:unit — unit tests with coveragepnpm test:form — form-engine specific (separate vitest config)pnpm test:e2e — Playwright E2Epnpm knip — dead code detection
Recommended Review Order
Start with chat.schema.ts (TopicData shape) and form.schema.ts. Then country-catalog.ts for the full country model. This sets the vocabulary for everything else.
3 files, fully tested, no deps. Verify scoring logic in confidence.ts, consistency checks in consistency.ts. Look for edge cases in date parsing.
Focus on model.ts → renderer.ts → renderers-acroform.ts → fill.ts. Verify page-4 is never touched. Check AcroForm checkbox hack in makeFiller.
Review run-chat-turn.ts for the LangGraph setup, prompt engineering in helpers.ts, and the assertion guards. Check fallback behavior and abort signal handling.
Verify routes are thin wrappers. Check middleware rate limiting. Review the apply page's useReducer state machine in _reducer.ts. Verify auth patterns in API routes.
Run pnpm test:unit with coverage. Check E2E specs cover the happy path. Run pnpm knip for dead code.
Things to Watch For
- Rate limiter is in-memory, per-instance only
- RLS policies on documents use a join — verify no bypass
- Signed URLs for R2 (15-min TTL) — verify enforcement
- CHAT_MESSAGE_MAX_LENGTH enforced?
- Legacy files kept "for constants" — are they still needed?
- Type casts at seams — verify they're safe
- AcroForm checkbox hack (bypassing pdf-lib) — fragile
- Country catalog duplicated (schemas + form-engine re-export)
- Fallback behavior when LLM fails — is it graceful?
- Assertion guards — are they run in production or tests only?
- AbortSignal handling — does it clean up properly?
- Accommodation lookup tool — injection surface?
- 30 collected vs 60 rendered fields — default handling
- Date parsing in rules — multiple formats handled?
- Non-Latin field detection in chat validation
- Page 4 never touched — verify in all code paths