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_RespectsLinkedInLengthLimitstresses 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'sVoiceSummarywhere available, with aSubjectline.
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.
Related docs¶
- Plain-language version: ../plain/outreach-drafting.md
- application-question-taxonomy.md / profile-data-gaps.md — the other half of this pass
- architecture.md · security.md
- Doc index: ../README.md