Architecture (technical)¶
WorkWingman is a local-first Windows desktop app: an Electron shell that spawns a loopback-only ASP.NET Core API and renders an Angular UI. Everything sensitive — the KeePass vault, resume files, and the automation browser — stays on the user's machine. Google Drive/Sheets is backup sync, not hosting.
Plain-language version: ../plain/architecture.md
C4 model views¶
Beyond the inline Mermaid below, the architecture is documented with the C4 model
(zoom levels), rendered from D2 sources in ../diagrams:
- System Context — WorkWingman and the outside world.
- Container — shell, renderer, loopback API, vault, store, automation browser.
- Component — inside the API: controllers, services, clients, automation, vault, doctor.
Sources + rebuild instructions: ../diagrams/README.md.
Layered view¶
flowchart TB
subgraph Shell["Electron shell (electron/main.js)"]
direction TB
Renderer["Angular 22 renderer<br/>(frontend/ — 10 screens, signals)"]
BrowserView["Automation browser<br/>(WebContentsView — roadmap)"]
end
subgraph API["Loopback .NET 10 API — 127.0.0.1:5211 only"]
direction TB
Controllers["Controllers<br/>Jobs · Runs · Documents · Study · Profile<br/>Applications · Connections · Vault · Dependencies"]
Core["WorkWingman.Core<br/>models + service interfaces"]
subgraph Infra["WorkWingman.Infrastructure"]
Clients["Flurl + Polly REST clients<br/>Google Sheets/Forms/Calendar · Apple · LinkedIn"]
Automation["Playwright automation<br/>LinkedIn scraper · Workday engine"]
Vault["KeePass vault<br/>(secrets never leave this layer)"]
Store["LocalJsonStore<br/>~/Wingman/data/*.json — source of truth"]
Doctor["Dependency doctor<br/>detect + winget/npm/Playwright install"]
end
end
subgraph External["External systems"]
LinkedIn["LinkedIn jobs-tracker"]
Workday["Workday ATS tenants"]
Google["Google Drive / Sheets / Gmail / Calendar"]
Apple["Apple Calendar"]
Claude["Claude"]
end
Renderer -->|"HTTP (loopback)"| Controllers
Controllers --> Core
Core -.implemented by.-> Infra
Clients --> Google
Clients --> Apple
Automation --> LinkedIn
Automation --> Workday
BrowserView -.watches.-> Workday
Clients --> Claude
The renderer never talks to external systems directly — it calls the local API, which owns all
credentials, resilience, and automation. The API binds 127.0.0.1 exclusively
(Program.cs), so nothing off-machine can reach it.
Request path — applying to a Workday job¶
sequenceDiagram
participant U as User
participant UI as Angular (Live run)
participant API as .NET API
participant Eng as WorkdayAutomationEngine
participant Vault as KeePass vault
participant WD as Workday tenant
U->>UI: Approve documents, start application
UI->>API: POST /api/runs/start/{jobId}
API->>Eng: StartRun(job)
Eng->>Vault: ResolveSecret("workday-<tenant>")
Vault-->>Eng: password (in-process only)
Eng->>WD: sign in, fill contact / work history / education…
Note over Eng,WD: each field logged with reasoning
Eng->>Eng: confidence < 90% on "Degree"?
Eng-->>UI: RunStatus = AwaitingJudgement + ranked options
U->>UI: pick "Bachelor of Science (BS)"
UI->>API: POST /api/runs/{id}/resolve {callId, choice}
API->>Eng: persist rule, resume
Eng->>WD: continue to review & submit
Note over U,WD: final submit ALWAYS waits for the user
Solution layout¶
| Path | Responsibility |
|---|---|
src/WorkWingman.Core |
Domain models + service interfaces (no dependencies) |
src/WorkWingman.Infrastructure |
Flurl clients, Polly pipelines, Playwright scraper/automation, KeePass vault, LocalJsonStore, dependency doctor |
src/WorkWingman.Api |
ASP.NET Core controllers, DI wiring, Security/ApiToken |
tests/WorkWingman.Tests |
381 xUnit tests (unit + in-memory API smoke) |
frontend |
Angular 22 app — 10 screens, standalone components, signals |
electron |
Electron main + preload (spawns API, hosts UI) |
Resilience & automation patterns¶
Reused from the reference scrapers (cgspectrum2notebooklm, FamilyRenewedScraper):
| Pattern | Where |
|---|---|
| Persistent Chromium profile (logins survive runs) | Automation/BrowserSession.cs |
Anti-automation flags, real UA, SlowMo, 900 ms pacing |
BrowserSession |
| Polly retry: 3 attempts, exp backoff, handle null/5xx/429 | Http/ResiliencePipelines.cs |
| Playwright cookies → Flurl header handoff | BrowserSession.GetCookieHeaderAsync |
| Fallback selector chains + JS bulk harvesting | Automation/LinkedInJobsScraper.cs |
| Log-and-continue (partial success > total failure) | scraper try/catch per job |
See connections-and-sso.md for the scraping-selector explainer and references.md for every tool link.
Frontend resilience¶
The renderer only makes one hop — to the loopback sidecar — so its resilience is scoped to
local failure modes, not external flakiness (which the backend's Polly already owns). A single
functional HTTP interceptor (frontend/src/app/core/resilience.interceptor.ts,
wired ahead of the token interceptor in app.config.ts) handles them:
- GET-only retry — idempotent GETs retry up to 3× with exponential backoff (300 ms · 2ⁿ) on
connection-refused (status 0) and 503. POST/PUT/DELETE are never retried — a retried
POST /api/runs/startcould launch a real job application twice. - Per-request timeout — 30 s ceiling on slow local ops.
- One toast, then re-throw — after retries are exhausted it shows exactly one
ToastServicemessage (connection-refused → "Can't reach the local engine — is it running?"; timeout → "That took too long."; else "Something went wrong.") and re-throws, so component-levelcatchError(() => of(...))fallbacks still run. It never swallows the error.
CI quality & security gates¶
.github/workflows/ci.yml builds and tests both stacks (backend on Windows, frontend on
Ubuntu) on every push/PR. A Codex review runs on every commit before push (bugs, OWASP Top
10, performance, quality). Deeper scanning (CodeQL, Semgrep, Trivy, gitleaks, ZAP) and mutation
testing are planned — see security.md and testing.md.
Hybrid automation: Playwright driver + PageAgent advisor¶
Overnight research (research-pageagent + the feature-pageagent-swap prototype — see
making-of.md § PageAgent research) asked
whether a Claude-driven agent reading the page directly could replace the per-ATS selector engines
above. The honest finding was hybrid fallback, not wholesale replacement — full-Claude
automation runs an estimated 45–80 LLM calls per application (real cost and minutes of latency
against Playwright's near-zero marginal cost per field), has no built-in never-submit guard, and —
critically — can't reach into a cross-origin iframe the way Playwright's FrameLocator does,
which the iCIMS/SuccessFactors/Taleo labs showed is load-bearing for real ATS coverage.
The resulting design keeps Playwright as the driver and adds PageAgent as an advisor, consulted only on genuine ambiguity:
- Playwright drives. Navigation, cross-origin iframes (
FrameLocator), the never-submit guard, and deterministic fills via the per-ATS selector fallback chains (see scraper-automation-learnings-summary.md) all stay exactly as hardened during the labs. This is the fast, near-zero-cost path and handles the large majority of fields. - PageAgent (Claude-driven, in-page) advises, through a seam interface —
IPageAdvisor— that the engine calls only when the deterministic path is uncertain: an ambiguous field the selector chain can't confidently resolve, a tenant's novel custom question with no fixed schema, or ranking best-match options (the same "degree dropdown" judgement-call shape every lab hit). The advisor reads the live DOM and returns a ranked hint with a confidence score — it does not act directly on the page. - The engine, not the advisor, decides. The advisor's ranked hint is scored against the same confidence threshold the engine already uses for judgement calls. Above threshold, the engine fills the field itself via Playwright. Below threshold, the flow still pauses for the user — consulting PageAgent narrows how often a human is asked, it does not remove that safeguard.
- Token cost is bounded by design. Because
IPageAdvisoris consulted only on ambiguity — not on every field of every application — the LLM call volume stays a small fraction of the 45–80-calls-per-application figure measured for a fully Claude-driven flow.
flowchart TB
Engine["AutomationEngine\n(per-ATS)"] --> Driver["Playwright driver loop\nnav · FrameLocator · deterministic fill\nnever-submit guard"]
Driver --> Confident{"Confident\nfill?"}
Confident -->|yes| Driver
Confident -->|no, ambiguous| Advisor["IPageAdvisor seam"]
Advisor --> PageAgent["PageAgent + Claude\n(in-page, reads DOM)"]
PageAgent -->|ranked hint + confidence| Advisor
Advisor --> Rank{"Above\nthreshold?"}
Rank -->|yes| Driver
Rank -->|no| Pause["AwaitingJudgement\npause for user"]
Pause -->|user picks| Driver
Driver --> Submit["Review & submit\nALWAYS user-gated"]
See hybrid-automation.svg for the full diagram, rendered
from hybrid-automation.d2.
Deliberate stubs (wired later)¶
Each compiles, is DI-registered, and has one obvious replacement point:
- Google OAuth —
UnconfiguredGoogleTokenProviderthrows with setup instructions; scopes enumerated inGoogleScopes. - KeePass kdbx —
KeePassVaultServicekeeps the contract (incl. locked-vault behavior); swap the in-memory store for KeePassLib reads/writes of~/Wingman/vault.kdbx. - Claude drafting —
TemplateDrafter : IClaudeDrafterproduces deterministic docs offline; swap in an Anthropic API client. - Claude study projects —
StudyPlanService.GenerateClaudeStudyProjectAsyncreturns a placeholder URL. - Gmail interview detection —
ApplicationTrackerService.SyncInterviewsAsyncis the hook. - Live-run browser — the Angular viewer shows a placeholder; in Electron, attach a
WebContentsViewand drive it fromWorkdayAutomationEngine. - Sheets mirroring — call
GoogleSheetsClient.AppendRowAsyncfromApplicationTrackerService.RecordSubmissionAsynconce OAuth is wired.
Related docs¶
- Plain-language: ../plain/architecture.md
- Deep dive on the advisor seam: pageagent-advisor-design.md
- overview.md · api-reference.md · security.md · testing.md · making-of.md
- Doc index: ../README.md