Skip to content

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/start could 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 ToastService message (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-level catchError(() => 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 IPageAdvisor is 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:

  1. Google OAuthUnconfiguredGoogleTokenProvider throws with setup instructions; scopes enumerated in GoogleScopes.
  2. KeePass kdbxKeePassVaultService keeps the contract (incl. locked-vault behavior); swap the in-memory store for KeePassLib reads/writes of ~/Wingman/vault.kdbx.
  3. Claude draftingTemplateDrafter : IClaudeDrafter produces deterministic docs offline; swap in an Anthropic API client.
  4. Claude study projectsStudyPlanService.GenerateClaudeStudyProjectAsync returns a placeholder URL.
  5. Gmail interview detectionApplicationTrackerService.SyncInterviewsAsync is the hook.
  6. Live-run browser — the Angular viewer shows a placeholder; in Electron, attach a WebContentsView and drive it from WorkdayAutomationEngine.
  7. Sheets mirroring — call GoogleSheetsClient.AppendRowAsync from ApplicationTrackerService.RecordSubmissionAsync once OAuth is wired.