Skip to content

Outreach drafting (technical)

WorkWingman can draft a short LinkedIn connection note and a longer InMail-style message for a specific job and a specific hiring-manager or recruiter target. It only ever produces text. There is no code path — anywhere in this app — that sends a message, calls a LinkedIn messaging API, or scrapes recruiter/contact lists from the live site. The user copies the draft and sends it themselves, the same way they already send every application (see architecture.md — "nothing submits without the user's review" is the same rule applied here).

Plain-language version: ../plain/outreach-drafting.md

The boundary, stated precisely

Allowed Never
Generate draft text (OutreachDraftGenerator) from a JobPosting, IntakeProfile, StoryProfile, and an OutreachTarget the user (or a future reviewed step) supplied. Call a LinkedIn API endpoint that sends a message or connection request.
Store an OutreachTarget the user typed in or pasted a link for. Scrape LinkedIn's people-search, recruiter lists, or "who's hiring" pages to auto-discover targets.
Mark a draft Approved = true once the user has reviewed it in the UI. Treat Approved as "sent" — it only records that a human read it.
Surface LinkedIn Premium InMail as the channel the user uses to actually send an InMail draft. Automate clicking send inside a LinkedIn tab, whether via the existing Playwright BrowserSession or any other driver.

OutreachDraftGenerator (below) has no dependency on BrowserSession, no HttpClient pointed at any messaging endpoint, and no method whose name suggests transmission — enforced in code by OutreachDraftGeneratorTests.Draft_DoesNotExposeAnySendMethod, which fails if a Send/Submit/ Post/Scrape method is ever added to the class.

How a target gets identified

An OutreachTarget (OutreachTarget.cs) is not discovered by automation. Today it's populated by the user — typing in a name/title they saw on the job posting, a recruiter's name from the "Meet the hiring team" LinkedIn widget, or a referral a contact gave them. SourceNote records how it was identified, for the user's own memory, not for any scraping audit trail (there isn't one, because there's no scraping).

A later, explicitly reviewed step could suggest targets from information already visible on a saved JobPosting (e.g. LinkedIn already shows "Meet the hiring team" on some postings the LinkedInJobsScraper already pulls) — but that is a roadmap note, not implemented here, and any such feature would still stop at suggesting a name, never at messaging them.

Drafting: OutreachDraftGenerator

OutreachDraftGenerator.cs mirrors the existing TemplateDrafter pattern (deterministic, template-based, works fully offline) rather than requiring a live Claude call — matching the "deliberate stub" philosophy in architecture.md. Given a JobPosting, IntakeProfile, StoryProfile, and OutreachTarget, Draft(...) returns an OutreachDraftSet with two OutreachDrafts:

  • ConnectionNote (OutreachChannel.ConnectionNote) — short: position + a one-line cover-letter-style intro, hard-capped at LinkedIn's own 300-character connection-request limit (OutreachDraftGenerator.ConnectionNoteMaxLength). If the templated text would exceed the cap (long job title + long company name), it's truncated with a trailing ellipsis rather than silently overflowing — OutreachDraftGeneratorTests.Draft_ConnectionNote_RespectsLinkedInLengthLimit stresses this with a deliberately long title/company. Connection notes have no subject line (Subject = string.Empty).
  • InMail (OutreachChannel.InMail) — longer: a condensed cover letter referencing the specific role, the job's matched-keyword fit (JobPosting.Fit.MatchedKeywords), the user's top skills, and the story profile's VoiceSummary where available, with a Subject line.

Both drafts are tagged with JobId and TargetId so the UI can group them under the job and target they belong to, and both start Approved = false until a human reviews them.

sequenceDiagram
    participant U as User
    participant UI as Angular (future outreach screen)
    participant Gen as OutreachDraftGenerator

    U->>UI: Add outreach target (name/title/company, typed or pasted)
    UI->>Gen: Draft(job, intake, story, target)
    Gen-->>UI: OutreachDraftSet { ConnectionNote, InMail }
    U->>UI: Review + edit draft text
    U->>UI: Mark Approved (records review only)
    Note over U: User copies the text into LinkedIn and sends it themselves —<br/>via their own Premium InMail credits or a connection request.<br/>WorkWingman never sends anything.

DI registration

Registered alongside the other domain services in Program.cs:

builder.Services.AddSingleton<IOutreachDraftGenerator, OutreachDraftGenerator>();

IOutreachDraftGenerator lives in WorkWingman.Core.Interfaces so a future Claude-backed implementation can be swapped in later, exactly like IClaudeDrafter/TemplateDrafter for documents — the interface's contract (Draft(...) returns text; nothing sends) is the part that must never change.

Tests

OutreachDraftGeneratorTests.cs covers: both channels are produced, the connection note respects the 300-character cap even under stress input, the connection note has no subject, both drafts reference the specific role and company, the InMail is longer than the connection note, the user's name comes from the intake profile, both drafts are tagged with job/target ids, new drafts start unapproved, and — as a structural guarantee — that the generator's public API exposes no send/submit/post/scrape method.