diff --git a/plugins/linkedin-studio/commands/newsletter.md b/plugins/linkedin-studio/commands/newsletter.md index 7fbdb45..c560a83 100644 --- a/plugins/linkedin-studio/commands/newsletter.md +++ b/plugins/linkedin-studio/commands/newsletter.md @@ -1495,6 +1495,71 @@ Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calenda --- +## Distribution channel — native LinkedIn Newsletter vs a long-form post (honest mechanics) + +A long edition can ship two ways: as a **regular long-form post** (what Steps 8–10 +deliver — pasted into the feed, ranked organically), or published through +LinkedIn's **native Newsletter feature** (subscribe-able, with its own notification +channel). They are not the same distribution, and the native newsletter is **not a +strict upgrade** — it earns its place only above a follower floor. This section is +the honest decision surface; it sells nothing. + +**What the native newsletter actually does (and doesn't):** + +- **It bypasses organic feed ranking** for the subscriber notification — that is the + real, defensible benefit. When you publish an edition, each subscriber gets **one + notification** routed to their preferred channel (in-app / push / email). The + channels are **deduplicated** — LinkedIn's own FAQ states that if you get an in-app + or push notification you should **not** also expect an email for the same event. So + it is **one deduplicated notification per subscriber per edition — not three + guaranteed touchpoints.** Treat any "3× notification" / "hits everyone on every + channel" framing as oversold. (The edition is still **also** posted to your feed and + can resurface via engagement and interest sections.) +- **The mass invite fires once.** On your **first** edition LinkedIn auto-invites all + your current connections/followers to subscribe (and invites each new follower once, + thereafter). That one-time "invite all followers" blast **spends at the size you + launch at** — launch with a small base and you permanently burn the blast on a tiny + audience. This is why there is a **~1–2K follower floor**: below it, **wait** — you + are not yet able to spend the launch blast well. (The floor aligns with the existing + ~1K `/linkedin:monetize` and `/linkedin:outreach` unlocks.) +- **Cold-start is slow — don't inflate it.** A genuine zero-audience start is roughly + **0–100 subscribers in months 1–3.** The viral "0→9K in 7 days" / "0→10K" newsletter + case studies were **not** cold starts — they leveraged existing audiences or long + grinds. Plan for the slow floor, not the screenshot. Cadence: **weekly** is common + among top performers; **biweekly** is a safe default for original analysis. + +**Honest downsides to disclose before you commit:** + +- **Subscribers are non-exportable** — pure platform lock-in. If LinkedIn ever sunsets + the feature, you lose the whole list with no portability. +- **No canonical control** — LinkedIn outranks your **own site** for the same article. + Harmful if you are trying to build an owned property (your domain/email list). +- **No read/open/unsubscribe analytics** — you cannot see opens, click detail, or who + left. (Consistent with the boundary the plugin states elsewhere: there is no + self-serve post-analytics API for a personal profile.) +- **Per-subscriber reach decays** as the list grows — a bigger list does not mean every + edition reaches everyone; notification delivery is not guaranteed. + +**Decision rule:** + +- **Below ~1–2K followers** → ship the edition as a normal long-form post (Steps 8–10) + and **keep building the base** with short-form / documents. Do **not** spend the + one-time launch blast yet. +- **At/above ~1–2K followers, posting regularly** → the native newsletter is worth it: + publish the **first** edition deliberately (that is when the blast fires), then hold a + steady weekly/biweekly cadence. Frame it as bypass-the-feed reach **with** the + lock-in / no-canonical / no-analytics / decay caveats above understood — not as a + guaranteed multi-touch megaphone. + +> **Sourcing.** Mechanics (5-newsletter max, 2-week cooldown, auto-invite, feed +> resurfacing, notification dedup) are from LinkedIn's own help docs; the follower +> floor and cold-start ranges are practitioner-sourced (medium confidence) — see +> `docs/remediation/research/03-coverage-gap-specs.md` §D5. Date every figure when you +> repeat it; the email-delivery behavior (dedup vs "always emailed") is genuinely +> contested and a one-edition live test would resolve it. + +--- + ## Reference Files - `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (16 phases including v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`) diff --git a/plugins/linkedin-studio/commands/outreach.md b/plugins/linkedin-studio/commands/outreach.md index bcf323a..e2446a0 100644 --- a/plugins/linkedin-studio/commands/outreach.md +++ b/plugins/linkedin-studio/commands/outreach.md @@ -1072,6 +1072,37 @@ Day 30: - Update speaker bio with new event ``` +### Step 8c: Persist the pipeline to tracked state + +The boards above are the working view; the **tracked record** lives in plugin +state so the pipeline survives across sessions (and shows the same partner the +next time you run `/linkedin:outreach`). After you add or advance a row, persist +it with the additive `recordOutreachContact` mutation — the same state pattern +`/linkedin:firsthour` uses (`recordFirstHourPlan`). It writes a newest-first row +to a `## Outreach Pipeline` section in `~/.claude/linkedin-studio.local.md` and +sets `last_outreach_date` / `outreach_active`, **without touching any existing +field** (a missing field is inserted, never required up front): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs" --record-outreach \ + --date "YYYY-MM-DD HH:MM" \ + --track collab \ + --partner "@name" \ + --stage pitched \ + --next "follow up if no reply" \ + --due YYYY-MM-DD +``` + +- `--track` — `collab` or `speaking` (the row is tagged so one board carries both). +- `--stage` — `warming` / `pitched` / `in-production` / `delivered` / `follow-up`. +- `--next` + `--due` — the next action and when it falls due (drives the + follow-up surfacing in `/linkedin:calendar` and the Step 10 dashboard). + +Re-running with the same partner appends a fresh dated row, so the section is a +running history of where each contact stands — not a single mutable cell. Read +the section back at Step 0 of the next outreach session to reconstruct the +board. + ## Step 9: Network & Progression ### Step 9a: Collab — Inner Circle Model diff --git a/plugins/linkedin-studio/commands/profile.md b/plugins/linkedin-studio/commands/profile.md index 0034bf2..d458231 100644 --- a/plugins/linkedin-studio/commands/profile.md +++ b/plugins/linkedin-studio/commands/profile.md @@ -38,6 +38,40 @@ LinkedIn's 150B parameter foundation model evaluates five criteria: | **Network** | Connected to professionals in this space? | MEDIUM - social proof | | **Engagement Patterns** | Do you comment on posts about your topics? | MEDIUM - active participation | +## Profile SEO — your profile is also a search surface + +Topic-relevance ranking (above) governs **content distribution**. Separately, +your profile is **indexed by LinkedIn search** — when someone searches a topic, a +role, or a skill, LinkedIn keyword-matches profile fields to decide who surfaces. +The two reinforce each other: the same keywords that tell the relevance model +what you're expert in are the ones that make you findable. Optimize for both. + +**The headline is your highest-weight search field.** It is keyword-matched, shown +in every search result and connection suggestion, and renders under your name +across the site — so it does the most SEO work per character. Lead with the plain +words people actually search (the role, the domain, the audience), not a clever +tagline. "AI Advisor · public-sector AI governance · Microsoft Copilot" is more +findable than "Turning chaos into clarity ✨". + +**Per-section keyword targets** (place the terms a searcher would type, in the +words they'd type them — not synonyms only you use): + +| Section | Keyword target | Why it ranks | +|---------|----------------|--------------| +| **Headline** | 3–4 primary topic terms + audience + role | Highest-weight search field; always visible | +| **About** | Same primary terms, front-loaded in the first 2–3 lines, then 5–8 supporting terms naturally across the body | Indexed for search; first lines double as the relevance model's expertise signal | +| **Experience (titles + body)** | The searchable job title (not an internal-only label) + 2–3 domain terms per role | Job titles are weighted in search; an internal title nobody searches is invisible | +| **Skills** | Your top 3 skills = your 3 core content topics, exact-match to common search terms | Matched directly against recruiter/search skill filters | +| **Featured** | Posts whose titles carry your topic terms | Reinforces the topic association for both search and relevance | + +**Rule of thumb:** pick your 3–5 core topics once, then make the *same* terms +appear — in the searcher's own words — in the headline, the About opener, the +skills, and your recent post topics. Keyword **consistency across sections** +beats keyword **stuffing in any one section**: LinkedIn rewards a coherent +expertise signal, and a profile crammed with unrelated terms reads as noise to +both the search index and the relevance model. Avoid buzzwords nobody searches +("thought leader", "guru", "ninja") — they cost a keyword slot and return nothing. + ## Profile Audit Walkthrough Guide the user through each section using AskUserQuestion for interactive feedback. diff --git a/plugins/linkedin-studio/config/state-file.template.md b/plugins/linkedin-studio/config/state-file.template.md index 09c9579..f2a3c6e 100644 --- a/plugins/linkedin-studio/config/state-file.template.md +++ b/plugins/linkedin-studio/config/state-file.template.md @@ -36,6 +36,10 @@ content_series_active: "" last_firsthour_date: null # "YYYY-MM-DD HH:MM" of the most recent first-hour plan firsthour_active: false # true while a first-hour loop is in progress +# Outreach (collab + speaking pipeline) +last_outreach_date: null # "YYYY-MM-DD HH:MM" of the most recent outreach contact +outreach_active: false # true while an outreach pipeline is being worked + # Profile expertise_areas: - "general" @@ -68,3 +72,8 @@ expertise_areas: + +## Outreach Pipeline + + + diff --git a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs index cc13ace..43433df 100644 --- a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs +++ b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan } from '../state-updater.mjs'; +import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs'; const SAMPLE_STATE = `--- last_post_date: "2026-04-05" @@ -365,3 +365,77 @@ describe('recordFirstHourPlan', () => { assert.ok(result.changes.length > 0); }); }); + +describe('recordOutreachContact', () => { + const CONTACT_OPTS = { + contactDate: '2026-05-30 14:00', + track: 'collab', + partner: '@bigvoice', + stage: 'pitched', + nextAction: 'follow up if no reply', + dueDate: '2026-06-06' + }; + + test('creates a non-R-initial Outreach Pipeline section when absent', () => { + // SAMPLE_STATE has no ## Outreach Pipeline section → must be created (additive) + assert.ok(!SAMPLE_STATE.includes('## Outreach Pipeline')); + const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + assert.ok(result.content.includes('## Outreach Pipeline')); + }); + + test('records track, partner, stage, next action and due in the entry', () => { + const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + assert.ok(result.content.includes('[2026-05-30 14:00]')); + assert.ok(result.content.includes('@bigvoice')); + assert.ok(result.content.includes('collab')); + assert.ok(result.content.includes('pitched')); + assert.ok(result.content.includes('follow up if no reply')); + assert.ok(result.content.includes('2026-06-06')); + }); + + test('is additive — inserts last_outreach_date when the field is absent', () => { + assert.ok(!/^last_outreach_date:/m.test(SAMPLE_STATE)); + const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_outreach_date: "2026-05-30 14:00"$/m); + }); + + test('updates an existing last_outreach_date without duplicating it', () => { + const withField = SAMPLE_STATE.replace( + 'last_post_date: "2026-04-05"', + 'last_post_date: "2026-04-05"\nlast_outreach_date: null' + ); + const result = recordOutreachContact(withField, { ...CONTACT_OPTS, contactDate: '2026-05-31 09:00' }); + assert.notEqual(result, null); + const hits = result.content.match(/^last_outreach_date:/gm) || []; + assert.equal(hits.length, 1, 'field must not be duplicated'); + assert.match(result.content, /^last_outreach_date: "2026-05-31 09:00"$/m); + }); + + test('leaves existing fields and sections untouched (round-trip)', () => { + const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_post_date: "2026-04-05"$/m); + assert.match(result.content, /^follower_count: 850$/m); + assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."')); + }); + + test('gracefully defaults empty optional fields', () => { + const result = recordOutreachContact(SAMPLE_STATE, { + contactDate: '2026-05-30 14:00', + partner: 'minimal-contact' + }); + assert.notEqual(result, null); + assert.ok(result.content.includes('## Outreach Pipeline')); + assert.ok(result.content.includes('minimal-contact')); + }); + + test('returns a changes array describing what changed', () => { + const result = recordOutreachContact(SAMPLE_STATE, CONTACT_OPTS); + assert.notEqual(result, null); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes.length > 0); + }); +}); diff --git a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs index a7948de..b9a6e7d 100644 --- a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs +++ b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs @@ -259,6 +259,63 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta return { content, changes }; } +/** + * Record an outreach contact / pipeline entry deterministically. Additive: an + * absent scalar is inserted (never required up front), an absent section is + * created, and no existing field is touched. Mirrors recordFirstHourPlan: + * scalar replace + newest-first section append. The section name is + * deliberately non-`R`-initial ("Outreach …") so it falls outside + * pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window. + * + * @param {string} stateContent - Full state file content + * @param {{ contactDate: string, track?: string, partner?: string, stage?: string, nextAction?: string, dueDate?: string }} opts + * @returns {{ content: string, changes: string[] } | null} + */ +export function recordOutreachContact(stateContent, { contactDate, track = '', partner = '', stage = '', nextAction = '', dueDate = '' }) { + let content = stateContent; + const changes = []; + + // 1. last_outreach_date — replace in place, else insert after last_firsthour_date + // if present, else after last_post_date (additive — never required up front) + if (/^last_outreach_date: .*/m.test(content)) { + content = replaceField(content, 'last_outreach_date', `"${contactDate}"`); + } else if (/^last_firsthour_date: .*/m.test(content)) { + content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + } else if (/^last_post_date: .*/m.test(content)) { + content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + } + changes.push(`last_outreach_date → ${contactDate}`); + + // 2. outreach_active flag — only touch if the field is declared (additive) + if (/^outreach_active: .*/m.test(content)) { + content = replaceField(content, 'outreach_active', 'true'); + changes.push('outreach_active → true'); + } + + // 3. Build the pipeline entry block + const fmt = (v) => (v && String(v).trim() ? String(v).trim() : '_(none)_'); + const heading = `### [${contactDate}] ${partner || '(contact)'}${track ? ` — ${track}` : ''}`.trimEnd(); + const entry = [ + heading, + `- **Stage:** ${fmt(stage)}`, + `- **Next action:** ${fmt(nextAction)}`, + `- **Due:** ${fmt(dueDate)}`, + '' + ].join('\n'); + + // 4. Append to ## Outreach Pipeline (newest first); create the section if absent (additive) + if (/^## Outreach Pipeline\b/m.test(content)) { + content = content.replace(/^(## Outreach Pipeline\n\n?)/m, `$1${entry}\n`); + } else { + const trimmed = content.replace(/\s*$/, ''); + content = `${trimmed}\n\n## Outreach Pipeline\n\n\n\n\n${entry}\n`; + } + changes.push(`Outreach Pipeline += ${contactDate} "${partner || '(contact)'}"`); + + if (content === stateContent) return null; + return { content, changes }; +} + /** * I/O wrapper: read state file, apply update function, write atomically. * @param {function(string): {content: string}|null} updateFn - Pure update function @@ -311,11 +368,22 @@ if (import.meta.url === `file://${process.argv[1]}`) { draftComments: splitList(getArg('--comments')), plan: splitList(getArg('--plan')) })); + } else if (args.includes('--record-outreach')) { + const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; + writeState(content => recordOutreachContact(content, { + contactDate: getArg('--date') || new Date().toISOString().slice(0, 16).replace('T', ' '), + track: getArg('--track') || '', + partner: getArg('--partner') || '', + stage: getArg('--stage') || '', + nextAction: getArg('--next') || '', + dueDate: getArg('--due') || '' + })); } else { console.log('Usage:'); console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post'); console.log(' node state-updater.mjs --prune [days]'); console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04'); console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"'); + console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD'); } } diff --git a/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md b/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md index 42f028c..0564456 100644 --- a/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md +++ b/plugins/linkedin-studio/references/linkedin-growth-playbook-2025-2026.md @@ -213,7 +213,7 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in **If using video:** - Optimal length: 60 seconds (2026 sweet spot — 30% completion rate minimum for any distribution) - Always add captions (85% watch with sound off) -- Use vertical 9:16 format (1080x1920) for immersive feed distribution boost +- **4:5 (1080×1350) or 1:1 (1080×1080) preferred** for broad feed distribution on a desktop-heavy professional audience; 9:16 is delivered mobile-only and crops to 1:1 on desktop — reserve it for the opt-in vertical video tab. No official 4:5-vs-9:16 engagement study exists, so treat the older "immersive distribution" framing as an uncorroborated heuristic, not a reach lever (see `references/linkedin-formats.md`). ### Text-Only Posts diff --git a/plugins/linkedin-studio/references/video-strategy-guide.md b/plugins/linkedin-studio/references/video-strategy-guide.md index 11d8410..f8644d2 100644 --- a/plugins/linkedin-studio/references/video-strategy-guide.md +++ b/plugins/linkedin-studio/references/video-strategy-guide.md @@ -297,7 +297,7 @@ LinkedIn's algorithm weights **completion rate** above all other video metrics. - Use "open loops" — tease what's coming ("and the third one surprised me...") - Vary pacing to prevent monotony - Place visual changes every 5-7 seconds -- Strong hook → the 3-second test determines 70% of retention +- Front-load value for muted autoplay — ~85% watch without sound, so the opening must read and land with the audio off. (The "three-second hook" is cross-platform folklore, not a LinkedIn-named ranking signal; LinkedIn's only official "3 seconds" is the minimum video length. Open strong because attention is scarce, not because a fixed-second rule sets retention.) ### Vertical Video Preference