feat(marketing): add linkedin plugin — organic presence with platform rules in code

Answers discussion #934, which asked for a strategic assistant for growing a
LinkedIn presence organically rather than a post generator.

Six skills under marketing/linkedin/: an orchestrator (context: fork) plus
profile, strategy, content, engagement, and analytics lanes. 17 stdlib-only
tools, 15 references, 2 agents, 8 /cs:* commands.

The design constraint is the differentiator: no LinkedIn credentials, no API
calls, no scraping, nothing auto-sent. Automated posting, connecting, and
commenting are prohibited by LinkedIn's User Agreement 8.2, and a restricted
account ends a compounding asset. linkedin_policy_gate.py runs before any
drafting and refuses seven request classes — automation, scraping, engagement
pods, bulk messaging, fake identity, fabricated proof, named third-party
automation platforms — each carrying the policy anchor and a compliant
substitute, so the gate never just says no.

Refusals are real rather than advisory. A cadence under 90 minutes a week
returns a comment-only plan instead of a schedule that dies in week five. A
newsletter whose six-month cost exceeds the budget is refused before the promise
is made. An experiment needing more posts than a quarter allows is reported
infeasible rather than quietly re-sized. The pattern miner refuses to test
anything below 10 posts and reports NOTHING_SURVIVED as a finding.

Evidence discipline: two widely repeated claims are corrected rather than
propagated. The "personalised note triples acceptance" claim is not supported by
the largest samples (acceptance is near-identical either way, ~26.4%); what a
note moves is the post-accept reply rate (~5.4% to ~9.4%), which is why the
message builder refuses an ask in a first-touch note. The ~19% in-body link
reach reduction has never been confirmed by LinkedIn as a penalty and has a
plausible dwell-time explanation, so it is a warning rather than a block. Every
reference carries per-claim confidence levels.

Accessibility is a blocking lint finding: Unicode pseudo-bold is announced by
screen readers as mathematical symbols and is not indexed by search.

All six SKILL.md files are 6/6 PASS on the write-a-skill checklist. Every tool
supports --help, --sample, and --output json with typed exit codes.

Counters: skills 380 -> 386; plugins 96 -> 97; tools 706 -> 723; refs 823 -> 838;
agents 114 -> 116; commands 138 -> 146 (derive_counters.py --check).

Also syncs three previously-merged skills (agent-memory, hivemind, skill-doctor)
into the .hermes/ and .vibe/ mirror trees, which had drifted behind .codex/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JSPxUHU6utqme7qC6EwHEh
This commit is contained in:
Claude 2026-08-25 07:32:30 +00:00
parent 437f321c8c
commit 645c523be1
No known key found for this signature in database
100 changed files with 8866 additions and 23 deletions

View file

@ -8,7 +8,7 @@
"homepage": "https://github.com/alirezarezvani/claude-skills",
"repository": "https://github.com/alirezarezvani/claude-skills",
"metadata": {
"description": "380 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 706 Python tools, 823 reference guides, 114 agents (cs-* + personas), 138 slash commands across 96 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"description": "386 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 723 Python tools, 838 reference guides, 116 agents (cs-* + personas), 146 slash commands across 97 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"version": "2.12.0"
},
"plugins": [
@ -1285,6 +1285,28 @@
],
"category": "marketing"
},
{
"name": "linkedin",
"source": "./marketing/linkedin",
"description": "Organic LinkedIn presence, end to end, with LinkedIn's own rules enforced in code. Orchestrator (context: fork) gates every request against User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity and fabricated proof, each with a compliant substitute — then routes to profile / strategy / content / engagement / analytics. Headline and whole-profile scoring with fixes ranked by points per hour; positioning brief with a mandatory exclusion list; cadence priced against real hours with a 90-minute floor; newsletter eligibility + six-month sustainability gate; post linter blocking on engagement bait and screen-reader-hostile Unicode pseudo-bold; repurposing with a reuse ledger; capped commenting roster and template-refusing outreach; analytics that test patterns against a seeded permutation null and refuse to conclude below 10 posts. 17 stdlib tools, 15 references with per-claim confidence levels. No credentials, no API calls, nothing auto-sent. Answers discussion #934.",
"version": "2.12.0",
"author": {
"name": "Alireza Rezvani"
},
"keywords": [
"linkedin",
"personal-brand",
"organic-growth",
"content-strategy",
"profile-optimization",
"outreach",
"newsletter",
"social-media",
"thought-leadership",
"career-change"
],
"category": "marketing"
},
{
"name": "pulse",
"source": "./research/pulse",

View file

@ -3,7 +3,7 @@
"name": "claude-code-skills",
"description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM",
"repository": "https://github.com/alirezarezvani/claude-skills",
"total_skills": 356,
"total_skills": 362,
"skills": [
{
"name": "agent-launcher-orchestrator",
@ -1223,18 +1223,18 @@
"category": "engineering-advanced",
"description": "Use when the user asks to design a RAG pipeline, choose a chunking strategy or embedding model, pick a vector database, or evaluate retrieval quality (precision@k, recall@k, NDCG). Examples: 'design a RAG system for our docs', 'what chunk size should I use for this corpus', 'evaluate my retriever against ground truth'. NOT for general LLM cost tuning (use llm-cost-optimizer) or agent loops over retrieval (use agenthub)."
},
{
"name": "run",
"source": "../../engineering/agenthub/skills/run",
"category": "engineering-advanced",
"description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end."
},
{
"name": "run",
"source": "../../engineering/autoresearch-agent/skills/run",
"category": "engineering-advanced",
"description": "Run a single experiment iteration. Edit the target file, evaluate, keep or discard. Use when the user runs /ar:run or asks for one manual autoresearch iteration."
},
{
"name": "run",
"source": "../../engineering/agenthub/skills/run",
"category": "engineering-advanced",
"description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end."
},
{
"name": "runbook-generator",
"source": "../../engineering/skills/runbook-generator",
@ -1541,6 +1541,42 @@
"category": "marketing",
"description": "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' 'product update,' 'GTM plan,' 'launch checklist,' or 'launch momentum.' This skill covers phased launches, channel strategy, and ongoing launch momentum."
},
{
"name": "linkedin-analytics",
"source": "../../marketing/linkedin/skills/linkedin-analytics",
"category": "marketing",
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts."
},
{
"name": "linkedin-content",
"source": "../../marketing/linkedin/skills/linkedin-content",
"category": "marketing",
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger."
},
{
"name": "linkedin-engagement",
"source": "../../marketing/linkedin/skills/linkedin-engagement",
"category": "marketing",
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent."
},
{
"name": "linkedin-profile",
"source": "../../marketing/linkedin/skills/linkedin-profile",
"category": "marketing",
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold."
},
{
"name": "linkedin-skills",
"source": "../../marketing/linkedin/skills/linkedin-skills",
"category": "marketing",
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts."
},
{
"name": "linkedin-strategy",
"source": "../../marketing/linkedin/skills/linkedin-strategy",
"category": "marketing",
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment."
},
{
"name": "local-seo-manager",
"source": "../../marketing-skill/skills/local-seo-manager",
@ -2194,7 +2230,7 @@
"description": "Financial analysis, valuation, and forecasting skills"
},
"marketing": {
"count": 50,
"count": 56,
"source": "../../marketing-skill",
"description": "Marketing, content, and demand generation skills"
},

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-analytics

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-content

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-engagement

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-profile

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-skills

View file

@ -0,0 +1 @@
../../marketing/linkedin/skills/linkedin-strategy

View file

@ -1,7 +1,7 @@
{
"version": "1.0.0",
"name": "gemini-cli-skills",
"total_skills": 430,
"total_skills": 436,
"skills": [
{
"name": "README",
@ -1793,6 +1793,36 @@
"category": "marketing-top-level",
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides."
},
{
"name": "linkedin-analytics",
"category": "marketing-top-level",
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts."
},
{
"name": "linkedin-content",
"category": "marketing-top-level",
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger."
},
{
"name": "linkedin-engagement",
"category": "marketing-top-level",
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent."
},
{
"name": "linkedin-profile",
"category": "marketing-top-level",
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold."
},
{
"name": "linkedin-skills",
"category": "marketing-top-level",
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts."
},
{
"name": "linkedin-strategy",
"category": "marketing-top-level",
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment."
},
{
"name": "agile-product-owner",
"category": "product",
@ -2208,7 +2238,7 @@
"description": "Marketing resources"
},
"marketing-top-level": {
"count": 1,
"count": 7,
"description": "Marketing-top-level resources"
},
"product": {

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-analytics/SKILL.md

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-content/SKILL.md

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-engagement/SKILL.md

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-profile/SKILL.md

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-skills/SKILL.md

View file

@ -0,0 +1 @@
../../../marketing/linkedin/skills/linkedin-strategy/SKILL.md

View file

@ -0,0 +1 @@
../../../../engineering/agent-memory/skills/agent-memory

View file

@ -0,0 +1 @@
../../../../engineering/hivemind

View file

@ -0,0 +1 @@
../../../../engineering/skill-doctor/skills/skill-doctor

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-analytics

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-content

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-engagement

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-profile

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-skills

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-strategy

View file

@ -1,6 +1,6 @@
{
"source": "claude-code-skills",
"total_skills": 353,
"total_skills": 362,
"domains": {
"engineering": [
{
@ -198,6 +198,11 @@
"description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).",
"path": "engineering/agent-harness"
},
{
"name": "agent-memory",
"description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.",
"path": "engineering/agent-memory"
},
{
"name": "agenthub",
"description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.",
@ -348,6 +353,11 @@
"description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.",
"path": "engineering/helm-chart-builder"
},
{
"name": "hivemind",
"description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".",
"path": "engineering/hivemind"
},
{
"name": "human-gate",
"description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).",
@ -393,6 +403,11 @@
"description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".",
"path": "engineering/security-guidance"
},
{
"name": "skill-doctor",
"description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.",
"path": "engineering/skill-doctor"
},
{
"name": "skillopt-sleep",
"description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.",
@ -1537,6 +1552,36 @@
"name": "landing",
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.",
"path": "marketing/landing"
},
{
"name": "linkedin-analytics",
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.",
"path": "marketing/linkedin-analytics"
},
{
"name": "linkedin-content",
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.",
"path": "marketing/linkedin-content"
},
{
"name": "linkedin-engagement",
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.",
"path": "marketing/linkedin-engagement"
},
{
"name": "linkedin-profile",
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.",
"path": "marketing/linkedin-profile"
},
{
"name": "linkedin-skills",
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.",
"path": "marketing/linkedin-skills"
},
{
"name": "linkedin-strategy",
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.",
"path": "marketing/linkedin-strategy"
}
],
"research": [

View file

@ -0,0 +1 @@
../../../../engineering/agent-memory/skills/agent-memory

View file

@ -0,0 +1 @@
../../../../engineering/hivemind

View file

@ -0,0 +1 @@
../../../../engineering/skill-doctor/skills/skill-doctor

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-analytics

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-content

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-engagement

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-profile

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-skills

View file

@ -0,0 +1 @@
../../../../marketing/linkedin/skills/linkedin-strategy

View file

@ -1,7 +1,7 @@
{
"source": "claude-code-skills",
"layout": "nested",
"total_skills": 353,
"total_skills": 362,
"domains": {
"engineering": [
{
@ -199,6 +199,11 @@
"description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).",
"path": "engineering/agent-harness"
},
{
"name": "agent-memory",
"description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.",
"path": "engineering/agent-memory"
},
{
"name": "agenthub",
"description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.",
@ -349,6 +354,11 @@
"description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.",
"path": "engineering/helm-chart-builder"
},
{
"name": "hivemind",
"description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".",
"path": "engineering/hivemind"
},
{
"name": "human-gate",
"description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).",
@ -394,6 +404,11 @@
"description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".",
"path": "engineering/security-guidance"
},
{
"name": "skill-doctor",
"description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.",
"path": "engineering/skill-doctor"
},
{
"name": "skillopt-sleep",
"description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.",
@ -1538,6 +1553,36 @@
"name": "landing",
"description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.",
"path": "marketing/landing"
},
{
"name": "linkedin-analytics",
"description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.",
"path": "marketing/linkedin-analytics"
},
{
"name": "linkedin-content",
"description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.",
"path": "marketing/linkedin-content"
},
{
"name": "linkedin-engagement",
"description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.",
"path": "marketing/linkedin-engagement"
},
{
"name": "linkedin-profile",
"description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.",
"path": "marketing/linkedin-profile"
},
{
"name": "linkedin-skills",
"description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.",
"path": "marketing/linkedin-skills"
},
{
"name": "linkedin-strategy",
"description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.",
"path": "marketing/linkedin-strategy"
}
],
"research": [

View file

@ -5,6 +5,65 @@ All notable changes to the Claude Skills Library will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added — marketing/linkedin: organic LinkedIn presence with the platform rules in code
New `marketing/linkedin/` plugin, answering
[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934), which
asked for a strategic assistant for growing a LinkedIn presence organically rather than a
post generator. Six skills, 17 stdlib-only tools, 15 references, 2 agents, 8 commands.
- **The design constraint is the differentiator.** The plugin holds no LinkedIn credentials,
makes no API calls, scrapes nothing, and sends nothing — automated posting, connecting,
commenting, and liking are prohibited by LinkedIn's User Agreement §8.2, and a restricted
account ends a compounding asset. `linkedin_policy_gate.py` runs before any drafting and
refuses seven classes of request (automation, scraping, engagement pods, bulk messaging,
fake identity, fabricated proof, named third-party automation platforms), each with the
policy anchor and a **compliant substitute** — the gate never just says no.
- **`linkedin-skills`** (orchestrator, `context: fork`) — policy gate + deterministic
five-lane router (route 0 / ask 2 / no-signal 3) with cross-lane prerequisites.
- **`linkedin-profile`** — headline scored on audience/outcome/proof/searchability/clarity
against the 220-char cap and the ~60-char front-load window; whole-profile audit across 14
weighted checks with fixes ranked by **points per hour** and a first-hour plan; About
builder that refuses a fold cutting mid-sentence or carrying no audience and no proof.
- **`linkedin-strategy`** — positioning brief validator (six real objectives, an audience
specific enough to exclude someone, 2-4 proof-backed pillars, a **mandatory exclusion
list**); cadence planner that prices the week in minutes and returns a comment-only plan
below a 90-minute floor; newsletter gate on LinkedIn's published 150-follower evaluation
threshold plus six-month cadence cost, with a stop rule written before issue one.
- **`linkedin-content`** — post linter across mechanics / hook / integrity / accessibility,
blocking on the 3,000-char cap, engagement bait, and **Unicode pseudo-bold** (screen
readers announce it as mathematical symbols; search does not index it as words); format
picker over nine native formats; repurpose splitter with a **content-hash reuse ledger**.
- **`linkedin-engagement`** — comment roster capped at two appearances per account per week;
message builder that refuses a template without a person-specific line and refuses an ask
in a first-touch connection note; volume guard that refuses above 40 invitations a day as
an automation plan regardless of intent.
- **`linkedin-analytics`** — median/MAD describer with Tukey bands (a mean describes a
distribution none of your posts belong to); four-gate permutation pattern miner with
**multiple-comparisons accounting** and mirrored-candidate de-duplication; experiment
planner that reports infeasibility rather than quietly shrinking the effect. Refuses to
conclude anything below 10 posts.
- **Evidence discipline — two widely repeated claims corrected rather than propagated.**
(1) "A personalised connection note triples acceptance (~45% vs ~15%)" is not supported by
the largest samples, which show acceptance close to identical either way (~26.4%); what a
note moves is the **post-accept reply rate** (~5.4% → ~9.4%), which is why the builder
refuses an ask in a first-touch note. (2) The ~19% in-body link reach reduction has never
been confirmed by LinkedIn as a penalty and has a plausible dwell-time explanation, so it
is a warning rather than a blocking finding. Every reference carries per-claim confidence
levels (🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore).
- All six SKILL.md files are a full **6/6 PASS** on the write-a-skill checklist. Every tool
supports `--help`, `--sample`, and `--output json` with typed exit codes.
- **Counters:** skills 380 → 386; plugins 96 → 97; tools 706 → 723; references 823 → 838;
agents 114 → 116; commands 138 → 146 (verified via `scripts/derive_counters.py --check`).
### Fixed
- Synced three previously-merged skills (`engineering/agent-memory`, `engineering/hivemind`,
`engineering/skill-doctor`) into the `.hermes/` and `.vibe/` mirror trees, which had
drifted behind `.codex/` and `.gemini/`.
## [2.12.0] - 2026-08-24 — consolidated release: 20 domains, 380 skills, full issue-triage sweep
**First tagged release since v2.9.0.** Versions 2.10.02.11.2 were documented in

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# Claude Code Skills & Plugins — Agent Skills for Every Coding Tool
**380 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
**386 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
The most comprehensive open-source library of Claude Code skills and agent plugins — also works with OpenAI Codex, Gemini CLI, Cursor, and 9 more coding agents. Reusable expertise packages covering engineering, DevOps, marketing (incl. AEO — Answer Engine Optimization for LLM citation), security (PreToolUse hooks), compliance, C-level advisory (incl. founder-mode CFO/CMO/CRO/CPO/COO/CHRO/CISO/GC/CDO/CAIO/CCO/VPE personas + 21 /cs:* slash commands), productivity (capture/email/reflect/weekly-review/deep-work/meetings), an academic research stack (litreview/grants/dossier/patent/syllabus/pulse/notebooklm/deep-research + hybrid router), and enterprise Research Operations (clinical-research/research-finance/market-research/product-research, v2.9.0).
@ -10,10 +10,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
[^vibe]: Mistral Vibe is also **BYO-sync tier**: the repo ships a pre-generated `.vibe/skills/claude-skills/` tree, run `./scripts/vibe-install.sh` once locally to install into `~/.vibe/skills/`. Same agentskills.io SKILL.md standard — no format conversion. Docs: <https://docs.mistral.ai/mistral-vibe/agents-skills>.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![Skills](https://img.shields.io/badge/Skills-380-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-114-blue?style=for-the-badge)](#agents)
[![Skills](https://img.shields.io/badge/Skills-386-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-116-blue?style=for-the-badge)](#agents)
[![Personas](https://img.shields.io/badge/Personas-7-purple?style=for-the-badge)](#personas)
[![Commands](https://img.shields.io/badge/Commands-138-orange?style=for-the-badge)](#commands)
[![Commands](https://img.shields.io/badge/Commands-146-orange?style=for-the-badge)](#commands)
[![Stars](https://img.shields.io/github/stars/alirezarezvani/claude-skills?style=for-the-badge)](https://github.com/alirezarezvani/claude-skills/stargazers)
[![SkillCheck Validated](https://img.shields.io/badge/SkillCheck-Validated-4c1?style=for-the-badge)](https://getskillcheck.com)
@ -29,7 +29,7 @@ Claude Code skills (also called agent skills or coding agent plugins) are modula
- **Python tools** — 706 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 823 templates, checklists, and domain-specific knowledge files
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 706 Python tools run anywhere Python runs.
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 723 Python tools run anywhere Python runs.
### Skills vs Agents vs Personas
@ -152,7 +152,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
## Skills Overview
**380 production-ready skills across 20 domains:**
**386 production-ready skills across 20 domains:**
| Domain | Skills | Highlights | Details |
|--------|--------|------------|---------|
@ -161,7 +161,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
| **🎯 Product** | 17 | Product manager, agile PO, strategist, UX researcher, UI design, landing pages, SaaS scaffolder, analytics, experiment designer, discovery, roadmap communicator, code-to-prd, apple-hig-expert | [product-team/](product-team/) |
| **📣 Marketing** | 49 | 8 pods: Content, SEO + AEO (`aeo` — E-E-A-T audit, citation tracking across 5 LLMs) + local (`local-seo-manager` — GBP/NAP/Map-Pack), CRO, Channels, Growth, Intelligence, Sales + `business-name-fit` (cross-cultural naming) + context foundation + orchestration router | [marketing-skill/](marketing-skill/) |
| **🚀 Productivity** | 12 | `capture` (brain-dump-to-action), `email` pair (inbox-setup + inbox-triage), `reflect` (journal), `handoff` (Matt Pocock-inspired), `andreessen` (market-first decision mode), `roast` (5-angle idea panel → GO/RESHAPE/KILL), `fable-goal` (ramble → autonomous /goal prompt), `weekly-review` (GTD loop with refusal gate), `deep-work` (time-blocking + shallow-work budget), `meetings` (cost gate + agenda + action items), `swedish-mentor` (CEFR-leveled Swedish learning paths) | [productivity/](productivity/) |
| **🎨 Marketing (top-level)** | 1 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator) | [marketing/](marketing/) |
| **🎨 Marketing (top-level)** | 7 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator); `linkedin` — organic LinkedIn presence with User Agreement §8.2 refusals in code (orchestrator + profile, strategy, content, engagement, analytics) | [marketing/](marketing/) |
| **🔬 Research (academic)** | 10 | `research` orchestrator (hybrid router + fallback) + 8 specialists: `pulse`, `litreview`, `grants` (NIH), `dossier`, `patent`, `syllabus`, `notebooklm`, `deep-research` (rigor-first meta-research), `deepread` (evidence-first reading of supplied documents) | [research/](research/) |
| **🧪 Research Operations** ✨v2.9.0 | 5 | Enterprise/cross-functional research: orchestrator + `clinical-research` (study design), `research-finance` (R&D program finance), `market-research` (sizing/survey/segmentation), `product-research` (user research) — each with onboarding + customization + opt-in autoresearch bridge | [research-ops/](research-ops/) |
| **📋 Project Management** | 9 | Senior PM, scrum master, Jira, Confluence, Atlassian admin, templates + bundled Atlassian Remote MCP | [project-management/](project-management/) |
@ -359,7 +359,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI,
No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release.
**Are the Python tools dependency-free?**
Yes. All 706 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
Yes. All 723 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
**How do I create my own Claude Code skill?**
Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide.

View file

@ -0,0 +1,11 @@
{
"source": {
"spec": "https://github.com/alirezarezvani/claude-skills/discussions/934",
"build_pattern": "Domain plugin — context: fork orchestrator with a policy gate in front of a deterministic five-lane router; every sub-skill ships 3 stdlib-only tools with typed exit codes, references citing 7 sources each with per-claim confidence levels (LinkedIn-official / third-party study / folklore), and fillable assets. Answers discussion #934's ten requested capabilities: content strategy, post ideation, multi-format drafting, posting cadence, newsletter planning, profile optimization, outreach messages, reach tactics via comments and communities, repurposing from articles/video/audio, and analysis of existing posts.",
"distinct_from": "marketing-skill/x-twitter-growth (X/Twitter mechanics — different platform, format economics, and rules); marketing-skill/social-content and social-media-manager (multi-platform brand calendars — this is one person's own presence on one platform, in depth); marketing-skill/social-media-analyzer (cross-platform campaign reporting — this reads your own LinkedIn export and refuses to conclude below 10 posts); marketing-skill/cold-email (different channel, different law, different caps); productivity/andreessen (venture judgement, not audience building)"
},
"attribution": {
"policy_basis": "Refusal rules are derived from LinkedIn's published User Agreement §8.2, the Prohibited Software and Extensions help article, and the Professional Community Policies. No LinkedIn code, data, or proprietary material is included, and the plugin makes no LinkedIn API calls.",
"evidence_discipline": "Two widely repeated third-party claims are corrected rather than propagated. (1) The claim that a personalised connection note roughly triples acceptance (~45% vs ~15%) is not supported by the largest available samples, which show acceptance close to identical either way (~26.4%); what the note moves is the post-accept reply rate (~5.4% to ~9.4%). (2) The in-body external-link reach reduction (~19% median in a ~1.3M-post third-party study) has never been confirmed by LinkedIn as a penalty and has a plausible non-punitive explanation via dwell time, so it is flagged as a warning rather than a blocking finding. Per-claim confidence levels are carried in every reference."
}
}

View file

@ -0,0 +1,20 @@
{
"name": "linkedin",
"description": "Organic LinkedIn presence, end to end, with the platform rules enforced in code. An orchestrator (context: fork) gates every request against LinkedIn's User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, and named third-party automation tools, each with a compliant substitute — then routes to one of five sub-skills. linkedin-profile scores a headline on five dimensions against the 220-character cap, audits the whole profile 0-100 with fixes ranked by points per hour, and assembles an About section that survives the '…see more' fold. linkedin-strategy validates a positioning brief (six real objectives, an audience specific enough to exclude someone, 2-4 proof-backed pillars, a mandatory exclusion list), sizes a week against the hours that actually exist and refuses below a 90-minute floor, and gates a newsletter on eligibility plus a six-month cadence commitment. linkedin-content lints posts 0-100 across mechanics, hook, integrity, and accessibility — blocking on the 3,000-character cap, engagement bait, and Unicode pseudo-bold that screen readers cannot read — picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger. linkedin-engagement builds a capped commenting roster, refuses template outreach without a person-specific line, and refuses volumes no human could type. linkedin-analytics reads your own export, reports medians rather than misleading means, and tests candidate patterns against a seeded permutation null with multiple-comparisons accounting — refusing to conclude anything below 10 posts. 17 stdlib-only Python tools, 15 references citing 7 sources each with per-claim confidence levels. No LinkedIn credentials, no API calls, nothing auto-sent.",
"version": "2.12.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/marketing/linkedin",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills/linkedin-skills",
"./skills/linkedin-profile",
"./skills/linkedin-strategy",
"./skills/linkedin-content",
"./skills/linkedin-engagement",
"./skills/linkedin-analytics"
]
}

View file

@ -0,0 +1,108 @@
# linkedin — organic LinkedIn presence, with the rules enforced in code
A strategic assistant for building an authentic LinkedIn presence over months, not a post
generator. Built in response to
[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934).
**The design constraint that shapes everything else:** this plugin holds no LinkedIn
credentials, makes no API calls, scrapes nothing, and sends nothing. Every output is text the
account holder posts themselves. Automated posting, connecting, commenting, and liking are
prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding
asset that took months to build. So the refusals are in code, at the front of every lane,
and each one names a compliant substitute.
```
/cs:linkedin # gate + route
/cs:grill-linkedin # five forcing questions before the work starts
/cs:linkedin-profile # headline, About, whole-profile audit
/cs:linkedin-plan # brief, pillars, cadence, newsletter
/cs:linkedin-post # format, draft, lint
/cs:linkedin-repurpose # article/talk -> posts, with a reuse ledger
/cs:linkedin-outreach # comment roster + one message at a time
/cs:linkedin-analyze # your own export, analysed honestly
```
---
## What is here
| Skill | Tools | Does |
|---|---|---|
| **linkedin-skills** (orchestrator, `context: fork`) | 2 | Policy gate (ALLOW / CONSTRAIN / REFUSE) then a deterministic five-lane router with cross-lane prerequisites |
| **linkedin-profile** | 3 | Headline scored on five dimensions vs the 220-char cap; whole profile 0-100 with fixes ranked by points per hour; About section assembled to survive the fold |
| **linkedin-strategy** | 3 | Positioning brief validator; cadence priced in minutes with a 90-minute floor; newsletter eligibility + six-month sustainability gate |
| **linkedin-content** | 3 | Post linter (mechanics / hook / integrity / accessibility); format picker; repurpose splitter with a reuse ledger |
| **linkedin-engagement** | 3 | Capped commenting roster; message builder that refuses templates; volume guard that refuses automation-shaped plans |
| **linkedin-analytics** | 3 | Median/MAD describer; four-gate permutation pattern miner; experiment planner |
Plus 2 agents, 8 commands, 15 references (7 sources each), 11 assets.
## The parts that are different
**The policy gate runs before anything is drafted.** Seven refusal rules covering automation,
scraping, engagement pods, bulk messaging, fake identity, fabricated proof, and named
third-party automation tools — each with the User Agreement clause and a substitute that
achieves the same goal legitimately. A REFUSE outranks any route.
**Evidence is graded, and two popular claims are corrected.** Every quantitative claim carries
🟢 (LinkedIn-official), 🟡 (third-party study), or 🔴 (folklore, named as folklore).
- The "a personalised note triples acceptance" claim is not supported by the largest samples,
which show acceptance close to identical either way (~26.4%). What a note actually moves is
the **post-accept reply rate** (~5.4% → ~9.4%). That changes what the note should say: it
earns the conversation, not the meeting — which is why `outreach_message_builder.py`
refuses an ask in a first-touch connection note.
- The ~19% in-body link reach reduction has **never been confirmed by LinkedIn as a penalty**
and has a plausible non-punitive explanation via dwell time. It is a warning, not a block.
**The analytics refuse to over-conclude.** `pattern_miner.py` puts every candidate through four
gates: a group-size floor, a 15% minimum relative effect, a seeded permutation test, and a
multiple-comparisons accounting that reports how many candidates would pass on noise alone.
Below 10 posts it refuses to test anything. `NOTHING_SURVIVED` is the most common honest
answer and is reported as a finding, not a failure.
**Accessibility is a blocking finding, not a footnote.** Unicode pseudo-bold — the output of
"bold text generators" — is a blocking lint failure, because screen readers announce those
characters as mathematical symbols and LinkedIn search does not index them as words.
**Refusals are refusals.** A cadence under 90 minutes a week returns a comment-only plan
rather than a schedule that will be abandoned in week five. A newsletter whose six-month cost
exceeds the budget is refused before the promise is made. An experiment that needs more posts
than a quarter allows is reported as infeasible rather than quietly re-sized.
## Quick start
```bash
# See every tool run with zero configuration
python3 skills/linkedin-skills/scripts/linkedin_policy_gate.py --sample --output human
python3 skills/linkedin-profile/scripts/headline_scorer.py --sample-weak --output human
python3 skills/linkedin-strategy/scripts/cadence_planner.py --sample --output human
python3 skills/linkedin-content/scripts/post_linter.py --sample --output human
python3 skills/linkedin-engagement/scripts/outreach_volume_guard.py --sample --output human
python3 skills/linkedin-analytics/scripts/pattern_miner.py --sample --output human
```
Every tool supports `--help`, `--sample`, and `--output json`, uses the standard library
only, and returns typed exit codes so an agent can branch on the verdict rather than parse
prose.
## Distinct from
- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform, different
format economics, different rules.
- **`marketing-skill/social-content`, `social-media-manager`** — multi-platform brand
calendars. This is one person's own presence on one platform, in depth.
- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting. This reads
your own LinkedIn export and refuses to conclude below 10 posts.
- **`marketing-skill/cold-email`** — different channel, different law, different caps.
## Provenance
Requested in [discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934).
Refusal rules derive from LinkedIn's published User Agreement §8.2, the Prohibited Software
and Extensions help article, and the Professional Community Policies. No LinkedIn code, data,
or proprietary material is included. See `.claude-plugin/authoring-notes.json`.
---
**Version:** 1.0.0 · MIT · 6 skills · 17 stdlib tools · 15 references · nothing auto-sent

View file

@ -0,0 +1,89 @@
---
name: cs-linkedin-editor
description: Drafts, edits, and lints LinkedIn posts to a publishable standard — hook that survives the ~140-character mobile fold, one idea, real numbers, no engagement bait, no Unicode pseudo-bold, alt text and captions written. Runs post_linter.py to a clean exit rather than declaring a draft done, picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger so nothing goes out twice. Refuses to invent a metric, client, result, or quote. Use when someone wants a LinkedIn post written, reviewed, rewritten, or repurposed from an article, talk, or transcript.
skills: marketing/linkedin/skills/linkedin-content
domain: marketing
model: opus
tools: [Read, Bash, Write, Edit]
---
# LinkedIn Editor Agent
## Purpose
`cs-linkedin-editor` owns the draft. Its job is to get the specific thing out of the author's
head and onto the page, then remove everything that would stop a stranger reading it.
1. **Interview for specifics first.** Never draft from a topic. Get the number, the mistake,
the sentence someone actually said, the thing that surprised them. A post with no
specifics cannot be fixed by editing.
2. **Pick the format from the material** (`format_picker.py`), not from what is working for
other people this month.
3. **Draft to the fold.** A sentence completes inside the first ~140 characters. That is
where the reader decides.
4. **Lint to a clean exit** (`post_linter.py`). Two passes is normal. Do not declare a draft
finished on a REVISE unless the user explicitly accepts a named warning.
5. **Hand back with the accessibility work done** — alt text written, captions flagged as
required, no pseudo-bold anywhere.
## Voice
- Cuts first, adds second. The first paragraph is usually throat-clearing; check whether the
post starts better at paragraph two, because it usually does.
- Reads drafts out loud. Every sentence you stumble on is one a reader stumbles on.
- Allergic to the interchangeable sentence. If a line would fit any post about anything, it
is not carrying meaning.
- Never flatters a draft. "This is close, and here are the three things stopping it" is
more useful than encouragement.
## Hard rules
1. **Never fabricate a number, client, result, credential, or quote.** If the proof does not
exist, the post is about the process — which is a legitimate post and it ages better.
2. **No engagement bait.** "Comment X below", "like if you agree", "tag someone", "Agree?".
Blocking in the linter, named as demoted content in LinkedIn's Professional Community
Policies, and recognisable to every reader.
3. **No Unicode pseudo-bold.** Screen readers announce it as mathematical symbols and search
does not index it as words. This is an accessibility failure, not a style preference.
4. **Alt text on every image, captions on every video.** LinkedIn does not add them for you,
and auto-captions mangle exactly the domain terms the post is about.
5. **Links in the first comment**, with "link in the comments" in the post — unless the click
is the goal and the user accepts the reach cost, which they should say out loud.
6. **One idea per post.** If it needs two, it is two posts.
7. **The author reads every line before it ships.** Say so on handover.
## Skill Integration
**Skill location:** `../skills/linkedin-content/`
### Tools
1. `skills/linkedin-content/scripts/post_linter.py` — 0-100 across mechanics, hook,
integrity, and accessibility; blocking findings for the 3,000-char cap, engagement bait,
and pseudo-bold.
2. `skills/linkedin-content/scripts/format_picker.py` — nine native formats scored against
goal, material, and minutes; refuses camera-less video and decision-less polls.
3. `skills/linkedin-content/scripts/repurpose_splitter.py` — standalone units with a
content-hash reuse ledger so the same idea does not go out twice across months.
### Knowledge bases
- `skills/linkedin-content/references/hook_and_fold_mechanics.md` (7 sources)
- `skills/linkedin-content/references/post_formats_canon.md` (7 sources)
- `skills/linkedin-content/references/repurposing_discipline.md` (7 sources)
- `skills/linkedin-content/references/accessibility_and_inclusion.md` (7 sources)
## Differentiates from siblings
- **vs `cs-copywriting` / `content-humanizer`** — general copy craft and de-AI passes. Reach
for those on tone; this one owns LinkedIn's mechanics, its fold, and its accessibility floor.
- **vs `cs-linkedin-orchestrator`** — that one routes and gates. This one writes.
## Related agents
- [cs-linkedin-orchestrator](cs-linkedin-orchestrator.md) — routing, policy gate, and the
other four lanes
---
**Version:** 1.0.0

View file

@ -0,0 +1,100 @@
---
name: cs-linkedin-orchestrator
description: Routes any LinkedIn organic-growth request to the right lane and gates it against LinkedIn's User Agreement before a word is drafted. Runs the policy gate (ALLOW / CONSTRAIN / REFUSE against §8.2 — automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, prohibited third-party tools), then the deterministic five-lane router (profile / strategy / content / engagement / analytics), then walks the forcing questions until the chosen lane can run honestly. Holds no LinkedIn credentials and sends nothing. Use when someone wants to grow an organic LinkedIn presence, fix a profile, plan content, write posts, do outreach, or understand their numbers.
skills: marketing/linkedin/skills/linkedin-skills
domain: marketing
model: opus
tools: [Read, Bash, Write, Edit]
---
# LinkedIn Orchestrator Agent
## Purpose
`cs-linkedin-orchestrator` runs the `linkedin` domain end to end for one person's own
presence. It is a router with a gate in front of it, and the gate comes first.
1. **Gate.** `linkedin_policy_gate.py` on the user's own words, before any drafting. Exit 4
REFUSE means a named rule is broken — do not draft it, name the rule, and offer the
substitute the gate prints. Exit 3 CONSTRAIN means proceed and say the constraint out
loud. Exit 0 ALLOW means proceed.
2. **Route.** `linkedin_goal_router.py` scores five lanes. Route at exit 0, ask exactly one
clarifying question at exit 2 (naming both candidates with a recommendation), and at
exit 3 ask what they want to walk away with rather than guessing.
3. **Grill.** Walk the five forcing questions one at a time, each with a recommended answer.
Stop as soon as the lane can run honestly — do not run the full set for its own sake.
4. **Run the lane.** Invoke the sub-skill, use its tools, iterate against their exit codes.
5. **Deliver.** The artifact, the confidence level on any platform claim, and the one thing
the user has to do next that no tool can do for them.
## Voice
- Blunt about the trade. The shortcuts work faster and risk the account. Say the arithmetic
once, then respect the answer.
- Evidence-graded by default. 🟢 LinkedIn-official, 🟡 third-party study, 🔴 folklore — and
folklore gets named as folklore rather than repeated.
- Refuses without moralising. One sentence, the rule, the substitute, then move on.
- Never impressed by follower counts, including the user's.
## Hard rules
1. **Gate before route, route before draft.** A REFUSE outranks any route.
2. **Nothing is sent and nothing is fetched.** No credentials, no API calls, no scraping.
Every output is text the user posts themselves.
3. **Never fabricate a number, client, result, credential, or quote** — not even as a
placeholder that "they'll fill in later". Placeholders ship.
4. **The account holder is the author of record.** Say it when handing over a draft.
5. **Never silently chain lanes.** Offer the next lane as a question with a recommendation.
6. **Refuse pods, automation tools, scraping, and bulk messaging** every time, including
when the user pushes back. If they reaffirm after the explanation, say the risk is theirs
and decline to build it — do not build a partial version.
7. **Under 10 posts, describe; do not conclude.** Applies to every claim about their data.
## Skill Integration
**Skill location:** `../skills/linkedin-skills/`
### Orchestrator tools
1. `skills/linkedin-skills/scripts/linkedin_policy_gate.py` — 7 refusal rules + 3 constraint
rules against User Agreement §8.2 and the Professional Community Policies, each with a
named compliant substitute.
2. `skills/linkedin-skills/scripts/linkedin_goal_router.py` — deterministic five-lane
classifier with cross-lane prerequisites (content needs a brief; analytics needs volume;
engagement wastes a weak profile).
### Lanes
- `linkedin-profile` — headline scorer, whole-profile auditor ranked by points per hour,
About builder that survives the fold.
- `linkedin-strategy` — positioning brief validator, cadence planner with a 90-minute floor,
newsletter eligibility and six-month sustainability gate.
- `linkedin-content` — post linter (mechanics / hook / integrity / accessibility), format
picker, repurpose splitter with a reuse ledger.
- `linkedin-engagement` — comment roster with per-account caps, message builder that refuses
templates, volume guard that refuses automation-shaped plans.
- `linkedin-analytics` — median/MAD describer, four-gate permutation pattern miner,
experiment planner.
### Knowledge bases
- `skills/linkedin-skills/references/linkedin_platform_canon.md` — LiRank, dwell time, the
link-penalty evidence, and what LinkedIn does not publish (7 sources)
- `skills/linkedin-skills/references/policy_and_account_safety.md` — §8.2, prohibited
software, restriction triggers, regional obligations (7 sources)
## Differentiates from siblings
- **vs `cs-social-media-manager` / `social-content`** — those plan multi-platform brand
social. This is one person's own LinkedIn presence, in depth, with platform rules attached.
- **vs `x-twitter-growth`** — different platform, different format economics, different rules.
- **vs `cs-cold-email`** — email outreach is a different channel with different law and
different caps.
## Related agents
- [cs-linkedin-editor](cs-linkedin-editor.md) — the drafting and linting counterpart
---
**Version:** 1.0.0

View file

@ -0,0 +1,73 @@
---
name: "cs-grill-linkedin"
description: "/cs:grill-linkedin — Interrogate a LinkedIn plan one forcing question at a time, each with a recommended answer anchored in the plugin's canon. Refuses to start the work until the objective, the audience, the hours, the proof, and the exclusion list survive the questions."
argument-hint: "[the LinkedIn plan or ambition you want pressure-tested]"
---
# /cs:grill-linkedin — One question at a time, with a recommendation
**Command:** `/cs:grill-linkedin [your plan]`
Most LinkedIn plans fail on inputs, not execution. This walks the five questions that decide
whether any of the tools can run honestly. One question per turn, each with a recommended
answer and the canon it comes from. Never bundle.
## When to run
- The ambition is real but the plan is vague ("I want to build a presence")
- Before committing a quarter to a cadence
- When a previous attempt stalled and nobody has said why
## The questions
**Q1 — What has to be true in 90 days for this to have been worth it?**
*Recommended: one observable outcome another person could verify — an inbound conversation,
an offer, a hire. Not a follower count.*
Canon: [`objective_to_pillars.md`](../skills/linkedin-strategy/references/objective_to_pillars.md). Follower count moves for
reasons unrelated to the objective; optimising the number you can see instead of the outcome
you want is the most common way a LinkedIn strategy fails while appearing to work.
**Q2 — Who is this for, specifically enough that someone is excluded?**
*Recommended: role + company stage + the problem they have this quarter.*
Canon: same document, and `positioning_brief.py` refuses "business leaders" at exit 3. An
audience that excludes nobody cannot guide a single editorial decision.
**Q3 — How many minutes a week will you protect, measured from a bad week?**
*Recommended: the honest number, not the aspirational one. Below 90, the answer is a
comment-only week.*
Canon: [`cadence_and_consistency.md`](../skills/linkedin-strategy/references/cadence_and_consistency.md). A cadence abandoned in
week five is worse than one never started, because the abandonment is visible.
**Q4 — What proof already exists?**
*Recommended: name shipped work, a measurement, a repo, a hire, a talk. If none exists, the
first pillar is process, not results.*
Canon: [`policy_and_account_safety.md`](../skills/linkedin-skills/references/policy_and_account_safety.md) — the fabrication refusal.
A pillar with no proof is a claim you would have to invent evidence for.
**Q5 — What will you not post about?**
*Recommended: two topics, including the trending one you have no edge on.*
Canon: `objective_to_pillars.md`. A positioning that excludes nothing is availability, and
the exclusion list is what settles the "should I comment on this news cycle" question in
advance.
## Discipline
- **One question per turn.** Wait for the answer. Never bundle.
- **Always recommend.** A question with no recommended answer is homework, not a grill.
- **Cite the canon** for each challenge — the reference document, not a feeling.
- **Stop early** when the lane can run honestly. The full set is not a ritual.
- **Push back once on a weak answer, then accept it.** Their presence, their call. Record the
weak answer in the brief so it is visible later rather than arguing it now.
## Stop conditions
- All five answered well enough that `positioning_brief.py` would exit 0 → hand off to
[`/cs:linkedin-plan`](cs-linkedin-plan.md).
- The user declines to answer Q1 or Q2 → say plainly that the work cannot be aimed without
them, and offer the profile lane instead, which needs neither.
## Related
- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md)
- Command: [`/cs:linkedin`](cs-linkedin.md)
- Agreement: [`linkedin_operating_agreement.md`](../skills/linkedin-skills/assets/linkedin_operating_agreement.md)

View file

@ -0,0 +1,72 @@
---
name: "cs-linkedin-analyze"
description: "/cs:linkedin-analyze — Read your own exported LinkedIn post data, report medians and outlier bands rather than misleading means, test candidate patterns against a seeded permutation null with multiple-comparisons accounting, and size a real experiment. Refuses to conclude anything below 10 posts."
argument-hint: "[path to your LinkedIn post export, or the pattern you think you see]"
---
# /cs:linkedin-analyze — Describe honestly, refuse to over-conclude
**Command:** `/cs:linkedin-analyze [export path or the claim to test]`
Your own data only. Export from LinkedIn Analytics → Post impressions → Export, or Settings
→ Data privacy → Get a copy of your data. Nothing is fetched; scraping post data is
prohibited by User Agreement §8.2 and none of this needs it.
## When to run
- "Why did my reach drop?"
- "Do carousels actually do better for me?"
- "What's working?"
- Before changing strategy on the basis of one post that did well
## What you get
1. **A description** — median and MAD, percentile bands, a 1.5×IQR breakout threshold, and a
per-post band from BREAKOUT to DUD.
2. **A verdict on the pattern** — SUPPORTED, NOT_SUPPORTED, TOO_SMALL, or NOT_TESTED, with
the reason for each, plus how many candidates would pass on noise alone.
3. **A sized experiment** if something survived — or an honest "this needs more posts than a
quarter allows".
## Workflow
```bash
python3 ../skills/linkedin-analytics/scripts/post_performance_analyzer.py \
--input export.csv --csv --output human
# exit 2 = under 10 posts. Descriptive only. Say so and stop.
python3 ../skills/linkedin-analytics/scripts/pattern_miner.py \
--input export.csv --csv --output human
# exit 2 = nothing survived. This is a real finding, not a failure.
# CV for the planner = 1.4826 * MAD / median, from step one
python3 ../skills/linkedin-analytics/scripts/experiment_planner.py \
--hypothesis "..." --variable "..." --cv 0.45 --effect 0.30 \
--posts-per-week 2 --max-weeks 12 --output human
```
## Discipline
- **Under 10 posts, describe; do not conclude.** State it plainly rather than hedging into
something that reads like a conclusion.
- **"Nothing survived" is the most common honest answer.** Report it as a finding.
- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made
carousels when you had structured material, on topics you knew best, in weeks you had time.
- **Never benchmark against someone else's numbers.** Different denominator, different
audience, usually a vendor's sample.
- **Follower count is not a success metric.** Point them at the Tier 1 log instead.
- **One good post is not evidence.** It is the least informative event available.
## Stop conditions
- Description delivered and the user knows which three outcome metrics to log by hand → done.
- Miner returns nothing supported → say so, recommend re-running in six weeks, and stop.
Do not keep slicing the data until something passes.
- Experiment planner says TOO_LONG → present the minimum detectable effect in their window
and let them decide. Do not quietly shrink the effect to make it fit.
## Related
- Skill: [`linkedin-analytics`](../skills/linkedin-analytics/SKILL.md)
- Log: [`measurement_log_template.md`](../skills/linkedin-analytics/assets/measurement_log_template.md)
- Reference: [`evidence_thresholds.md`](../skills/linkedin-analytics/references/evidence_thresholds.md)

View file

@ -0,0 +1,69 @@
---
name: "cs-linkedin-outreach"
description: "/cs:linkedin-outreach — Build a weekly commenting roster inside a real time budget, and write one outreach message at a time that refuses to be a template. Caps volume against LinkedIn's observed invitation limits and refuses automation-shaped plans. Nothing is ever sent."
argument-hint: "[who you want to reach, and how much time you have]"
---
# /cs:linkedin-outreach — Comments first, messages second
**Command:** `/cs:linkedin-outreach [who, and how much time]`
**Nothing here is sent.** No credentials, no API calls. Automated connecting, messaging,
commenting, liking, and sharing are prohibited by LinkedIn's User Agreement §8.2.
## When to run
- "Who should I be commenting on?"
- "Write a connection request to X"
- "How many invites can I safely send this week?"
- Reach is flat and you are only publishing
## What you get
1. **A five-day comment roster** from accounts you name, capped at two appearances per
account per week and balanced across tiers.
2. **A volume verdict** — safe, tight, over a cap, or refused as an automation plan.
3. **One assembled message** with the person-specific line enforced and the 200/300-character
cap checked.
4. **The follow-up rule**, stated once.
## Workflow
```bash
# 1. Roster (tiers: huge | larger | peer | smaller)
python3 ../skills/linkedin-engagement/scripts/comment_target_planner.py \
--account "Priya Raman:5:4:larger" --account "Tomas Lind:5:3:peer" \
--minutes-per-day 18 --output human
# 2. Volume check BEFORE writing anything
python3 ../skills/linkedin-engagement/scripts/outreach_volume_guard.py \
--invites 20 --pending 5 --minutes 120 --acceptance 0.42 --output human
# exit 4 = refused as an automation plan. Do not build a smaller version of it silently.
# 3. One message, for one person
python3 ../skills/linkedin-engagement/scripts/outreach_message_builder.py \
--type connection --recipient "Priya" --specific-line "..." --reason "..." --output human
```
## Discipline
- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under
§8.2 regardless of who pressed the key.
- **The tool builds the roster, never the comments.** A generated comment is exactly what the
rule names, and it is recognisable anyway.
- **Every message carries a line that could only have been written for that person.**
- **No ask in a first-touch connection note.** The note earns the conversation, not the meeting.
- **Acceptance below 20% is a stop signal**, not a reason to send more.
- **One follow-up, a week later, only with something new to say.**
## Stop conditions
- Roster built and the first day's comments written by the user → done.
- Volume guard at exit 4 → refuse, explain once, offer the manual cadence. If the user
reaffirms, say the risk is theirs and decline to build it.
- Message at exit 0 → hand it over with "send this yourself, to this one person".
## Related
- Skill: [`linkedin-engagement`](../skills/linkedin-engagement/SKILL.md)
- Worksheet: [`outreach_worksheet.md`](../skills/linkedin-engagement/assets/outreach_worksheet.md)

View file

@ -0,0 +1,63 @@
---
name: "cs-linkedin-plan"
description: "/cs:linkedin-plan — Build the positioning brief (objective, audience, 2-4 proof-backed pillars, exclusion list), size a weekly cadence against the hours that actually exist, and gate a newsletter against eligibility and a six-month commitment."
argument-hint: "[what you want LinkedIn to do for you in 90 days]"
---
# /cs:linkedin-plan — Brief, cadence, newsletter
**Command:** `/cs:linkedin-plan [your 90-day goal]`
Three decisions in order. Out of order is why most LinkedIn efforts produce a feed of
unrelated observations and stop in week five.
## When to run
- "What should I post about?" / "How often should I post?"
- "Should I start a LinkedIn newsletter?"
- Before drafting anything — posts without pillars are noise
## What you get
1. **A validated positioning brief** — one of six real objectives, an audience specific
enough to exclude someone, two to four pillars with shares summing to 100, and at least
two exclusions.
2. **Observable 90-day criteria.** Follower count is deliberately not among them.
3. **A weekly plan priced in minutes**, allocated by stage, plus the minimum viable week that
survives a bad week.
4. **A newsletter verdict** — green, thin, or refused, with a 12-issue arc and a stop rule.
## Workflow
```bash
python3 ../skills/linkedin-strategy/scripts/positioning_brief.py --input brief.json --output human
# exit 3 = objective or audience too vague to proceed. Fix that before anything else.
python3 ../skills/linkedin-strategy/scripts/cadence_planner.py \
--minutes 240 --stage starting --target-posts 3 --output human
# exit 2 = below the 90-minute floor -> comment-only week returned
# exit 3 = over budget, with the overage named
python3 ../skills/linkedin-strategy/scripts/newsletter_planner.py \
--followers 1800 --cadence biweekly --minutes-per-month 420 --output human
```
## Discipline
- **One objective.** Two serve neither; sequence instead of blending.
- **The exclusion list is the positioning.** Do not skip it because it feels negative.
- **Price the week from a bad week.** The plan has to survive week five, not week one.
- **From a standing start, most of the budget belongs in other people's comments.**
- **Review quarterly, not weekly.** A brief revised monthly is a mood.
## Stop conditions
- Brief at exit 0 and the cadence plan fits → done; hand over the minimum viable week.
- Cadence over budget twice on the same target → the target is the problem, not the plan.
Say so and cut it.
- Newsletter refused → say why once, recommend posts on a fixed day instead, and stop.
## Related
- Skill: [`linkedin-strategy`](../skills/linkedin-strategy/SKILL.md)
- Next: [`/cs:linkedin-post`](cs-linkedin-post.md) once the brief exists

View file

@ -0,0 +1,69 @@
---
name: "cs-linkedin-post"
description: "/cs:linkedin-post — Pick the format the material actually supports, draft to the ~140-character mobile fold, and lint 0-100 across mechanics, hook, integrity, and accessibility. Blocking findings for the 3,000-character cap, engagement bait, and Unicode pseudo-bold."
argument-hint: "[the post idea, or paste a draft to be reviewed]"
---
# /cs:linkedin-post — Format, draft, lint
**Command:** `/cs:linkedin-post [idea or draft]`
## When to run
- "Write a LinkedIn post about X" / "review my draft"
- "Is this hook any good?"
- "Should this be a carousel or a text post?"
## When NOT to run
- No positioning brief yet → [`/cs:linkedin-plan`](cs-linkedin-plan.md) first
- A comment or a DM → [`/cs:linkedin-outreach`](cs-linkedin-outreach.md); different craft
- Repurposing a long source → [`/cs:linkedin-repurpose`](cs-linkedin-repurpose.md)
## What you get
1. **A format recommendation** with the constraint it carries — or one question when the top
two score within a point.
2. **A draft** built from your specifics, with a sentence completing inside the first ~140
characters.
3. **A lint score 0-100** with every finding carrying a fix.
4. **Accessibility done** — alt text written, captions flagged, no pseudo-bold.
## Workflow
```bash
# 1. Format from the material, not from fashion
python3 ../skills/linkedin-content/scripts/format_picker.py \
--goal authority --material data --material tutorial --minutes 120 --output human
# 2. Draft (the interview comes first: the number, the mistake, the sentence someone said)
# 3. Lint to a clean exit
python3 ../skills/linkedin-content/scripts/post_linter.py \
--input draft.md --has-image --output human
# exit 0 SHIP · exit 2 REVISE (or any blocking finding) · exit 3 REWRITE
```
## Discipline
- **Interview before drafting.** A post with no specifics cannot be fixed by editing.
- **Never fabricate a number, client, result, or quote** — not even as a placeholder.
- **Write to the mobile fold.** A sentence completes before character 140.
- **Links in the first comment**, and say so in the post.
- **No engagement bait.** Ask the question the post actually earned.
- **One idea per post.** If it needs two, it is two posts.
- **Cut the first paragraph** and check whether the post starts better at paragraph two.
## Stop conditions
- Linter at exit 0 → done. Hand over with "you are the author of record; read every line".
- Linter at exit 2 with only warnings the user has knowingly accepted → done, with the
accepted warnings restated.
- Three REWRITE passes on the same draft → the problem is the idea, not the wording. Go back
to the specifics.
## Related
- Agent: [`cs-linkedin-editor`](../agents/cs-linkedin-editor.md)
- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md)
- Assets: [`post_templates.md`](../skills/linkedin-content/assets/post_templates.md)

View file

@ -0,0 +1,64 @@
---
name: "cs-linkedin-profile"
description: "/cs:linkedin-profile — Audit a LinkedIn profile 0-100, rank every fix by points per hour, score the headline on five dimensions against the 220-character cap, and assemble an About section that survives the '…see more' fold."
argument-hint: "[your headline, or a description of your profile section by section]"
---
# /cs:linkedin-profile — Audit first, rewrite what pays
**Command:** `/cs:linkedin-profile [headline or profile description]`
Nothing is fetched. You describe your own profile, or fill in
`skills/linkedin-profile/assets/profile_worksheet.md`.
## When to run
- "Fix my headline" / "rewrite my About section"
- "My profile gets views but nothing happens"
- Before any outreach push — comments and DMs drive profile visits, and a weak headline
wastes every one of them
## What you get
1. **A completeness score 0-100** across 14 weighted checks, with every gap ranked by points
per hour and a first-hour plan.
2. **A headline score** on audience / outcome / proof / searchability / clarity, plus the
220-character cap and the front-load check on the first ~60 characters.
3. **An assembled About section** that ends a sentence before the fold and carries audience
or proof above it.
4. **Rewritten experience bullets** — outcomes, not duties.
## Workflow
```bash
# 1. Whole profile, fixes ranked by leverage
python3 ../skills/linkedin-profile/scripts/profile_completeness_auditor.py \
--input profile.json --output human # or --sample to see the shape
# 2. Headline — iterate to exit 0
python3 ../skills/linkedin-profile/scripts/headline_scorer.py \
--headline "..." --output human
# 3. About — refuses a broken fold, a missing CTA, or an over-length section
python3 ../skills/linkedin-profile/scripts/about_section_builder.py \
--input about.json --output human
```
## Discipline
- **Never invent a credential, metric, or role.** Everything on a profile is checkable.
- **First person.** Third person on a personal profile reads as a press release.
- **The fold is the section** — whatever sits above "…see more" is what most readers get.
- **Front-load the headline**; the first 60 characters do most of the work.
- Start with the first-hour plan, not with the About section you want to agonise over.
## Stop conditions
- Headline at exit 0 (SHIP), or the user would say it out loud to a peer → done.
- About at exit 0, or the user knowingly accepts a named warning → done.
- Auditor at STRONG, or the first-hour plan completed and the rest scheduled → done.
## Related
- Skill: [`linkedin-profile`](../skills/linkedin-profile/SKILL.md)
- Run [`/cs:linkedin-plan`](cs-linkedin-plan.md) first if the audience answer is still fuzzy

View file

@ -0,0 +1,63 @@
---
name: "cs-linkedin-repurpose"
description: "/cs:linkedin-repurpose — Split an article, talk transcript, README, or write-up into standalone LinkedIn units, score each on whether it survives without its context, and skip anything already in the reuse ledger so the same idea never goes out twice."
argument-hint: "[path to the source: article, transcript, README, notes]"
---
# /cs:linkedin-repurpose — One source, many posts, no duplicates
**Command:** `/cs:linkedin-repurpose [path to source]`
## When to run
- "Turn my talk into LinkedIn posts"
- "I wrote this article — what can I post from it?"
- You have a body of work and an empty content calendar
## What you get
1. **Scored standalone units** — length, dangling references, evidence, substance. A unit
opening with "This meant that…" is disqualified regardless of its score, because it refers
to something the reader never saw.
2. **A suggested format** per unit.
3. **Ledger-aware output** — anything already posted is skipped, with its date available on
request.
4. **The named gap you have to fill** — the first-person sentence only you can write.
## Workflow
```bash
# Split and see what is available
python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \
--input talk.md --ledger .linkedin-ledger.json --output human
# After publishing unit 2, record it
python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \
--input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
# Then lint the drafted post
python3 ../skills/linkedin-content/scripts/post_linter.py --input draft.md --output human
```
Commit the ledger alongside the source. It is project state, not a cache.
## Discipline
- **Every unit is source material, not a post.** Add what it cost, what you assumed, or what
you would do differently. That sentence is the only genuinely new thing in a repurposed post.
- **Consent and confidentiality first** for post-mortems, customer notes, and anything with a
named third party. The pattern is publishable; the customer is not.
- **Never publish identical text on two platforms the same day.**
- **One source should not carry a quarter.** When units start needing more setup than
payload, the ledger is telling you to go do something new.
## Stop conditions
- Usable units identified and the first one drafted and linted clean → done.
- `NOT_SPLITTABLE` (exit 3) → this is one post, not a series. Say so and write the one post.
- All units already in the ledger → the source is mined out. Do not re-cut it.
## Related
- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md)
- Reference: [`repurposing_discipline.md`](../skills/linkedin-content/references/repurposing_discipline.md)

View file

@ -0,0 +1,78 @@
---
name: "cs-linkedin"
description: "/cs:linkedin — Route any organic LinkedIn goal to the right lane (profile / strategy / content / engagement / analytics) after gating it against LinkedIn's User Agreement. Refuses automation, scraping, engagement pods, bulk DMs, fake identity, and fabricated proof before a word is drafted. Nothing is ever sent."
argument-hint: "[what you want from LinkedIn — a goal, a profile problem, a post, an outreach plan, or a question about your numbers]"
---
# /cs:linkedin — Gate, then route
**Command:** `/cs:linkedin [your goal]`
This plugin holds no LinkedIn credentials, makes no API calls, and sends nothing. Every
output is text you paste and post yourself.
## When to run
- "Help me grow on LinkedIn" / "I want to build an audience"
- You do not know which part of the problem to work on first
- Any LinkedIn request where you want the platform rules checked before the work starts
## When NOT to run
- You already know the lane → go straight to `/cs:linkedin-profile`, `/cs:linkedin-plan`,
`/cs:linkedin-post`, `/cs:linkedin-outreach`, or `/cs:linkedin-analyze`
- X/Twitter → `marketing-skill/x-twitter-growth`
- Multi-platform brand social calendars → `marketing-skill/social-content`
- Cold email → `marketing-skill/cold-email`
## What you get
1. **A policy verdict** — ALLOW, CONSTRAIN (with the constraint stated), or REFUSE (with the
rule named and a compliant substitute offered).
2. **A route** — one of five lanes, with the matched signals, or one clarifying question when
two lanes are genuinely close.
3. **Forcing questions**, one at a time with a recommended answer, until the lane can run
honestly.
4. **The lane's artifact** — audit, brief, draft, roster, or analysis.
## Workflow
```bash
# 1. Gate first, always
python3 ../skills/linkedin-skills/scripts/linkedin_policy_gate.py \
--text "<the user's own words>" --output human
# exit 4 REFUSE -> name the rule, offer the substitute, route there instead
# exit 3 CONSTRAIN -> proceed and state the constraint out loud
# exit 0 ALLOW -> proceed
# 2. Route
python3 ../skills/linkedin-skills/scripts/linkedin_goal_router.py \
--text "<the goal>" --output human
# exit 0 route · exit 2 ask ONE question · exit 3 ask what they want to walk away with
```
## Trigger phrases (auto-invoke without /cs:)
"grow my LinkedIn" · "LinkedIn strategy" · "build an audience on LinkedIn" ·
"my LinkedIn isn't working" · "what should I post on LinkedIn"
## Discipline
- **Gate before route, route before draft.** A REFUSE outranks any route.
- **Never silently chain lanes.** Offer the next one as a question with a recommendation.
- **Evidence-graded claims.** 🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore, and
folklore gets named rather than repeated.
- **Nothing is fetched or sent.**
## Stop conditions
- Lane delivered its artifact and the user knows the one thing only they can do next → done.
- REFUSE delivered with a substitute offered, and the user declines the substitute → done.
Do not build a partial version of the refused thing.
- Router returns NO_SIGNAL twice on the same input → hand the question back plainly.
## Related
- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md)
- Skill: [`linkedin-skills`](../skills/linkedin-skills/SKILL.md)
- Siblings: [`/cs:grill-linkedin`](cs-grill-linkedin.md) and the six lane commands

View file

@ -0,0 +1,100 @@
---
name: linkedin-analytics
description: Use when someone wants to understand their own LinkedIn numbers — which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on "why did my reach drop", "what's working on my LinkedIn", "analyze my posts", "do carousels do better for me", "should I test this", "LinkedIn analytics". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment — refusing to conclude anything below 10 posts.
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
---
# LinkedIn Analytics — describe honestly, then refuse to over-conclude
The characteristic sentence of LinkedIn analytics is "carousels do 3x better for me", built
on four posts. With engagement as heavy-tailed as it is, four posts will show a 3x difference
between almost any two groups you care to define. These three scripts stop that sentence
becoming a strategy.
**Your own data only.** Nothing is fetched; scraping post or profile data is prohibited by
User Agreement §8.2 and none of this analysis needs it.
## Workflow
**1. Get the export.** LinkedIn Analytics → Post impressions → Export, or Settings → Data
privacy → Get a copy of your data. CSV and JSON both work.
**2. Describe it.** Exit 0 analysed / 2 below the 10-post floor, descriptive only / 3
unusable. Reports median and MAD rather than mean and standard deviation — one breakout post
makes a mean describe a distribution none of your posts belong to — plus Tukey percentile
bands and a 1.5×IQR breakout threshold, so "this did well" has a number behind it.
```bash
python3 scripts/post_performance_analyzer.py --input posts.csv --csv --output human
```
**3. Test the pattern they think they see.**
```bash
python3 scripts/pattern_miner.py --input posts.json --output human
```
Exit 0 something survived / 2 nothing survived / 3 under 10 posts. Four gates: 5 posts in and
5 out; a 15% relative difference in medians; beating 90% of 2,000 seeded label shuffles; and
a multiple-comparisons accounting of how many candidates would pass on noise alone.
**"Nothing survived" is the most common honest answer and it is a real finding.** Report it
as one. Do not soften it into a hedge that reads like a conclusion.
**4. Turn a survivor into a test.**
```bash
python3 scripts/experiment_planner.py --hypothesis "..." --variable "..." \
--cv 0.45 --effect 0.30 --posts-per-week 2 --max-weeks 12 --output human
```
CV comes from step 2: `1.4826 * MAD / median`. Exit 0 feasible / 2 too long, with the minimum
detectable effect in their window / 3 refused. It will frequently say the test needs more
posts than a quarter allows — **that is the honest answer**, and more useful than a confident
conclusion from retrospective data.
## Rules
- **Under 10 posts, describe; do not conclude.** Say so plainly.
- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made
carousels when you had structured material, on topics you knew best, in weeks you had time.
No statistics on the same data removes that.
- **Never benchmark against someone else's numbers.** Different denominator, different
audience, usually a vendor's sample.
- **Follower count is not a success metric.** Track inbound conversations, specific
references, invitations — the Tier 1 metrics you count by hand.
- **Report the confidence level.** LinkedIn-official 🟢, third-party study 🟡, folklore 🔴.
- **One good post is not evidence.** It is the most common cause of a strategy change and the
least informative event available.
## Scripts
| Script | Role |
|---|---|
| [`scripts/post_performance_analyzer.py`](scripts/post_performance_analyzer.py) | Median/MAD, percentile bands, IQR outlier fence, per-post BREAKOUT→DUD classification; refuses conclusions below 10 posts. |
| [`scripts/pattern_miner.py`](scripts/pattern_miner.py) | Four-gate permutation test with multiple-comparisons accounting; reports why every rejected candidate failed. |
| [`scripts/experiment_planner.py`](scripts/experiment_planner.py) | Sizes a two-arm posting experiment, names the confounds to hold constant, and writes the falsification condition before the first post. |
## References and assets
- [`references/linkedin_metrics_canon.md`](references/linkedin_metrics_canon.md) — what each number is, what it is not, and which three tiers to track (7 sources)
- [`references/evidence_thresholds.md`](references/evidence_thresholds.md) — the four gates, forking paths, and the uncomfortable arithmetic of LinkedIn A/B tests (7 sources)
- [`assets/example_post_export.csv`](assets/example_post_export.csv) — a 12-post export in the expected shape
- [`assets/measurement_log_template.md`](assets/measurement_log_template.md) — the Tier 1 outcome log you keep by hand
## Distinct from
- **`marketing-skill/social-media-analyzer`** — cross-platform brand campaign reporting. This
is one person's own LinkedIn export, with refusals attached.
- **`linkedin-strategy`** — decides what to do next. This says what happened.
- **`product-team/experiment-designer`** — product A/B tests with real traffic; here n is
posts, and usually too small.
---
**Version:** 1.0.0

View file

@ -0,0 +1,13 @@
date,title,impressions,reactions,comments,reposts,format,pillar,chars
2026-05-04,Onboarding 6 weeks to 4 days,8120,214,63,11,text-post,trust-debt,1450
2026-05-11,dbt exposures rollout,3050,61,9,2,text-post,modelling,620
2026-05-18,Hiring the first analytics engineer,2110,39,6,1,text-post,hiring,540
2026-05-25,Cost teardown carousel,5400,132,28,9,document-carousel,trust-debt,900
2026-06-01,Why we deleted the kickoff call,4300,97,24,5,text-post,trust-debt,1610
2026-06-08,Modelling tradeoffs,1780,28,3,0,text-post,modelling,480
2026-06-15,Analytics engineer job spec,2450,44,11,2,text-post,hiring,700
2026-06-22,Queue time carousel,6900,168,41,14,document-carousel,trust-debt,1100
2026-06-29,Three dbt anti-patterns,3600,74,15,3,document-carousel,modelling,950
2026-07-06,What I got wrong about capacity,5100,121,33,6,text-post,trust-debt,1720
2026-07-13,Interview loop for analytics engineers,1950,31,4,1,text-post,hiring,510
2026-07-20,Freshness SLAs,2800,52,12,2,text-post,modelling,820
1 date title impressions reactions comments reposts format pillar chars
2 2026-05-04 Onboarding 6 weeks to 4 days 8120 214 63 11 text-post trust-debt 1450
3 2026-05-11 dbt exposures rollout 3050 61 9 2 text-post modelling 620
4 2026-05-18 Hiring the first analytics engineer 2110 39 6 1 text-post hiring 540
5 2026-05-25 Cost teardown carousel 5400 132 28 9 document-carousel trust-debt 900
6 2026-06-01 Why we deleted the kickoff call 4300 97 24 5 text-post trust-debt 1610
7 2026-06-08 Modelling tradeoffs 1780 28 3 0 text-post modelling 480
8 2026-06-15 Analytics engineer job spec 2450 44 11 2 text-post hiring 700
9 2026-06-22 Queue time carousel 6900 168 41 14 document-carousel trust-debt 1100
10 2026-06-29 Three dbt anti-patterns 3600 74 15 3 document-carousel modelling 950
11 2026-07-06 What I got wrong about capacity 5100 121 33 6 text-post trust-debt 1720
12 2026-07-13 Interview loop for analytics engineers 1950 31 4 1 text-post hiring 510
13 2026-07-20 Freshness SLAs 2800 52 12 2 text-post modelling 820

View file

@ -0,0 +1,64 @@
# Measurement Log
The Tier 1 metrics — the ones tied to your objective — are not in LinkedIn's analytics. They
have to be written down as they happen, which is why almost nobody has them and why the
people who do can actually tell whether the work is working.
Five minutes a week. Keep it in the repo or wherever the positioning brief lives.
---
## Tier 1 — outcomes (count these by hand)
| Date | What happened | Traceable to | Objective advanced? |
|---|---|---|---|
| | Inbound conversation started by them | post / comment / profile / referral | |
| | Someone referenced a specific post in their first message | | |
| | Invitation (podcast, panel, guest post, talk) | | |
| | Referral or intro offered without asking | | |
| | Qualified enquiry (they named budget, timeline, or a scoped problem) | | |
Attribution is never clean. Record what they said, not what you infer.
## Tier 2 — behavioural proxies (weekly)
| Week | Posts | Median comments/post | Comment share (comments ÷ total interactions) | Notes |
|---|---|---|---|---|
| | | | | |
Comment share is the cleanest available proxy for whether the work lands with people who
care: a comment costs a reader thirty seconds and a small reputational exposure; a reaction
costs a tap.
## Tier 3 — reach (monthly, to notice large changes only)
| Month | Posts | Median impressions | Median engagement rate | Followers | What changed |
|---|---|---|---|---|---|
| | | | | | |
Do not make weekly decisions on these. They are noisy, redefined without notice, and easy to
move in ways that do not serve the objective.
## Quarterly review
Against the 90-day criteria in the positioning brief:
- [ ] Criterion 1: ______ — met / not met
- [ ] Criterion 2: ______ — met / not met
- [ ] Criterion 3: ______ — met / not met
Then three questions:
1. Which pillar produced the **outcomes**, as opposed to the engagement?
2. Did the experimental pillar earn promotion, or should it be replaced?
3. Has the audience description got vaguer? (It always drifts broader. Drag it back.)
## Re-run the tools
```bash
python3 ../scripts/post_performance_analyzer.py --input export.csv --csv --output human
python3 ../scripts/pattern_miner.py --input export.csv --csv --output human
```
Expect `NOTHING_SURVIVED` most times. That is what honest analysis of a small sample looks
like, and it is a finding — not a reason to keep slicing the data until something passes.

View file

@ -0,0 +1,132 @@
# Evidence Thresholds — why most LinkedIn "insights" are noise
The characteristic sentence of LinkedIn analytics is: "carousels do 3x better for
me." It is usually built on four posts. With engagement as heavy-tailed as it is,
four posts will show a 3x difference between almost any two groups you care to
define — including groups defined by the first letter of the first word.
`pattern_miner.py` exists to kill that sentence before it becomes a strategy.
---
## The four gates
A candidate pattern has to pass all four. Most fail at the first.
### 1. Group size floor — at least 5 in, 5 out
Below five, the median is one or two posts and any difference is a coin flip.
Reported as `NOT_TESTED`, with the counts, because "you do not have enough data
yet" is a real and useful finding.
### 2. Effect floor — 15% relative difference in medians
A statistically detectable 3% difference is not a decision. If you would not
change what you write over it, testing it is a waste of the posts. The floor is
set at the level where a rational person would actually act.
### 3. Permutation test — beat 90% of 2,000 label shuffles
The labels ("carousel" / "not carousel") are shuffled 2,000 times against a fixed
seed, the difference of medians recomputed each time, and the observed difference
must be larger than at least 90% of them.
A permutation test is the right instrument here because it makes **no
distributional assumption**. A t-test assumes something approximately normal;
engagement rate is not. The permutation null asks the only question that matters:
*given these exact numbers, how often would random labelling produce a gap this
big?*
The seed is fixed, so the same data always produces the same verdict. An analysis
tool that returns a different answer on re-run is not an analysis tool.
### 4. Multiple comparisons — count every test, report the expected false positives
This is the gate nobody else implements, and it is the one that catches the most
self-deception.
If you test twenty candidate patterns at α = 0.10, **two will pass on noise
alone**. That is not a flaw in the method; it is what the threshold means. The
miner reports how many candidates reached the test, how many you would expect to
pass by chance, and how many actually did. When those numbers are close, it says
so.
It also skips mirrored candidates: for a two-value attribute, "carousel vs rest"
and "text vs rest" are the same comparison with the sign flipped, and counting
both would double-count it in the accounting.
Gelman and Loken's "garden of forking paths" is the sharper version of the
problem: even without formally testing twenty hypotheses, an analyst who *would
have* tested a different cut had the data looked different is effectively
multiple-testing. The defence is to declare the cuts in advance — which is why
the miner takes a fixed attribute list rather than searching for whatever splits
best.
## Why a found pattern is a hypothesis, not a finding
Everything the miner reports is retrospective. It found a difference in posts you
already wrote, chosen for reasons that correlate with everything else about them:
you probably made carousels when you had structured material, on topics you knew
best, in weeks when you had time.
That is confounding, and no amount of statistics on the same dataset removes it.
The only way to get a finding is a deliberate test: decide the variable in
advance, alternate the arms, hold the confounds constant, and run the window.
`experiment_planner.py` sizes it — and will frequently tell you the test needs
more posts than you can produce in a quarter. **That is an honest answer**, and
it is more useful than a confident conclusion from retrospective data.
## The uncomfortable arithmetic
At a realistic coefficient of variation (0.35-0.6 for most accounts) and a
30% target effect, a two-arm test needs roughly 20-60 posts. At two posts a week,
that is 5-30 weeks.
Which means: **most of the LinkedIn A/B tests people describe are not runnable at
their actual posting volume.** The honest responses are to test only variables
where you expect a large effect, to accept a large minimum detectable effect and
say so, or to stop testing and write the thing you would rather write.
## Things that are not evidence
- **One post that did well.** The single most common cause of a strategy change,
and the least informative event available. A breakout post tells you a specific
post worked, in a specific week, with a specific audience state.
- **Comparing this month to last month.** Confounded by season, news cycle,
audience growth, and LinkedIn product changes, all at once.
- **Someone else's benchmark.** Different denominator, different audience,
usually a vendor's sample.
- **A pattern that appeared after you went looking for one.** See forking paths
above.
## What to do instead of measuring more
Post consistently for a quarter against a brief. Track the Tier 1 outcome metrics
by hand — conversations, references, invitations. Re-run the miner every six
weeks and expect it to say "nothing survived" most times, because that is what
honest analysis of a small sample looks like.
The compounding comes from consistency, not from optimisation, and the
optimisation is unavailable at this sample size anyway.
---
## Sources
1. Good, P. **Permutation, Parametric, and Bootstrap Tests of Hypotheses**
(3rd ed.) — the permutation framework and its distribution-free guarantee.
2. Gelman, A. & Loken, E. **"The Garden of Forking Paths"** (2013) — why
researcher degrees of freedom produce false positives without any explicit
p-hacking.
3. Cohen, J. **Statistical Power Analysis for the Behavioral Sciences** (2nd ed.)
— the two-sample sizing formula used by `experiment_planner.py`, and the case
for declaring a minimum effect of interest.
4. Tukey, J. **Exploratory Data Analysis** (1977) — robust summaries, and the
distinction between exploratory and confirmatory analysis that this whole
document rests on.
5. Ioannidis, J. **"Why Most Published Research Findings Are False."** *PLoS
Medicine*, 2005 — the relationship between small samples, many tests, and
false discovery.
6. Benjamini, Y. & Hochberg, Y. **"Controlling the False Discovery Rate."**
*JRSS-B*, 1995 — the formal treatment of the multiple-comparisons accounting
the miner reports informally.
7. Taleb, N.N. **Statistical Consequences of Fat Tails** (2020) — why sample
means and standard deviations mislead for heavy-tailed processes, and why
medians are used throughout.

View file

@ -0,0 +1,136 @@
# LinkedIn Metrics Canon — what each number is, and what it is not
LinkedIn gives creators a small set of numbers and almost no definitions. The
result is a advice ecosystem built on metrics whose meaning has changed at least
twice, compared across periods where they meant different things.
This is what each one actually is, and which ones are worth tracking.
---
## The numbers LinkedIn shows you
### Impressions
The count of times a post was rendered in a feed. **Not unique people.** One
person scrolling past your post twice can produce two impressions. LinkedIn has
revised the counting rules (notably around what counts as "viewed") more than
once, without a changelog a creator can read.
Consequence: **compare posts from the same period, not across a year.** A 2024
impression and a 2026 impression are not obviously the same object.
### Members reached
Where available, this is closer to unique people and is the better denominator.
It is not exposed everywhere, which is why engagement rate is usually computed on
impressions.
### Reactions, comments, reposts
Counts of the three interaction types. Reposts split into "repost" and "repost
with your thoughts", which behave differently and are usually reported together.
### Engagement rate
LinkedIn does not publish a single definition, and neither does anyone else
consistently. This plugin uses:
```
engagement_rate = (reactions + comments + reposts) / impressions
```
Stated explicitly because the alternative definitions (including clicks,
including follows, dividing by followers) produce numbers that differ by 3-5x.
**Any benchmark you read elsewhere is on an unknown denominator.** Compare
against your own history, not against a published benchmark.
### Profile views
Weakly attributable and heavily lagged. Useful as a trend across weeks, useless
per post.
### Followers gained
The metric people optimise and should not. It moves for reasons unrelated to
whether the work is working: one post reaching an adjacent audience adds
followers who will never engage again. A quarter of excellent, well-targeted work
can add very few.
---
## What to actually track
Three tiers, in descending reliability.
**Tier 1 — outcome metrics.** Inbound conversations, specific references
("I saw your post on X"), invitations, referrals, qualified enquiries. Counted by
hand, in a note. These are the only numbers tied to the objective, and they are
the ones nobody tracks because they require writing things down.
**Tier 2 — behavioural proxies.** Comment count and comment *share*
(comments / total interactions). A comment costs a reader thirty seconds and a
small reputational exposure; a reaction costs a tap. Comment share is the
cleanest available proxy for whether the work is landing with people who care.
**Tier 3 — reach metrics.** Impressions, engagement rate, followers. Noisy,
redefined without notice, and easy to move in ways that do not serve the
objective. Track them to notice large changes, not to make weekly decisions.
## Why median, not mean
`post_performance_analyzer.py` reports median and MAD (median absolute
deviation), not mean and standard deviation. LinkedIn post performance is
heavy-tailed: a small number of posts reach far outside the normal range, and one
of them drags a mean to a value that describes none of your posts.
The bands the analyzer reports are Tukey's: Q1, Q3, and a 1.5×IQR fence above Q3
for "breakout". This is the standard robust definition of an outlier and it means
"breakout" has a threshold rather than a feeling.
## The floor
The analyzer refuses to characterise a body of work under **10 posts**, and the
pattern miner refuses to test anything under 10.
This is not conservatism. The between-post variance on LinkedIn is routinely
larger than any group difference eight posts could show — which means with eight
posts you can "discover" almost any pattern you go looking for. See
`evidence_thresholds.md`.
## Exporting your own data
LinkedIn provides two routes, both to *your own* data:
- **Analytics → Post impressions → Export** for per-post performance.
- **Settings → Data privacy → Get a copy of your data** for a fuller archive.
Both tools in this skill read these exports. **Nothing is fetched from LinkedIn
and no other member's data is involved** — scraping profiles or post data is
prohibited by User Agreement §8.2, and there is no version of this analysis that
needs it.
## Benchmarks against other people
Don't. Three reasons, any one of which is sufficient:
1. The denominators differ and are usually unstated.
2. Audience composition dominates. A 12% engagement rate on 400 followers who
all know you personally is not comparable to 2% on 20,000.
3. The published benchmarks come from samples of accounts that opted into being
measured, usually by a vendor selling to them.
Your own history is the only honest baseline, which is another argument for
posting consistently enough to have one.
---
## Sources
1. LinkedIn Help. **Analytics and post performance** documentation — the
definitions LinkedIn does publish, and the export path.
2. LinkedIn Help. **"Get a copy of your data"** — the supported self-export.
3. LinkedIn. **User Agreement §8.2** — why analysis here is limited to your own
exports.
4. Tukey, J. **Exploratory Data Analysis** (1977) — median, IQR, and the 1.5×IQR
outlier fence used for the band thresholds.
5. Huber, P. & Ronchetti, E. **Robust Statistics** (2nd ed.) — MAD as a robust
scale estimate, and the 1.4826 consistency constant used to derive CV.
6. Taleb, N.N. **The Black Swan** / **Statistical Consequences of Fat Tails**
why sample means are unreliable descriptors for heavy-tailed processes.
7. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the
structural reason interaction counts are small and skewed relative to reach.

View file

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""experiment_planner.py — turn a LinkedIn hypothesis into a test you could actually lose.
A pattern found in past posts is a hypothesis. This sizes the deliberate test:
how many posts per arm, how many weeks that takes at your cadence, what to hold
constant, and the part that gets skipped what result would make you abandon
the idea.
Sizing uses the standard two-sample formula with the coefficient of variation of
your own engagement rate:
n per arm = 2 * (z_alpha/2 + z_beta)^2 * CV^2 / effect^2
Get CV from post_performance_analyzer.py: a robust estimate is
1.4826 * MAD / median. It is typically 0.3-0.6 for an established account and
higher for a new one, which is why honest LinkedIn experiments need more posts
than people expect.
Treat the number as a planning estimate, not inference. Engagement rate is
heavy-tailed and posts are not independent draws the same week's news moves all
of them. Analyse the result with pattern_miner.py's permutation test rather than
a t-test.
Exit codes:
0 FEASIBLE the test fits the window
2 TOO_LONG does not fit; the minimum detectable effect in the window is returned
3 REFUSED the hypothesis or the effect size is not worth testing
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import math
import sys
# Two-sided z for alpha, one-sided z for power. Small table, no scipy.
Z_ALPHA = {0.20: 1.282, 0.10: 1.645, 0.05: 1.960}
Z_POWER = {0.70: 0.524, 0.80: 0.842, 0.90: 1.282}
MIN_ACTIONABLE_EFFECT = 0.10
CONFOUNDS = [
"Post at the same time of day in both arms. Time of day and day of week move "
"engagement more than most of the variables people test.",
"Alternate arms post by post. Running arm A for a month and arm B for the next month "
"tests the month, not the variable.",
"Hold the pillar mix steady. If arm A is all your strongest topic, you measured the topic.",
"Do not change the profile, headline, or posting cadence mid-test.",
"Log the arm before you publish, not after. Deciding which arm a post belonged to once "
"you have seen the numbers is how every informal test goes wrong.",
]
# Chosen to be feasible so the sample shows the full happy path. Note how narrow
# the feasible region is: at CV 0.45 and a 30% target effect this same test needs
# 28 weeks. Most LinkedIn "A/B tests" people describe are not runnable at their
# actual posting volume, and the tool says so rather than pretending otherwise.
SAMPLE = {
"hypothesis": "Document carousels earn a higher engagement rate than text posts for my audience",
"variable": "format (document-carousel vs text-post)",
"baseline_median_er": 0.025,
"cv": 0.35,
"effect": 0.40,
"posts_per_week": 3,
"max_weeks": 16,
}
def plan(hypothesis: str, variable: str, cv: float, effect: float,
posts_per_week: float, max_weeks: int, alpha: float, power: float,
baseline: float) -> dict:
if not hypothesis.strip() or not variable.strip():
return {"verdict": "REFUSED", "exit_code": 3,
"finding": "No hypothesis or no named variable.",
"fix": "State it as a sentence you could be wrong about: 'X earns a higher "
"engagement rate than Y for my audience.' If you cannot say what would "
"disprove it, it is a preference, not a hypothesis."}
if effect < MIN_ACTIONABLE_EFFECT:
return {"verdict": "REFUSED", "exit_code": 3,
"finding": f"A {effect:.0%} relative effect is below the "
f"{MIN_ACTIONABLE_EFFECT:.0%} floor.",
"fix": "Test something you would actually change your approach over. Detecting a "
"5% difference needs hundreds of posts and would not change a single "
"decision when you found it."}
if cv <= 0:
return {"verdict": "REFUSED", "exit_code": 3,
"finding": "Coefficient of variation must be positive.",
"fix": "Run post_performance_analyzer.py and compute 1.4826 * MAD / median. "
"With fewer than 10 posts you do not have a usable CV yet."}
z_a = Z_ALPHA[alpha]
z_b = Z_POWER[power]
n_per_arm = math.ceil(2 * (z_a + z_b) ** 2 * cv ** 2 / effect ** 2)
total = n_per_arm * 2
weeks = math.ceil(total / posts_per_week) if posts_per_week > 0 else 10 ** 6
common = {
"hypothesis": hypothesis.strip(),
"variable": variable.strip(),
"design": {
"arms": 2, "alpha": alpha, "power": power, "cv": cv,
"target_relative_effect": effect,
"baseline_median_engagement_rate": baseline,
"posts_per_arm": n_per_arm, "total_posts": total,
"weeks_at_cadence": weeks, "posts_per_week": posts_per_week,
},
"hold_constant": CONFOUNDS,
"analysis_rule": "Analyse with pattern_miner.py (difference of medians, permutation "
"test). Do not use a t-test: engagement rate is heavy-tailed and one "
"breakout post will carry a mean on its own.",
"caveat": "Posts are not independent draws — a busy news week moves every arm at once. "
"This sizing is a planning aid, not a guarantee of power.",
}
if weeks > max_weeks:
affordable_per_arm = max(1, int(max_weeks * posts_per_week / 2))
mde = math.sqrt(2 * (z_a + z_b) ** 2 * cv ** 2 / affordable_per_arm)
return {**common, "verdict": "TOO_LONG", "exit_code": 2,
"finding": f"{total} posts at {posts_per_week}/week is {weeks} weeks, past the "
f"{max_weeks}-week window.",
"options": [
f"Accept a bigger minimum detectable effect: in {max_weeks} weeks you can "
f"detect about {mde:.0%} relative, not {effect:.0%}. If a {mde:.0%} "
"difference would still change your decision, run it.",
"Raise the cadence — but only if the cadence is sustainable for the whole "
"window; an abandoned test is worse than none.",
"Accept lower power (0.70) and treat the result as directional.",
"Do not run it, and pick the option you would rather write anyway. Not every "
"question is worth a quarter of your output.",
],
"minimum_detectable_effect_in_window": round(mde, 3),
"stop_rule": "If you change the plan mid-test, the test is over. Start again or "
"accept the result as anecdote."}
return {**common, "verdict": "FEASIBLE", "exit_code": 0,
"schedule": f"{n_per_arm} posts per arm, alternating, {posts_per_week}/week — "
f"{weeks} weeks.",
"falsification": f"If the median engagement rate of the {variable.strip()} arm is not "
f"at least {effect:.0%} above the other arm at the end of the window, "
"the hypothesis failed. Write that down now, before the first post.",
"stop_rule": [
"Run the full window. Stopping early because the numbers look good is how a "
"coin flip becomes a strategy.",
"One exception: stop if something outside the test changes — a job change, a "
"viral post, a LinkedIn product change. Then restart rather than salvage.",
]}
def render_human(r: dict) -> str:
if r["verdict"] == "REFUSED":
return (f"Experiment: REFUSED\n{'=' * 44}\n{r['finding']}\nfix → {r['fix']}")
d = r["design"]
lines = [f"Experiment: {r['verdict']}", "=" * 60,
f"Hypothesis : {r['hypothesis']}",
f"Variable : {r['variable']}",
f"Design : 2 arms, alpha {d['alpha']}, power {d['power']}, CV {d['cv']}, "
f"target effect {d['target_relative_effect']:.0%}",
f"Sample : {d['posts_per_arm']} posts/arm ({d['total_posts']} total) = "
f"{d['weeks_at_cadence']} weeks at {d['posts_per_week']}/week", ""]
if r["verdict"] == "TOO_LONG":
lines += [r["finding"], "", "Options:"]
for o in r["options"]:
lines.append(f" - {o}")
lines += ["", f"Stop rule: {r['stop_rule']}"]
else:
lines += [f"Schedule : {r['schedule']}", "",
f"Falsification: {r['falsification']}", "", "Stop rule:"]
for s in r["stop_rule"]:
lines.append(f" - {s}")
lines.append("\nHold constant:")
for c in r["hold_constant"]:
lines.append(f" - {c}")
lines += ["", r["analysis_rule"], "", r["caveat"]]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Size a LinkedIn posting experiment "
"(feasible=0 / too-long=2 / refused=3).")
ap.add_argument("--hypothesis", default="", help="The claim, stated so it can fail.")
ap.add_argument("--variable", default="", help="The one thing that differs between arms.")
ap.add_argument("--cv", type=float, default=0.0,
help="Coefficient of variation of your engagement rate "
"(1.4826 * MAD / median from post_performance_analyzer.py).")
ap.add_argument("--effect", type=float, default=0.30,
help="Relative effect you would act on (default 0.30 = 30%%).")
ap.add_argument("--posts-per-week", type=float, default=2.0)
ap.add_argument("--max-weeks", type=int, default=12)
ap.add_argument("--alpha", type=float, choices=sorted(Z_ALPHA), default=0.10)
ap.add_argument("--power", type=float, choices=sorted(Z_POWER), default=0.80)
ap.add_argument("--baseline-er", type=float, default=0.0,
help="Your median engagement rate, for reference in the output.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Size a built-in example experiment.")
args = ap.parse_args()
if args.sample:
s = SAMPLE
result = plan(s["hypothesis"], s["variable"], s["cv"], s["effect"],
s["posts_per_week"], s["max_weeks"], 0.10, 0.80,
s["baseline_median_er"])
else:
if not args.hypothesis:
ap.error("--hypothesis is required (or use --sample)")
result = plan(args.hypothesis, args.variable, args.cv, args.effect,
args.posts_per_week, args.max_weeks, args.alpha, args.power,
args.baseline_er)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""pattern_miner.py — test whether an apparent LinkedIn pattern survives a null test.
The standard failure of LinkedIn analytics is a confident sentence built on four
posts: "carousels do 3x better for you". With post engagement as heavy-tailed as
it is, four posts will show a 3x difference between almost any two groups you
care to define.
So this tool tries to kill every candidate pattern before reporting it:
1. Group size floor at least 5 posts in the group and 5 outside it.
2. Effect floor the median engagement rate must differ by at least 15%
relative. A statistically detectable 3% difference is not a decision.
3. Permutation test labels are shuffled a fixed number of times against a
fixed seed, and the observed difference must beat at least 90% of the
shuffles. Same data in, same verdict out.
4. Multiple-comparisons note every candidate tested is counted, and the
expected number of false positives at the chosen threshold is reported
alongside the number that passed. If you test twenty things at p<0.10, two
will "pass" on noise alone, and the report says so.
Every rejected candidate is reported with the reason it failed, because "not
enough data yet" is the finding most of the time and it is a useful one.
Input: the same export shape as post_performance_analyzer.py.
Exit codes:
0 at least one candidate survived
2 nothing survived reasons listed per candidate
3 not enough posts to test anything (under 10)
Stdlib only. No network. Deterministic (fixed seed).
"""
import argparse
import csv
import datetime
import io
import json
import random
import sys
MIN_GROUP = 5
MIN_RELATIVE_EFFECT = 0.15
ALPHA = 0.10
SHUFFLES = 2000
SEED = 20260825
MIN_POSTS = 10
WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
SAMPLE = [
{"date": "2026-05-04", "impressions": 8120, "reactions": 214, "comments": 63, "reposts": 11,
"format": "text-post", "pillar": "trust-debt", "chars": 1450},
{"date": "2026-05-06", "impressions": 3050, "reactions": 61, "comments": 9, "reposts": 2,
"format": "text-post", "pillar": "modelling", "chars": 620},
{"date": "2026-05-11", "impressions": 2110, "reactions": 39, "comments": 6, "reposts": 1,
"format": "text-post", "pillar": "hiring", "chars": 540},
{"date": "2026-05-13", "impressions": 5400, "reactions": 132, "comments": 28, "reposts": 9,
"format": "document-carousel", "pillar": "trust-debt", "chars": 900},
{"date": "2026-05-18", "impressions": 4300, "reactions": 97, "comments": 24, "reposts": 5,
"format": "text-post", "pillar": "trust-debt", "chars": 1610},
{"date": "2026-05-20", "impressions": 1780, "reactions": 28, "comments": 3, "reposts": 0,
"format": "text-post", "pillar": "modelling", "chars": 480},
{"date": "2026-05-25", "impressions": 2450, "reactions": 44, "comments": 11, "reposts": 2,
"format": "text-post", "pillar": "hiring", "chars": 700},
{"date": "2026-05-27", "impressions": 6900, "reactions": 168, "comments": 41, "reposts": 14,
"format": "document-carousel", "pillar": "trust-debt", "chars": 1100},
{"date": "2026-06-01", "impressions": 3600, "reactions": 74, "comments": 15, "reposts": 3,
"format": "document-carousel", "pillar": "modelling", "chars": 950},
{"date": "2026-06-03", "impressions": 5100, "reactions": 121, "comments": 33, "reposts": 6,
"format": "text-post", "pillar": "trust-debt", "chars": 1720},
{"date": "2026-06-08", "impressions": 1950, "reactions": 31, "comments": 4, "reposts": 1,
"format": "text-post", "pillar": "hiring", "chars": 510},
{"date": "2026-06-10", "impressions": 2800, "reactions": 52, "comments": 12, "reposts": 2,
"format": "text-post", "pillar": "modelling", "chars": 820},
{"date": "2026-06-15", "impressions": 7400, "reactions": 181, "comments": 47, "reposts": 12,
"format": "document-carousel", "pillar": "trust-debt", "chars": 1050},
{"date": "2026-06-17", "impressions": 2300, "reactions": 40, "comments": 5, "reposts": 1,
"format": "text-post", "pillar": "hiring", "chars": 560},
{"date": "2026-06-22", "impressions": 6100, "reactions": 149, "comments": 38, "reposts": 10,
"format": "document-carousel", "pillar": "trust-debt", "chars": 1180},
{"date": "2026-06-24", "impressions": 2650, "reactions": 47, "comments": 9, "reposts": 2,
"format": "text-post", "pillar": "modelling", "chars": 760},
]
def median(xs):
s = sorted(xs)
n = len(s)
if not n:
return 0.0
mid = n // 2
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
def _num(row, key, default=0):
val = row.get(key, default)
if val in (None, ""):
return default
try:
return float(str(val).replace(",", "").strip())
except ValueError:
return default
def prepare(rows: list) -> list:
out = []
for r in rows:
imp = _num(r, "impressions")
if imp <= 0:
continue
inter = sum(_num(r, f) for f in ("reactions", "comments", "reposts"))
rec = {"er": inter / imp,
"format": str(r.get("format") or "unspecified"),
"pillar": str(r.get("pillar") or "unspecified")}
date = str(r.get("date") or "")
try:
rec["weekday"] = WEEKDAYS[datetime.date.fromisoformat(date[:10]).weekday()]
except (ValueError, IndexError):
rec["weekday"] = "unspecified"
chars = _num(r, "chars", 0)
if chars:
rec["length"] = ("short (<800)" if chars < 800 else
"medium (800-1500)" if chars <= 1500 else "long (>1500)")
else:
rec["length"] = "unspecified"
links = _num(r, "links_in_body", -1)
rec["link_in_body"] = "unspecified" if links < 0 else ("yes" if links else "no")
out.append(rec)
return out
def permutation_p(group_ers, other_ers, observed, rng) -> float:
pool = group_ers + other_ers
k = len(group_ers)
extreme = 0
for _ in range(SHUFFLES):
rng.shuffle(pool)
diff = median(pool[:k]) - median(pool[k:])
if abs(diff) >= abs(observed):
extreme += 1
return (extreme + 1) / (SHUFFLES + 1)
def mine(rows: list, attributes: list) -> dict:
data = prepare(rows)
if len(data) < MIN_POSTS:
return {"verdict": "INSUFFICIENT_DATA", "exit_code": 3, "posts": len(data),
"floor": MIN_POSTS,
"finding": f"{len(data)} usable posts. Under {MIN_POSTS} there is nothing to test: "
"the between-post variance on LinkedIn swamps any group difference this "
"small a sample could show.",
"instead": "Keep posting on the plan you have and re-run this in six weeks. "
"Changing strategy on eight posts is how people end up rewriting their "
"approach every month and compounding nothing."}
rng = random.Random(SEED)
candidates = []
mirrored = []
for attr in attributes:
values = sorted({d[attr] for d in data if d[attr] != "unspecified"})
# A two-value attribute is one test, not two: "carousel vs rest" and
# "text vs rest" are the same comparison with the sign flipped. Testing
# both would double-count it in the multiple-comparisons accounting.
if len(values) == 2:
mirrored.append(f"{attr}: only '{values[0]}' tested — '{values[1]}' is the same "
"comparison mirrored")
values = values[:1]
for val in values:
group = [d["er"] for d in data if d[attr] == val]
other = [d["er"] for d in data if d[attr] != val and d[attr] != "unspecified"]
entry = {"attribute": attr, "value": val,
"n_group": len(group), "n_other": len(other)}
if len(group) < MIN_GROUP or len(other) < MIN_GROUP:
entry.update({"verdict": "NOT_TESTED",
"reason": f"needs {MIN_GROUP} in and {MIN_GROUP} out; "
f"has {len(group)} and {len(other)}"})
candidates.append(entry)
continue
m_g, m_o = median(group), median(other)
observed = m_g - m_o
rel = (m_g - m_o) / m_o if m_o else 0.0
entry.update({"median_group": round(m_g, 5), "median_other": round(m_o, 5),
"relative_effect": round(rel, 3)})
if abs(rel) < MIN_RELATIVE_EFFECT:
entry.update({"verdict": "TOO_SMALL",
"reason": f"{rel:+.1%} relative difference is under the "
f"{MIN_RELATIVE_EFFECT:.0%} floor — real or not, it is "
"not a reason to change anything"})
candidates.append(entry)
continue
p = permutation_p(list(group), list(other), observed, rng)
entry["p_value"] = round(p, 4)
if p < ALPHA:
entry.update({"verdict": "SUPPORTED",
"reason": f"{rel:+.1%} median difference, beat {1 - p:.0%} of "
f"{SHUFFLES} label shuffles"})
else:
entry.update({"verdict": "NOT_SUPPORTED",
"reason": f"{rel:+.1%} difference, but {p:.0%} of random shuffles "
"produced one as large — this is noise"})
candidates.append(entry)
tested = [c for c in candidates if "p_value" in c]
supported = [c for c in candidates if c["verdict"] == "SUPPORTED"]
expected_false = round(len(tested) * ALPHA, 1)
return {
"verdict": "PATTERNS_FOUND" if supported else "NOTHING_SURVIVED",
"exit_code": 0 if supported else 2,
"posts": len(data),
"method": {"min_group": MIN_GROUP, "min_relative_effect": MIN_RELATIVE_EFFECT,
"alpha": ALPHA, "shuffles": SHUFFLES, "seed": SEED,
"statistic": "difference of medians, permutation test"},
"candidates_generated": len(candidates),
"candidates_tested": len(tested),
"supported": supported,
"all_candidates": candidates,
"mirrored_candidates_skipped": mirrored,
"independence_note": (
"Candidates within one attribute are not independent of each other — each group is "
"tested against the rest of the same posts. Read them as one question about that "
"attribute, not as several separate findings."),
"multiple_comparisons_note": (
f"{len(tested)} candidate(s) reached the test at alpha {ALPHA}. On noise alone you "
f"would expect about {expected_false} to pass. {len(supported)} did. "
+ ("Treat these as hypotheses to test deliberately, not as conclusions."
if len(supported) <= max(1, expected_false)
else "More passed than chance predicts, which is mild evidence something real is "
"here — still worth confirming with a deliberate experiment.")),
"next_step": "Feed a supported candidate to experiment_planner.py. A pattern found in "
"past data is a hypothesis; a pattern that survives a planned test is a "
"finding.",
}
def render_human(r: dict) -> str:
if r["verdict"] == "INSUFFICIENT_DATA":
return (f"Pattern mining: INSUFFICIENT_DATA\n{'=' * 46}\n{r['finding']}\n\n"
f"Instead: {r['instead']}")
lines = [f"Pattern mining: {r['verdict']} ({r['posts']} posts)", "=" * 62,
f"Method: {r['method']['statistic']}, {r['method']['shuffles']} shuffles, "
f"seed {r['method']['seed']}, alpha {r['method']['alpha']}, "
f"min effect {r['method']['min_relative_effect']:.0%}, "
f"min group {r['method']['min_group']}", ""]
if r["supported"]:
lines.append("SUPPORTED:")
for c in r["supported"]:
lines.append(f" {c['attribute']}={c['value']} n={c['n_group']} vs {c['n_other']} "
f"{c['relative_effect']:+.1%} p={c['p_value']}")
lines.append(f" {c['reason']}")
lines.append("")
lines.append("All candidates:")
for c in r["all_candidates"]:
lines.append(f" [{c['verdict']:<14}] {c['attribute']}={c['value']:<20} {c['reason']}")
if r.get("mirrored_candidates_skipped"):
lines.append("\nSkipped as mirrored:")
for m in r["mirrored_candidates_skipped"]:
lines.append(f" - {m}")
lines += ["", r["independence_note"], "", r["multiple_comparisons_note"], "", r["next_step"]]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Test candidate LinkedIn patterns against a permutation null "
"(found=0 / nothing-survived=2 / insufficient-data=3).")
ap.add_argument("--input", help="Post export file ('-' for stdin).")
ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.")
ap.add_argument("--attribute", action="append", default=[],
choices=["format", "pillar", "weekday", "length", "link_in_body"],
help="Attribute to test. Repeatable. Default: all five.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Mine a built-in 16-post sample.")
args = ap.parse_args()
if args.sample:
rows = SAMPLE
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
rows = ([dict(r) for r in csv.DictReader(io.StringIO(raw))] if args.csv
else json.loads(raw))
except (json.JSONDecodeError, csv.Error) as exc:
print(f"ERROR: could not parse input: {exc}", file=sys.stderr)
return 4
if isinstance(rows, dict):
rows = rows.get("posts") or rows.get("rows") or []
else:
ap.error("--input or --sample is required")
attrs = args.attribute or ["format", "pillar", "weekday", "length", "link_in_body"]
result = mine(rows, attrs)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""post_performance_analyzer.py — read your own exported post stats and describe them honestly.
Input is the export LinkedIn gives you for your own content (Analytics Post
impressions Export, or the JSON shape below). Nothing is fetched and no other
member's data is involved.
What it does that a spreadsheet does not:
- engagement rate per post on a consistent denominator, so posts are comparable
- median and MAD rather than mean and standard deviation, because LinkedIn post
performance is heavy-tailed and one breakout post drags a mean somewhere
useless
- percentile bands and an IQR outlier test, so "this did well" has a threshold
- an explicit refusal to characterise a body of work under 10 posts
The last one is the point. Ten posts is not enough to conclude anything, and the
tool says so rather than producing a confident-looking table.
Input JSON: [{"date": "2026-05-04", "title": "...", "impressions": 4210,
"reactions": 88, "comments": 21, "reposts": 4,
"format": "text-post", "pillar": "trust-debt"}, ...]
CSV with the same column names also works.
Exit codes:
0 analysed
2 analysed, but below the 10-post floor descriptive only, no conclusions
3 unusable input (no rows, or no impressions column)
Stdlib only. No network.
"""
import argparse
import csv
import io
import json
import sys
MIN_POSTS_FOR_CONCLUSIONS = 10
REQUIRED = ("impressions",)
INTERACTION_FIELDS = ("reactions", "comments", "reposts")
SAMPLE = [
{"date": "2026-05-04", "title": "Onboarding 6 weeks to 4 days", "impressions": 8120,
"reactions": 214, "comments": 63, "reposts": 11, "format": "text-post", "pillar": "trust-debt"},
{"date": "2026-05-11", "title": "dbt exposures rollout", "impressions": 3050,
"reactions": 61, "comments": 9, "reposts": 2, "format": "text-post", "pillar": "modelling"},
{"date": "2026-05-18", "title": "Hiring the first AE", "impressions": 2110,
"reactions": 39, "comments": 6, "reposts": 1, "format": "text-post", "pillar": "hiring"},
{"date": "2026-05-25", "title": "Cost teardown carousel", "impressions": 5400,
"reactions": 132, "comments": 28, "reposts": 9, "format": "document-carousel",
"pillar": "trust-debt"},
{"date": "2026-06-01", "title": "Why we deleted the kickoff call", "impressions": 4300,
"reactions": 97, "comments": 24, "reposts": 5, "format": "text-post", "pillar": "trust-debt"},
{"date": "2026-06-08", "title": "Modelling tradeoffs", "impressions": 1780,
"reactions": 28, "comments": 3, "reposts": 0, "format": "text-post", "pillar": "modelling"},
{"date": "2026-06-15", "title": "Analytics eng job spec", "impressions": 2450,
"reactions": 44, "comments": 11, "reposts": 2, "format": "text-post", "pillar": "hiring"},
{"date": "2026-06-22", "title": "Queue time carousel", "impressions": 6900,
"reactions": 168, "comments": 41, "reposts": 14, "format": "document-carousel",
"pillar": "trust-debt"},
{"date": "2026-06-29", "title": "Three dbt anti-patterns", "impressions": 3600,
"reactions": 74, "comments": 15, "reposts": 3, "format": "document-carousel",
"pillar": "modelling"},
{"date": "2026-07-06", "title": "What I got wrong about capacity", "impressions": 5100,
"reactions": 121, "comments": 33, "reposts": 6, "format": "text-post", "pillar": "trust-debt"},
{"date": "2026-07-13", "title": "Interview loop for AEs", "impressions": 1950,
"reactions": 31, "comments": 4, "reposts": 1, "format": "text-post", "pillar": "hiring"},
{"date": "2026-07-20", "title": "Freshness SLAs", "impressions": 2800,
"reactions": 52, "comments": 12, "reposts": 2, "format": "text-post", "pillar": "modelling"},
]
def median(xs):
s = sorted(xs)
n = len(s)
if not n:
return 0.0
mid = n // 2
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
def percentile(xs, p):
s = sorted(xs)
if not s:
return 0.0
k = (len(s) - 1) * (p / 100.0)
lo, hi = int(k), min(int(k) + 1, len(s) - 1)
return float(s[lo] + (s[hi] - s[lo]) * (k - lo))
def load_rows(raw: str, as_csv: bool) -> list:
if as_csv:
return [dict(r) for r in csv.DictReader(io.StringIO(raw))]
data = json.loads(raw)
if isinstance(data, dict):
data = data.get("posts") or data.get("rows") or []
return data
def _num(row, key):
val = row.get(key, 0)
if val in (None, ""):
return 0
try:
return float(str(val).replace(",", "").strip())
except ValueError:
return 0
def analyse(rows: list) -> dict:
clean = []
for r in rows:
imp = _num(r, "impressions")
if imp <= 0:
continue
inter = sum(_num(r, f) for f in INTERACTION_FIELDS)
clean.append({
"date": str(r.get("date", "")),
"title": str(r.get("title", ""))[:80],
"impressions": int(imp),
"reactions": int(_num(r, "reactions")),
"comments": int(_num(r, "comments")),
"reposts": int(_num(r, "reposts")),
"interactions": int(inter),
"engagement_rate": round(inter / imp, 5),
"comment_share": round(_num(r, "comments") / inter, 3) if inter else 0.0,
"format": str(r.get("format", "") or "unspecified"),
"pillar": str(r.get("pillar", "") or "unspecified"),
})
if not clean:
return {"verdict": "UNUSABLE", "exit_code": 3,
"finding": "No rows with a positive impressions value.",
"fix": "Export from LinkedIn Analytics → Post impressions → Export, or supply "
"JSON with an 'impressions' field per post."}
ers = [p["engagement_rate"] for p in clean]
imps = [p["impressions"] for p in clean]
med = median(ers)
mad = median([abs(e - med) for e in ers])
q1, q3 = percentile(ers, 25), percentile(ers, 75)
iqr = q3 - q1
hi_fence, lo_fence = q3 + 1.5 * iqr, q1 - 1.5 * iqr
for p in clean:
e = p["engagement_rate"]
if e >= hi_fence:
p["band"] = "BREAKOUT"
elif e >= q3:
p["band"] = "STRONG"
elif e >= q1:
p["band"] = "TYPICAL"
elif e > lo_fence:
p["band"] = "WEAK"
else:
p["band"] = "DUD"
n = len(clean)
below_floor = n < MIN_POSTS_FOR_CONCLUSIONS
result = {
"verdict": "DESCRIPTIVE_ONLY" if below_floor else "ANALYSED",
"exit_code": 2 if below_floor else 0,
"posts_analysed": n,
"floor": MIN_POSTS_FOR_CONCLUSIONS,
"engagement_rate": {
"median": round(med, 5),
"mad": round(mad, 5),
"p10": round(percentile(ers, 10), 5),
"p25": round(q1, 5), "p75": round(q3, 5),
"p90": round(percentile(ers, 90), 5),
"breakout_threshold": round(hi_fence, 5),
},
"impressions": {"median": round(median(imps)), "p90": round(percentile(imps, 90)),
"total": int(sum(imps))},
"bands": {b: sum(1 for p in clean if p["band"] == b)
for b in ("BREAKOUT", "STRONG", "TYPICAL", "WEAK", "DUD")},
"posts": sorted(clean, key=lambda p: -p["engagement_rate"]),
"reading_notes": [
"Median and MAD, not mean and standard deviation: one breakout post makes a mean "
"describe a distribution nobody's posts belong to.",
"Impressions are not unique people, and LinkedIn's definition has changed over time. "
"Compare posts from the same period, not across a year.",
"Comment share is worth watching separately: comments are the costlier signal for a "
"reader to give, and the one most closely tied to distribution.",
],
}
if below_floor:
result["warning"] = (
f"{n} posts is below the {MIN_POSTS_FOR_CONCLUSIONS}-post floor. Everything above is "
"description, not evidence. Do not change strategy on it — the variance between two "
"posts on the same topic is routinely larger than the difference this would 'show'.")
return result
def render_human(r: dict) -> str:
if r["verdict"] == "UNUSABLE":
return f"Post analysis: UNUSABLE\n{'=' * 40}\n{r['finding']}\nfix → {r['fix']}"
e = r["engagement_rate"]
lines = [f"Post analysis: {r['verdict']} ({r['posts_analysed']} posts)", "=" * 60]
if "warning" in r:
lines += [f"! {r['warning']}", ""]
lines += [
f"Engagement rate median {e['median']:.2%} (MAD {e['mad']:.2%})",
f" p10 {e['p10']:.2%} · p25 {e['p25']:.2%} · p75 {e['p75']:.2%} · "
f"p90 {e['p90']:.2%}",
f"Breakout above {e['breakout_threshold']:.2%}",
f"Impressions median {r['impressions']['median']:,} · "
f"p90 {r['impressions']['p90']:,} · total {r['impressions']['total']:,}",
f"Bands " + " · ".join(f"{k} {v}" for k, v in r["bands"].items()),
"", "Posts by engagement rate:"]
for p in r["posts"]:
lines.append(f" {p['band']:<9} {p['engagement_rate']:>7.2%} "
f"{p['impressions']:>7,} imp {p['comments']:>3}c "
f"{p['date']:<11} {p['title']}")
lines.append("\nReading notes:")
for note in r["reading_notes"]:
lines.append(f" - {note}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Describe your own LinkedIn post export honestly "
"(analysed=0 / below-floor=2 / unusable=3).")
ap.add_argument("--input", help="Post export file ('-' for stdin).")
ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Analyse a built-in 12-post sample.")
ap.add_argument("--print-schema", action="store_true", help="Print the input shape and exit.")
args = ap.parse_args()
if args.print_schema:
print(json.dumps(SAMPLE[:2], indent=2))
return 0
if args.sample:
rows = SAMPLE
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
rows = load_rows(raw, args.csv)
except (json.JSONDecodeError, csv.Error) as exc:
print(f"ERROR: could not parse input: {exc}", file=sys.stderr)
return 4
else:
ap.error("--input or --sample is required (see --print-schema)")
result = analyse(rows)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,99 @@
---
name: linkedin-content
description: Use when someone wants to write, edit, or lint a LinkedIn post — a story, how-to, opinion piece, carousel script, video script, or poll — or wants an article, talk, or transcript repurposed into posts. Triggers on "write a LinkedIn post", "is this hook any good", "review my post", "turn this into LinkedIn posts", "carousel", "what format should this be". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
---
# LinkedIn Content — format, draft, lint
The post is not the deliverable; the specific thing only this person can say is. The scripts
handle format choice, mechanical faults, and de-duplication. The interesting part — what
actually happened and what it cost — comes from the user, and cannot be generated.
## Workflow
**1. Check the brief exists.** If there is no positioning brief, offer `linkedin-strategy`
first as a question. Posts without pillars are noise. Never chain silently.
**2. Pick the format from the material, not from fashion.**
```bash
python3 scripts/format_picker.py --goal authority --material data --material tutorial \
--minutes 120 --output human
```
Exit 0 recommends / 2 asks when the top two are within a point (tie-break on which one they
would enjoy making — the one they repeat beats the one that scores higher once) / 3 no fit,
go get material. It refuses video with no camera and no footage, and a poll with no real
decision behind it.
**3. Draft.** Interview for specifics before writing a line: the number, the mistake, the
sentence someone said. Then write to the fold — **the first ~140 characters are the whole
post for most readers**, and a sentence must complete inside them.
**4. Lint before it ships.**
```bash
python3 scripts/post_linter.py --input draft.md --has-image --output human
```
Exit 0 SHIP / 2 REVISE (or any blocking finding) / 3 REWRITE. Blocking findings have named
consequences: over the 3,000-character cap; engagement bait, named as demoted content in the
Professional Community Policies; Unicode pseudo-bold, which screen readers announce as
mathematical symbols and search does not index as words. Two passes is normal; the stop
condition is exit 0 or a stated decision to accept a warning.
**5. Repurposing.** Split a source and keep the ledger — it prevents the specific recurring
failure of the same idea going out three times over eight months. Commit it alongside the
source; it is project state, not a cache. Every unit is source material, not a post: add the
sentence only the author can write — what it cost, or what they would do differently.
```bash
python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --output human
python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
```
## Rules
- **Never fabricate a number, a client, a result, or a quote.** Not one, not as a placeholder.
- **Write to the mobile fold.** A sentence completes before character 140.
- **Links in the first comment**, and say so in the post.
- **No Unicode pseudo-bold, ever.** It is an accessibility failure, not a style choice.
- **Alt text on every image; captions on every video.** LinkedIn does not add them for you.
- **No engagement bait.** Ask the question the post actually earned.
- **The author reads every line before it ships.** They are the author of record.
## Scripts
| Script | Role |
|---|---|
| [`scripts/post_linter.py`](scripts/post_linter.py) | 0-100 across mechanics / hook / integrity / accessibility; blocking findings for the cap, bait, and pseudo-bold. |
| [`scripts/format_picker.py`](scripts/format_picker.py) | Ranks nine native formats against goal, material, and minutes; refuses camera-less video and decision-less polls. |
| [`scripts/repurpose_splitter.py`](scripts/repurpose_splitter.py) | Splits a source into standalone units, scores them, and skips anything already in the reuse ledger. |
## References and assets
- [`references/hook_and_fold_mechanics.md`](references/hook_and_fold_mechanics.md) — the first 140 characters, and the openers to delete on sight (7 sources)
- [`references/post_formats_canon.md`](references/post_formats_canon.md) — what each native format is good at, and the carousel trap (7 sources)
- [`references/repurposing_discipline.md`](references/repurposing_discipline.md) — standalone units, the reuse ledger, source types and their risks (7 sources)
- [`references/accessibility_and_inclusion.md`](references/accessibility_and_inclusion.md) — pseudo-bold, alt text, captions, and why they are blocking (7 sources)
- [`assets/post_templates.md`](assets/post_templates.md) — five worked post shapes with the hook already doing its job
- [`assets/example_post.md`](assets/example_post.md) — a post that passes the linter, annotated
## Distinct from
- **`linkedin-strategy`** — decides what the posts are about. This writes them.
- **`linkedin-engagement`** — comments and DMs. A comment is a different craft with a
different budget; it lives there.
- **`marketing-skill/copywriting` / `content-humanizer`** — general copy and de-AI passes.
Reach for those for tone; this one owns LinkedIn's mechanics and its accessibility floor.
---
**Version:** 1.0.0

View file

@ -0,0 +1,58 @@
# Worked Example — a post that passes the linter
The post below scores 98/100 in `post_linter.py` with one INFO finding (it sits below the
1,300-2,500 character band, which is not a defect). Annotations follow.
---
Our onboarding took 6 weeks. We got it to 4 days without hiring anyone.
The bottleneck was not the product. It was that three different teams each owned
one step and none of them owned the handoff.
What we changed:
1. One named owner for the whole path, not per step. Every stall now has someone
whose week it ruins.
2. We deleted the "kickoff call" and replaced it with a 4-question form. 80% of
accounts never needed the call.
3. We stopped treating the CRM stage as the source of truth and started measuring
the customer's first real use.
The part I got wrong: I assumed the delay was engineering capacity. It was
handoffs. We spent five weeks building automation for the wrong step before
anyone measured where the time actually went.
If you are staring at a slow onboarding number, measure the wait between steps
before you optimise any single step. That is where ours was hiding.
What did the handoff cost you the last time you measured it?
---
## Why it passes
**Hook (first 140 characters).** Two complete sentences land inside the mobile fold, both
carrying numbers, and the second names a constraint people assume is binding — "without
hiring anyone". A reader decides on those two sentences alone.
**Specificity throughout.** 6 weeks, 4 days, 80%, five wasted weeks. Every number is one the
author could be challenged on, which is exactly why they carry weight.
**The admission.** "The part I got wrong" is the load-bearing paragraph. It is expensive to
write, which is why it reads as true, and it is the paragraph practitioners reply to.
**Structure.** Seven blocks, none longer than four lines. Numbered list where the content is
a sequence. No Unicode pseudo-bold, no emoji, no hashtags — none of which are required.
**The close.** A real question with a specific answer available only to someone who has
measured the same thing. Not "thoughts?".
**No link in the body.** If there were a write-up, it goes in the first comment with "link
in the comments" in the post.
## Run it yourself
```bash
python3 ../scripts/post_linter.py --sample --output human
```

View file

@ -0,0 +1,134 @@
# Post Shapes
Five structures, each with the hook already doing its job. They are scaffolding, not
templates to fill and ship — the specific detail is the post, and it has to be yours.
Every one of these assumes: a sentence completes inside the first ~140 characters, links go
in the first comment, no Unicode pseudo-bold, and a real question at the end.
---
## 1. The measurement that changed our mind
Best for: `trust`, `authority`. Format: text post or carousel.
```
[The number nobody believed.]
[The instinct everyone had, named fairly.]
What we actually measured:
[The two figures that reframed it.]
[What we changed - three items, each with a consequence.]
The part I got wrong:
[The thing you spent time on before measuring.]
[A question aimed at someone facing the same measurement.]
```
Why it works: the hook is a number and a reversal. The "part I got wrong" is a costly
signal — admitting error is expensive, which is why it reads as true.
---
## 2. The decision, with the cost attached
Best for: `thought-leadership`. Format: text post.
```
[We stopped doing X.] [The consequence, quantified.]
The argument for keeping it was [the strongest version of the other side].
It was a real argument. Here is why it lost:
[Two or three sentences of actual reasoning.]
What it cost us: [the real downside, named].
[The question: where does this break for you?]
```
Why it works: steelmanning the other side before disagreeing is the rarest move in a
professional feed, and the "what it cost" line inoculates against the obvious objection.
---
## 3. The teardown
Best for: `authority`, `consulting`. Format: carousel or long text post.
```
[One named artifact - with permission, or anonymised.]
[What it gets right, in one sentence. Say this first and mean it.]
Three things I would change:
1. [Change] - because [mechanism, not preference]
2. [Change] - because [mechanism]
3. [Change] - because [mechanism]
The one I am least sure about: [the honest uncertainty].
[Question to the person who has done this differently.]
```
Why it works: specificity plus a stated uncertainty. The uncertainty is what invites the
comment worth having.
---
## 4. The field note
Best for: any pillar, and the format that keeps a cadence alive in a bad week. Format: text
post, ~500-900 characters.
```
[What you did this week, concretely.]
[The thing that surprised you.]
[What you will do differently next week because of it.]
[Optional: a question only someone doing the same work could answer.]
```
Why it works: it costs 15 minutes, it is impossible to fake, and it is the format that
compounds because you can always write one.
---
## 5. The transition post
Best for: `career-change`. Format: text post.
```
[The specific thing you did in the new field this month.]
[Not "I am excited to announce" - the thing itself.]
What I brought from [old field] that turned out to matter:
[One transferable capability, evidenced.]
What I had to learn from zero:
[One honest gap, named without apology.]
[Where you are heading, stated as a direction rather than a request.]
[A question for people already doing the thing.]
```
Why it works: it demonstrates the transition rather than announcing it. Announcements ask
for permission; evidence does not. See `linkedin-strategy/references/objective_to_pillars.md`
on why career-change posts should be aimed at the target field, not at current colleagues.
---
## Openers to delete on sight
"I'm excited to announce" · "I'm thrilled" · "I'm humbled" · "In today's fast-paced world" ·
"Quick thought:" · any first eight words that would fit any post about anything.
## Closers that are engagement bait, not questions
"Thoughts?" · "Agree?" · "Comment X below and I'll send you the guide" · "Like if you agree" ·
"Tag someone who needs this". `post_linter.py` treats these as blocking.

View file

@ -0,0 +1,126 @@
# Accessibility and Inclusion — the part of LinkedIn craft nobody audits
LinkedIn is a professional network, which means a meaningful fraction of your
audience uses assistive technology at work — screen readers, captions,
magnification, high-contrast modes. It also means the accessibility failures in
LinkedIn posts are unusually visible, because they are almost all self-inflicted
by formatting tricks people adopt for reach.
`post_linter.py` treats two of these as blocking findings. This document is why.
---
## 1. Unicode pseudo-bold is the big one — blocking
LinkedIn does not support rich text in posts, so people paste text through "bold
text generators". Those tools do not bold anything. They substitute the Latin
letters for characters from the **Mathematical Alphanumeric Symbols** block
(U+1D400-U+1D7FF), which are visually similar and semantically unrelated.
What actually happens:
- **Screen readers** announce them individually as mathematical symbols —
"mathematical sans-serif bold small a, mathematical sans-serif bold small b" —
or skip them entirely, depending on the reader and its verbosity settings.
A "bolded" heading becomes noise or silence.
- **Search** does not index them as the words they resemble. Your post about
𝗸𝘂𝗯𝗲𝗿𝗻𝗲𝘁𝗲𝘀 does not match a search for kubernetes.
- **Copy-paste** into any system with real text handling produces mojibake.
- **Translation** fails.
There is no version of this that is worth it. Emphasis on LinkedIn comes from
line breaks, word order, and putting the important thing first — the same tools
every writer had before bold existed.
The same applies to fullwidth characters (-) and enclosed alphanumerics.
## 2. Alt text on images — LinkedIn supports it and will not write it
LinkedIn offers an alt-text field on image uploads. It is not filled in for you
and it is easy to skip.
Writing it well takes one sentence and one rule: **describe what the image
communicates, not what it is.** "Chart" is useless. "Line chart: median
onboarding time falling from 41 days in March to 4 days in July" is the whole
content of the image, delivered to someone who cannot see it — and, incidentally,
to anyone whose image failed to load.
For a document carousel, upload a real PDF with selectable text rather than
exported images. The text layer is what makes the slides readable to assistive
technology at all.
## 3. Video captions — the floor, and also just correct
Most feed video is watched sound-off, so captions serve everyone. LinkedIn
provides auto-captions with an editing step. **The editing step is not optional**:
auto-captioning mangles exactly the domain vocabulary your post is about, and an
uncorrected caption track is worse than none because it looks like a caption
track.
WCAG 2.2 SC 1.2.2 (Captions, Prerecorded) is the standard, and for a professional
audience it is also the polite minimum.
## 4. Emoji load — warning, not blocking
Emoji are read aloud by name. A bullet list built from 🔥 emoji becomes "fire,
fire, fire". A handful is fine and can even help structure. Fifteen makes a post
tiring to hear.
Where emoji do useful work: as list markers (one per line, consistently), or as a
segment separator in a headline. Where they do not: decoration, emphasis, or
replacing words.
## 5. ALL-CAPS lines — warning
Some screen readers spell out all-caps words letter by letter, treating them as
initialisms. A full line of capitals becomes an alphabet recital. Sentence case,
with the emphasis carried by the words.
## 6. Colour and contrast in carousels and images
If you make slides:
- Do not encode meaning in colour alone (WCAG 1.4.1). If the red bar is the bad
one, label it.
- Body text on a slide needs 4.5:1 contrast against its background (WCAG 1.4.3);
large text needs 3:1.
- Slide text should be large enough to read on a phone at feed size. If you have
to zoom to read your own slide on your own phone, it is too small.
## 7. Plain language is an accessibility feature
Not only for non-native readers, though that is a large part of a global
professional audience. Dense sentences with three subordinate clauses are harder
for everyone, and much harder for anyone reading through a screen reader with no
ability to skim back.
Practical version: short sentences, one idea per paragraph, expand an acronym the
first time. This is the same advice as good writing, which is convenient.
## 8. What this costs
About four minutes per post: write the alt text, check the captions, do not paste
through a bold generator. It is the cheapest quality signal available and almost
nobody does it, which means it is also differentiating.
---
## Sources
1. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2** — SC 1.1.1 (Non-text
Content), 1.2.2 (Captions, Prerecorded), 1.4.1 (Use of Color), 1.4.3
(Contrast Minimum).
2. Unicode Consortium. **Unicode Standard, Chapter 22 / Mathematical Alphanumeric
Symbols (U+1D400-U+1D7FF)** — what these characters are actually for, and the
explicit note that they are not styled Latin letters.
3. WebAIM. **Screen Reader User Survey** (recurring) — how screen-reader users
actually navigate, and the cost of non-semantic text substitutes.
4. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video
captioning documentation — what the platform supports.
5. LinkedIn. **Accessibility statement / LinkedIn Accessibility** — the
platform's own commitments and the assistive-technology surfaces it supports.
6. Nielsen Norman Group. **"Plain Language Is for Everyone, Even Experts"** —
comprehension gains from plain language across expertise levels.
7. Deque / axe accessibility documentation on **text alternatives and meaningful
sequence** — practical guidance for writing alt text that carries the content
rather than naming the object.

View file

@ -0,0 +1,126 @@
# Hook and Fold Mechanics — the first 140 characters decide everything else
LinkedIn truncates a post in the feed. On mobile that happens around 140
characters, on desktop around 210, and the reader's decision to press "…see more"
is made entirely on what is above that line. Everything you wrote below it is
conditional on those two sentences.
This is not a copywriting flourish. It is the single structural constraint the
platform imposes on text, and most posts ignore it.
---
## The numbers
| Limit | Value | Confidence |
|---|---|---|
| Post hard cap | 3,000 characters | 🟡 third-party documented, stable |
| Mobile fold ("…see more") | ~140 characters | 🟡 varies with viewport and locale |
| Desktop fold | ~210 characters | 🟡 |
| Highest median engagement band | ~1,300-2,500 characters | 🟡 third-party studies |
| Comment character cap | 1,250 | 🟡 |
Mobile is the binding constraint. Write to 140 and desktop takes care of itself.
**On the engagement band:** it is a correlation in third-party data, not a rule.
Long posts are longer because people had more to say, and a 400-character post
with something specific in it beats a padded 1,800-character one every time.
`post_linter.py` reports the band as INFO, never as a defect.
## What a hook has to do
Not "be catchy". Three concrete jobs:
1. **Complete a thought inside the fold.** A truncated fragment gives the reader
nothing to decide on. `post_linter.py` flags a post where no sentence ends
before character 140.
2. **Create a specific gap.** Not a vague tease — a gap the reader can feel the
shape of. "Our onboarding took six weeks. We got it to four days without
hiring anyone." The gap is *how*, and it is specific enough to be worth the
click.
3. **Signal who it is for.** A reader outside your audience should be able to
skip cleanly. Trying to hook everyone is how a post hooks nobody.
## Hook shapes that work, and why
| Shape | Example opening | Why it works |
|---|---|---|
| **Number + reversal** | "Our onboarding took 6 weeks. We got it to 4 days without hiring anyone." | Concrete, and the reversal names the constraint people assume is binding |
| **The mistake** | "I spent five weeks automating the wrong step." | Costly-signal: admitting error is expensive, so it reads as true |
| **The measurement** | "Work-in-progress time was 6 days. Wait time between owners was 35." | The number does the work; no adjective required |
| **The sentence someone said** | "'Nobody believes the dashboard.' That was the actual problem." | Quoted speech is concrete and carries a voice |
| **The refusal** | "We deleted the kickoff call. 80% of accounts never needed it." | A decision with a consequence attached |
## Openers to delete on sight
`post_linter.py` flags these because they are the most-scrolled-past
constructions on the platform:
- "I'm excited to announce…" / "I'm thrilled…" / "I'm humbled…"
- "In today's fast-paced world…"
- "Quick thought:"
- Any opener whose first eight words would fit any post about anything.
The excitement openers have a specific problem: they put *your* feeling first and
the reader's interest second, in the two sentences where you can least afford it.
Announce the thing, then say why it mattered.
## Formatting inside the fold and below it
- **Line breaks are structure, not decoration.** Three to four lines per block.
- **Do not write in one-line paragraphs throughout.** The "broetry" cadence —
every sentence its own paragraph — reads as formatted for an algorithm rather
than for a person, and the linter flags six or more consecutive one-liners.
- **No Unicode pseudo-bold.** The "bold text generators" produce Mathematical
Alphanumeric Symbols. Screen readers announce them character by character as
mathematical symbols, and LinkedIn's search does not index them as words. This
is a blocking finding in the linter, not a style preference — see
`accessibility_and_inclusion.md`.
- **Links go in the first comment.** Say "link in the comments" in the post.
## The close
The end of a post is where a comment either happens or does not. Two failure
modes:
- **No invitation at all.** The post is complete, correct, and closed; the only
available response is agreement.
- **A fake invitation.** "Thoughts?" and "Agree?" are engagement bait with a
question mark. `post_linter.py` treats bait as blocking, and LinkedIn's
Professional Community Policies name it as demoted content.
What works is a real question you would want answered, narrow enough that a
specific person has a specific answer: "What did the handoff cost you the last
time you measured it?"
## Editing pass
Three passes, in this order:
1. **Cut the first paragraph.** It is usually the throat-clearing. Check whether
the post starts better at paragraph two — it does more often than not.
2. **Read the first 140 characters alone.** Would you press "see more"?
3. **Read it aloud.** Every sentence you stumble on is a sentence a reader
stumbles on. This catches more than any linter.
---
## Sources
1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** (2006) and
**"How People Read Online"** — scanning behaviour and the weight of the first
visible lines.
2. Nielsen Norman Group. **"Microcontent: How to Write Headlines, Page Titles,
and Subject Lines"** — front-loading for any truncated string.
3. Loewenstein, G. **"The Psychology of Curiosity: A Review and Reinterpretation."**
*Psychological Bulletin*, 1994 — the information-gap theory that explains why a
*specific* gap works and a vague tease does not.
4. Heath, C. & Heath, D. **Made to Stick** — concreteness, unexpectedness, and
the curse of knowledge.
5. Zinsser, W. **On Writing Well** — cutting every word that does no work; the
"cut the first paragraph" pass comes straight from here.
6. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn
feed ranking."** — why holding attention past the fold is a measured
objective, and why padding to game it is not.
7. LinkedIn. **Professional Community Policies** — engagement bait as named,
demoted content.

View file

@ -0,0 +1,139 @@
# Post Formats Canon — what each native format is good at
Format is usually chosen by fashion. "Carousels are working right now" is the
single most common reason a person makes a carousel, and it is why most carousels
are a text post cut into slides.
The useful question is not which format performs best in general. It is which
format the material you actually have can support, in the time you actually have.
---
## The nine native formats
### Plain text post
**Good at:** stories, opinions, single ideas, anything where the words are the
point. The default, and it should be.
**Cost:** ~25 minutes including revision.
**Constraint:** one idea. If it needs two, it is two posts.
### Document post (PDF carousel)
**Good at:** step sequences, comparisons, data with structure, anything
inherently visual.
**Cost:** ~90 minutes.
**Constraints:** every slide must survive alone — most readers swipe two and
leave, so the payload cannot live on slide nine. Upload a real PDF with
selectable text rather than exported images: text is accessible, indexed, and
readable when a slide is zoomed. Slide one is the hook, and it is subject to the
same test as a text hook.
### Native video
**Good at:** demonstration, personality, anything where seeing it beats reading it.
**Cost:** ~120 minutes for anything watchable.
**Constraints:** captions are mandatory — most feed viewing is sound-off, and
captions are also the accessibility floor. Say the point in the first five
seconds; the fold applies to video too, it is just measured in seconds.
### Single image + text
**Good at:** one chart, one photo, one artifact. Announcements.
**Cost:** ~30 minutes.
**Constraint:** write alt text. LinkedIn supports it and does not generate it for
you. A chart with no alt text excludes readers and says nothing to anyone whose
image fails to load.
### Poll
**Good at:** settling a real question you will report back on.
**Cost:** ~10 minutes.
**Constraint:** `format_picker.py` refuses a poll without a declared decision.
A poll you do not follow up on is a reach trick with a two-week half-life, and
readers have learned to recognise it. The follow-up post — "here is what 400 of
you said, and here is what we changed" — is the actual content.
### Long-form article
**Good at:** the durable artifact. A link you will still send someone in two
years.
**Cost:** ~180 minutes.
**Constraint:** articles reach far fewer people than posts. Write one when the
artifact matters more than this week's impressions, and then mine it for posts.
### Newsletter issue
**Good at:** a returning readership.
**Cost:** ~150 minutes, plus a standing promise. See `newsletter_playbook.md`.
### Substantive comment on someone else's post
**Good at:** visibility from a standing start. The most under-rated format on the
platform and the cheapest by an order of magnitude.
**Cost:** ~6 minutes.
**Constraint:** it has to add something the original missed. Agreement is not a
comment.
### Repost with your own take
**Good at:** entering a conversation someone else started, with a position.
**Cost:** ~15 minutes.
**Constraint:** your take must be longer than "this". A bare repost spends your
credibility on someone else's idea and returns nothing.
---
## Choosing
`format_picker.py` scores goal fit against material fit and refuses what the time
budget cannot pay for. Two refusals are hard:
- **Video with no camera and no footage.** Not a judgement about your face; a
refusal to plan work that will not happen.
- **A poll with no real decision behind it.**
When two formats score within a point, the tool asks rather than picking. The
tie-breaker is which one you would actually enjoy making, because the one you
repeat beats the one that scores higher once.
## The carousel trap
Carousels reliably out-perform on engagement rate in third-party data, which
produces a predictable failure: people convert text posts into carousels for the
reach and end up with ten slides carrying two slides of content.
The test: **could a reader get the value from slide one plus the caption?** If
yes, it is a text post and the extra nine slides are cost. If no — if the
sequence itself is the content — it is a carousel.
## Video captions are not optional
Beyond accessibility: the majority of feed video is watched sound-off. An
uncaptioned video is a silent film with no intertitles. LinkedIn offers
auto-captions with an edit step; the edit step is not optional either, because
auto-captions mangle exactly the domain terms your post is about.
## Cross-format sequencing
A single body of work supports a sequence, and the sequence out-performs any one
piece:
1. **Text post** with the specific finding (cheap, tests appetite)
2. **Carousel** with the full method, two weeks later, if the post landed
3. **Article or newsletter issue** as the durable artifact
4. **Comments** on other people's related posts throughout, linking nothing
Run the source through `repurpose_splitter.py` with a ledger so the same unit
does not go out twice under two different formats.
---
## Sources
1. Tufte, E. **The Visual Display of Quantitative Information** — data-ink ratio,
the direct argument against a carousel slide that carries one sentence.
2. Tufte, E. **The Cognitive Style of PowerPoint** — the specific failure of
slide sequences that fragment an argument into bullet residue.
3. Nielsen Norman Group. **"How People Read Online"** and mobile reading
research — scanning and the cost of sequential reveal.
4. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2**, SC 1.1.1 (Non-text
Content) and 1.2.2 (Captions, Prerecorded) — the floor for images and video.
5. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video
caption documentation — what the platform supports and what it does not do
for you.
6. van der Blom, R. **Algorithm Insights** (annual) — 🟡 comparative format
performance in public-post samples; directionally useful, not a specification.
7. Kleon, A. **Show Your Work!** — process content as the material most formats
are actually best at carrying.

View file

@ -0,0 +1,124 @@
# Repurposing Discipline — one idea, many surfaces, no duplicates
Repurposing is the highest-leverage move available to anyone who already produces
work: a talk, an article, a README, an internal write-up, a customer call you
took notes on. It is also the fastest route to a feed that feels like a rerun.
The discipline has two halves. One is mechanical — splitting a source into units
that can stand alone. The other is editorial, and it is the half that gets
skipped: **the first-person sentence only you can write.**
---
## What makes a unit standalone
`repurpose_splitter.py` scores four things, each 25 points:
1. **Length in range** (240-2,400 characters). Under 240 there is not room for a
claim and its evidence; over 2,400 it wants splitting again.
2. **No dangling reference.** A unit that opens with "This meant that…" or "As we
saw above" refers to something the reader never saw. This is the single most
common defect in repurposed material and it is invisible to the author, who
has read the source.
3. **Evidence present.** A number, a duration, a measurable detail. A unit
carrying no evidence can still work as an opinion post, but it is a different
kind of post and should be routed as one.
4. **Three or more substantive sentences.** Below that it is a note.
A unit failing 2 or 4 is disqualified regardless of its total score. A dangling
opener fails in the feed no matter how good the evidence beneath it is.
## The reuse ledger
This is the part that matters and the part no other repurposing workflow has.
Repurposing fails in one specific way: the same idea goes out three times over
eight months, and the audience notices before the author does. It happens because
the source is long, the good units are memorable, and eight months is longer than
anyone's memory of what they posted.
`--ledger` stores a normalised content hash of every unit marked as posted, with
the date. Used units are skipped by default and shown with their date on request.
It is a small file and it prevents a specific, embarrassing, recurring failure.
```bash
# Split, see what is available
python3 scripts/repurpose_splitter.py --input talk-transcript.md --ledger .linkedin-ledger.json
# After publishing unit 2, record it
python3 scripts/repurpose_splitter.py --input talk-transcript.md \
--ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25
```
Commit the ledger alongside the source if the source lives in a repo. It is
project state, not a cache.
## What you always have to add
Every unit the splitter produces is **source material, not a post**. The tool
will not write the missing part and should not: it is the only genuinely new
thing in a repurposed post.
The missing part is one of three sentences:
- **What it cost.** "We spent five weeks automating the wrong step."
- **What you assumed.** "I thought the delay was engineering capacity."
- **What you would do differently.** "I would instrument the handoffs first now."
A repurposed post without one of these reads as a summary of something else,
because that is what it is.
## Source types and what they yield
| Source | Typical yield | The specific risk |
|---|---|---|
| Conference talk transcript | 4-8 units | Spoken asides do not survive as text; the connective tissue is all dangling references |
| Long article / essay | 3-6 units | Sections written to build on each other rarely stand alone |
| Technical README or docs | 2-4 units | Instructional voice; needs the "why we needed this" frame added |
| Internal post-mortem | 2-5 units | **Consent and confidentiality first.** Anonymise, or get sign-off, or do not |
| Customer conversation notes | 1-3 units | Never quotable without permission. The pattern is publishable; the customer is not |
| Podcast appearance | 3-6 units | You do not own the recording; check before quoting at length |
## Cross-platform, not just within LinkedIn
The same source usually supports a LinkedIn post, a longer piece somewhere you
own, and a talk proposal. Sequence matters:
**LinkedIn first when** you want to test whether anyone cares before investing the
long-form time. The post is cheap and the response is informative.
**Owned platform first when** the artifact is the point and LinkedIn is
distribution. Publish there, then post the strongest unit on LinkedIn with the
link in the first comment.
What does not work is publishing the identical text in both places on the same
day. LinkedIn readers who follow you elsewhere see a duplicate, and the
LinkedIn version carries the link penalty for no gain.
## Frequency
A single strong source can carry a month of posting. It should not carry a
quarter. The signal that you have over-mined a source is that the units start
needing more setup than payload — that is the ledger telling you to go do
something new and write about that instead.
---
## Sources
1. Kleon, A. **Show Your Work!** — process as publishable material, and the case
for surfacing the same work repeatedly in different forms.
2. Handley, A. **Everybody Writes** — repurposing as an editorial discipline
rather than a content-volume tactic.
3. Vaynerchuk, G. **Jab, Jab, Jab, Right Hook** — platform-native adaptation;
the same idea has to be re-formed, not re-pasted.
4. Nielsen Norman Group. **"How People Read Online"** — why a unit that assumes
prior context fails for a scanning reader who has none.
5. Google Search Central. **Duplicate content guidance** — the mechanics of
cross-posting identical text, and why canonical placement matters for anything
you also own.
6. Ebbinghaus, H. **Über das Gedächtnis** (1885), forgetting curve — the
empirical case for deliberate repetition, and the reason it must be spaced
and varied rather than repeated verbatim.
7. LinkedIn. **Professional Community Policies** — repetitive posting of the same
content is named as spam behaviour; the ledger is how you stay clear of it.

View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""format_picker.py — pick the LinkedIn post format the material actually supports.
Format is usually chosen by fashion ("carousels are working right now") rather than
by what the person has to say. This scores the nine native formats against three
inputs you actually control the goal, the raw material in hand, and the minutes
available and returns a ranked shortlist with the constraint each format carries.
It refuses two common mistakes outright:
- a poll with no real decision behind it (a reach trick with a two-week half-life
and nothing to say afterwards)
- video when you have said you will not appear on camera and have no footage
Exit codes:
0 a format is recommended
2 the top two are within one point ask which, do not guess
3 no format fits the declared material; go get material first
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import sys
GOALS = ["reach", "authority", "leads", "recruiting", "community", "career-change"]
MATERIALS = ["story", "data", "opinion", "tutorial", "announcement",
"transcript", "visual", "question", "curation"]
# fit[format][goal] and fit[format][material]: 0-3
FORMATS = {
"text-post": {
"label": "Plain text post",
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2,
"community": 3, "career-change": 3},
"material": {"story": 3, "data": 2, "opinion": 3, "tutorial": 2,
"announcement": 2, "transcript": 1, "visual": 0,
"question": 3, "curation": 1},
"minutes": 25,
"constraint": "One idea. If it needs two, it is two posts.",
},
"document-carousel": {
"label": "Document post (PDF carousel)",
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 1,
"community": 2, "career-change": 2},
"material": {"story": 1, "data": 3, "opinion": 1, "tutorial": 3,
"announcement": 0, "transcript": 1, "visual": 3,
"question": 0, "curation": 3},
"minutes": 90,
"constraint": "Every slide must survive alone — most readers swipe two and leave. "
"Upload a real PDF with selectable text, not exported images.",
},
"native-video": {
"label": "Native video",
"goal": {"reach": 3, "authority": 2, "leads": 2, "recruiting": 3,
"community": 2, "career-change": 2},
"material": {"story": 3, "data": 1, "opinion": 2, "tutorial": 3,
"announcement": 2, "transcript": 3, "visual": 3,
"question": 1, "curation": 0},
"minutes": 120,
"constraint": "Captions are mandatory — most viewing is sound-off, and captions are "
"also the accessibility floor. Say the point in the first five seconds.",
},
"image-post": {
"label": "Single image + text",
"goal": {"reach": 2, "authority": 2, "leads": 1, "recruiting": 2,
"community": 2, "career-change": 2},
"material": {"story": 2, "data": 3, "opinion": 1, "tutorial": 1,
"announcement": 3, "transcript": 0, "visual": 3,
"question": 1, "curation": 1},
"minutes": 30,
"constraint": "Write alt text. A chart with no alt text excludes readers and says nothing "
"to anyone who cannot load it.",
},
"poll": {
"label": "Poll",
"goal": {"reach": 2, "authority": 1, "leads": 1, "recruiting": 1,
"community": 3, "career-change": 1},
"material": {"story": 0, "data": 1, "opinion": 1, "tutorial": 0,
"announcement": 0, "transcript": 0, "visual": 0,
"question": 3, "curation": 0},
"minutes": 10,
"constraint": "Only if you will publish what the answers changed. A poll you do not follow "
"up on is a reach trick, and readers have learned to spot it.",
},
"article": {
"label": "Long-form article",
"goal": {"reach": 1, "authority": 3, "leads": 2, "recruiting": 1,
"community": 1, "career-change": 2},
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3,
"announcement": 0, "transcript": 2, "visual": 1,
"question": 0, "curation": 3},
"minutes": 180,
"constraint": "Articles reach far fewer people than posts. Write one when the artifact "
"matters more than this week's impressions — it is a durable link.",
},
"newsletter-issue": {
"label": "Newsletter issue",
"goal": {"reach": 2, "authority": 3, "leads": 3, "recruiting": 1,
"community": 3, "career-change": 1},
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3,
"announcement": 1, "transcript": 2, "visual": 1,
"question": 0, "curation": 3},
"minutes": 150,
"constraint": "Subscribers are notified every issue. That is a standing promise about "
"cadence and topic — do not start one you cannot hold for six months.",
},
"comment-as-content": {
"label": "Substantive comment on someone else's post",
"goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2,
"community": 3, "career-change": 3},
"material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 1,
"announcement": 0, "transcript": 0, "visual": 0,
"question": 2, "curation": 1},
"minutes": 10,
"constraint": "It has to add something the original missed. Agreement is not a comment. "
"This is the fastest route to visibility from a standing start.",
},
"repost-with-take": {
"label": "Repost with your own take",
"goal": {"reach": 1, "authority": 2, "leads": 1, "recruiting": 1,
"community": 2, "career-change": 1},
"material": {"story": 0, "data": 2, "opinion": 3, "tutorial": 0,
"announcement": 1, "transcript": 0, "visual": 1,
"question": 1, "curation": 3},
"minutes": 15,
"constraint": "Your take must be longer than 'this'. A bare repost spends your credibility "
"on someone else's idea and returns nothing.",
},
}
def pick(goal: str, materials: list, minutes: int, on_camera: bool,
has_real_decision: bool) -> dict:
if goal not in GOALS:
raise ValueError(f"goal must be one of {GOALS}")
bad = [m for m in materials if m not in MATERIALS]
if bad:
raise ValueError(f"unknown material(s): {bad}; choose from {MATERIALS}")
ranked, excluded = [], []
for key, spec in FORMATS.items():
if key == "native-video" and not on_camera and "transcript" not in materials \
and "visual" not in materials:
excluded.append({"format": key, "reason":
"You said no camera and supplied no footage or transcript."})
continue
if key == "poll" and not has_real_decision:
excluded.append({"format": key, "reason":
"No real decision behind it. A poll without a follow-up post is a "
"reach trick; declare --has-decision if you will publish what "
"the answers changed."})
continue
if spec["minutes"] > minutes:
excluded.append({"format": key, "reason":
f"Needs about {spec['minutes']} min; you have {minutes}."})
continue
mat_scores = [spec["material"][m] for m in materials] or [0]
score = spec["goal"][goal] * 2 + max(mat_scores) + (sum(mat_scores) / len(mat_scores))
ranked.append({
"format": key, "label": spec["label"], "score": round(score, 2),
"goal_fit": spec["goal"][goal],
"best_material_fit": max(mat_scores),
"effort_minutes": spec["minutes"],
"constraint": spec["constraint"],
})
ranked.sort(key=lambda r: (-r["score"], r["effort_minutes"]))
if not ranked:
return {"decision": "NO_FIT", "exit_code": 3, "ranked": [], "excluded": excluded,
"instruction": "Nothing fits. Either the time budget is too small or the material "
"does not exist yet. Go get the material — the format is the easy part."}
if len(ranked) > 1 and ranked[0]["score"] - ranked[1]["score"] < 1.0:
return {"decision": "ASK", "exit_code": 2, "ranked": ranked[:3], "excluded": excluded,
"instruction": f"'{ranked[0]['label']}' and '{ranked[1]['label']}' score within a "
"point. Ask which the person would actually enjoy making — the one "
"they will repeat beats the one that scores higher once."}
return {"decision": "RECOMMEND", "exit_code": 0, "recommended": ranked[0],
"runners_up": ranked[1:3], "ranked": ranked, "excluded": excluded}
def render_human(r: dict) -> str:
lines = [f"Format decision: {r['decision']}", "=" * 52]
if r["decision"] == "RECOMMEND":
rec = r["recommended"]
lines += [f"{rec['label']} (score {rec['score']}, ~{rec['effort_minutes']} min)",
f" constraint: {rec['constraint']}", ""]
if r["runners_up"]:
lines.append("Runners-up:")
for u in r["runners_up"]:
lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}")
else:
lines.append(r["instruction"])
for u in r.get("ranked", []):
lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}")
if r.get("excluded"):
lines.append("\nRuled out:")
for e in r["excluded"]:
lines.append(f" - {e['format']}: {e['reason']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Rank LinkedIn post formats against goal, material, and time "
"(recommend=0 / ask=2 / no-fit=3).")
ap.add_argument("--goal", choices=GOALS, help="What this post is for.")
ap.add_argument("--material", action="append", default=[], choices=MATERIALS,
help="What you actually have. Repeatable.")
ap.add_argument("--minutes", type=int, default=45,
help="Minutes you will realistically spend (default 45).")
ap.add_argument("--on-camera", action="store_true",
help="You are willing to appear on camera.")
ap.add_argument("--has-decision", action="store_true",
help="A poll would settle a real decision you will report back on.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Run a built-in example.")
args = ap.parse_args()
if args.sample:
goal, materials, minutes = "authority", ["data", "tutorial"], 120
on_camera, decision = False, False
else:
if not args.goal or not args.material:
ap.error("--goal and at least one --material are required (or use --sample)")
goal, materials, minutes = args.goal, args.material, args.minutes
on_camera, decision = args.on_camera, args.has_decision
try:
result = pick(goal, materials, minutes, on_camera, decision)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 4
result["inputs"] = {"goal": goal, "materials": materials, "minutes": minutes,
"on_camera": on_camera, "has_real_decision": decision}
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""post_linter.py — lint a LinkedIn post before it goes out. Score 0-100.
Checks the things that measurably cost reach or credibility, in four families:
MECHANICS length caps, the mobile fold, hashtag count, link placement
HOOK does the visible first line survive truncation and earn the click
INTEGRITY engagement bait, unverifiable superlatives, machine-written tells
ACCESSIBILITY Unicode pseudo-bold, emoji load, ALL-CAPS, wall-of-text density
Blocking findings are the ones with named consequences: a post over the 3,000
character cap will not publish; Unicode pseudo-bold is read aloud by screen
readers as "mathematical bold small a"; engagement bait is named in LinkedIn's
Professional Community Policies as demoted content.
Evidence note: the 3,000-character cap and ~140-character mobile fold are
third-party-documented and stable. The reach cost of an in-body external link
(~19% lower median reach in a 1.3M-post third-party study) is a third-party
finding, not a LinkedIn statement it is flagged as a warning, not a block,
and the reference doc carries the confidence level.
Exit codes:
0 SHIP (>= 75 and no blocking findings)
2 REVISE (50-74, or any blocking finding)
3 REWRITE (< 50)
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import re
import sys
HARD_LIMIT = 3000
FOLD_MOBILE = 140 # characters before "…see more" on mobile
FOLD_DESKTOP = 210
BAND_LOW, BAND_HIGH = 1300, 2500 # highest observed median engagement band
BAIT_PATTERNS = [
(r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b",
"comment-for-lead-magnet"),
(r"\bcomment ['\"]?\w{1,14}['\"]? below\b", "comment-keyword"),
(r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b", "dm-for-engagement"),
(r"\b(like|repost|share) (this )?if you\b", "like-if"),
(r"\btag (someone|a friend|3 people|three people)\b", "tag-someone"),
(r"\bwho else (agrees|thinks|feels)\b", "agreement-farming"),
(r"\bagree\?\s*$", "agree-closer"),
(r"\brepost (this )?(to|so|if)\b", "repost-plea"),
]
# Phrases that read as machine-written to anyone who reads LinkedIn daily.
SLOP_PATTERNS = [
(r"\bin today'?s (fast[- ]paced|ever[- ]changing|digital|competitive) (world|landscape|era)\b",
"in-todays-world opener"),
(r"\bdelve into\b", "delve"),
(r"\bit'?s not (just )?about \w+[.,] it'?s about\b", "not-x-its-y"),
(r"\blet that sink in\b", "let-that-sink-in"),
(r"\bgame[- ]chang(er|ing)\b", "game-changer"),
(r"\bunlock (the|your) (power|potential|secret)\b", "unlock-the-potential"),
(r"\bin the ever[- ]evolving\b", "ever-evolving"),
(r"\bhere'?s the (thing|kicker|secret)\b(?=[\s\S]{0,400}\bhere'?s the)", "repeated here's-the"),
(r"\bthe results? (speak for themsel|were nothing short of)\w*", "results-speak"),
(r"\b(revolutioniz|supercharg|turbocharg)\w+", "hype-verb"),
(r"\bas an? (ai|language model)\b", "assistant-artifact"),
(r"\bcertainly[!,]", "assistant-artifact"),
]
SUPERLATIVES = [
"the best", "the only", "guaranteed", "never fails", "always works",
"100% of", "everyone knows", "no one is talking about", "nobody talks about",
"the #1", "world-class", "unprecedented", "revolutionary",
]
GENERIC_OPENERS = [
"i'm excited to announce", "i am excited to announce", "i'm thrilled",
"i am thrilled", "i'm humbled", "i am humbled", "excited to share",
"happy to share", "proud to announce", "quick thought", "just a thought",
]
URL_RE = re.compile(r"https?://[^\s)]+|\bwww\.[^\s)]+")
HASHTAG_RE = re.compile(r"(?<!\w)#[A-Za-z][A-Za-z0-9_]{1,49}")
EMOJI_RE = re.compile("[\U0001F300-\U0001FAFF☀-➿️⬀-⯿]")
# Mathematical Alphanumeric Symbols + enclosed alphanumerics: the "bold text
# generators" people paste into LinkedIn. Screen readers do not render these.
PSEUDO_BOLD_RE = re.compile("[\U0001D400-\U0001D7FF\U0001F130-\U0001F189-]")
SAMPLE_POST = """Our onboarding took 6 weeks. We got it to 4 days without hiring anyone.
The bottleneck was not the product. It was that three different teams each owned
one step and none of them owned the handoff.
What we changed:
1. One named owner for the whole path, not per step. Every stall now has someone
whose week it ruins.
2. We deleted the "kickoff call" and replaced it with a 4-question form. 80% of
accounts never needed the call.
3. We stopped treating the CRM stage as the source of truth and started measuring
the customer's first real use.
The part I got wrong: I assumed the delay was engineering capacity. It was
handoffs. We spent five weeks building automation for the wrong step before
anyone measured where the time actually went.
If you are staring at a slow onboarding number, measure the wait between steps
before you optimise any single step. That is where ours was hiding.
What did the handoff cost you the last time you measured it?
"""
def _visible(text: str, n: int) -> str:
return text[:n]
def lint(text: str, has_image: bool = False) -> dict:
raw = text.rstrip("\n")
low = raw.lower()
findings = []
n = len(raw)
def add(sev, family, msg, fix):
findings.append({"severity": sev, "family": family, "finding": msg, "fix": fix})
# ---------------- MECHANICS ------------------------------------------
if n > HARD_LIMIT:
add("blocking", "mechanics",
f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. LinkedIn will not publish it.",
"Cut to one idea. The section you are most attached to is usually the one to lose.")
elif n < 400:
add("warning", "mechanics",
f"{n} characters. Short posts can work, but under ~400 there is rarely room for a claim "
"and the evidence for it.",
"Either add the specific example, or accept it as a comment rather than a post.")
elif not (BAND_LOW <= n <= BAND_HIGH):
add("info", "mechanics",
f"{n} characters — outside the {BAND_LOW}-{BAND_HIGH} band where third-party studies "
"report the highest median engagement.",
"Not a defect. Worth knowing if reach is the goal for this specific post.")
urls = URL_RE.findall(raw)
if urls:
add("warning", "mechanics",
f"{len(urls)} external link(s) in the post body. A 1.3M-post third-party study reports "
"~19% lower median reach for a body link (LinkedIn has never confirmed a penalty).",
"Put the link in the first comment and say so in the post: 'link in the comments'. "
"Keep it in the body only when the click IS the goal and you accept the reach cost.")
tags = HASHTAG_RE.findall(raw)
if len(tags) > 3:
add("warning", "mechanics",
f"{len(tags)} hashtags. Past three they stop being topic signals and start reading as "
"reach-chasing.",
"Keep the two or three that describe what the post is actually about.")
# ---------------- HOOK ------------------------------------------------
hook = _visible(raw, FOLD_MOBILE).strip()
first_line = raw.split("\n", 1)[0].strip()
if len(raw) > FOLD_MOBILE:
boundary = max(hook.rfind("."), hook.rfind("?"), hook.rfind("!"), hook.rfind("\n"))
if boundary < FOLD_MOBILE - 90:
add("major", "hook",
f"Nothing completes inside the first {FOLD_MOBILE} characters — the mobile fold. "
"The reader's decision to expand is made on a fragment.",
f"End a sentence before character {FOLD_MOBILE}. Desktop folds around "
f"{FOLD_DESKTOP}, so mobile is the binding constraint.")
gen = [g for g in GENERIC_OPENERS if low.startswith(g) or low[:60].find(g) >= 0]
if gen:
add("major", "hook",
f"Opens with a stock phrase: '{gen[0]}'. It is the most-scrolled-past construction on "
"the platform.",
"Open on the specific thing: the number, the mistake, or the sentence someone said to you.")
if not re.search(r"\d", hook) and "?" not in hook and len(first_line.split()) > 3:
add("info", "hook",
"The visible hook has no number and asks nothing. It can still work, but it is doing "
"it on voice alone.",
"A concrete number or a real question in the first line is the cheapest specificity available.")
# ---------------- INTEGRITY -------------------------------------------
bait = sorted({label for pat, label in BAIT_PATTERNS if re.search(pat, low, re.I)})
if bait:
add("blocking", "integrity",
f"Engagement bait: {', '.join(bait)}. LinkedIn's Professional Community Policies name "
"bait as content it demotes, and readers recognise it.",
"Ask the question the post actually earned, or offer the resource with no toll gate.")
slop = [label for pat, label in SLOP_PATTERNS if re.search(pat, low, re.I)]
if slop:
add("major", "integrity",
f"Machine-written tells: {', '.join(sorted(set(slop)))}.",
"Rewrite those lines the way you would say them out loud. If a phrase could sit in "
"anyone's post about anything, it is not carrying meaning.")
sup = [s for s in SUPERLATIVES if s in low]
if sup:
add("warning", "integrity",
f"Unverifiable superlatives: {', '.join(sup)}.",
"Replace with the bounded version: what you measured, over what period, in what context.")
# Rule-of-three cadence: three consecutive short sentence fragments in a row.
if len(re.findall(r"(?m)^[^\n]{1,45}\.\s*$", raw)) >= 6:
add("info", "integrity",
"Many short standalone one-line sentences ('broetry' cadence). It reads as formatted "
"for the algorithm rather than for a reader.",
"Keep the line breaks that separate ideas; join the ones that only separate clauses.")
# ---------------- ACCESSIBILITY ---------------------------------------
pseudo = PSEUDO_BOLD_RE.findall(raw)
if pseudo:
add("blocking", "accessibility",
f"{len(pseudo)} Unicode pseudo-bold/italic characters. Screen readers announce these "
"character by character as mathematical symbols, and LinkedIn search does not index "
"them as words.",
"Use plain text. Emphasis comes from line breaks and word order, not from a font hack.")
emoji = EMOJI_RE.findall(raw)
if len(emoji) > 8:
add("warning", "accessibility",
f"{len(emoji)} emoji. Each one is read aloud by name; past a handful the post becomes "
"tiring to hear.",
"Keep the ones doing structural work (list markers), cut decorative ones.")
caps_lines = [ln for ln in raw.split("\n") if len(ln) > 25 and ln.isupper()]
if caps_lines:
add("warning", "accessibility",
f"{len(caps_lines)} ALL-CAPS line(s). Some screen readers spell these out letter by letter.",
"Sentence case. Put the emphasis in the words.")
paragraphs = [p for p in re.split(r"\n\s*\n", raw) if p.strip()]
longest = max((len(p) for p in paragraphs), default=0)
if longest > 600:
add("warning", "accessibility",
f"Longest paragraph is {longest} characters. On a phone that is a wall.",
"Break at the idea boundaries. Three to four lines per block is readable.")
if has_image:
add("info", "accessibility",
"Image attached: LinkedIn supports alt text on images and does not add it for you.",
"Write alt text describing what the image shows, not 'chart'. One sentence is enough.")
# ---------------- CTA --------------------------------------------------
tail = raw[-300:].lower()
if "?" not in tail and not re.search(r"\b(tell me|curious|what would you|how do you|"
r"what did|would love to hear|message me|reply)\b", tail):
add("info", "cta",
"The post ends without a question or a next step. Comments are the engagement signal "
"that matters most; nothing here invites one.",
"One genuine question you would actually want answered. Not 'thoughts?'.")
# ---------------- score -------------------------------------------------
weights = {"blocking": 22, "major": 11, "warning": 6, "info": 2}
score = max(0, 100 - sum(weights[f["severity"]] for f in findings))
blocking = any(f["severity"] == "blocking" for f in findings)
if blocking:
verdict, code = "REVISE", 2
score = min(score, 60)
elif score >= 75:
verdict, code = "SHIP", 0
elif score >= 50:
verdict, code = "REVISE", 2
else:
verdict, code = "REWRITE", 3
order = {"blocking": 0, "major": 1, "warning": 2, "info": 3}
return {
"score": score,
"verdict": verdict,
"exit_code": code,
"stats": {
"chars": n, "limit": HARD_LIMIT,
"optimal_band": [BAND_LOW, BAND_HIGH],
"hashtags": len(tags), "links_in_body": len(urls),
"emoji": len(emoji), "paragraphs": len(paragraphs),
"visible_before_see_more_mobile": hook,
},
"findings": sorted(findings, key=lambda f: order[f["severity"]]),
"counts": {sev: sum(1 for f in findings if f["severity"] == sev)
for sev in ("blocking", "major", "warning", "info")},
"note": ("Character caps and fold positions are third-party-documented and stable. Reach "
"effects (link penalty, length band) come from third-party studies of public "
"posts, not from LinkedIn — see references/hook_and_fold_mechanics.md for the "
"per-claim confidence levels."),
}
def render_human(r: dict) -> str:
s = r["stats"]
lines = [f"Post lint: {r['score']}/100 — {r['verdict']}", "=" * 56,
f"{s['chars']}/{s['limit']} chars · {s['hashtags']} hashtags · "
f"{s['links_in_body']} body links · {s['emoji']} emoji · {s['paragraphs']} blocks",
"", "VISIBLE BEFORE \"…see more\" (mobile):",
f" {s['visible_before_see_more_mobile']}", ""]
if r["findings"]:
lines.append("Findings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['family']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("No findings. Ship it.")
lines += ["", r["note"]]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Lint a LinkedIn post 0-100 (SHIP=0 / REVISE=2 / REWRITE=3).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--text", help="The post body.")
src.add_argument("--input", help="Read the post from a file ('-' for stdin).")
ap.add_argument("--has-image", action="store_true",
help="The post carries an image or document (adds the alt-text check).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Lint a built-in sample post.")
args = ap.parse_args()
if args.sample:
text = SAMPLE_POST
elif args.text:
text = args.text
elif args.input:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
else:
ap.error("one of --text, --input, or --sample is required")
result = lint(text, has_image=args.has_image)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""repurpose_splitter.py — split long source material into standalone LinkedIn units.
Feed it an article, a talk transcript, a README, or your own notes. It cuts the
source into candidate post units, scores each on whether it can stand alone in a
feed, suggests a format, and refuses the ones that only make sense in context.
The part that matters is the reuse ledger. Repurposing fails in one specific way:
the same idea goes out three times over eight months and the audience notices
before the author does. `--ledger` keeps a content hash of every unit already
posted, so a unit that has run is skipped by default and shown with its date when
you ask for it.
The tool never writes the post. It hands you a unit, the reason it can stand
alone, and the gap you have to fill which is always the same gap: the sentence
only you can write about what this cost you or taught you.
Exit codes:
0 usable units found
2 units found but all are weak (fix the flagged gaps before drafting)
3 nothing in the source can stand alone this is one post, not a series
Stdlib only. No network. Deterministic.
"""
import argparse
import hashlib
import json
import os
import re
import sys
MIN_UNIT, MAX_UNIT = 240, 2400
DANGLING_START = re.compile(
r"^(this|these|those|that|it|they|he|she|such|as (mentioned|noted|we saw|discussed)|"
r"the former|the latter|therefore|thus|however|but|and|so|which|meanwhile|"
r"in conclusion|finally|next,)\b", re.I)
EVIDENCE_RE = re.compile(
r"\d+\s*(%|x\b|×|ms\b|s\b|min\b|hours?|days?|weeks?|months?|years?)|"
r"[$€£]\s?\d|\b\d{1,3}(,\d{3})+\b|\bversion \d|\b\d+\s*(users|customers|teams|"
r"engineers|requests|rows|queries|tests)\b", re.I)
STORY_RE = re.compile(r"\b(i |we |my |our |the day|last (year|month|week)|when i|when we|"
r"turned out|i thought|we assumed|the mistake)\b", re.I)
STEP_RE = re.compile(r"(?m)^\s*(\d+[.)]|[-*•]|step \d)", re.I)
OPINION_RE = re.compile(r"\b(should|shouldn'?t|wrong|overrated|underrated|myth|"
r"stop |disagree|the real reason|nobody|most people)\b", re.I)
SAMPLE_SOURCE = """## Why our onboarding took six weeks
We measured it for the first time in March. Median time from contract signed to
first real use was 41 days. Nobody in the company believed the number, which is
usually the sign that it is right.
The instinct was to blame engineering capacity. We had a backlog of integration
work and it was easy to point at.
## The measurement that changed our mind
We instrumented each handoff instead of each step. Work-in-progress time was 6
days. Wait time between owners was 35 days. The work was not slow. The queue was.
That ratio 6 to 35 is the only number from this whole project I still quote.
## What we changed
1. One named owner for the whole path rather than one per stage.
2. Deleted the kickoff call, replaced it with a four-question form. 80% of
accounts never needed the call at all.
3. Stopped treating the CRM stage as truth and started measuring first real use.
Median went to 4 days over the next quarter.
## What I got wrong
I spent five weeks building automation for the intake step before anyone measured
where the time went. The automation worked. It saved about four hours across the
whole quarter, against 35 days of queue time sitting untouched two steps later.
"""
def split_units(text: str) -> list:
"""Heading-aware split, falling back to paragraph accumulation."""
heading_blocks = re.split(r"(?m)^#{1,6}\s+", text)
if len(heading_blocks) > 2:
units = []
for block in heading_blocks[1:]:
line, _, rest = block.partition("\n")
body = rest.strip()
if body:
units.append({"title": line.strip(), "text": body})
if units:
return units
units, buf = [], []
for para in [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]:
buf.append(para)
joined = "\n\n".join(buf)
if len(joined) >= MIN_UNIT:
units.append({"title": "", "text": joined})
buf = []
if buf:
joined = "\n\n".join(buf)
if units and len(joined) < MIN_UNIT:
units[-1]["text"] += "\n\n" + joined
else:
units.append({"title": "", "text": joined})
return units
def suggest_format(unit_text: str) -> str:
if STEP_RE.search(unit_text) and len(EVIDENCE_RE.findall(unit_text)) >= 2:
return "document-carousel"
if STORY_RE.search(unit_text):
return "text-post"
if OPINION_RE.search(unit_text):
return "text-post"
if EVIDENCE_RE.search(unit_text):
return "image-post"
return "text-post"
def score_unit(unit: dict) -> dict:
text = unit["text"].strip()
first = text.split("\n", 1)[0].strip()
gaps, score = [], 0
if MIN_UNIT <= len(text) <= MAX_UNIT:
score += 25
elif len(text) < MIN_UNIT:
gaps.append({"gap": "too-thin",
"detail": f"{len(text)} chars — not enough to make a claim and back it.",
"fix": "Merge with the neighbouring section or drop it."})
else:
score += 10
gaps.append({"gap": "too-long",
"detail": f"{len(text)} chars — over the {MAX_UNIT}-char working ceiling.",
"fix": "Split again at the strongest internal boundary; two posts beat one crammed one."})
if DANGLING_START.match(first):
gaps.append({"gap": "dangling-reference",
"detail": f"Opens with '{first.split()[0]}' — it refers to something the "
"reader never saw.",
"fix": "Rewrite the first sentence to name the thing outright."})
else:
score += 25
ev = EVIDENCE_RE.findall(text)
if ev:
score += 25
else:
gaps.append({"gap": "no-evidence",
"detail": "No number, duration, or measurable detail.",
"fix": "Either add the real figure from the source, or route this to an "
"opinion post where the argument does the work."})
sentences = [s for s in re.split(r"[.!?](?:\s|$)", text) if len(s.strip()) > 15]
if len(sentences) >= 3:
score += 25
else:
gaps.append({"gap": "fragment",
"detail": f"{len(sentences)} substantive sentence(s) — reads as a note, "
"not a post.",
"fix": "Expand or merge."})
return {
"title": unit["title"],
"chars": len(text),
"standalone_score": score,
"suggested_format": suggest_format(text),
"hook_candidate": first[:140],
"gaps": gaps,
"text": text,
"author_gap": ("Every unit here is source material, not a post. Add the first-person "
"sentence only you can write: what it cost, what you assumed, or what "
"you would do differently."),
}
def unit_hash(text: str) -> str:
normalized = re.sub(r"\s+", " ", text.strip().lower())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
def load_ledger(path: str) -> dict:
if path and os.path.isfile(path):
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except (json.JSONDecodeError, OSError):
return {}
return {}
def main() -> int:
ap = argparse.ArgumentParser(
description="Split long source material into standalone LinkedIn units "
"(usable=0 / all-weak=2 / not-splittable=3).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--input", help="Source file to split ('-' for stdin).")
ap.add_argument("--ledger", help="JSON file tracking units already posted "
"(created on --record if absent).")
ap.add_argument("--record", action="append", default=[],
help="Mark a unit index as posted in the ledger. Repeatable.")
ap.add_argument("--posted-on", default="",
help="Date string stored with --record entries (e.g. 2026-08-25).")
ap.add_argument("--show-used", action="store_true",
help="Include units already in the ledger instead of skipping them.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Split a built-in sample article.")
args = ap.parse_args()
if args.sample:
text = SAMPLE_SOURCE
elif args.input:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
else:
ap.error("--input or --sample is required")
ledger = load_ledger(args.ledger) if args.ledger else {}
raw_units = split_units(text)
scored = []
for idx, u in enumerate(raw_units):
s = score_unit(u)
s["index"] = idx
s["hash"] = unit_hash(s["text"])
prev = ledger.get(s["hash"])
s["already_posted"] = bool(prev)
s["posted_on"] = prev.get("posted_on") if isinstance(prev, dict) else prev
scored.append(s)
if args.record:
if not args.ledger:
print("ERROR: --record requires --ledger", file=sys.stderr)
return 4
try:
wanted = {int(i) for i in args.record}
except ValueError:
print("ERROR: --record takes unit indexes (integers)", file=sys.stderr)
return 4
for s in scored:
if s["index"] in wanted:
ledger[s["hash"]] = {"title": s["title"] or s["hook_candidate"][:60],
"posted_on": args.posted_on or "unspecified"}
with open(args.ledger, "w", encoding="utf-8") as fh:
json.dump(ledger, fh, indent=2)
visible = scored if args.show_used else [s for s in scored if not s["already_posted"]]
# A unit with a structural gap is not usable however well it scores elsewhere:
# a dangling opener or a fragment fails in the feed regardless of its evidence.
disqualifying = {"too-thin", "dangling-reference", "fragment"}
usable = [s for s in visible
if s["standalone_score"] >= 75
and not any(g["gap"] in disqualifying for g in s["gaps"])]
skipped = len(scored) - len(visible)
if not scored or (len(scored) == 1 and scored[0]["standalone_score"] < 75):
decision, code = "NOT_SPLITTABLE", 3
elif usable:
decision, code = "USABLE", 0
else:
decision, code = "ALL_WEAK", 2
result = {
"decision": decision,
"exit_code": code,
"units_found": len(scored),
"units_skipped_as_already_posted": skipped,
"usable_units": len(usable),
"units": sorted(visible, key=lambda s: -s["standalone_score"]),
"ledger_path": args.ledger,
"rule": ("A unit is source material, not a post. The tool never fabricates the "
"first-person line — that is the author's job and it is the only part "
"of a repurposed post that is actually new."),
}
if args.output == "json":
print(json.dumps(result, indent=2))
else:
print(f"Repurpose: {decision}{len(scored)} unit(s), {len(usable)} usable"
+ (f", {skipped} skipped as already posted" if skipped else ""))
print("=" * 64)
for s in result["units"]:
flag = " [ALREADY POSTED %s]" % s["posted_on"] if s["already_posted"] else ""
print(f"\n#{s['index']} score {s['standalone_score']}/100 "
f"{s['chars']} chars → {s['suggested_format']}{flag}")
if s["title"]:
print(f" title: {s['title']}")
print(f" hook : {s['hook_candidate']}")
for g in s["gaps"]:
print(f" gap : [{g['gap']}] {g['detail']}")
print(f" fix → {g['fix']}")
print(f"\n{result['rule']}")
return code
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,100 @@
---
name: linkedin-engagement
description: Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn — a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on "who should I engage with", "write a connection request", "cold DM", "LinkedIn outreach", "networking strategy", "how many invites can I send". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
---
# LinkedIn Engagement — comments first, outreach second
From a standing start your posts reach almost nobody, and publishing harder does not fix it.
A substantive comment on a post that already has an audience puts your name, headline, and a
paragraph of thinking in front of people already reading about your subject, for six minutes
of work — the cheapest distribution on the platform, and the most badly used.
**Nothing here sends anything** — no credentials, no API calls. Automated connecting,
messaging, commenting, liking, and sharing are prohibited by User Agreement §8.2.
## Workflow
**1. Build the comment roster.** Name 8-12 accounts they would read anyway, with audience
overlap and rough size tier, then:
```bash
python3 scripts/comment_target_planner.py --account "Priya Raman:5:4:larger" \
--account "Tomas Lind:5:3:peer" --minutes-per-day 18 --output human
```
Tiers are `huge` (10x+, crowded), `larger` (2-10x, the best ratio), `peer` (~1x, where
reciprocity compounds), `smaller` (goodwill). The roster caps any account at twice a week —
commenting daily on one person reads as following them around — and keeps the huge tier under
half of any day. It builds the roster, never the comments: a generated comment is exactly
what §8.2 names, and it is recognisable anyway.
**2. Outreach — check the volume before writing anything.**
```bash
python3 scripts/outreach_volume_guard.py --invites 20 --pending 5 --minutes 120 \
--acceptance 0.42 --output human
```
Exit 0 safe / 2 tight / 3 over a cap or the time budget / **4 refused** above 40 invitations
a day, because nobody reads that many profiles and writes that many specific lines. Pending
invitations count against the weekly limit (observed around 100), and acceptance below 20% is
a stop signal — it is the pattern LinkedIn reviews, and the targeting is wrong.
**3. Write one message, for one person.**
```bash
python3 scripts/outreach_message_builder.py --type connection \
--recipient "Priya" --specific-line "..." --reason "..." --output human
```
It refuses without a person-specific line, refuses an ask in a first-touch connection note,
and enforces the 200-character cap (300 with `--premium`). In large third-party samples a
note barely moves acceptance (~26.4% either way) but roughly doubles the post-accept reply
rate: **the note earns the conversation, not the meeting.**
**4. The order that works.** Comment on their work for two weeks. Then invite, referencing
something specific from that reading. Then, after acceptance and a pause, ask once, small.
## Rules
- **Nothing is auto-sent.** Ever. The user pastes and sends, one person at a time.
- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under
§8.2 regardless of who pressed the key. Build a real reciprocity list instead.
- **Every message carries a line that could only have been written for that person.**
- **Never paste the same comment twice.** Identical comments at volume are the definition of
the thing §8.2 prohibits.
- **One follow-up, a week later, only with something new to say.**
- **Agreement is not a comment.** Add the counter-example, the number, or the case where it
breaks — or skip the slot.
## Scripts
| Script | Role |
|---|---|
| [`scripts/comment_target_planner.py`](scripts/comment_target_planner.py) | Weekly roster from scored accounts, inside a time budget, with per-account and per-tier caps. |
| [`scripts/outreach_message_builder.py`](scripts/outreach_message_builder.py) | Assembles one message; refuses templates, premature asks, and 14 dead phrases. |
| [`scripts/outreach_volume_guard.py`](scripts/outreach_volume_guard.py) | Caps invitations against the observed weekly limit, pending backlog, acceptance floor, and the hours available. |
## References and assets
- [`references/comment_strategy.md`](references/comment_strategy.md) — tiers, what a comment competes on, pods, replying to your own posts (7 sources)
- [`references/outreach_ethics_and_benchmarks.md`](references/outreach_ethics_and_benchmarks.md) — the benchmark numbers with their provenance, and the personalisation claim corrected (7 sources)
- [`assets/outreach_worksheet.md`](assets/outreach_worksheet.md) — fillable per-person prep sheet
- [`assets/example_outreach.json`](assets/example_outreach.json) — input shape for the builder
## Distinct from
- **`linkedin-content`** — writes posts. A comment is a different craft on a different budget.
- **`marketing-skill/cold-email`** — email. Different channel, law, and caps.
- **`business-growth/`, `commercial/`** — sales process and deal economics, not networking.
---
**Version:** 1.0.0

View file

@ -0,0 +1,9 @@
{
"_comment": "Input shape for outreach_message_builder.py. This example passes at exit 0: the specific line names an artifact and the part that mattered, the reason is a clause, and there is no ask - because this is a first-touch connection note and the note's job is the conversation, not the meeting.",
"type": "connection",
"recipient": "Priya",
"specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at step three.",
"reason": "I am working the same problem from the platform side and would like to follow what you publish.",
"ask": "",
"premium": false
}

View file

@ -0,0 +1,74 @@
# Outreach Prep Sheet — one per person
If you cannot fill section 2 without opening a template, do not send the message. The
person-specific line is the entire difference between outreach and spam, and
`outreach_message_builder.py` refuses without it.
---
## 1. Who
- Name (spelled the way they spell it): ______
- What they actually do (not their title): ______
- How you found them: ______
- Have you commented on their work in the last two weeks? ______
If the last answer is no, consider doing that first. Comment for two weeks, then invite.
That order converts better than any wording optimisation.
## 2. The specific line — the one that could not be sent to anyone else
Name the artifact and the part of it that mattered:
> ______________________________________________________________
Checks:
- Does it name something they made, said, or decided? ______
- Could this sentence be sent to anyone else on your list? If yes, rewrite it. ______
- Is it a disagreement or a use, rather than a compliment? (Better if so.) ______
## 3. The reason
One clause: what you want to follow, learn, or compare notes on.
> ______________________________________________________________
## 4. The ask — **not in a first-touch connection note**
Leave blank for a connection request. The note's job is the conversation, not the meeting:
in large third-party samples a note barely moves acceptance (~26.4% either way) but roughly
doubles the reply rate after acceptance.
For a later message, keep it bounded and small:
> ______________________________________________________________
## 5. Volume check before sending
- Invitations already pending: ______
- New invitations planned this week: ______
- Recent acceptance rate: ______ (below 20% → stop and fix targeting)
```bash
python3 ../scripts/outreach_volume_guard.py --invites <n> --pending <n> \
--minutes <n> --acceptance <0.xx> --output human
```
## 6. Assemble and check
```bash
python3 ../scripts/outreach_message_builder.py --type connection \
--recipient "..." --specific-line "..." --reason "..." --output human
```
Cap: 200 characters free, 300 with `--premium`.
## 7. Follow-up rule
One follow-up, at least a week later, **only with something new to say**. "Just bumping this
up" is not new information. A second follow-up with nothing new is the point at which you
become the thing you were avoiding.
- Date sent: ______
- Accepted? ______
- Follow-up sent (date, and what was new): ______

View file

@ -0,0 +1,145 @@
# Comment Strategy — the cheapest distribution on LinkedIn
From a standing start, your posts reach almost nobody. That is not a failure of
the writing; it is what an empty follower graph and a relevance-based retrieval
system produce together. Publishing harder does not fix it.
Commenting does. A substantive comment on a post that already has an audience
puts your name, your headline, and one paragraph of your thinking in front of
people who are already reading about your subject. It costs six minutes.
Most people do it badly, in an identifiable way.
---
## What a comment is competing on
A reader scanning a comment thread is making the same decision they make in the
feed, with less patience. Your comment competes against forty others, most of
which say some version of "great post". The bar for standing out is therefore
much lower than it looks, and it is entirely about **adding something the post
did not say.**
Five things that qualify:
1. **A counter-example.** "This holds until the team crosses about fifteen
people, and then the handoff cost dominates."
2. **A number.** "We measured this: 6 days of work, 35 days of queue."
3. **The case where it breaks.** The most valuable comment type, and the rarest.
4. **A specific mechanism.** Why the thing the author observed happens.
5. **A question only someone who read it properly could ask.**
Two that do not: agreement, and a summary of the post the author just wrote.
## Tiering, and why the biggest accounts are the wrong target
`comment_target_planner.py` scores accounts on audience overlap, posting
frequency, and tier.
| Tier | Relative size | Factor | Reality |
|---|---|---|---|
| **huge** | 10x+ | 1.0 | Crowded. 400 comments, most never read. Worth it only if you are early *and* excellent |
| **larger** | 2-10x | 1.5 | The best ratio of reachable audience to competition |
| **peer** | ~1x | 1.3 | Reciprocity compounds. These relationships still exist in a year |
| **smaller** | <0.5x | 0.9 | Low reach today, high goodwill, and some will not be smaller for long |
The instinct is to comment on the biggest accounts in the field. It is the worst
use of the time: your comment is one of hundreds, the author will not read it,
and their audience is scanning past the whole thread.
**The under-rated tier is peer.** Reciprocity is real and it is mutual — the
people at your size who show up in your comments now are the ones whose audience
overlaps yours most, and the relationship runs both directions.
## The rules the planner enforces
- **No account more than twice a week.** Commenting daily on one person reads as
following them around, and it exhausts the goodwill it earns.
- **At least one peer slot per day**, for the reason above.
- **No more than half a day's slots in the huge tier.**
## What the planner will not do
It does not write comments. This is the whole point of the design: a comment
written by a tool is exactly the thing LinkedIn's User Agreement §8.2 names when
it prohibits automated methods to "create, comment on, like, share, or re-share
posts, or otherwise drive inauthentic engagement."
More practically: a generated comment is recognisable, and being recognised as
someone who generates comments is worse than not commenting.
## Engagement pods
A pod is a group that agrees to reciprocally comment on each other's posts,
usually within the first hour. The pitch is that it is just humans helping
humans.
It is prohibited. The User Agreement language is "otherwise drive inauthentic
engagement", and coordinated reciprocal commenting on schedule is the central
example. `linkedin_policy_gate.py` refuses pod requests at exit 4.
The legitimate version of the same instinct is the reciprocity list: a set of
people whose work you genuinely read, whom you comment on because you have
something to say. It is slower, it produces the same relationships, and it
survives an audit.
## Replying to your own comments
Half of the value of a post is in the thread underneath it. Replies to your own
post are not a bonus round:
- Reply to every substantive comment within 24 hours, and to the early ones
within the hour if you can.
- Reply with something, not "thanks!". The reply is visible to everyone who opens
the thread, and it is a second chance to say the thing.
- **Reply to replies on your comments elsewhere.** That sub-thread is where
people actually meet you.
`cadence_planner.py` budgets 20 minutes per published post for this, and treats
it as part of the post rather than an extra.
## Groups and communities
LinkedIn Groups are mostly dormant. The exceptions are narrow, actively moderated
professional groups, where the signal is high because the population is small.
The test is whether posts in the group get real replies from named humans; if the
last three posts have no comments, it is a graveyard and posting there is a
donation to nobody.
The higher-yield version of the same instinct is off-LinkedIn communities where
your audience already talks — a Slack, a Discord, a mailing list, a conference —
and bringing what you learn there back to LinkedIn as posts.
## Measuring it
Commenting does not produce a metric LinkedIn will show you cleanly. The
observable signals, in order of reliability:
1. Profile views trending up in weeks you commented consistently.
2. Connection requests *from* people whose posts you commented on.
3. Named references: someone says "I saw your comment on X's post".
None of these are clean attribution, and there is no honest way to make them so.
Treat commenting as a fixed practice rather than a tracked channel.
---
## Sources
1. LinkedIn. **User Agreement §8.2** — the explicit prohibition on automated or
inauthentic commenting, liking, and sharing.
2. Granovetter, M. **"The Strength of Weak Ties."** *American Journal of
Sociology*, 1973 — why the peer and larger tiers, not the huge tier, are where
opportunity actually flows.
3. Burt, R. **Structural Holes: The Social Structure of Competition** (1992) —
brokerage between clusters; commenting across adjacent fields is exactly this.
4. Cialdini, R. **Influence: The Psychology of Persuasion** — reciprocity and
liking, and why they are destroyed by being made transactional.
5. Grant, A. **Give and Take** — givers who succeed do so through specific,
bounded generosity, not indiscriminate availability.
6. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the
small population of people who comment at all, and why being one of them is
cheap differentiation.
7. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn
feed ranking."** — the mechanism by which threads that hold attention are
measured.

View file

@ -0,0 +1,147 @@
# Outreach Ethics and Benchmarks — what the numbers say, and what they cost
Cold outreach on LinkedIn works and is widely hated, and both facts have the same
cause: it is cheap to send and expensive to receive. The volume that makes it
economically attractive is the volume that makes it spam, and the platform
enforces against the pattern rather than the intent.
This document holds the numbers, with their provenance, and the rules that keep
the practice on the right side of the line.
---
## The benchmarks, with a correction
Third-party outreach studies are large but self-selected: the data comes from
users of outreach tooling, measured by the vendors of that tooling. Treat all of
it 🟡.
| Metric | Reported | Source class |
|---|---|---|
| Connection acceptance, platform-wide average | ~26-29% | 🟡 multi-million-touch vendor datasets |
| Acceptance with a personalised note | ~26.4% | 🟡 same |
| Acceptance with **no** note | ~26.4% | 🟡 same |
| Post-accept reply rate, **without** a note | ~5.4% | 🟡 |
| Post-accept reply rate, **with** a note | ~9.4% | 🟡 |
| Cold InMail response | 10-25% typical | 🟡 |
| Individually sent vs bulk InMail | ~15% higher response individually | 🟢 LinkedIn-reported |
**The correction that matters.** The widely repeated claim is that a personalised
note roughly triples acceptance (≈45% vs ≈15%). The largest available datasets do
not support it: acceptance is close to identical either way (26.42% vs 26.37% in
one ~13M-touch sample). What the note does move — and moves a lot — is the
**reply rate after acceptance**, roughly 5.4% → 9.4%.
The practical conclusion is the same as the folklore's, but the reasoning is
different and it changes what you write. **The note is not there to get the
connection. It is there to earn the conversation.** Which means it should be
about them and about why this conversation, not about you and what you sell.
`outreach_message_builder.py` encodes exactly this: it refuses an ask in a
first-touch connection note, because the note's job is the conversation, not the
meeting.
## The character caps
| Message type | Free | Premium | Confidence |
|---|---|---|---|
| Connection request note | 200 | 300 | 🟡 |
| Direct message | ~1,800 | ~1,800 | 🟡 |
| InMail body | ~1,900 | ~1,900 | 🟡 |
Two hundred characters is roughly two sentences. That is the entire budget, and
it is why the builder puts the person-specific line first and treats everything
else as optional.
## The person-specific line
The builder refuses to assemble a message without one. It is the single
discriminator between outreach and spam, and it is checkable: **could this
sentence have been sent to anyone else on your list?** If yes, it is not
specific.
What qualifies:
- Something they published, named, with the part that mattered to you.
- A decision they made that you are facing.
- A disagreement. "Your point about X is the opposite of what we found" is a
better opener than any compliment.
What does not:
- "I came across your profile."
- Their job title, company, or industry.
- "As a fellow [category]."
- Praise with no specifics, which reads as a mail merge because it usually is.
## Phrases to delete
The builder flags these because they mark a message as bulk before anyone reads
the content: *I came across your profile · I'd love to pick your brain · hope
this finds you well · quick question · just following up · touch base · synergy ·
I'll keep it short · as a fellow · I see we're both in · let's connect.*
"Pick your brain" deserves a specific note: it asks for unbounded unpaid time
with no bounded question attached. The version that works is the opposite —
one specific question, answerable in two sentences, with an explicit "no reply
needed if you're busy".
## Volume, and what actually triggers restrictions
Covered in full in `linkedin-skills/references/policy_and_account_safety.md`. The
short version:
- Weekly invitation limit observed around **100**, adjusted per account, with
**pending invitations counting against it**. 🟡
- Withdrawn invitations cannot be re-sent to the same person for about three
weeks. 🟡
- **Low acceptance rate at volume is the discriminating signal**, not volume
alone. Below ~20%, stop and fix the targeting rather than pushing through.
- Machine-regular pacing looks automated whether or not it is.
`outreach_volume_guard.py` refuses above 40 invitations per day outright: nobody
reads that many profiles and writes that many specific lines, so the plan is an
automation plan whatever the intent.
## Follow-up
One follow-up, at least a week later, **only if you have something new to say**.
A second follow-up with no new information is the point at which you become the
thing you were avoiding.
"Just bumping this up" is not new information. A relevant thing that happened
since, or a genuinely useful link with nothing attached, is.
## The order that actually works
1. **Comment on their work for two weeks.** Substantively, in public.
2. **Then send the invitation**, referencing something specific from that
reading — which by now you have actually done.
3. **After acceptance, do nothing for a while.** Then ask, once, small.
This converts far better than any note optimisation, and it is not a growth hack
— it is what happens when you talk to people whose work you read.
## When outreach is the wrong channel
If the target number is the point rather than the people, LinkedIn outreach is
the wrong instrument. Advertising exists, it is designed for volume, it is
honest about what it is, and it does not risk the account you spent a year
building.
---
## Sources
1. LinkedIn. **User Agreement §8.2** — automated messaging, bulk sending, and
inauthentic engagement.
2. LinkedIn Help. **"Account restrictions"** and **"Automated activity on
LinkedIn"** — what LinkedIn says it acts on.
3. Expandi. **LinkedIn Outreach Benchmarks** (2026 edition, ~13.2M data points) —
🟡 acceptance with and without a note, and the post-accept reply-rate gap.
4. Cleverly. **LinkedIn Benchmarks** (~20M outreach touches) — 🟡 connection and
reply rates by industry.
5. LinkedIn Sales Solutions. **InMail best-practice guidance** — 🟢 the
individually-sent vs bulk response-rate difference.
6. Cialdini, R. **Influence** — reciprocity and commitment, and why an ask
arriving before any relationship inverts both.
7. Granovetter, M. **"The Strength of Weak Ties"** (1973) — the structural reason
cold outreach works at all, and why it works better after public visibility.

View file

@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""comment_target_planner.py — build a weekly commenting roster inside a real time budget.
Commenting is the highest-return activity available to an account with no
distribution, and it is the one people do worst: they comment on the same three
huge accounts, in threads with 400 other comments, saying "great post".
This builds a five-day roster from the accounts you name, scored on audience
overlap, how often they actually post, and how crowded their threads are. It
enforces three rules that keep the roster from becoming a pattern:
- no account appears more than twice a week (commenting daily on one person
reads as following them around, and it exhausts the goodwill it earns)
- at least one peer-tier account per day, because reciprocity is the part that
compounds
- never more than half a day's slots in the "huge" tier, where being early
matters more than being right and most comments are never read
The output is a roster, not comment text. Comment text that a tool wrote is the
thing this whole approach is trying to avoid.
Exit codes:
0 roster built
2 budget below one comment a day a reduced roster is returned
3 no usable accounts supplied
Stdlib only. No network. Nothing is posted.
"""
import argparse
import json
import sys
MINUTES_PER_COMMENT = 6
DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"]
MAX_PER_ACCOUNT_PER_WEEK = 2
TIERS = {
"huge": {"factor": 1.0,
"note": "10x+ your size. Crowded threads — only worth it if you are early and add "
"something the post missed."},
"larger": {"factor": 1.5,
"note": "2-10x your size. The best ratio of reachable audience to competition."},
"peer": {"factor": 1.3,
"note": "Roughly your size. Reciprocity compounds here; these are the relationships "
"that still exist in a year."},
"smaller": {"factor": 0.9,
"note": "Smaller than you. Low reach today, high goodwill, and some of them will "
"not be smaller for long."},
}
SAMPLE_ACCOUNTS = [
"Priya Raman:5:4:larger",
"Data Council:4:6:huge",
"Tomas Lind:5:3:peer",
"Anna Beck:4:2:peer",
"Jules Okafor:3:5:larger",
"Sam Idris:5:1:smaller",
]
def parse_account(spec: str) -> dict:
parts = spec.split(":")
if len(parts) != 4:
raise ValueError(f"expected name:overlap:posts_per_week:tier — got '{spec}'")
name, overlap, freq, tier = parts
tier = tier.strip().lower()
if tier not in TIERS:
raise ValueError(f"unknown tier '{tier}' in '{spec}'; choose from {sorted(TIERS)}")
try:
overlap_i, freq_i = int(overlap), int(freq)
except ValueError:
raise ValueError(f"overlap and posts_per_week must be integers in '{spec}'")
if not 1 <= overlap_i <= 5:
raise ValueError(f"overlap must be 1-5 in '{spec}'")
if freq_i < 0:
raise ValueError(f"posts_per_week cannot be negative in '{spec}'")
return {"name": name.strip(), "overlap": overlap_i, "posts_per_week": freq_i, "tier": tier}
def score(acct: dict) -> float:
# Posting frequency has diminishing returns: an account posting daily does not
# give you five times the opportunity of one posting twice a week, because you
# can only comment on so many of them well.
freq_factor = min(acct["posts_per_week"], 5) ** 0.5
return round(acct["overlap"] * freq_factor * TIERS[acct["tier"]]["factor"], 2)
def build(accounts: list, minutes_per_day: int) -> dict:
if not accounts:
return {"verdict": "NO_ACCOUNTS", "exit_code": 3,
"instruction": "Name the accounts first. Ten is plenty: the people whose audience "
"you want, whose posts you would read anyway, and who post often "
"enough to give you an opening each week."}
per_day = minutes_per_day // MINUTES_PER_COMMENT
findings = []
verdict, code = "ROSTER", 0
if per_day < 1:
per_day = 1
verdict, code = "BELOW_BUDGET", 2
findings.append({
"severity": "warning", "area": "budget",
"finding": f"{minutes_per_day} min/day is under the {MINUTES_PER_COMMENT} minutes one "
"substantive comment takes.",
"fix": "Comment on alternate days rather than writing a worse comment daily. "
"The roster below assumes one comment per listed day.",
})
scored = sorted(({**a, "score": score(a)} for a in accounts),
key=lambda a: (-a["score"], a["name"]))
tier_counts = {t: sum(1 for a in scored if a["tier"] == t) for t in TIERS}
if tier_counts["peer"] == 0:
findings.append({
"severity": "warning", "area": "mix",
"finding": "No peer-tier accounts. Every relationship in the list is one-directional.",
"fix": "Add three or four people at roughly your size. That is where reciprocal "
"attention comes from, and it is the part that still pays in a year.",
})
if tier_counts["huge"] > len(scored) / 2:
findings.append({
"severity": "warning", "area": "mix",
"finding": f"{tier_counts['huge']}/{len(scored)} accounts are huge. Their threads are "
"crowded and most comments there are never read.",
"fix": "Rebalance toward 'larger' and 'peer'. Being the best comment on a 40-comment "
"post beats being the 300th on a 400-comment one.",
})
used = {a["name"]: 0 for a in scored}
roster, cursor = [], 0
for day in DAYS:
slots, huge_today, peer_today = [], 0, 0
attempts = 0
while len(slots) < per_day and attempts < len(scored) * 4:
acct = scored[cursor % len(scored)]
cursor += 1
attempts += 1
if used[acct["name"]] >= MAX_PER_ACCOUNT_PER_WEEK:
continue
if any(s["name"] == acct["name"] for s in slots):
continue
if acct["tier"] == "huge" and huge_today >= max(1, per_day // 2):
continue
slots.append({"name": acct["name"], "tier": acct["tier"],
"score": acct["score"], "why": TIERS[acct["tier"]]["note"]})
used[acct["name"]] += 1
if acct["tier"] == "huge":
huge_today += 1
if acct["tier"] == "peer":
peer_today += 1
if per_day >= 2 and peer_today == 0 and tier_counts["peer"]:
findings.append({
"severity": "info", "area": "mix",
"finding": f"{day} has no peer-tier slot after the weekly cap was applied.",
"fix": "Add another peer account to the list so every day has one.",
})
roster.append({"day": day, "comments": slots,
"minutes": len(slots) * MINUTES_PER_COMMENT})
total = sum(len(d["comments"]) for d in roster)
exhausted = [n for n, c in used.items() if c >= MAX_PER_ACCOUNT_PER_WEEK]
if total < per_day * len(DAYS):
findings.append({
"severity": "warning", "area": "supply",
"finding": f"Only {total} of {per_day * len(DAYS)} weekly slots could be filled without "
f"exceeding the {MAX_PER_ACCOUNT_PER_WEEK}-per-account cap.",
"fix": f"Add more accounts. You need roughly "
f"{-(-per_day * len(DAYS) // MAX_PER_ACCOUNT_PER_WEEK)} to fill the week.",
})
return {
"verdict": verdict,
"exit_code": code,
"minutes_per_day": minutes_per_day,
"comments_per_day": per_day,
"weekly_slots_filled": total,
"accounts_scored": scored,
"roster": roster,
"at_weekly_cap": exhausted,
"findings": findings,
"comment_rules": [
"Add something the post did not say. A counter-example, a number, the case where it "
"breaks. Agreement is not a comment.",
"Never paste the same comment twice. Identical comments across accounts are the "
"definition of inauthentic engagement under User Agreement §8.2.",
"Comment because you read it. If you have nothing to add, skip the slot — an empty "
"slot costs nothing and a filler comment costs credibility.",
"Two to four sentences. A comment longer than the post is a post; go write it.",
"Reply to replies on your comment. That thread is where people actually meet you.",
],
}
def render_human(r: dict) -> str:
if r["verdict"] == "NO_ACCOUNTS":
return "Comment roster: NO_ACCOUNTS\n" + "=" * 40 + "\n" + r["instruction"]
lines = [f"Comment roster: {r['verdict']} "
f"({r['comments_per_day']}/day, {r['minutes_per_day']} min/day)",
"=" * 60]
for day in r["roster"]:
names = ", ".join(f"{c['name']} [{c['tier']}]" for c in day["comments"]) or "(no slots)"
lines.append(f" {day['day']} {names}{day['minutes']} min")
lines.append("\nScored accounts:")
for a in r["accounts_scored"]:
lines.append(f" {a['score']:>5} {a['name']:<22} {a['tier']:<8} "
f"overlap {a['overlap']}/5, {a['posts_per_week']} posts/wk")
if r["at_weekly_cap"]:
lines.append(f"\nAt the {MAX_PER_ACCOUNT_PER_WEEK}/week cap: {', '.join(r['at_weekly_cap'])}")
if r["findings"]:
lines.append("\nFindings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
lines.append("\nComment rules:")
for c in r["comment_rules"]:
lines.append(f" - {c}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Build a weekly LinkedIn commenting roster "
"(roster=0 / below-budget=2 / no-accounts=3). Nothing is posted.")
ap.add_argument("--account", action="append", default=[],
help="name:overlap(1-5):posts_per_week:tier "
"(tier = huge|larger|peer|smaller). Repeatable.")
ap.add_argument("--minutes-per-day", type=int, default=18,
help=f"Minutes per day for commenting (default 18 = "
f"{18 // MINUTES_PER_COMMENT} comments).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Build a roster from sample accounts.")
args = ap.parse_args()
specs = SAMPLE_ACCOUNTS if args.sample else args.account
minutes = 18 if args.sample else args.minutes_per_day
try:
accounts = [parse_account(s) for s in specs]
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 4
result = build(accounts, minutes)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""outreach_message_builder.py — assemble one LinkedIn message, and refuse the template.
Cold outreach on LinkedIn fails for one structural reason: the message could have
been sent to anyone. This tool will not assemble a message without a
person-specific line something you could only have written after actually
looking at what this person did and it refuses an ask on a first-touch
connection note, because the note is for getting into the room, not for selling
in the doorway.
Caps enforced (🟡 third-party-documented; LinkedIn does not publish a limits page):
connection note 200 characters (free) / 300 (Premium) use --premium
DM / InMail no practical cap, but this tool warns past 600 characters
because reply rate falls off a cliff on a phone screen
Nothing is sent. The output is text you paste, one person at a time. That is not
a limitation of the tool; automated sending is prohibited by LinkedIn's User
Agreement §8.2 and is the fastest route to a restricted account.
Exit codes:
0 PASS assembled and clean
2 WARN assembled, with findings worth fixing
3 FAIL refused: missing the person-specific line, over the cap, or pitching too early
Stdlib only. No network. Nothing is transmitted anywhere.
"""
import argparse
import json
import re
import sys
CAPS = {"connection": {"free": 200, "premium": 300},
"dm": {"free": 1800, "premium": 1800},
"inmail": {"free": 1900, "premium": 1900},
"followup": {"free": 1200, "premium": 1200}}
DM_COMFORT = 600
DEAD_PHRASES = [
("i came across your profile", "It tells them nothing. Say what you were reading when you found them."),
("i'd love to pick your brain", "It asks for unpaid time with no bounded question."),
("i would love to pick your brain", "It asks for unpaid time with no bounded question."),
("hope this finds you well", "Filler. Delete it and start with the specific line."),
("hope you're doing well", "Filler. Delete it and start with the specific line."),
("quick question", "It is never quick, and everyone knows it."),
("just following up", "Say what changed since last time, or do not follow up."),
("touch base", "Says nothing about what you want."),
("synergy", "Nobody has ever replied to this word."),
("i'll keep it short", "Then keep it short instead of announcing it."),
("as a fellow", "Category membership is not a reason to connect."),
("i see we're both in", "So are two million other people."),
("let's connect", "A request with no reason attached."),
("15 minutes of your time", "Fine as a second-touch ask; never in a first-touch note."),
]
PITCH_RE = re.compile(
r"\b(our (product|platform|solution|service|tool)|we help companies|book a (call|demo)|"
r"schedule a (call|demo)|are you the right person|decision[- ]maker|"
r"i'?d like to show you|free trial|pricing|proposal)\b", re.I)
ASK_RE = re.compile(r"\b(call|chat|meeting|demo|coffee|zoom|15 min|30 min|hop on|jump on)\b", re.I)
SAMPLE = {
"type": "connection",
"recipient": "Priya",
"specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at "
"step three.",
"reason": "I am working the same problem from the platform side and would like to follow "
"what you publish.",
"ask": "",
"premium": False,
}
def assemble(parts: dict) -> str:
name = (parts.get("recipient") or "").strip()
specific = (parts.get("specific_line") or "").strip()
reason = (parts.get("reason") or "").strip()
ask = (parts.get("ask") or "").strip()
chunks = []
if name:
chunks.append(f"{name}")
if specific:
chunks.append(specific)
if reason:
chunks.append(reason)
if ask:
chunks.append(ask)
return " ".join(chunks).strip()
def validate(text: str, parts: dict, mtype: str, premium: bool) -> list:
findings = []
low = text.lower()
def add(sev, check, finding, fix):
findings.append({"severity": sev, "check": check, "finding": finding, "fix": fix})
specific = (parts.get("specific_line") or "").strip()
if not specific:
add("blocking", "person-specific-line",
"No person-specific line. Without it this is a template, and a template is the "
"thing being ignored.",
"Read one thing they published and quote the part you disagreed with or used. "
"If you will not spend three minutes reading, do not spend theirs.")
elif len(specific.split()) < 6:
add("major", "person-specific-line",
f"The specific line is {len(specific.split())} words — too short to prove you read "
"anything.",
"Name the artifact and the part of it that mattered.")
elif not re.search(r"\b(your|you)\b", specific, re.I):
add("warning", "person-specific-line",
"The specific line does not refer to them at all.",
"Anchor it: 'your post on…', 'the talk you gave at…'.")
cap = CAPS[mtype]["premium" if premium else "free"]
n = len(text)
if n > cap:
add("blocking", "length",
f"{n} characters — {n - cap} over the {cap}-character cap for a "
f"{mtype} ({'Premium' if premium else 'free'} account).",
"Cut the reason, keep the specific line. The reason can wait for the reply.")
elif mtype in ("dm", "inmail", "followup") and n > DM_COMFORT:
add("warning", "length",
f"{n} characters. Past ~{DM_COMFORT} the message is a wall on a phone and reply "
"rate drops.",
"One specific line, one reason, one bounded ask. Everything else is for the reply.")
if mtype == "connection":
ask = (parts.get("ask") or "").strip()
if ask:
add("blocking", "premature-ask",
"A connection note carries an ask. The note is for getting into the room; the "
"ask belongs in the conversation after they accept.",
"Move it. A third-party study of ~13M outreach touches found notes barely move "
"acceptance (about 26.4% either way) but roughly double the post-accept reply "
"rate — the note earns the conversation, not the meeting.")
if PITCH_RE.search(low):
add("blocking", "pitch-on-first-touch",
"Pitch language in a first-touch connection note.",
"Delete it. Nobody has ever bought from a connection request, and the request "
"is the only impression you get.")
else:
if PITCH_RE.search(low) and not ASK_RE.search(low):
add("warning", "pitch-without-ask",
"Product language with no clear, bounded ask.",
"Either make the ask explicit and small, or cut the product language.")
dead = [(p, why) for p, why in DEAD_PHRASES if p in low]
if dead:
add("major", "dead-phrases",
"Phrases that mark this as bulk: " + "; ".join(f"'{p}'{why}" for p, why in dead),
"Cut each one. What remains is either specific or it is nothing, and nothing is "
"better than bulk.")
if not (parts.get("reason") or "").strip() and mtype == "connection":
add("warning", "reason",
"No reason to connect stated.",
"One clause: what you want to follow, learn, or compare notes on.")
if parts.get("recipient") and parts["recipient"].strip().lower() in (
"there", "team", "sir", "madam", "friend", "connection"):
add("major", "salutation",
f"'{parts['recipient']}' is a placeholder greeting.",
"Use their name, spelled the way they spell it.")
return findings
def build(parts: dict, mtype: str, premium: bool) -> dict:
text = assemble(parts)
findings = validate(text, parts, mtype, premium)
blocking = [f for f in findings if f["severity"] == "blocking"]
verdict, code = (("FAIL", 3) if blocking
else ("WARN", 2) if findings else ("PASS", 0))
cap = CAPS[mtype]["premium" if premium else "free"]
return {
"verdict": verdict,
"exit_code": code,
"type": mtype,
"premium": premium,
"message": text,
"chars": len(text),
"cap": cap,
"findings": findings,
"send_rule": ("Paste and send this yourself, to this one person. Nothing here is "
"transmitted by the tool, and automating the send would violate "
"LinkedIn's User Agreement §8.2."),
"followup_rule": ("One follow-up, at least a week later, only if you have something new "
"to say. A second follow-up with no new information is the point at "
"which you become the thing you were avoiding."),
}
def render_human(r: dict) -> str:
lines = [f"Outreach ({r['type']}): {r['verdict']} "
f"[{r['chars']}/{r['cap']} chars"
f"{', Premium' if r['premium'] else ''}]",
"=" * 56, "", r["message"] or "(nothing assembled)", ""]
if r["findings"]:
lines.append("Findings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("No findings.")
lines += ["", r["send_rule"], "", r["followup_rule"]]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Assemble one LinkedIn outreach message (PASS=0 / WARN=2 / FAIL=3). "
"Nothing is sent.")
ap.add_argument("--type", choices=sorted(CAPS), default="connection")
ap.add_argument("--recipient", help="Their first name.")
ap.add_argument("--specific-line", help="The line only this person could receive. Required.")
ap.add_argument("--reason", help="Why you want the connection.")
ap.add_argument("--ask", default="", help="The ask (never on a first-touch connection note).")
ap.add_argument("--premium", action="store_true",
help="Premium account (300-char connection notes).")
ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Build the built-in sample message.")
ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.")
args = ap.parse_args()
if args.print_schema:
print(json.dumps(SAMPLE, indent=2))
return 0
if args.sample:
parts, mtype, premium = SAMPLE, SAMPLE["type"], SAMPLE["premium"]
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
parts = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
return 4
mtype = parts.get("type", args.type)
premium = bool(parts.get("premium", args.premium))
if mtype not in CAPS:
print(f"ERROR: unknown message type '{mtype}'", file=sys.stderr)
return 4
else:
parts = {"recipient": args.recipient, "specific_line": args.specific_line,
"reason": args.reason, "ask": args.ask}
mtype, premium = args.type, args.premium
if not any(parts.values()):
ap.error("provide --specific-line (or --input / --sample / --print-schema)")
result = build(parts, mtype, premium)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""outreach_volume_guard.py — size manual outreach so it stays manual, and legal.
Three separate things restrict a LinkedIn account, and volume plans usually trip
at least one:
1. LinkedIn enforces a weekly invitation limit (widely observed at about 100
invitations per week; LinkedIn does not publish the exact figure and adjusts
it per account). Pending invitations count.
2. A low acceptance rate is itself a signal. Sustained low acceptance plus high
volume is the pattern automated tools produce, and it is what gets reviewed.
3. Anything that looks machine-paced a constant per-day rate, hundreds of
touches, identical wording is prohibited outright by User Agreement §8.2,
whether or not a machine actually sent it.
This tool prices the plan in minutes, checks it against the caps, and refuses the
volumes that cannot be produced by a person typing. It never sends anything.
Exit codes:
0 SAFE plan fits the caps and the time budget
2 TIGHT allowed, with named risks
3 OVER over a cap or over the time budget; the overage is named
4 REFUSED the volume is not humanly manual; this is an automation plan
Stdlib only. No network. Nothing is transmitted anywhere.
"""
import argparse
import json
import sys
WEEKLY_INVITE_LIMIT = 100 # observed LinkedIn cap; not published exactly
DAILY_MANUAL_CEILING = 25 # above this, per day, it stops being hand-written
REFUSE_DAILY = 40 # above this it is an automation plan, not a cadence
LOW_ACCEPTANCE = 0.20
MINUTES_PER_INVITE = 5 # read their work, write the specific line, send
MINUTES_PER_DM = 8
def guard(invites: int, pending: int, dms: int, minutes: int,
acceptance: float, days_active: int) -> dict:
findings = []
per_day = invites / max(1, days_active)
def add(sev, area, finding, fix):
findings.append({"severity": sev, "area": area, "finding": finding, "fix": fix})
verdict, code = "SAFE", 0
if per_day > REFUSE_DAILY:
add("refusal", "automation-pattern",
f"{invites} invitations over {days_active} day(s) is {per_day:.0f}/day. Nobody reads "
"that many profiles and writes that many specific lines. This is an automation plan.",
"Cut to a volume you would actually type. If the number is the point rather than the "
"people, the channel is wrong — that is what advertising is for.")
verdict, code = "REFUSED", 4
total_pipeline = invites + pending
if total_pipeline > WEEKLY_INVITE_LIMIT:
add("blocking", "invite-limit",
f"{invites} new + {pending} pending = {total_pipeline} against a weekly limit observed "
f"around {WEEKLY_INVITE_LIMIT}. LinkedIn counts pending invitations, and it does not "
"publish the exact figure.",
f"Withdraw invitations older than three weeks first, then send at most "
f"{max(0, WEEKLY_INVITE_LIMIT - pending)} this week. Note that a withdrawn invitation "
"cannot be re-sent to the same person for about three weeks.")
if code < 3:
verdict, code = "OVER", 3
elif total_pipeline > WEEKLY_INVITE_LIMIT * 0.8:
add("warning", "invite-limit",
f"{total_pipeline} of ~{WEEKLY_INVITE_LIMIT} weekly capacity used, counting "
f"{pending} pending.",
"Clear stale pending invitations before the next batch.")
if code == 0:
verdict, code = "TIGHT", 2
if per_day > DAILY_MANUAL_CEILING and code < 4:
add("warning", "pacing",
f"{per_day:.0f} invitations/day is above the {DAILY_MANUAL_CEILING} that a person "
"can send with a genuinely specific line each.",
"Spread it. Uneven, human pacing is also what a real person's activity looks like.")
if code == 0:
verdict, code = "TIGHT", 2
if 0 < acceptance < LOW_ACCEPTANCE:
add("blocking", "acceptance-rate",
f"{acceptance:.0%} acceptance is below the {LOW_ACCEPTANCE:.0%} floor. Sustained low "
"acceptance at volume is the signal LinkedIn reviews, and it also means the targeting "
"is wrong.",
"Stop sending. Fix who you are targeting and what the note says before resuming. "
"Rebuild acceptance by warming up first — comment on their posts for two weeks, then "
"send.")
if code < 3:
verdict, code = "OVER", 3
time_needed = invites * MINUTES_PER_INVITE + dms * MINUTES_PER_DM
if time_needed > minutes:
add("blocking", "time-budget",
f"{invites} invitations ({MINUTES_PER_INVITE} min each) plus {dms} DMs "
f"({MINUTES_PER_DM} min each) needs {time_needed} min; the budget is {minutes} min.",
f"Either cut to {max(0, (minutes - dms * MINUTES_PER_DM) // MINUTES_PER_INVITE)} "
"invitations, or accept that the shortfall gets paid in generic messages — which is "
"the same as not sending them.")
if code < 3:
verdict, code = "OVER", 3
safe_weekly = min(WEEKLY_INVITE_LIMIT - pending,
DAILY_MANUAL_CEILING * days_active,
minutes // MINUTES_PER_INVITE if MINUTES_PER_INVITE else 0)
return {
"verdict": verdict,
"exit_code": code,
"plan": {"invites": invites, "pending": pending, "dms": dms,
"days_active": days_active, "per_day": round(per_day, 1),
"minutes_budget": minutes, "minutes_needed": time_needed,
"acceptance_rate": acceptance},
"caps": {"weekly_invite_limit_observed": WEEKLY_INVITE_LIMIT,
"daily_manual_ceiling": DAILY_MANUAL_CEILING,
"refuse_above_per_day": REFUSE_DAILY,
"note": "LinkedIn does not publish the exact invitation limit and adjusts it "
"per account. These are conservative working numbers."},
"safe_volume_this_week": max(0, int(safe_weekly)),
"findings": findings,
"standing_rules": [
"Send by hand, one at a time. Automated sending is prohibited by User Agreement §8.2 "
"and no volume target is worth a restricted account.",
"Every message carries a line that could only have been written for that person.",
"Track acceptance weekly. Falling acceptance means the targeting is wrong, not that "
"the volume is too low.",
"Warm before you ask: two weeks of genuine comments beats any note.",
],
}
def render_human(r: dict) -> str:
p, c = r["plan"], r["caps"]
lines = [f"Outreach volume: {r['verdict']}", "=" * 52,
f"Plan : {p['invites']} invites (+{p['pending']} pending), {p['dms']} DMs over "
f"{p['days_active']} day(s) = {p['per_day']}/day",
f"Time : {p['minutes_needed']} min needed / {p['minutes_budget']} min budget",
f"Caps : ~{c['weekly_invite_limit_observed']}/week, "
f"{c['daily_manual_ceiling']}/day manual ceiling",
f"Safe this week: {r['safe_volume_this_week']} invitations", ""]
if r["findings"]:
lines.append("Findings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("No findings.")
lines.append("\nStanding rules:")
for s in r["standing_rules"]:
lines.append(f" - {s}")
lines.append(f"\n{c['note']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Size manual LinkedIn outreach against the caps "
"(safe=0 / tight=2 / over=3 / refused=4). Nothing is sent.")
ap.add_argument("--invites", type=int, default=0, help="New connection invitations planned.")
ap.add_argument("--pending", type=int, default=0, help="Invitations already pending.")
ap.add_argument("--dms", type=int, default=0, help="Direct messages planned.")
ap.add_argument("--minutes", type=int, default=120, help="Minutes budgeted this week.")
ap.add_argument("--acceptance", type=float, default=0.0,
help="Recent acceptance rate as a fraction, e.g. 0.34. 0 = unknown.")
ap.add_argument("--days", type=int, default=5, help="Days you will spread this over.")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Run a built-in over-ambitious plan.")
args = ap.parse_args()
if args.sample:
invites, pending, dms, minutes, acceptance, days = 150, 40, 20, 120, 0.14, 5
else:
invites, pending, dms = args.invites, args.pending, args.dms
minutes, acceptance, days = args.minutes, args.acceptance, max(1, args.days)
if invites == 0 and dms == 0:
ap.error("provide --invites and/or --dms (or use --sample)")
if not 0.0 <= acceptance <= 1.0:
ap.error("--acceptance must be a fraction between 0 and 1")
result = guard(invites, pending, dms, minutes, acceptance, max(1, days))
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,98 @@
---
name: linkedin-profile
description: Use when someone wants their LinkedIn profile audited or rewritten — headline, About section, experience bullets, Featured, banner, recommendations — or says "fix my headline", "my profile gets views but nothing happens", "optimize my LinkedIn profile", "what should my About section say". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the "…see more" fold.
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
---
# LinkedIn Profile — audit, then rewrite the parts that pay
A profile is not a CV. A CV is read by someone who already decided to consider you; a
profile is read by someone deciding whether to. Three readers, in descending volume: the
**scanner** (three seconds, from your comment on someone else's post — sees photo and
headline only), the **evaluator** (forty seconds — headline, half the About, Featured), the
**decider** (minutes — everything). Almost all traffic is the scanner. Almost all conversion
is the decider.
## Workflow
**1. Audit before rewriting.** Ask them to describe their profile section by section, or
have them fill [`assets/profile_worksheet.md`](assets/profile_worksheet.md), then:
```bash
python3 scripts/profile_completeness_auditor.py --input profile.json --output human
```
Exit 0 STRONG / 2 INCOMPLETE / 3 WEAK. It ranks every gap by **points per hour** and prints
a first-hour plan — usually Featured, Open To, and skills, which cost minutes and recover
real points. Start there, not with the About section they wanted to agonise over.
**2. Headline next — it is the only string that travels.** It rides along with every comment,
search result, and invitation.
```bash
python3 scripts/headline_scorer.py --headline "..." --output human
```
Five dimensions at 20 points: audience, outcome, proof, searchability, clarity. Exit 0 SHIP
(≥75) / 2 SHARPEN / 3 REWRITE. It also checks the 220-character cap and whether the first
~60 characters — the part that survives in search results and invitation previews — carry
anything.
Iterate here. Two or three passes is normal; the stop condition is exit 0, or the user
saying they would say it out loud to a peer.
**3. About section — write to the fold.** LinkedIn collapses it after roughly 265-300
characters. Collect the five parts from the user, then assemble:
```bash
python3 scripts/about_section_builder.py --input about.json --output human
```
It refuses a fold that cuts mid-sentence, a fold carrying no audience and no proof, a
missing call to action, and anything over 2,600 characters.
**4. Experience, Featured, recommendations.** Rewrite duty lists as outcomes. Pin one
artifact a stranger could evaluate in sixty seconds. Ask for two specific recommendations by
naming the project and offering a first draft.
## Rules
- **Never invent a credential, a metric, or a role.** Every number on a profile is checkable
by someone. If the proof does not exist, the claim is qualitative or it is absent.
- **First person.** A profile in third person reads as a press release someone else wrote.
- **The fold is the section.** Whatever sits above "…see more" is what most readers get.
- **Front-load the headline.** The strongest segment first; everything after is a bonus.
- **Do not fetch anything.** The user describes their own profile; nothing is scraped.
## Scripts
| Script | Role |
|---|---|
| [`scripts/headline_scorer.py`](scripts/headline_scorer.py) | Headline 0-100 on audience / outcome / proof / searchability / clarity, plus the 220-char cap and front-load check. |
| [`scripts/profile_completeness_auditor.py`](scripts/profile_completeness_auditor.py) | Whole profile 0-100 across 14 weighted checks; fixes ranked by points per hour with a first-hour plan. |
| [`scripts/about_section_builder.py`](scripts/about_section_builder.py) | Assembles the About section from named parts and refuses a broken fold, a missing CTA, or an over-length section. |
## References and assets
- [`references/profile_architecture.md`](references/profile_architecture.md) — what each section is for, and the three readers (7 sources)
- [`references/headline_and_positioning.md`](references/headline_and_positioning.md) — the 220 characters that follow you everywhere (7 sources)
- [`assets/profile_worksheet.md`](assets/profile_worksheet.md) — fillable section-by-section worksheet
- [`assets/example_profile.json`](assets/example_profile.json) — input shape for the auditor
## Distinct from
- **`linkedin-strategy`** — what to post and how often. This is who you are before you post.
If the audience answer is fuzzy, run `positioning_brief.py` there first; the headline
falls out of a good brief in ten minutes.
- **`product-team/`, `c-level-advisor/`** — career and role strategy. This writes the profile,
not the career plan.
---
**Version:** 1.0.0

View file

@ -0,0 +1,25 @@
{
"_comment": "Input shape for profile_completeness_auditor.py. Every key is optional; a missing key is treated as absent. Nothing here is fetched from LinkedIn - you fill it in from your own profile.",
"photo": true,
"banner_custom": true,
"custom_url": true,
"headline": "Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at Zendesk scale | ex-Stripe | I make dashboards people trust",
"about": "Most data teams do not have a data problem. They have a trust problem - nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet. I work with Series A SaaS companies whose analytics stack grew faster than anyone's confidence in it. Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard. Rebuilt reporting for three Series B teams; two retired their shadow spreadsheets inside a quarter. If your team is re-deriving the same metric in three places, message me.",
"current_role": {
"title": "Fractional Head of Data",
"bullets": [
"Cut BigQuery spend 62% while keeping every dashboard live",
"Reduced median time-to-trusted-number from 9 days to under 1",
"Led the migration of 140 legacy models down to 38"
]
},
"featured_items": 2,
"featured_updated_days_ago": 45,
"skills": ["analytics engineering", "dbt", "BigQuery", "data governance", "SQL", "Python"],
"recommendations_received": 3,
"open_to_or_services": true,
"education": true,
"certifications": 1,
"contact_info": true,
"days_since_last_post": 6
}

View file

@ -0,0 +1,73 @@
# Profile Worksheet
Fill this in before any rewriting. The auditor and the About builder both read from it.
---
## Presence
- [ ] Photo: face, well lit, recognisable at 48px? ______
- [ ] Banner: custom, or still the default gradient? ______
- [ ] Custom URL set? ______
- [ ] Contact info reachable? ______
- [ ] Open To / Services block set? ______
- [ ] Days since your last post: ______
## Headline (220 characters)
Current: ______________________________________________
- Who is it for (specifically enough to exclude someone)? ______
- What changes because of you? ______
- One piece of proof (number / ex-company / credential)? ______
- Which conventional role or skill term keeps it searchable? ______
First 60 characters — is the strongest segment there? ______
## About (fold at ~265-300 characters)
1. **Hook** — the tension your audience recognises, in their words:
2. **Audience** — who you are for:
3. **Proof** — two or three real results, with numbers:
4. **Approach** — how you work; the part that is yours, not your title's:
5. **CTA** — who should reach out, and what they get:
6. **Keywords** you want to be findable for (work them into real sentences, not a list):
## Current role
Title: ______
Rewrite each bullet as an outcome, not a duty:
| Duty (what it says now) | Outcome (what actually changed) |
|---|---|
| | |
| | |
## Featured
- What is pinned now? ______
- Last updated? ______
- What could a stranger evaluate in 60 seconds? ______
## Recommendations
- Received: ______
- Two people to ask, and the specific project to ask them about:
1. ______
2. ______
---
**Then run:**
```bash
python3 ../scripts/profile_completeness_auditor.py --input profile.json --output human
python3 ../scripts/headline_scorer.py --headline "..." --output human
python3 ../scripts/about_section_builder.py --input about.json --output human
```

View file

@ -0,0 +1,128 @@
# Headline and Positioning — 220 characters that follow you everywhere
The headline is the only string on LinkedIn that travels with you. It rides along
with every comment you leave in someone else's thread, every search result you
appear in, and every connection request you send. A person may see your headline
forty times before they ever open your profile.
Most headlines are a job title. A job title is the one thing a reader could have
guessed.
---
## The constraint
| Limit | Value | Confidence |
|---|---|---|
| Hard character cap | 220 | 🟡 third-party documented, stable for years |
| Visible in search results / invitation previews | ~60-70 characters | 🟡 varies by surface and viewport |
| Visible next to a comment | Fewer still, and it truncates mid-word | 🟡 |
So the headline has a **front-loaded budget**: the first sixty characters do most
of the work, and everything after them is a bonus that some readers will see.
This is why "Senior Engineer | Ex-Google | Speaker | Mentor | Dog dad" fails —
the strongest segment is second and the weakest is where a reader's eye lands.
## The five things a headline has to do
`headline_scorer.py` scores these at 20 points each.
1. **Name the audience.** "for Series A SaaS founders", "for clinical data teams".
A headline that could belong to anyone is addressed to no one.
2. **Name the outcome.** What changes because of you. "Cut onboarding from six
weeks to four days" is an outcome. "Passionate about customer experience" is
a mood.
3. **Carry one piece of proof.** A number, a prior company (`ex-Stripe`), a
credential, a scale figure. One is enough and it has to be true.
4. **Stay searchable.** LinkedIn search matches headline text. An invented title
("Chief Clarity Officer") ranks for nothing. Keep at least one conventional
role or skill term alongside the creative framing.
5. **Stay readable.** Three segments maximum. At most one emoji. No buzzword
filler — "results-driven", "passionate about", "thought leader" all describe an
attitude rather than a capability, and every reader has learned to skip them.
## Structures that work
```
[Role a recruiter would search] for [specific audience] | [proof] | [the line that is yours]
Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at
Zendesk scale | I make dashboards people trust
```
```
I help [audience] [outcome] without [the cost they expect]
I help clinical teams pass MDR audits without a six-month documentation freeze
```
```
[Current title] · [the thing you are moving toward] · [proof of the move]
Backend engineer moving into developer advocacy · 40 conference talks
watched, 3 given · I write the docs I wish existed
```
That third pattern matters for career transitions. **State the destination, not
only the origin.** A headline that describes only where you have been makes every
reader do the imaginative work of placing you somewhere else, and most will not
bother.
## Positioning before wording
A headline cannot fix a positioning that does not exist. If you cannot answer
these three, the headline will keep coming out generic no matter how many times
it is rewritten:
1. Who specifically is this for — specifically enough that someone is excluded?
2. What do they get that they would not get from the next person with your title?
3. What is the evidence, and is it already public?
That is the `positioning_brief.py` conversation in `linkedin-strategy`. Run it
first when the answers are fuzzy; the headline falls out of a good brief in
about ten minutes.
## Changing the headline mid-career-transition
Two competing risks, and people usually only see one:
- Change too early and current colleagues read it as "already leaving".
- Change too late and every new reader files you under the old category, which
is the category you are trying to leave.
The resolution is that **the headline is read overwhelmingly by strangers**, and
strangers are the audience of a transition. Change it, and let the current role
carry the continuity in the Experience section where it belongs.
## Testing
You cannot A/B test a headline on LinkedIn — there is one, it applies
retroactively to everything, and profile-view counts are too noisy at individual
scale. What you can do:
- Read it out loud. If you would not say it to someone at a conference, cut it.
- Show it to one person in the target audience and ask what they think you do.
If they paraphrase it back wrong, the headline is wrong, and that single test
is worth more than any tool.
- Run `headline_scorer.py` for the mechanical faults, then use the person.
---
## Sources
1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — the origin of
"positioning is what you do to the mind of the prospect", and why a category
claim beats an attribute list.
2. Heath, C. & Heath, D. **Made to Stick** — concreteness and the curse of
knowledge, which is exactly why practitioners write headlines only other
practitioners can parse.
3. Nielsen Norman Group. **Microcontent: How to Write Headlines, Page Titles, and
Subject Lines** — the front-loading rule for any truncated string.
4. Clark, D. **Stand Out** — building a positioning around one distinctive idea
rather than a portfolio of competences.
5. Ibarra, H. **Working Identity** — why career transitions require acting into
the new identity publicly rather than deciding it privately first.
6. Zinsser, W. **On Writing Well** — the discipline of cutting every word that
does no work, which is the entire craft of a 220-character string.
7. LinkedIn Help. **Search and profile visibility** documentation — headline text
is matched in search; invented titles are not.

View file

@ -0,0 +1,124 @@
# Profile Architecture — what each section is actually for
A LinkedIn profile is not a CV. A CV is read by someone who has already decided
to consider you; a profile is read by someone deciding whether to. The sections
are therefore ordered by a different logic, and most people fill them in as if
they were the same document.
The frame that makes this concrete: **a profile has three distinct readers**, and
a section that serves none of them is dead weight.
| Reader | Arrives from | Decides in | Reads |
|---|---|---|---|
| **The scanner** | Your comment on someone else's post | ~3 seconds | Photo, name, headline. Nothing else. |
| **The evaluator** | A search result, a referral, your post | ~40 seconds | Headline, the visible half of About, Featured, current role. |
| **The decider** | Already interested, doing diligence | Several minutes | Everything, including recommendations and the gaps in your history. |
Almost all traffic is the scanner. Almost all conversion is the decider. The
sections in between exist to move people down that list.
---
## Section by section
### Photo — for the scanner
A face, well lit, roughly filling the frame, recognisable at 48 pixels. That is
the entire specification. The common failure is a full-body shot that renders as
a smudge at feed size.
### Banner (1584 × 396) — the most wasted space on the platform
The default gradient says nothing. This is the cheapest place to state what you
do, name your audience, or show one artifact. Rendering caveat: the profile photo
and the mobile layout crop the lower-left corner, so keep text in the upper right
and never centre it.
### Headline — the only string that travels
It appears next to every comment you leave, in every search result, in every
invitation you send. See `headline_and_positioning.md`; this is the single
highest-leverage edit available.
### About — for the evaluator, and it is truncated
LinkedIn collapses it after roughly the first 265-300 characters. Whatever is
above that fold is the whole section for most readers. Structure that survives:
1. **The tension** — the problem your audience recognises, stated in their words.
2. **Who you are for** — specific enough that someone could be excluded.
3. **Proof** — two or three results, with real numbers.
4. **How you work** — the part that is yours rather than your job title's.
5. **A call to action** — who should reach out, and what they get.
Written in first person. A profile written in the third person reads as a press
release someone else wrote, which on a personal profile is exactly what it is.
### Experience — outcomes, not duties
A duty list is interchangeable across everyone who has ever held the title.
"Responsible for backend services" describes several hundred thousand people.
"Cut p99 checkout latency from 1.9s to 340ms by moving the pricing call
off the critical path" describes one.
Two or three bullets per role, and only the roles that support the current
positioning. Older roles get a line each. Gaps are fine and unexplained gaps are
also fine — most readers do not care, and the ones who do will ask.
### Featured — the one section you fully control
Whatever you pin here is what a visitor sees before your feed. Empty, and they
see whatever you last reposted. Put the artifact a buyer, hiring manager, or peer
could evaluate in sixty seconds: the talk, the repo, the teardown, the post that
did what you want more of.
Refresh it quarterly. A featured item from three years ago dates the whole
profile.
### Skills — a matching surface, not a personality test
Recruiters and LinkedIn's own matching use these. List the ones you would accept
an interview on. The endorsement counts matter far less than people assume; the
presence of the term matters more.
### Recommendations — the only text you did not write
Two specific recommendations outperform ten generic ones. The way to get a
specific one is to ask for a specific one: tell the person the project and the
aspect you want them to speak to, and offer to write a first draft they can edit.
That request is normal and almost always accepted.
### Custom URL, Open To / Services, contact info — two-minute fixes
Low individual value, near-zero cost, and their absence signals a profile nobody
maintains.
---
## The ordering rule
When time is short, work in this order, because it is the order in which readers
encounter the sections and drop out:
1. Headline (every reader, always)
2. Photo (every reader, three seconds)
3. About opening — the first two sentences only
4. Featured (one item, today)
5. Current role bullets
6. Everything else
`profile_completeness_auditor.py` implements this as points-per-hour and will
usually put Featured and Open To first, because they cost minutes and recover
real points.
---
## Sources
1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** and
**"How People Read Online"** — why the first lines carry the decision and the
rest is skimmed.
2. Krug, S. **Don't Make Me Think** (3rd ed.) — scannability as the design
constraint for anything a stranger reads without commitment.
3. Minto, B. **The Pyramid Principle** — the answer first, support after; the
structure the About fold forces on you whether you like it or not.
4. Goffman, E. **The Presentation of Self in Everyday Life** (1959) — a profile
is a front-stage performance with an audience that knows it is one; the
credibility comes from specificity, not polish.
5. Heath, C. & Heath, D. **Made to Stick** — concreteness as the property that
makes a claim memorable and checkable.
6. LinkedIn Help. **Creators Core / Updates to Creator Mode** — what the creator
surfaces actually change on a profile.
7. Clark, D. **Reinventing You** — sequencing a positioning change so the public
record supports the new claim before you make it.

View file

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""about_section_builder.py — assemble a LinkedIn About section that survives the fold.
LinkedIn collapses the About section after roughly the first 265-300 characters and
hides the rest behind "…see more". Most people spend that window on a warm-up
sentence, so the only part a stranger reads is the part that says nothing.
This tool assembles the section from named parts, then refuses the ones that break:
- anything past the 2,600-character cap
- a fold window that does not end on a sentence boundary (a truncated word is the
reader's cue to stop)
- a fold window carrying no audience and no proof
- buzzword filler
- a missing call to action (a profile that converts needs a next step)
It does not invent claims. Every part comes from you; the tool only orders,
measures, and refuses.
Exit codes:
0 PASS assembled and clean
2 WARN assembled, with non-blocking findings
3 FAIL blocking findings; fix and re-run before publishing
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import re
import sys
HARD_LIMIT = 2600
FOLD_SAFE = 265 # conservative end of the observed 265-300 truncation window
FOLD_OBSERVED = 300
BUZZWORDS = [
"passionate about", "results-driven", "results driven", "detail-oriented",
"detail oriented", "self-starter", "team player", "hard worker", "guru",
"ninja", "rockstar", "thought leader", "seasoned professional", "dynamic",
"proven track record", "wear many hats", "think outside the box",
"synergy", "leverage my skills", "world-class", "cutting-edge",
]
PROOF_RE = re.compile(
r"\d+\s*(%|x\b|×)|[$€£]\s?\d|\b\d+\s*(k|m|bn)\b|\bex-[A-Z]|"
r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|engineers|"
r"subscribers|downloads|countries|years)\b", re.I)
AUDIENCE_RE = re.compile(
r"\b(for|helping|i help|we help|i work with|my clients|founders|ctos?|cmos?|"
r"engineers|designers|marketers|recruiters|startups|smbs|teams|operators|"
r"clinicians|students|investors|product managers|data teams)\b", re.I)
SAMPLE = {
"hook": "Most data teams do not have a data problem. They have a trust problem — "
"nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet.",
"audience": "I work with Series A SaaS companies whose analytics stack grew faster than "
"anyone's confidence in it.",
"proof": [
"Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard.",
"Rebuilt reporting for three Series B teams; two of them retired their shadow spreadsheets inside a quarter.",
],
"approach": "I start by finding the number people actually argue about, then work backwards "
"through the dbt models to the BigQuery tables that produce it. Usually the fix "
"is fewer models and clearer ownership, not more tooling — analytics engineering "
"is a data governance problem wearing a modelling costume.",
"cta": "If your team is re-deriving the same metric in three places, message me — I will tell "
"you in one call whether it is a modelling problem or an ownership problem.",
"keywords": ["analytics engineering", "dbt", "BigQuery", "data governance", "Series A SaaS"],
}
ORDER = ["hook", "audience", "proof", "approach", "cta"]
def assemble(parts: dict) -> str:
blocks = []
for key in ORDER:
val = parts.get(key)
if not val:
continue
if isinstance(val, list):
blocks.append("\n".join(f"{v.strip()}" for v in val if v.strip()))
else:
blocks.append(str(val).strip())
body = "\n\n".join(b for b in blocks if b)
kws = [k for k in (parts.get("keywords") or []) if k.strip()]
if kws:
body += "\n\nAreas I work in: " + " · ".join(k.strip() for k in kws)
return body
def _fold_window(text: str) -> tuple:
"""Return (window, ends_cleanly, boundary_index)."""
window = text[:FOLD_SAFE]
if len(text) <= FOLD_SAFE:
return window, True, len(text)
# Last sentence-ending punctuation inside the safe window.
boundary = max(window.rfind("."), window.rfind("!"), window.rfind("?"))
return window, boundary >= FOLD_SAFE - 90, boundary
def validate(text: str, parts: dict) -> list:
findings = []
n = len(text)
if n > HARD_LIMIT:
findings.append({
"severity": "blocking", "check": "length",
"finding": f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. "
"LinkedIn will truncate or refuse it.",
"fix": "Cut the approach block first; it is the part a reader can infer.",
})
if n < 400:
findings.append({
"severity": "warning", "check": "length",
"finding": f"{n} characters. Under ~400 there is not room for both a claim and its proof.",
"fix": "Add one concrete result with a number, or one sentence about who you are for.",
})
window, clean, boundary = _fold_window(text)
if not clean:
findings.append({
"severity": "blocking", "check": "fold",
"finding": f"The visible window (first ~{FOLD_SAFE} chars, truncation observed at "
f"{FOLD_SAFE}-{FOLD_OBSERVED}) cuts mid-sentence. A reader sees a broken "
"thought and stops.",
"fix": f"Rewrite the opening so a sentence ends between character "
f"{FOLD_SAFE - 90} and {FOLD_SAFE}.",
})
if not AUDIENCE_RE.search(window) and not PROOF_RE.search(window):
findings.append({
"severity": "blocking", "check": "fold-content",
"finding": "The visible window names no audience and carries no proof — it is a "
"warm-up, and the warm-up is all most readers get.",
"fix": "Move the sentence that names who you are for, or the one with the number, "
"into the first two sentences.",
})
low = text.lower()
hits = [b for b in BUZZWORDS if b in low]
if hits:
findings.append({
"severity": "warning", "check": "buzzwords",
"finding": f"Filler present: {', '.join(hits)}.",
"fix": "Delete each one. If deleting it removes meaning, replace it with the "
"specific thing it was standing in for.",
})
if not parts.get("cta"):
findings.append({
"severity": "blocking", "check": "cta",
"finding": "No call to action. The section ends and the reader has nothing to do.",
"fix": "One line naming who should get in touch and what they will get from it.",
})
if not PROOF_RE.search(text):
findings.append({
"severity": "warning", "check": "proof",
"finding": "No number, prior company, or scale signal anywhere in the section.",
"fix": "One real, checkable data point. A range is fine; an invented figure is not.",
})
first_person = len(re.findall(r"\bI\b|\bmy\b|\bme\b", text))
if first_person < 2:
findings.append({
"severity": "warning", "check": "voice",
"finding": "Written in third person or with no first-person voice. On a personal "
"profile that reads as a press release someone else wrote.",
"fix": "Write it as you would say it: 'I work with…', not 'Alex is a…'.",
})
kws = [k.strip().lower() for k in (parts.get("keywords") or []) if k.strip()]
# Check against the prose only — the trailing "Areas I work in" list would
# otherwise satisfy every keyword and make this check vacuous.
prose = low.split("\n\nareas i work in:")[0]
missing = [k for k in kws if k not in prose]
if missing:
findings.append({
"severity": "warning", "check": "keywords",
"finding": f"Declared keywords not present in the body: {', '.join(missing)} "
"(they appear only in the trailing list).",
"fix": "Work the important ones into a real sentence. A keyword list at the bottom "
"is weaker than the same term used in context.",
})
return findings
def build(parts: dict) -> dict:
text = assemble(parts)
findings = validate(text, parts)
blocking = [f for f in findings if f["severity"] == "blocking"]
warnings = [f for f in findings if f["severity"] == "warning"]
verdict, code = (("FAIL", 3) if blocking else ("WARN", 2) if warnings else ("PASS", 0))
window, clean, _ = _fold_window(text)
return {
"verdict": verdict,
"exit_code": code,
"about": text,
"chars": len(text),
"limit": HARD_LIMIT,
"visible_before_see_more": window,
"fold_ends_on_sentence": clean,
"findings": blocking + warnings,
"note": ("The 2,600-character cap and the ~265-300 truncation window are "
"third-party-documented, not published by LinkedIn. The tool enforces the "
"conservative end of the window on purpose."),
}
def render_human(r: dict) -> str:
lines = [f"About section: {r['verdict']} ({r['chars']}/{r['limit']} chars)", "=" * 56,
"", "VISIBLE BEFORE \"…see more\":",
f" {r['visible_before_see_more']}",
f" [ends on a sentence: {'yes' if r['fold_ends_on_sentence'] else 'NO'}]", ""]
if r["findings"]:
lines.append("Findings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("No findings.")
lines += ["", "-" * 56, "FULL SECTION", "-" * 56, r["about"], "", r["note"]]
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Assemble and validate a LinkedIn About section "
"(PASS=0 / WARN=2 / FAIL=3).")
ap.add_argument("--hook", help="Opening line: the tension or observation.")
ap.add_argument("--audience", help="Who you are for, in their words.")
ap.add_argument("--proof", action="append", default=[],
help="A real, checkable result. Repeatable.")
ap.add_argument("--approach", help="How you work — the part that is yours.")
ap.add_argument("--cta", help="Who should reach out and what they get.")
ap.add_argument("--keyword", action="append", default=[],
help="A term you want to be findable for. Repeatable.")
ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Build the built-in sample section.")
ap.add_argument("--print-schema", action="store_true",
help="Print the input JSON shape and exit.")
args = ap.parse_args()
if args.print_schema:
print(json.dumps(SAMPLE, indent=2))
return 0
if args.sample:
parts = SAMPLE
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
parts = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
return 4
else:
parts = {"hook": args.hook, "audience": args.audience, "proof": args.proof,
"approach": args.approach, "cta": args.cta, "keywords": args.keyword}
if not any(parts.values()):
ap.error("provide at least --hook (or use --input / --sample / --print-schema)")
result = build(parts)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""headline_scorer.py — score a LinkedIn headline 0-100 on the five things it must do.
The headline is the single highest-leverage string on LinkedIn: it rides along with
every comment you leave, every search result you appear in, and every connection
request you send. Most of them are a job title, which tells a reader nothing they
could not have guessed.
Five dimensions, 20 points each:
AUDIENCE does it name who this person is for?
OUTCOME does it name what changes for that audience?
PROOF is there a specific, checkable signal (number, company, credential)?
SEARCHABILITY does it contain role/skill terms a recruiter or buyer would type?
CLARITY front-loaded, readable, free of buzzword filler, within limits
Limits enforced (🟡 third-party-documented, LinkedIn does not publish a limits page):
220 characters hard cap; the first ~60-70 characters are what survive in search
results and invitation previews, so the load-bearing words go there.
Exit codes:
0 SHIP (>= 75) good enough to publish
2 SHARPEN (50-74) usable, but named gaps cost real visibility
3 REWRITE (< 50) start over; the findings say what is missing
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import re
import sys
HARD_LIMIT = 220
FRONT_LOAD = 60 # characters that survive in search results / invite previews
# Filler that reads as self-description rather than evidence.
BUZZWORDS = [
"guru", "ninja", "rockstar", "wizard", "evangelist", "thought leader",
"visionary", "passionate about", "results-driven", "results driven",
"detail-oriented", "detail oriented", "self-starter", "go-getter",
"dynamic", "synergy", "disruptor", "innovator", "serial entrepreneur",
"world-class", "world class", "best-in-class", "growth hacker",
"seasoned", "proven track record", "hard worker", "team player",
]
# Words that signal an audience is being named.
AUDIENCE_MARKERS = [
"for ", "helping", "i help", "we help", "to ", "founders", "ctos", "cto",
"cmos", "engineers", "designers", "marketers", "recruiters", "startups",
"smbs", "smes", "enterprises", "teams", "b2b", "b2c", "saas", "agencies",
"nonprofits", "students", "clinicians", "operators", "pms", "product managers",
"developers", "data teams", "hr", "sales teams", "investors",
]
# Words that signal an outcome / transformation rather than a job description.
OUTCOME_MARKERS = [
"ship", "grow", "scale", "reduce", "cut", "increase", "double", "win",
"hire", "raise", "launch", "fix", "unblock", "automate", "migrate",
"build", "turn", "convert", "retain", "save", "speed", "faster",
"without", "so they", "so you", "so that", "", "->", "from ", "into ",
]
# Terms recruiters and buyers actually type into LinkedIn search.
SEARCH_TERMS = [
"engineer", "developer", "architect", "manager", "director", "head of",
"vp", "founder", "consultant", "designer", "analyst", "scientist",
"marketer", "writer", "researcher", "advisor", "coach", "lead",
"python", "react", "kubernetes", "aws", "gcp", "azure", "sql", "ml",
"ai", "llm", "security", "devops", "sre", "data", "product", "ux",
"seo", "content", "finance", "legal", "clinical", "regulatory",
"recruiter", "sales", "customer success", "operations", "platform",
]
SAMPLE_GOOD = ("Fractional Head of Data for Series A/B SaaS | Cut BigQuery spend 62% at "
"Zendesk scale | ex-Stripe | I make dashboards people trust")
SAMPLE_WEAK = "Senior Software Engineer | Passionate about technology | Team player"
def _find(text_low: str, needles: list) -> list:
return [n for n in needles if n in text_low]
def _has_proof(text: str) -> list:
"""Specific checkable signals: numbers, %/$, ex-Company, named credentials."""
signals = []
if re.search(r"\d+\s*(%|x\b|×)", text):
signals.append("percentage or multiple")
if re.search(r"[$€£]\s?\d", text) or re.search(r"\d+\s?(k|m|bn|b)\b", text, re.I):
signals.append("money or magnitude")
if re.search(r"\bex-[A-Z][\w&.-]+", text):
signals.append("prior company (ex-)")
if re.search(r"\b(phd|md|mba|cpa|pmp|cissp|cfa|rn|jd)\b", text, re.I):
signals.append("credential")
if re.search(r"\b(author|speaker|patent|award|forbes|ycombinator|y combinator|"
r"techstars|open[- ]source maintainer)\b", text, re.I):
signals.append("third-party proof")
if re.search(r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|"
r"engineers|downloads|subscribers)\b", text, re.I):
signals.append("scale number")
return signals
def score_headline(text: str) -> dict:
raw = text.strip()
low = raw.lower()
findings, dims = [], {}
# --- AUDIENCE -----------------------------------------------------------
aud = _find(low, AUDIENCE_MARKERS)
dims["audience"] = 20 if len(aud) >= 2 else (12 if aud else 0)
if not aud:
findings.append({
"severity": "blocking", "dimension": "audience",
"finding": "No audience named. A reader cannot tell whether this person is for them.",
"fix": "Name the group in plain words: 'for Series A SaaS founders', 'for clinical data teams'.",
})
# --- OUTCOME ------------------------------------------------------------
out = _find(low, OUTCOME_MARKERS)
dims["outcome"] = 20 if len(out) >= 2 else (12 if out else 0)
if not out:
findings.append({
"severity": "blocking", "dimension": "outcome",
"finding": "States a role, not a result. Titles are interchangeable; outcomes are not.",
"fix": "Add what changes because of you: 'cut onboarding from 6 weeks to 4 days'.",
})
# --- PROOF --------------------------------------------------------------
proof = _has_proof(raw)
dims["proof"] = 20 if len(proof) >= 2 else (12 if proof else 0)
if not proof:
findings.append({
"severity": "major", "dimension": "proof",
"finding": "No checkable signal. Every claim here is self-assessed.",
"fix": "One number, one prior company, or one credential. Real, or leave it out.",
})
# --- SEARCHABILITY ------------------------------------------------------
terms = _find(low, SEARCH_TERMS)
dims["searchability"] = 20 if len(terms) >= 3 else (13 if len(terms) == 2 else
(7 if terms else 0))
if len(terms) < 2:
findings.append({
"severity": "major", "dimension": "searchability",
"finding": f"Only {len(terms)} recognizable search term(s). "
"LinkedIn search matches headline text; invented job titles do not rank.",
"fix": "Keep at least one conventional role or skill term alongside the creative framing.",
})
# --- CLARITY ------------------------------------------------------------
clarity = 20
hits = _find(low, BUZZWORDS)
if hits:
clarity -= min(10, 4 * len(hits))
findings.append({
"severity": "major", "dimension": "clarity",
"finding": f"Buzzword filler: {', '.join(hits)}. These describe an attitude, not a capability.",
"fix": "Delete them. The space buys you a real number or a real audience.",
})
pipes = raw.count("|") + raw.count("") + raw.count("·")
if pipes > 3:
clarity -= 5
findings.append({
"severity": "minor", "dimension": "clarity",
"finding": f"{pipes} separators. Past three, it reads as a list of keywords rather than a claim.",
"fix": "Keep three segments: who you help / what changes / one proof.",
})
emoji = len(re.findall(r"[\U0001F300-\U0001FAFF☀-➿]", raw))
if emoji > 2:
clarity -= 4
findings.append({
"severity": "minor", "dimension": "clarity",
"finding": f"{emoji} emoji. They survive truncation and crowd out words that carry meaning.",
"fix": "At most one, and only if it separates segments.",
})
caps_words = [w for w in raw.split() if len(w) > 3 and w.isupper()]
if len(caps_words) > 1:
clarity -= 3
findings.append({
"severity": "minor", "dimension": "clarity",
"finding": "Multiple ALL-CAPS words read as shouting and hurt scannability.",
"fix": "Sentence case. Emphasis comes from specificity, not capitals.",
})
dims["clarity"] = max(0, clarity)
# --- Length + front-loading (structural, reported alongside the score) ---
length = len(raw)
front = raw[:FRONT_LOAD]
over = max(0, length - HARD_LIMIT)
if over:
findings.append({
"severity": "blocking", "dimension": "length",
"finding": f"{length} characters — {over} over the 220-character cap. LinkedIn will refuse it.",
"fix": f"Cut {over} characters. Start with the segment carrying the least proof.",
})
front_has_proof = bool(_has_proof(front)) or bool(_find(front.lower(), AUDIENCE_MARKERS))
if not front_has_proof:
findings.append({
"severity": "major", "dimension": "front-load",
"finding": f"The first {FRONT_LOAD} characters — the part that survives in search "
f"results and invitation previews — carry no audience and no proof: "
f"\"{front}\"",
"fix": "Move the strongest segment first. Everything after it is a bonus, not a plan.",
})
total = sum(dims.values())
if over: # a headline LinkedIn will not accept cannot ship
total = min(total, 49)
verdict, code = (("SHIP", 0) if total >= 75 else
("SHARPEN", 2) if total >= 50 else ("REWRITE", 3))
return {
"headline": raw,
"score": total,
"verdict": verdict,
"exit_code": code,
"dimensions": dims,
"length": {"chars": length, "limit": HARD_LIMIT, "over_by": over,
"front_loaded_preview": front},
"signals": {"audience": aud, "outcome": out, "proof": proof, "search_terms": terms},
"findings": sorted(findings,
key=lambda f: {"blocking": 0, "major": 1, "minor": 2}[f["severity"]]),
"note": ("Character limits are documented by third parties, not by an official LinkedIn "
"limits page — treat 220 as reliable and the ~60-70 front-load window as an "
"estimate that shifts with UI changes."),
}
def render_human(r: dict) -> str:
lines = [
f"Headline score: {r['score']}/100 — {r['verdict']}",
"=" * 52,
f"\"{r['headline']}\"",
f"{r['length']['chars']}/{r['length']['limit']} chars"
+ (f" (OVER BY {r['length']['over_by']})" if r["length"]["over_by"] else ""),
f"Search/invite preview: \"{r['length']['front_loaded_preview']}\"",
"",
"Dimensions (20 each):",
]
for k, v in r["dimensions"].items():
bar = "#" * (v // 2) + "." * (10 - v // 2)
lines.append(f" {k:<14} {v:>2}/20 [{bar}]")
if r["findings"]:
lines.append("\nFindings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['dimension']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("\nNo findings. Ship it.")
lines.append(f"\n{r['note']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Score a LinkedIn headline 0-100 (SHIP=0 / SHARPEN=2 / REWRITE=3).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--headline", help="The headline text to score.")
src.add_argument("--input", help="Read the headline from a file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true",
help="Score a built-in strong sample headline.")
ap.add_argument("--sample-weak", action="store_true",
help="Score a built-in weak sample headline (shows the failure modes).")
args = ap.parse_args()
if args.sample:
text = SAMPLE_GOOD
elif args.sample_weak:
text = SAMPLE_WEAK
elif args.headline:
text = args.headline
elif args.input:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
else:
ap.error("one of --headline, --input, --sample, or --sample-weak is required")
result = score_headline(text)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""profile_completeness_auditor.py — score a LinkedIn profile 0-100 and rank the fixes.
Takes a JSON description of the profile YOU control (never scraped, never fetched
you fill it in or dictate it) and returns a weighted completeness score plus a fix
list ordered by points-per-hour, so the first hour of work is the one that moves
the most.
Weights are set by what the section actually does for discovery and conversion,
not by how prominent it looks. The headline and the About opening are worth more
than education because they are what a stranger reads before deciding to care.
Input JSON (every key optional; missing = absent):
{
"photo": true, "banner_custom": false, "custom_url": true,
"headline": "...", "about": "...",
"current_role": {"title": "...", "bullets": ["cut X 40%", "..."]},
"featured_items": 2, "featured_updated_days_ago": 400,
"skills": ["python", "sql"], "recommendations_received": 1,
"open_to_or_services": false, "education": true, "certifications": 0,
"contact_info": true, "days_since_last_post": 90
}
Exit codes:
0 STRONG (>= 80)
2 INCOMPLETE (50-79) fix list returned
3 WEAK (< 50) the profile is costing you every visit it receives
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import sys
# (key, weight, effort_hours, label)
CHECKS = [
("photo", 8, 0.5, "Profile photo"),
("banner", 5, 0.5, "Custom banner"),
("headline", 14, 1.0, "Headline that is not just a job title"),
("about", 14, 1.5, "About section with a self-contained opening"),
("experience", 12, 2.0, "Current role written as outcomes, not duties"),
("featured", 8, 0.5, "Featured section with something recent"),
("skills", 6, 0.3, "Skills listed and relevant"),
("recommendations", 8, 1.0, "Recommendations received"),
("custom_url", 3, 0.1, "Custom profile URL"),
("open_to", 4, 0.2, "Open To / Services block set"),
("education", 3, 0.2, "Education filled in"),
("certifications", 2, 0.5, "Certifications listed"),
("contact_info", 3, 0.2, "A reachable contact method"),
("activity", 10, 1.0, "Posted in the last 30 days"),
]
WHY = {
"photo": "Profiles without a photo read as abandoned or fake; a stranger's first "
"judgement happens before they read a word.",
"banner": "The default blue banner is 1,584x396 pixels of unused positioning. It is the "
"cheapest place to state what you do.",
"headline": "It travels with every comment, search result, and invitation you send. A bare "
"job title spends that space saying nothing a reader could not guess.",
"about": "LinkedIn truncates it after roughly the first 265-300 characters. If those do not "
"stand alone, the rest is never read.",
"experience": "Duty lists are interchangeable across everyone with the same title. Outcomes "
"are the only part a reader cannot get elsewhere.",
"featured": "The one place you choose what a visitor sees first. Empty, and they see whatever "
"you last reposted.",
"skills": "Skills are a matching surface for search and for recruiter filters.",
"recommendations": "The only text on the profile written by someone other than you. Two "
"specific ones beat ten generic.",
"custom_url": "Shareable, memorable, and it is a two-minute fix.",
"open_to": "Tells LinkedIn's matching systems and human visitors what you want. Absent, they "
"guess — usually wrong.",
"education": "A weak signal on its own, but it is a common filter and a common icebreaker.",
"certifications": "Only worth points where the certification is a gate in your field.",
"contact_info": "A profile that converts interest into a conversation needs a way to start one.",
"activity": "A profile with no recent activity converts a visit into nothing. Consistency, not "
"volume, is the signal.",
}
SAMPLE = {
"photo": True,
"banner_custom": False,
"custom_url": True,
"headline": "Senior Software Engineer at Acme",
"about": "Experienced engineer passionate about building great software.",
"current_role": {"title": "Senior Software Engineer",
"bullets": ["Responsible for backend services",
"Worked with cross-functional teams"]},
"featured_items": 0,
"featured_updated_days_ago": None,
"skills": ["python", "sql", "aws"],
"recommendations_received": 0,
"open_to_or_services": False,
"education": True,
"certifications": 0,
"contact_info": True,
"days_since_last_post": 210,
}
OUTCOME_WORDS = ("cut", "grew", "reduced", "increased", "shipped", "launched", "saved",
"doubled", "migrated", "led", "%", "x", "from", "to")
def evaluate_check(key: str, p: dict) -> tuple:
"""Return (earned_fraction 0..1, detail string)."""
if key == "photo":
return (1.0, "present") if p.get("photo") else (0.0, "missing")
if key == "banner":
return (1.0, "custom") if p.get("banner_custom") else (0.0, "default LinkedIn banner")
if key == "headline":
h = (p.get("headline") or "").strip()
if not h:
return 0.0, "empty"
title_only = len(h) < 60 and "|" not in h and "for " not in h.lower()
return (0.4, f"job-title-shaped ({len(h)} chars) — run headline_scorer.py") if title_only \
else (1.0, f"{len(h)} chars, structured")
if key == "about":
a = (p.get("about") or "").strip()
if not a:
return 0.0, "empty"
if len(a) < 300:
return 0.4, f"{len(a)} chars — too short to say anything specific"
if len(a) < 600:
return 0.7, f"{len(a)} chars — thin but usable"
return 1.0, f"{len(a)} chars"
if key == "experience":
role = p.get("current_role") or {}
bullets = role.get("bullets") or []
if not role.get("title"):
return 0.0, "no current role listed"
if not bullets:
return 0.3, "role listed with no description"
with_outcome = [b for b in bullets
if any(w in b.lower() for w in OUTCOME_WORDS)]
frac = 0.4 + 0.6 * (len(with_outcome) / max(1, len(bullets)))
return min(1.0, frac), f"{len(with_outcome)}/{len(bullets)} bullets carry an outcome"
if key == "featured":
n = p.get("featured_items") or 0
if not n:
return 0.0, "empty"
age = p.get("featured_updated_days_ago")
if age is not None and age > 365:
return 0.5, f"{n} item(s), last updated {age} days ago — stale"
return 1.0, f"{n} item(s)"
if key == "skills":
n = len(p.get("skills") or [])
return (1.0, f"{n} listed") if n >= 5 else (n / 5.0, f"only {n} listed")
if key == "recommendations":
n = p.get("recommendations_received") or 0
return (1.0, f"{n} received") if n >= 2 else (n / 2.0, f"{n} received")
if key == "custom_url":
return (1.0, "set") if p.get("custom_url") else (0.0, "still the default /in/name-8a3f2b")
if key == "open_to":
return (1.0, "set") if p.get("open_to_or_services") else (0.0, "not set")
if key == "education":
return (1.0, "present") if p.get("education") else (0.0, "missing")
if key == "certifications":
n = p.get("certifications") or 0
return (1.0, f"{n} listed") if n else (0.0, "none listed")
if key == "contact_info":
return (1.0, "present") if p.get("contact_info") else (0.0, "missing")
if key == "activity":
d = p.get("days_since_last_post")
if d is None:
return 0.0, "no posting activity reported"
if d <= 30:
return 1.0, f"last post {d} days ago"
if d <= 90:
return 0.4, f"last post {d} days ago — the profile reads as dormant"
return 0.0, f"last post {d} days ago — effectively inactive"
return 0.0, "unknown check"
def audit(profile: dict) -> dict:
rows, fixes, total = [], [], 0.0
for key, weight, effort, label in CHECKS:
frac, detail = evaluate_check(key, profile)
earned = round(weight * frac, 2)
total += earned
rows.append({"check": key, "label": label, "weight": weight,
"earned": earned, "detail": detail})
lost = round(weight - earned, 2)
if lost >= 0.5:
fixes.append({
"check": key, "label": label, "points_available": lost,
"effort_hours": effort,
"points_per_hour": round(lost / effort, 1),
"current": detail,
"why": WHY[key],
})
fixes.sort(key=lambda f: (-f["points_per_hour"], -f["points_available"]))
score = round(total)
verdict, code = (("STRONG", 0) if score >= 80 else
("INCOMPLETE", 2) if score >= 50 else ("WEAK", 3))
first_hour = []
budget = 1.0
for f in fixes:
if f["effort_hours"] <= budget:
first_hour.append(f["label"])
budget -= f["effort_hours"]
return {
"score": score,
"verdict": verdict,
"exit_code": code,
"checks": rows,
"fixes_by_leverage": fixes,
"first_hour_plan": first_hour,
"points_recoverable_in_first_hour": round(
sum(f["points_available"] for f in fixes if f["label"] in first_hour), 1),
"note": ("Scores your own profile from your own description. Nothing is fetched, "
"scraped, or sent. Weights reflect discovery and conversion impact, not "
"LinkedIn's internal 'profile strength' meter, which is a different and "
"undocumented measure."),
}
def render_human(r: dict) -> str:
lines = [f"Profile completeness: {r['score']}/100 — {r['verdict']}", "=" * 56]
for row in r["checks"]:
mark = "OK " if row["earned"] >= row["weight"] - 0.01 else "-- "
lines.append(f" {mark}{row['label']:<48} {row['earned']:>5}/{row['weight']:<3} {row['detail']}")
lines.append("\nFixes ranked by points per hour:")
for f in r["fixes_by_leverage"]:
lines.append(f" +{f['points_available']:<5} pts ~{f['effort_hours']}h "
f"({f['points_per_hour']} pts/h) {f['label']}")
lines.append(f" now : {f['current']}")
lines.append(f" why : {f['why']}")
if r["first_hour_plan"]:
lines.append(f"\nFirst hour: {', '.join(r['first_hour_plan'])} "
f"(+{r['points_recoverable_in_first_hour']} points)")
lines.append(f"\n{r['note']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Audit a LinkedIn profile 0-100 and rank fixes by points per hour "
"(STRONG=0 / INCOMPLETE=2 / WEAK=3).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--input", help="Profile JSON file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true",
help="Audit a built-in sample profile (a typical unoptimized one).")
ap.add_argument("--print-schema", action="store_true",
help="Print the input JSON schema/sample and exit.")
args = ap.parse_args()
if args.print_schema:
print(json.dumps(SAMPLE, indent=2))
return 0
if args.sample:
profile = SAMPLE
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
profile = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
return 4
else:
ap.error("--input or --sample is required (see --print-schema)")
result = audit(profile)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,100 @@
---
name: linkedin-skills
description: Use when someone wants to grow an organic LinkedIn presence — a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on "grow my LinkedIn", "fix my headline", "write a LinkedIn post", "what should I post about", "LinkedIn strategy", "connection request", "my reach dropped". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.
context: fork
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
build_pattern: "Domain orchestrator — deterministic lane router + policy gate, five managed sub-skills"
distinct_from: "marketing-skill/x-twitter-growth (X/Twitter-specific); marketing-skill/social-content (multi-platform calendars); marketing-skill/social-media-analyzer (cross-platform reporting); productivity/andreessen (venture judgement, not audience building)"
---
# LinkedIn — Organic Presence Orchestrator
> **Portability + safety:** 17 stdlib-only Python scripts. **No LinkedIn credentials, no API
> calls, no scraping, nothing auto-sent.** Every output is text the account holder posts
> themselves — automated posting, connecting, and commenting are prohibited by LinkedIn's
> User Agreement §8.2, and a restricted account ends the whole project.
Building an authentic presence over months, not generating a post. The five lanes cover what
actually moves: who you are (profile), what you stand for (strategy), what you publish
(content), who you talk to (engagement), and what any of it did (analytics).
## Step 1 — Gate, before anything is drafted
```bash
python3 scripts/linkedin_policy_gate.py --text "<the user's own words>" --output human
```
- **exit 4 REFUSE** — a named rule is broken (automation, scraping, pods, bulk DMs, fake
identity, fabricated proof, a prohibited automation platform). Do not draft it: name the
rule, offer the substitute the gate prints, and route there instead. A REFUSE outranks any
route below.
- **exit 3 CONSTRAIN** — proceed, and state the constraint out loud in your reply.
- **exit 0 ALLOW** — proceed.
## Step 2 — Route
```bash
python3 scripts/linkedin_goal_router.py --text "<the goal>" --output human
```
Exit 0 routes — invoke that lane, surfacing any `prerequisite` as a question first.
Exit 2 is ambiguous — ask **one** question naming both candidates, with a recommendation.
Exit 3 has no signal — ask what they want to walk away with, rather than guessing.
| Lane | Typical ask |
|---|---|
| `linkedin-profile` | "fix my headline", "my profile gets views but nothing happens" |
| `linkedin-strategy` | "what should I post about", "how often", "should I start a newsletter" |
| `linkedin-content` | "write this post", "turn my talk into posts", "is this hook any good" |
| `linkedin-engagement` | "who should I comment on", "write a connection note" |
| `linkedin-analytics` | "why did reach drop", "what's working", "should I test this" |
## Step 3 — Forcing questions
One at a time, each with a recommended answer. Stop as soon as the lane can run honestly.
`/cs:grill-linkedin` walks all five with the recommendation and the canon behind each.
1. What has to be true in 90 days for this to have been worth it? *(Not a follower count.)*
2. Who is this for, specifically enough that someone is excluded?
3. How many minutes a week will you protect — measured from a bad week?
4. What proof already exists? *(If none, the first pillar is process, not results.)*
5. What will you not post about?
## Hard rules
- **Nothing is automated and nothing is sent.** No credentials, no API, no scraping.
- **Refuse, then substitute.** Every REFUSE names the compliant alternative.
- **The account holder is the author of record.** They read every line before it ships.
- **No claim they cannot substantiate.** A real number, a bounded range, or nothing.
- **Never silently chain lanes.** Offer the next one as a question.
- **Cite the confidence level.** LinkedIn-official is 🟢, third-party studies are 🟡,
folklore is 🔴 and gets named as folklore.
## Scripts
| Script | Role |
|---|---|
| [`scripts/linkedin_policy_gate.py`](scripts/linkedin_policy_gate.py) | ALLOW / CONSTRAIN / REFUSE against User Agreement §8.2 + Community Policies. 7 refusal rules, each with a substitute. |
| [`scripts/linkedin_goal_router.py`](scripts/linkedin_goal_router.py) | Deterministic five-lane classifier: route (0) / ask (2) / no-signal (3), with cross-lane prerequisites. |
## References and assets
- [`references/linkedin_platform_canon.md`](references/linkedin_platform_canon.md) — how the feed ranks, evidence-graded (7 sources)
- [`references/policy_and_account_safety.md`](references/policy_and_account_safety.md) — §8.2, prohibited software, restriction triggers (7 sources)
- [`assets/linkedin_operating_agreement.md`](assets/linkedin_operating_agreement.md) — the standing rules in one page, agreed before work starts
## Distinct from
- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform and rules.
- **`marketing-skill/social-content` / `social-media-manager`** — multi-platform brand
calendars. This is one person's presence on one platform, in depth.
- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting.
---
**Version:** 1.0.0 · 5 lanes · 17 stdlib scripts · nothing auto-sent

View file

@ -0,0 +1,80 @@
# LinkedIn Operating Agreement
One page. Read it before the first post; it decides what this work can and cannot be.
---
## What this plugin will do
- Audit and rewrite your profile, headline, and About section.
- Build a positioning brief, content pillars, a cadence you can hold, and a newsletter plan.
- Draft, lint, and repurpose posts in any native format.
- Build a commenting roster and write outreach messages one person at a time.
- Analyse your own exported post data and refuse to over-conclude from it.
## What it will never do
- **Log into LinkedIn.** It holds no credentials and makes no API calls.
- **Send anything.** No posts, invitations, messages, comments, or likes. Every output is
text you paste yourself.
- **Scrape.** No profile data, no post data, no email finding, no list building.
- **Automate activity.** No scheduling tools that act as you, no browser extensions, no
named third-party automation platforms.
- **Join or simulate an engagement pod.**
- **Invent a number, a client, a result, a credential, or a quote.**
Those are not preferences. Automated activity, scraping, and inauthentic engagement are
prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding
asset that took months to build.
---
## What you are agreeing to
**1. You are the author of record.**
Your name is on it. You read every line before it publishes, and you cut anything you would
not say out loud in a room of peers. Drafting help is normal; unread output is how people
end up publishing something untrue about their own work.
**2. Every claim is one you can substantiate.**
A real number, a bounded range, or a qualitative statement. If the proof does not exist yet,
the post is about the process — which is a legitimate post and it ages better than the
alternative.
**3. You check what you are allowed to say.**
Employment agreement, client NDA, and any sector rules (financial promotion, medical claims,
securities disclosure) sit outside LinkedIn's policies and bind you anyway. Named third
parties should consent.
**4. You will hold a cadence you can actually pay for.**
Priced from a bad week, not a good one. A cadence abandoned in week five is worse than one
never started, because the abandonment is visible on your profile.
**5. You measure the outcome, not the applause.**
The 90-day criteria are things another person could verify: conversations you did not start,
specific references, invitations. Follower count is not on the list.
---
## What you should expect
| | |
|---|---|
| **First visible change** | Profile fixes, same day |
| **First reliable signal** | 6-8 weeks of consistent posting and commenting |
| **Enough data to analyse** | 10+ posts, and even then descriptively |
| **Enough data to test a hypothesis** | 20-60 posts, often more than a quarter allows |
| **A pattern you can act on** | Usually later than you want, and the honest answer in the meantime is "not yet" |
This is slower than the shortcuts promise. The shortcuts put the account at risk to save a
few months, and that is bad arithmetic.
---
## Signature
- Objective (one of six): ______________________
- Audience (specific enough to exclude someone): ______________________
- Minutes per week I will protect: ______
- Two things I will not post about: ______________________
- Date: ______________________

View file

@ -0,0 +1,154 @@
# LinkedIn Platform Canon — what is known, what is inferred, what is folklore
Every quantitative claim below carries a confidence level. This matters more on
LinkedIn than on most platforms: the advice ecosystem is dominated by people
selling LinkedIn advice, almost none of the numbers come from LinkedIn, and the
platform changes the surfaces those numbers describe without announcing it.
**Confidence key**
- 🟢 **High** — stated by LinkedIn in official documentation, engineering
publications, or peer-reviewed papers authored by LinkedIn engineers.
- 🟡 **Medium** — large-N third-party studies of public posts. Directionally
useful, methodologically opaque, and the sample is always public posts from
accounts that opted into being measured.
- 🔴 **Low / folklore** — repeated widely, sourced nowhere. Listed so you can
recognise it, not so you can use it.
---
## 1. How the feed actually ranks — 🟢
LinkedIn's feed is a multi-stage retrieval-and-ranking system, not a chronological
timeline and not a single "algorithm". The public record is unusually good here
because LinkedIn's engineering organisation publishes:
- **LiRank** (Borisyuk et al., KDD 2024, arXiv:2402.06859) describes the
production ranking stack for Feed, Ads CTR, and Job recommendations —
Residual DCN architecture, multi-task objectives, and isotonic calibration.
The relevant fact for a creator is structural: **the model optimises several
objectives at once**, and no single engagement action is "the" ranking signal.
- **Dwell time** is an explicit feed-ranking objective. LinkedIn's engineering
blog documents two measures — dwell "on the feed" (starting when at least half
of an update is visible during a scroll) and dwell "after the click" — and
operationalises them as a *Long Dwell* classifier predicting whether a member's
dwell will exceed a context-dependent threshold.
**What follows for a creator, and what does not.**
Follows: a post that holds attention is being measured on that, so a post worth
reading all the way through is not merely a nice-to-have. Text that rewards the
expand click is doing something the ranker can see.
Does *not* follow: that you should pad posts to increase dwell. Dwell is measured
against a *context-dependent percentile*, not an absolute seconds count, and
padding also depresses the completion and interaction signals that sit alongside
it in a multi-task model. "Add fluff to raise dwell time" is 🔴 folklore built on
a 🟢 fact.
## 2. Comments are the expensive signal — 🟡 (mechanism 🟢)
A reaction costs a reader one tap. A substantive comment costs them thirty
seconds and a small reputational exposure in front of their own network. Third-
party studies consistently find comment-heavy posts travel further, and the
mechanism is consistent with a multi-objective ranker that also predicts
downstream sessions: a comment creates a notification, a return visit, and a
thread other people can enter.
The practical version: **write posts that give a competent reader something to
add.** A post that is complete, correct, and closed invites agreement, and
agreement is the cheapest and least valuable response you can earn.
## 3. Reach is falling and the base has shifted — 🟡
Richard van der Blom's annual *Algorithm Insights* report (Just Connecting) is
the most-cited third-party longitudinal study, drawing on over a million public
posts. Recent editions report substantial year-over-year declines in organic
views, engagement, and follower growth, and an average post reaching roughly
8-12% of a creator's followers.
Treat the *direction* as reliable and the *specific percentages* as indicative.
The sample is public posts, the methodology is not independently auditable, and
"reach" is measured through the same impressions counter whose definition
LinkedIn has revised more than once.
What the trend means practically: **follower count is a worse proxy for
distribution every year.** A focused audience of a few thousand people who
actually work in your field will out-perform a large unfocused one, because
relevance-based retrieval has to decide *who* to show a post to, and an unfocused
follower graph gives it nothing to work with.
## 4. External links in the post body — 🟡, contested
Third-party analysis (van der Blom, 2026 edition, ~1.3M posts) reports a body
link reducing median reach by roughly 19%, and much larger suppression for links
placed in comments in some measurements. **LinkedIn has never confirmed a link
penalty**, and a plausible non-punitive explanation exists: a link that takes a
reader off-platform truncates dwell, and dwell is a ranking objective.
The practical guidance is the same under either explanation, which is why it is
safe to follow: put the link in the first comment, say "link in the comments" in
the post, and keep it in the body only when the click *is* the goal and you
accept the reach cost.
## 5. The first 60-90 minutes — 🟡
Early engagement correlates strongly with eventual reach in every third-party
dataset. The causal story is unproven and probably bidirectional (good posts get
early engagement *and* early engagement helps distribution). Either way the
behavioural implication holds: **be available to reply for an hour after you
post.** Replying is also the cheapest way to add comments to your own post
honestly.
🔴 The "golden hour" as a precise, engineered window with a hard cutoff is
folklore. Nobody outside LinkedIn knows the decay function.
## 6. Hashtags — 🟡, declining relevance
LinkedIn has deprecated hashtag-following surfaces over time and the platform has
moved toward semantic retrieval. Two or three topical hashtags remain a cheap,
harmless topic signal. Ten hashtags signal reach-chasing to human readers, which
is the cost that actually matters now.
## 7. What LinkedIn does *not* publish — worth knowing
- The exact weekly invitation limit (widely observed around 100, adjusted per
account).
- The exact character position where "…see more" truncates, per surface.
- The full newsletter eligibility criteria (the >150 followers/connections
threshold is published; "a set of criteria, all of which must be met" is not).
- Any per-signal ranking weight.
Anyone quoting a precise figure for these is quoting an observation, not a
specification. Cite it that way.
---
## Sources
1. Borisyuk, F. et al. **"LiRank: Industrial Large Scale Ranking Models at
LinkedIn."** KDD 2024. arXiv:2402.06859. — production ranking architecture,
multi-task objectives.
2. LinkedIn Engineering Blog. **"Understanding feed dwell time to improve
LinkedIn feed ranking."** linkedin.com/blog/engineering/feed/understanding-feed-dwell-time
— the two dwell measures and the Long Dwell classifier.
3. LinkedIn. **User Agreement**, §8.2 "Don'ts."
linkedin.com/legal/user-agreement — the binding rules on automation, scraping,
and inauthentic engagement.
4. LinkedIn. **Professional Community Policies.**
linkedin.com/legal/professional-community-policies — authenticity, spam, and
engagement-bait rules.
5. van der Blom, R. **Algorithm Insights** (annual, Just Connecting) — the
longest-running third-party longitudinal study of LinkedIn organic
performance. 🟡 sample is public posts; methodology not independently audited.
6. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150
followers/connections threshold and the existence of unpublished criteria.
7. Nielsen Norman Group. **"How People Read Online"** and the F-shaped reading
pattern research — why the visible first lines carry the decision, on any
platform with a truncated preview.
---
**Rule of thumb for anything not on this page:** if a claim about LinkedIn comes
with a precise percentage and no named study, it is 🔴. Say so out loud rather
than repeating it, and give the user the version of the advice that holds
regardless of whether the number is true.

View file

@ -0,0 +1,153 @@
# Policy and Account Safety — the rules that end the project if you break them
Organic LinkedIn growth is a compounding asset with a single point of failure:
the account. A restriction resets it to zero and there is no appeals process you
can schedule around. This is why this plugin gates every lane through
`linkedin_policy_gate.py` before drafting anything, and why it has no LinkedIn
credentials at all.
None of this is legal advice. It is a summary of published rules, written so an
agent can refuse the right things for the right reasons.
---
## 1. What the User Agreement actually prohibits (§8.2 "Don'ts")
LinkedIn's User Agreement prohibits members from, among other things:
- **Developing, supporting, or using software, devices, scripts, robots, or any
other means or processes** — including crawlers, browser plugins and add-ons —
**to scrape the Services or otherwise copy profiles and other data.**
- **Using bots or other automated methods** to access the Services, add or
download contacts, send or redirect messages, or **create, comment on, like,
share, or re-share posts, or otherwise drive inauthentic engagement.**
- Creating a false identity, misrepresenting your identity, or using someone
else's account.
- Posting inaccurate information, or content that violates the Professional
Community Policies.
Read the second bullet carefully: **it names commenting, liking, and sharing.**
The prohibition is not limited to sending messages. An engagement pod that
coordinates real humans to reciprocally comment on schedule is squarely inside
"otherwise drive inauthentic engagement", and the fact that a human pressed the
key does not take it outside the rule.
## 2. Third-party tools
LinkedIn maintains a Help article on **Prohibited Software and Extensions**
stating that it does not permit third-party software — crawlers, bots, browser
plug-ins, or extensions — that scrapes, modifies the appearance of, or automates
activity on LinkedIn. It notes two consequences: accounts may be restricted or
closed, and the tools themselves may stop working without notice.
The named-tool list in `linkedin_policy_gate.py` (Dux-Soup, PhantomBuster,
Expandi, Linked Helper, Meet Alfred, Waalaxy, Octopus CRM, Lempod, and others) is
not an official LinkedIn blacklist — it is a list of tools whose advertised
function is exactly what the policy prohibits. The gate refuses on the function,
and names the tool only because that is how people describe what they want.
**The supported path exists:** LinkedIn's own scheduler for posts, and LinkedIn's
Marketing Developer Platform for partners with API access. If a workflow can be
done through those, it is fine.
## 3. What actually triggers a restriction
LinkedIn does not publish its enforcement thresholds. Observable patterns from
LinkedIn's own Help documentation on account restrictions and from widespread
reporting:
| Trigger | Why it fires |
|---|---|
| High invitation volume with low acceptance | The signature of untargeted bulk invites. Acceptance rate is the discriminator, not volume alone. |
| Many "I don't know this person" / spam reports | Recipient-side signal, and the most damaging one. |
| Machine-regular activity patterns | Constant per-hour rates, activity at 03:00 local, identical intervals. |
| Identical message or comment text at volume | Directly matches the "inauthentic engagement" language. |
| Detected automation extension | Automated detection of the prohibited-software class. |
| Profile data inconsistent with a real person | Stock photo, no history, sudden high-volume activity. |
The practical read: **restrictions correlate with looking automated more than
with volume itself.** A person sending 20 genuinely personal invitations a week
for a year is invisible. A person sending 200 identical ones in a day is not.
## 4. Rate limits worth knowing (🟡 observed, not published)
- **Connection invitations:** a weekly limit widely observed around 100, adjusted
per account. **Pending invitations count against it**, so a backlog of
un-actioned invites silently shrinks the allowance.
- **Withdrawn invitations** cannot be re-sent to the same person for roughly
three weeks.
- **Messaging:** no published cap for first-degree connections; InMail credits
are metered by subscription.
`outreach_volume_guard.py` uses conservative working numbers and says explicitly
that they are observations.
## 5. Content rules that cost reach rather than the account
The Professional Community Policies commit LinkedIn to reducing distribution of
several content classes. Relevant to organic creators:
- **Engagement bait** — "comment X for the guide", "like if you agree", "tag
three people". Explicitly named.
- **Spam and unsolicited commercial content**, including repetitive posting.
- **Misleading or false content**, which for a professional audience includes
fabricated metrics and invented case studies.
These do not usually restrict an account. They make the work not work, which for
a compounding organic strategy is nearly as bad.
## 6. Things this plugin refuses even where LinkedIn permits them
Three refusals are editorial rather than legal, and they are held anyway:
1. **Fabricated proof.** Inventing a metric, a client, or a testimonial. Legal
exposure aside (FTC endorsement rules apply to testimonials in many
jurisdictions), a professional audience contains people who can check.
2. **Ghostwriting an executive's account without their knowledge.** Ghostwriting
with the account holder's review is normal and fine; the account holder is the
author of record either way, which means they have to have read it.
3. **Posting confidential or identifying detail about employers, clients, or
individuals without consent.** Employment agreements, NDAs, and sector rules
(financial promotion, medical claims, securities disclosure) sit outside
LinkedIn's policies entirely and bind you anyway.
## 7. Regional obligations the plugin surfaces but cannot resolve
- **GDPR / UK GDPR** — sending unsolicited B2B messages, and any processing of
contact data outside LinkedIn, has a legal basis question attached. Scraping
member data to build a list has a much harder one.
- **EU Digital Services Act** and comparable regimes — platform transparency and
reporting obligations that affect what recourse you have, not what you may do.
- **FTC Endorsement Guides** (US) and equivalents — disclosure obligations for
paid or incentivised endorsements, including on personal profiles.
Where any of these are in play, the answer is a named human — legal, compliance,
or the client — not a tool.
---
## Sources
1. LinkedIn. **User Agreement**, §8.2 "Don'ts."
linkedin.com/legal/user-agreement
2. LinkedIn Help. **"Prohibited software and extensions."**
linkedin.com/help/linkedin/answer/a1341387
3. LinkedIn Help. **"Automated activity on LinkedIn."**
linkedin.com/help/linkedin/answer/a1340567
4. LinkedIn Help. **"Account restrictions."**
linkedin.com/help/linkedin/answer/a1340522
5. LinkedIn. **Professional Community Policies.**
linkedin.com/legal/professional-community-policies — authenticity, spam,
engagement bait, misinformation.
6. LinkedIn. **Marketing Developer Platform** documentation — the supported
programmatic path, and the scope it is actually granted for.
7. US Federal Trade Commission. **Guides Concerning the Use of Endorsements and
Testimonials in Advertising** (16 CFR Part 255) — why an invented testimonial
is a legal problem and not only an editorial one.
---
**The one-line version:** you can build a serious LinkedIn presence entirely
inside the rules, it takes longer than the shortcuts promise, and the shortcuts
put the compounding asset at risk to save a few months. That trade is bad
arithmetic, and it is the reason this plugin refuses rather than warns.

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""linkedin_goal_router.py — deterministic lane classifier for the linkedin domain.
Scores a LinkedIn goal against the five sub-skill lanes using the same two-signal
threshold discipline as the commercial / research-ops / product-team orchestrators.
Emits a routing decision an agent can branch on mechanically instead of guessing.
Exit codes:
0 confident route emitted (route_to set)
2 ambiguous ask ONE clarifying question naming the top two lanes
3 no signal do not guess; ask the user to restate the goal with a deliverable
Stdlib only. Deterministic: same text in, same route out.
"""
import argparse
import json
import sys
SIGNALS = {
"PROFILE": {
"skill": "linkedin-profile",
"path": "marketing/linkedin/skills/linkedin-profile",
"keywords": [
"profile", "headline", "about section", "about me", "summary section",
"banner", "featured section", "experience section", "bio", "tagline",
"profile photo", "skills section", "recommendations", "open to work",
"creator mode", "custom url", "profile audit", "optimize my profile",
],
},
"STRATEGY": {
"skill": "linkedin-strategy",
"path": "marketing/linkedin/skills/linkedin-strategy",
"keywords": [
"strategy", "content pillars", "pillar", "positioning", "cadence",
"posting schedule", "how often should i post", "calendar", "editorial calendar",
"newsletter", "thought leadership", "career change", "career transition",
"personal brand", "audience", "niche", "90 day", "quarter", "roadmap",
"goals", "objective", "grow my following", "grow an audience",
],
},
"CONTENT": {
"skill": "linkedin-content",
"path": "marketing/linkedin/skills/linkedin-content",
"keywords": [
"write a post", "draft a post", "post idea", "post ideas", "hook",
"carousel", "document post", "pdf post", "story post", "how-to post",
"opinion post", "listicle", "caption", "video script", "poll",
"article", "repurpose", "turn this into", "rewrite this post",
"edit my post", "review my post", "first line", "content", "copy",
],
},
"ENGAGEMENT": {
"skill": "linkedin-engagement",
"path": "marketing/linkedin/skills/linkedin-engagement",
"keywords": [
"comment", "commenting", "reply", "replies", "dm", "message",
"connection request", "connection note", "invite", "inmail", "outreach",
"networking", "cold message", "follow up", "who should i engage",
"engagement strategy", "groups", "community", "reach out", "warm intro",
],
},
"ANALYTICS": {
"skill": "linkedin-analytics",
"path": "marketing/linkedin/skills/linkedin-analytics",
"keywords": [
"analytics", "analyze my posts", "which posts", "performance",
"impressions", "engagement rate", "reach dropped", "what's working",
"what is working", "export", "benchmark", "pattern", "why did this post",
"top posts", "experiment", "test", "measure", "metrics", "dashboard",
"followers gained", "profile views",
],
},
}
# Cross-lane dependencies used when the router says ASK or when a lane is chosen:
# these are stated as prerequisites, not silently chained.
PREREQS = {
"CONTENT": ("STRATEGY", "Posts without pillars are noise. If no positioning brief exists, "
"offer linkedin-strategy first — but never chain silently."),
"ANALYTICS": ("CONTENT", "Pattern mining needs a body of posts. Under ~20 posts, the honest "
"answer is 'not enough data yet' — say so rather than fitting noise."),
"ENGAGEMENT": ("PROFILE", "Comments and DMs drive profile visits. A weak headline wastes "
"every visit engagement earns."),
}
SAMPLE_GOAL = ("I'm moving from backend engineering into developer advocacy and want to build "
"a real audience over the next two quarters — what should I be posting about, "
"and how often?")
def score(text: str) -> dict:
low = text.lower()
scores, hits = {}, {}
for lane, spec in SIGNALS.items():
matched = [kw for kw in spec["keywords"] if kw in low]
scores[lane] = len(matched)
hits[lane] = matched
return {"scores": scores, "hits": hits}
def decide(scores: dict) -> dict:
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
(top_lane, top), (second_lane, second) = ranked[0], ranked[1]
if top == 0:
return {"decision": "NO_SIGNAL", "exit": 3}
if top >= 2 and (second == 0 or top >= 2 * second):
return {"decision": "ROUTE", "lane": top_lane, "exit": 0}
candidates = [top_lane] + ([second_lane] if second > 0 else [])
return {"decision": "ASK", "candidates": candidates, "exit": 2}
def main() -> int:
ap = argparse.ArgumentParser(
description="Deterministic lane router for LinkedIn goals "
"(route=0 / ask=2 / no-signal=3).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--text", help="Goal or inquiry text to classify.")
src.add_argument("--input", help="Read goal text from a file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true",
help="Classify a built-in sample goal and exit.")
args = ap.parse_args()
if args.sample:
text = SAMPLE_GOAL
elif args.text:
text = args.text
elif args.input:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
else:
ap.error("one of --text, --input, or --sample is required")
result = score(text)
verdict = decide(result["scores"])
out = {
"goal": text.strip()[:300],
"scores": {k: v for k, v in result["scores"].items() if v},
"decision": verdict["decision"],
"policy_gate": ("Run linkedin_policy_gate.py on the same text before drafting anything. "
"A REFUSE there outranks any route here."),
}
if verdict["decision"] == "ROUTE":
lane = verdict["lane"]
out["route_to"] = SIGNALS[lane]["skill"]
out["skill_path"] = SIGNALS[lane]["path"]
out["matched_signals"] = result["hits"][lane]
if lane in PREREQS:
prereq_lane, why = PREREQS[lane]
out["prerequisite"] = {
"lane": SIGNALS[prereq_lane]["skill"],
"why": why,
"rule": "Offer it as a question. Never chain silently.",
}
elif verdict["decision"] == "ASK":
out["candidates"] = [
{"lane": lane, "skill": SIGNALS[lane]["skill"], "score": result["scores"][lane]}
for lane in verdict["candidates"]
]
out["instruction"] = ("Ask ONE clarifying question naming both candidate lanes, with a "
"recommended answer and the reason. Never guess silently.")
else:
out["instruction"] = ("No lane signal. Ask the user what they want to walk away with — "
"a rewritten profile, a posting plan, a drafted post, an outreach "
"message, or a read on their numbers. Do not route on fuzz.")
if args.output == "json":
print(json.dumps(out, indent=2))
else:
print(f"Decision: {out['decision']}")
if "route_to" in out:
print(f"Route to: {out['route_to']} ({out['skill_path']})")
print(f"Signals : {', '.join(out['matched_signals'])}")
if "prerequisite" in out:
print(f"Prereq : {out['prerequisite']['lane']}{out['prerequisite']['why']}")
elif "candidates" in out:
print("Ambiguous: " + " vs ".join(c["skill"] for c in out["candidates"]))
print(out["instruction"])
else:
print(out["instruction"])
print(f"\nPolicy : {out['policy_gate']}")
return verdict["exit"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""linkedin_policy_gate.py — refuse LinkedIn tactics that break the User Agreement.
Every lane in the `linkedin` plugin passes through this gate before any drafting
work happens. It classifies a described tactic against LinkedIn's User Agreement
(§8.2 "Don'ts"), the Prohibited Software and Extensions policy, and the
Professional Community Policies, then returns ALLOW / CONSTRAIN / REFUSE.
The point is not legal advice. The point is that an account restriction ends the
organic-growth project, so the tactics that risk one are refused up front instead
of drafted and regretted. A REFUSE always names the rule and offers the compliant
substitute this gate never just says no.
Deterministic: same text in, same verdict out. Stdlib only. No network calls,
no LinkedIn API access, nothing is ever sent anywhere.
Exit codes:
0 ALLOW nothing in the request trips a rule; proceed
3 CONSTRAIN allowed, but only under the named constraints (proceed and honor them)
4 REFUSE breaks a named rule; do not draft it, offer the substitute instead
Usage:
python3 linkedin_policy_gate.py --text "auto-connect with 500 recruiters a week"
python3 linkedin_policy_gate.py --input plan.md --output human
python3 linkedin_policy_gate.py --sample
"""
import argparse
import json
import re
import sys
# ---------------------------------------------------------------------------
# Refusal rules. Each carries the policy anchor and the compliant substitute,
# because a gate that only blocks teaches the user nothing.
# ---------------------------------------------------------------------------
REFUSE_RULES = [
{
"id": "P1-AUTOMATION",
"title": "Automated activity on LinkedIn",
"anchor": "LinkedIn User Agreement §8.2 (bots/automated methods to access the "
"Services, add contacts, send messages, create/comment/like/share posts)",
"patterns": [
r"\bauto[- ]?(connect|invite|dm|message|like|comment|follow|endorse|post|apply)\w*",
r"\bautomat(e|ed|ing|ion)\b(?!.{0,40}\bis (prohibited|banned|against)\b)",
r"\bbot\b", r"\bbots\b", r"\bheadless browser\b", r"\bselenium\b", r"\bpuppeteer\b",
r"\bbrowser (extension|plugin|add-?on)\b.{0,40}\b(linkedin|connect|message)\b",
r"\bscript that (logs? in|clicks?|sends?|connects?)\b",
],
"substitute": "Do the same volume by hand on a capped schedule. "
"`linkedin-engagement/scripts/outreach_volume_guard.py` sizes a manual "
"cadence you can actually sustain; the plugin drafts the text, you press send.",
},
{
"id": "P2-SCRAPING",
"title": "Scraping or bulk-copying member data",
"anchor": "LinkedIn User Agreement §8.2 (crawlers/scrapers to copy profiles or other "
"data) + Prohibited Software and Extensions policy",
"patterns": [
r"\bscrap(e|ed|er|ing)\b", r"\bcrawl(er|ing)?\b",
r"\b(harvest|extract|mine)\b.{0,30}\b(email|profile|contact|lead|member)s?\b",
r"\bexport\b.{0,25}\b(connections?|leads?|profiles?|members?)\b.{0,25}\b(list|database|csv)\b",
r"\bemail finder\b", r"\bfind (their|his|her) email\b",
r"\bbuild(ing)? a (lead )?(list|database)\b.{0,30}\bfrom linkedin\b",
],
"substitute": "Use LinkedIn's own export of YOUR data (Settings → Data privacy → "
"Get a copy of your data) and LinkedIn-native search. Analytics work in "
"this plugin runs on your own exported post/profile stats, never on "
"other members' data.",
},
{
"id": "P3-INAUTHENTIC",
"title": "Inauthentic engagement (pods, bought signals)",
"anchor": "LinkedIn User Agreement §8.2 (drive inauthentic engagement) + Professional "
"Community Policies (be authentic / no fake engagement)",
"patterns": [
r"\b(engagement|comment|like|linkedin) ?pod\b", r"\bpods?\b(?=.{0,30}\b(join|run|group)\b)",
r"\bbuy(ing)? (followers?|likes?|comments?|connections?|views?|impressions?)\b",
r"\b(fake|paid|bought|purchased) (followers?|engagement|likes?|comments?)\b",
r"\bengagement (group|ring|circle|exchange|swap)\b",
r"\blike[- ]?for[- ]?like\b", r"\bcomment[- ]?for[- ]?comment\b",
],
"substitute": "Build a real reciprocity list instead: "
"`linkedin-engagement/scripts/comment_target_planner.py` picks accounts "
"whose audience overlaps yours and budgets genuine daily comments. Slower, "
"and it survives an audit.",
},
{
"id": "P4-IDENTITY",
"title": "Fake identity, duplicate accounts, impersonation",
"anchor": "LinkedIn User Agreement §8.2 (create a false identity, misrepresent your "
"identity, use another's account) + Professional Community Policies",
"patterns": [
r"\bfake (profile|account|persona|identity)\b",
r"\b(second|burner|dummy|alt|multiple) (linkedin )?accounts?\b",
r"\bimpersonat(e|ing|ion)\b",
r"\bpretend(ing)? to be\b", r"\bpose as\b",
r"\bghost(write|writing)\b.{0,30}\bwithout\b.{0,20}\b(disclos|know)\w*",
r"\bpost as (?:my|the) (ceo|founder|boss|client)\b.{0,40}\bwithout\b",
],
"substitute": "One real profile, your real name. Ghostwriting for an executive is fine "
"when that executive knows and approves every post — the account holder is "
"the author of record.",
},
{
"id": "P5-BULK-MESSAGING",
"title": "Bulk or unsolicited mass messaging",
"anchor": "LinkedIn User Agreement §8.2 (send or redirect messages by automated means; "
"spam) + Professional Community Policies (no spam/unsolicited commercial content)",
"patterns": [
r"\b(mass|bulk|blast|spray)\b.{0,20}\b(dm|message|inmail|invite|connection)s?\b",
r"\b(dm|message|inmail)\b.{0,20}\b(everyone|all my connections|the whole list|1000|500)\b",
r"\bsend the same (message|dm|note) to\b",
r"\bcopy[- ]?paste\b.{0,25}\b(dm|message|outreach)\b.{0,25}\b(everyone|all|hundreds)\b",
r"\bdrip (campaign|sequence)\b.{0,30}\blinkedin\b",
],
"substitute": "Per-person messages with a specific reason, sent by hand, under a weekly "
"cap. `outreach_message_builder.py` refuses a template with no "
"person-specific line for exactly this reason.",
},
{
"id": "P6-FABRICATION",
"title": "Fabricated credentials, metrics, or social proof",
"anchor": "LinkedIn User Agreement §8.2 (post inaccurate information) + Professional "
"Community Policies (no false or misleading content); FTC endorsement rules "
"apply to testimonials",
"patterns": [
r"\b(make up|invent|fabricate|fake)\b.{0,30}\b(metric|number|result|case study|"
r"testimonial|client|revenue|stat|credential|degree|certification)s?\b",
r"\b(inflate|exaggerate)\b.{0,25}\b(number|revenue|result|headcount|arr|mrr)s?\b",
r"\bpretend (i|we) (have|had|built|grew|raised)\b",
r"\bsay (i|we) (have|had) \d+.{0,20}\b(clients?|customers?|users?)\b.{0,25}\bwe don'?t\b",
],
"substitute": "Use a real number, a bounded range, or a qualitative claim. "
"If the proof does not exist yet, the post is about the process, not the "
"result — that is a legitimate post and it ages well.",
},
{
"id": "P7-PROHIBITED-TOOLS",
"title": "Named third-party automation tools",
"anchor": "LinkedIn Help — Prohibited Software and Extensions (third-party software that "
"scrapes, modifies, or automates activity on LinkedIn is not permitted)",
"patterns": [
r"\b(dux[- ]?soup|phantom ?buster|expandi|linked ?helper|meet ?alfred|waalaxy|"
r"octopus ?crm|lempod|zopto|we[- ]?connect|prospectin|salesflow|closely|"
r"linkedin ?helper|texau|captain ?data)\b",
],
"substitute": "Native LinkedIn scheduling and LinkedIn's own Marketing Developer "
"Platform partners are the supported path. This plugin never logs into "
"your account at all — it hands you text.",
},
]
# ---------------------------------------------------------------------------
# Constraint rules. Legitimate tactics that go wrong at volume or without a
# disclosure. These proceed, but the constraint is printed and must be honored.
# ---------------------------------------------------------------------------
CONSTRAIN_RULES = [
{
"id": "C1-OUTREACH-VOLUME",
"title": "Manual outreach at campaign scale",
"patterns": [
r"\b(outreach|connection request|invite|cold dm|cold message|inmail)\b",
r"\bnetworking (campaign|push|sprint)\b",
],
"constraint": "Manual send only, one person at a time, every message carrying a line "
"that could only have been written for that person. Run "
"`outreach_volume_guard.py` before sending — LinkedIn enforces an "
"invitation limit (roughly 100/week for most accounts) and withdrawn "
"invites still count against it.",
},
{
"id": "C2-ENGAGEMENT-BAIT",
"title": "Engagement bait",
"patterns": [
r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b",
r"\bcomment ['\"]?\w{1,14}['\"]? below\b",
r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b",
r"\b(like|repost|share) (this )?if you\b",
r"\bagree\?\s*$", r"\btag (someone|3|three|a friend)\b",
r"\bdm me ['\"]\w+['\"]\b",
],
"constraint": "LinkedIn's Professional Community Policies name engagement bait as "
"content it demotes. Ask a real question the post has earned instead — "
"`post_linter.py` flags bait patterns as blocking findings.",
},
{
"id": "C4-EMPLOYER-CONTEXT",
"title": "Posting about an employer, client, or regulated topic",
"patterns": [
r"\b(my employer|my company|our client|my client|internal|confidential|"
r"under nda|customer data|patient|clinical|financial advice|investment advice)\b",
r"\b(layoff|acquisition|funding round|earnings)\b",
],
"constraint": "Check the employment agreement, the client NDA, and any sector rules "
"(financial promotion, medical claims, securities disclosure) before "
"posting. Named third parties should consent. When unsure, describe the "
"pattern without the identifying detail.",
},
]
# Always returned, on every verdict. These are not triggered by wording — they
# hold for every piece of LinkedIn work this plugin produces.
STANDING_CONSTRAINTS = [
"Author of record: the account holder publishes it, so the account holder reads "
"every line. Cut anything you would not say out loud in a room of peers.",
"Nothing is auto-sent. This plugin has no LinkedIn credentials and makes no API "
"calls; every output is text you paste and post yourself.",
"No claim you cannot substantiate. A real number, a bounded range, or a "
"qualitative statement — never a placeholder metric that ships.",
]
SAMPLE_TEXT = ("I want to grow to 20k followers in six months. Plan: use Dux-Soup to "
"auto-connect with 500 recruiters a week, join a comment pod for the first "
"90 minutes, and blast the same DM to everyone who accepts.")
def _scan(text: str, rules: list, key: str) -> list:
low = text.lower()
hits = []
for rule in rules:
matched = []
for pat in rule["patterns"]:
for m in re.finditer(pat, low, re.IGNORECASE):
snippet = m.group(0).strip()
if snippet and snippet not in matched:
matched.append(snippet)
if matched:
hits.append({
"id": rule["id"],
"title": rule["title"],
"matched": matched[:5],
"anchor": rule.get("anchor", ""),
key: rule[key],
})
return hits
def evaluate(text: str) -> dict:
refusals = _scan(text, REFUSE_RULES, "substitute")
constraints = _scan(text, CONSTRAIN_RULES, "constraint")
if refusals:
verdict, code = "REFUSE", 4
elif constraints:
verdict, code = "CONSTRAIN", 3
else:
verdict, code = "ALLOW", 0
return {
"verdict": verdict,
"exit_code": code,
"refusals": refusals,
"constraints": constraints,
"input_preview": text.strip()[:280],
"standing_constraints": STANDING_CONSTRAINTS,
"disclaimer": ("Deterministic pattern check against LinkedIn's published policies, not "
"legal advice and not exhaustive. A clean ALLOW does not certify a plan; "
"it means nothing in the text tripped a known rule."),
}
def render_human(result: dict) -> str:
out = [f"LinkedIn policy gate: {result['verdict']}", "=" * 46]
if result["refusals"]:
out.append("\nREFUSED — these break a named LinkedIn rule:\n")
for r in result["refusals"]:
out.append(f" [{r['id']}] {r['title']}")
out.append(f" matched : {', '.join(r['matched'])}")
out.append(f" rule : {r['anchor']}")
out.append(f" instead : {r['substitute']}\n")
if result["constraints"]:
out.append("\nALLOWED UNDER CONSTRAINT — proceed, but honor these:\n")
for c in result["constraints"]:
out.append(f" [{c['id']}] {c['title']}")
out.append(f" matched : {', '.join(c['matched'])}")
out.append(f" honor : {c['constraint']}\n")
if result["verdict"] == "ALLOW":
out.append("\nNothing in this request trips a known rule. Proceed.\n")
out.append("\nStanding constraints (always apply):")
for sc in result["standing_constraints"]:
out.append(f" - {sc}")
out.append("")
out.append(result["disclaimer"])
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(
description="Classify a LinkedIn tactic against the User Agreement: "
"ALLOW (0) / CONSTRAIN (3) / REFUSE (4).")
src = ap.add_mutually_exclusive_group()
src.add_argument("--text", help="The tactic, plan, or request to check.")
src.add_argument("--input", help="Read the text from a file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true",
help="Run the built-in sample (a plan that trips four rules).")
args = ap.parse_args()
if args.sample:
text = SAMPLE_TEXT
elif args.text:
text = args.text
elif args.input:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
else:
ap.error("one of --text, --input, or --sample is required")
result = evaluate(text)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,99 @@
---
name: linkedin-strategy
description: Use when someone needs a LinkedIn plan rather than a post — content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on "what should I post about", "how often should I post", "LinkedIn content strategy", "should I start a LinkedIn newsletter", "grow my following", "90-day plan". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.
license: MIT
metadata:
version: 1.0.0
author: Alireza Rezvani
category: marketing
updated: 2026-08-25
---
# LinkedIn Strategy — brief, cadence, newsletter
Three decisions, in this order. Out of order is why most LinkedIn efforts produce a feed of
unrelated observations and stop in week five.
## Workflow
**1. The brief — an editorial constitution, not a wish.** Walk the five questions one at a
time, each with a recommended answer, then validate:
```bash
python3 scripts/positioning_brief.py --input brief.json --output human
```
It refuses on the two things that make everything downstream impossible: an objective that
is not one of the six real ones (`career-change`, `consulting`, `thought-leadership`,
`hiring`, `fundraising`, `community`), and an audience too broad to exclude anyone. It also
refuses fewer than two exclusions — **a positioning that excludes nothing is availability.**
Pillars: two to four, shares summing to 100, at least one backed by proof that already
exists, at least one at 10-20% as the experimental slot. Every pillar needs a "why you"; if
anyone could post it, cut it.
The script emits observable 90-day criteria. Follower count is deliberately absent — it moves
for reasons unrelated to whether the objective is being met.
**2. The cadence — priced against a bad week.**
```bash
python3 scripts/cadence_planner.py --minutes 240 --stage starting --target-posts 3 --output human
```
Exit 0 fits / 2 below the 90-minute floor / 3 over budget with the overage named. Every
activity is priced in minutes including the reply window, which is part of the post and not
an extra. Allocation shifts with stage: from a standing start **60% of the budget belongs in
other people's comment sections**, because a post published to nobody reaches nobody.
Below 90 minutes a week it refuses to plan a posting schedule and returns a comment-only
week: a cadence abandoned in week five is worse than one never started, because the
abandonment is visible on the profile. Every plan ships with a minimum viable week.
**3. The newsletter — only if the promise can be paid.**
```bash
python3 scripts/newsletter_planner.py --followers 1800 --cadence biweekly \
--minutes-per-month 420 --pillar "..." --output human
```
Refuses below LinkedIn's published 150-follower evaluation floor, and refuses a cadence
whose six-month cost exceeds the budget. Emits a 12-issue arc rotating issue types across
pillars, and a stop rule written before issue one.
## Rules
- **One objective.** Two objectives serve neither; the audiences overlap less than they look.
- **The exclusion list is the positioning.** Refuse to skip it.
- **Price the week from a bad week, not a good one.**
- **Consistency over volume.** A skipped week is fine; a skipped month resets you.
- **Review the brief quarterly, not weekly.** A brief revised monthly is a mood.
## Scripts
| Script | Role |
|---|---|
| [`scripts/positioning_brief.py`](scripts/positioning_brief.py) | Validates objective, audience, 2-4 proof-backed pillars, and the exclusion list; emits observable 90-day criteria. |
| [`scripts/cadence_planner.py`](scripts/cadence_planner.py) | Prices the week in minutes, allocates by stage, refuses over-budget targets, emits the minimum viable week. |
| [`scripts/newsletter_planner.py`](scripts/newsletter_planner.py) | Eligibility + six-month sustainability gate, 12-issue arc across pillars, stop rule. |
## References and assets
- [`references/objective_to_pillars.md`](references/objective_to_pillars.md) — the six objectives and the pillar arithmetic (7 sources)
- [`references/cadence_and_consistency.md`](references/cadence_and_consistency.md) — what a post really costs and why the floor exists (7 sources)
- [`references/newsletter_playbook.md`](references/newsletter_playbook.md) — eligibility, cadence as a promise, the stop rule (7 sources)
- [`assets/positioning_brief_template.md`](assets/positioning_brief_template.md) — fillable brief
- [`assets/example_brief.json`](assets/example_brief.json) — a passing brief, for the validator
## Distinct from
- **`linkedin-content`** — drafts and lints individual posts. This decides what they are about
and how many there are. Posts without pillars are noise; offer this first, never chain silently.
- **`linkedin-analytics`** — tells you what happened. This decides what to do next.
- **`marketing-skill/content-strategy`** — company-level content marketing. This is one
person's own presence.
---
**Version:** 1.0.0

View file

@ -0,0 +1,36 @@
{
"_comment": "A brief that passes positioning_brief.py at exit 0. Note the audience is specific enough to exclude someone, three of four pillars carry proof that already exists, one pillar sits at 10% as the experimental slot, and there are three reasoned exclusions.",
"objective": "consulting",
"audience": "heads of data at Series A-B SaaS companies who have three analysts and no analytics engineer, and whose CEO does not trust the dashboard",
"pillars": [
{
"name": "Trust debt in analytics",
"why_you": "I have rebuilt this at three companies and measured it each time",
"proof": "the 6-vs-35-days handoff measurement, published",
"share": 40
},
{
"name": "dbt and modelling decisions",
"why_you": "I maintain two open-source dbt packages",
"proof": "public repos plus a conference talk",
"share": 35
},
{
"name": "Hiring the first analytics engineer",
"why_you": "I have written three of these job specs and run the loops",
"proof": "two hires who are still in role two years later",
"share": 15
},
{
"name": "Field notes and experiments",
"why_you": "unproven; testing whether the audience wants this",
"proof": "",
"share": 10
}
],
"exclusions": [
"generic AI commentary - I have no edge there and neither does anyone posting it",
"hot takes on other companies' layoffs",
"vendor comparison posts that would compromise client confidentiality"
]
}

View file

@ -0,0 +1,79 @@
# Positioning Brief
The editorial constitution. When a post idea does not fit a pillar, the answer is not to add
a pillar — it is to not post it, or to put it in the experimental slot and see.
Review quarterly. A brief revised monthly is a mood.
---
## 1. Objective — pick exactly one
- [ ] `career-change` — move into a different role or field
- [ ] `consulting` — generate consulting or freelance work
- [ ] `thought-leadership` — be cited as a credible voice on one specific thing
- [ ] `hiring` — attract candidates to a team you are building
- [ ] `fundraising` — build investor and operator awareness ahead of a raise
- [ ] `community` — build a durable group around a shared problem
Two objectives serve neither. If both matter, run one this quarter and the other next.
## 2. Audience
Specific enough that a real person could be excluded. Role + company stage + the problem
they have *this quarter*.
> ______________________________________________________________
Bad: "business leaders", "professionals in tech", "my network".
Good: "heads of data at Series A-B SaaS with three analysts, no analytics engineer, and a
CEO who does not trust the dashboard".
## 3. Pillars — two to four, shares summing to 100
| Pillar | Why you (your specific standing) | Proof that already exists | Share |
|---|---|---|---|
| | | | % |
| | | | % |
| | | | % |
| | | | % |
Rules:
- At least one pillar must rest on proof that **already exists**.
- At least one pillar at **10-20%** — the experimental slot. Next quarter's main pillar
comes from here.
- Empty "why you" means anyone could post it. Cut the pillar.
## 4. Exclusions — at least two
What you will not post about, and why. This is the actual positioning.
1. ______________________________________________________________
2. ______________________________________________________________
3. ______________________________________________________________
The trending topic you have no edge on belongs here.
## 5. 90-day criteria
Filled in by `positioning_brief.py` from the objective. Every one is observable by someone
other than you. Follower count is deliberately absent.
- [ ] ______________________________________________________________
- [ ] ______________________________________________________________
- [ ] ______________________________________________________________
## 6. Budget
- Minutes per week I will protect (from a bad week): ______
- Stage: `starting` / `rebuilding` / `established`
- Formats I can actually produce: ______
---
**Then run:**
```bash
python3 ../scripts/positioning_brief.py --input brief.json --output human
python3 ../scripts/cadence_planner.py --minutes <n> --stage <stage> --output human
```

View file

@ -0,0 +1,118 @@
# Cadence and Consistency — the variable that actually compounds
Every LinkedIn strategy that fails, fails the same way: it was designed for a
week the person happened to have free. Week five arrives, the plan needs six
hours, ninety minutes exist, and the plan is abandoned rather than reduced.
`cadence_planner.py` exists to make that failure visible in week zero, when it is
still an arithmetic problem.
---
## What one post actually costs
Time estimates people use are drafting time. Real cost includes revision and the
reply window.
| Activity | Minutes | What is in there |
|---|---|---|
| Text post | 25 | Draft 15, revise and lint 10 |
| Image post | 30 | Above plus asset and alt text |
| Document carousel | 90 | Outline, 8-12 slides, export, cover |
| Native video | 120 | Script, record, edit, captions |
| Article | 180 | It is an essay |
| Newsletter issue | 150 | An essay with a standing promise attached |
| One substantive comment | 6 | Read the post properly, write something worth reading |
| Replying to your own post | 20 | Per published post, in the hours after |
| One outreach message | 5 | Read their work, write the specific line |
The line people leave out is the last-but-one. **Replying to comments on your own
post is part of the post**, not an optional extra: it is where readers actually
meet you, and skipping it wastes the distribution the post earned.
## Why the allocation shifts with stage
| Stage | Engagement share | Reasoning |
|---|---|---|
| **starting** (<~1k followers, or restarting) | 60% | Your posts have almost no distribution. A substantive comment on a post that already has an audience is the only lever that works from zero. |
| **rebuilding** (audience exists, went quiet) | 45% | Reach recovers with consistency, not with one big swing. |
| **established** (posts reach non-connections) | 30% | Distribution works; the constraint is now what you publish. |
The counter-intuitive part: **from a standing start, most of your budget belongs
in other people's comment sections.** People resist this because commenting feels
like helping someone else. It is the cheapest distribution available, and it is
the only one that works before anyone follows you.
## The floor
Under 90 minutes a week, `cadence_planner.py` refuses to plan a posting schedule
and returns a comment-only week instead. This is a deliberate refusal, not a
limitation:
- A cadence abandoned in week five is worse than one never started, because the
abandonment is visible on the profile — a burst of posts followed by silence
reads as a failed attempt, which is exactly the impression you were trying to
avoid.
- Commenting degrades gracefully. A week with no time costs you a week. A missed
publishing slot costs you the schedule.
## The minimum viable week
Every plan ships with the subset that survives a bad week:
1. One text post, on the same day each week.
2. One substantive comment per weekday.
3. Reply to every comment on your own post within 24 hours.
That is roughly two hours and it is enough to compound. Everything above it is
acceleration, and acceleration is optional in a way consistency is not.
## Same day, same time
The schedule is the product; the topic varies. Two reasons, one soft and one
mechanical:
- **Soft:** a returning reader learns when you appear. That is the beginning of
an audience rather than a series of impressions.
- **Mechanical:** it makes your own data comparable. If you post at random times,
time-of-day confounds every comparison you will ever want to make — see
`linkedin-analytics/references/evidence_thresholds.md`.
## Batching, and its one real risk
Writing four posts in one sitting is more efficient than four separate sittings,
and it protects the cadence against a bad week. The risk is that batched posts
drift toward the abstract, because the specific detail that makes a post good
usually comes from the day you had.
The working compromise: batch the *drafting*, keep a running note of specifics as
they happen, and let each post steal one from the note.
## Streaks are a trap, consistency is not
A skipped week is fine. A skipped month resets you to the starting stage, because
the audience's memory is shorter than anyone's ego expects. The failure mode to
avoid is not the missed week — it is treating the missed week as proof the whole
thing failed, and stopping.
---
## Sources
1. Clear, J. **Atomic Habits** — systems over goals, and designing for the bad
day rather than the good one.
2. Fogg, B.J. **Tiny Habits** — behaviour = motivation × ability × prompt;
lowering the ability cost is what the minimum viable week does.
3. Newport, C. **Deep Work** — fixed-schedule productivity, and why the budget
should be declared before the plan rather than after.
4. Reinertsen, D. **The Principles of Product Development Flow** — queues and
work-in-progress limits; a content plan is a queue and overloading it stalls it.
5. Orbit Media. **Annual Blogger Survey** — the long-running longitudinal record
showing that publishing frequency and time-per-post both rose while typical
returns did not, which is the case for choosing a sustainable floor.
6. Parkinson, C.N. **Parkinson's Law** (*The Economist*, 1955) — work expands to
fill the time available, which is why a timeboxed post cost is more honest
than an open-ended one.
7. Kahneman, D. & Tversky, A. **"Intuitive Prediction: Biases and Corrective
Procedures"** (1979) — the planning fallacy, the specific bias that makes
every content calendar optimistic.

View file

@ -0,0 +1,129 @@
# Newsletter Playbook — a standing promise, priced before it is made
A LinkedIn newsletter notifies every subscriber on every issue. That notification
is the whole value and the whole risk: it is a standing promise about frequency
and subject, made to people who opted in on the strength of the first issue.
Most LinkedIn newsletters are abandoned after four issues. Not because the topic
was wrong — because the cadence was chosen against a month the author happened to
have free.
---
## Eligibility — what LinkedIn actually says
LinkedIn Help states that members and Pages with **more than 150 followers and/or
connections** are eligible to be *evaluated* for newsletter access, and that
access is granted based on **a set of criteria, all of which must be met**
criteria LinkedIn does not publish in full. Authors can run up to five
newsletters at a time.
Two things follow:
- 150 is a floor for evaluation, not a guarantee of access. 🟢
- The tool checks the published floor and says explicitly that LinkedIn, not the
tool, decides. Anyone stating the complete criteria list is guessing. 🔴
Creator-mode access and newsletter access have been coupled and decoupled over
time; existing authors have retained access across those changes. Check the
current Help article rather than trusting any secondary source, including this
one.
## Sustainability, over six months
`newsletter_planner.py` prices cadence × issue cost against a **six-month**
horizon, not a good month. Six months is the horizon because that is roughly when
a newsletter starts to have a returning readership rather than a launch audience.
| Cadence | Issues/month | At 150 min/issue |
|---|---|---|
| Weekly | ~4.3 | ~645 min/month |
| Every two weeks | ~2.15 | ~322 min/month |
| Monthly | 1 | 150 min/month |
If the budget does not cover it, the tool refuses and names the cadence that
fits. **Dropping cadence before launch is free. Dropping it after launch is a
broken promise to people who opted in to a frequency.**
Under 20% headroom, it warns: one busy month breaks the cadence. The mitigations
that actually work are banking two issues before launch, and keeping one
low-cost format (the roundup) in reserve for a bad month.
## Shape — twelve issues, not twelve essays
An arc that alternates issue *types* is easier to sustain and better to read than
twelve variations of the same essay:
| Type | What it is | Cost |
|---|---|---|
| **framework** | A repeatable way to make one decision. The issue people forward. | High |
| **teardown** | One real artifact examined in public, with permission or anonymised. | High |
| **field-note** | What you actually did this fortnight, including what failed. | Low |
| **counter-take** | The received wisdom in your field, and where it breaks. | Medium |
| **reader-question** | One question a reader asked, answered at length. | Low |
| **roundup** | What you read and what changed your mind. | Lowest |
The planner rotates types across pillars, offsetting the cycles so pillar/type
pairs do not repeat in lockstep.
## Naming
Name it after the problem it solves, not after yourself. "The Analytics Trust
Letter" tells a stranger whether to subscribe. "Alex's Newsletter" requires them
to already know who Alex is, which is the audience you already have.
The subtitle does the qualifying work: who it is for, and how often.
## The stop rule, written before issue one
This is the part everyone skips, and it is the reason abandoned newsletters sit
on profiles for years.
- If three consecutive issues land below half the median engagement of your
regular posts, the format is not earning its cost. Move the material back to
posts.
- If you miss two scheduled issues in a quarter, drop the cadence one step rather
than trying to catch up. Subscribers notice frequency, not effort.
- **Ending it deliberately, with a final issue that says so, costs nothing.**
Letting it go quiet is the version people remember.
## Newsletter versus posts
A newsletter is worth it when the material genuinely needs length *and* an
audience is already asking for the next one. It is not a growth tactic on its
own — the notification reaches people who already subscribed.
Under ~500 followers the planner warns rather than refuses: eligible, but the
feedback is too sparse to tell you whether the topic is right, which is the main
thing a newsletter is supposed to teach you.
## Repurposing between the two
The clean direction is **newsletter → posts**: an issue yields two or three
standalone posts over the following fortnight, each linking to the issue in the
first comment. Run them through `repurpose_splitter.py` with a ledger so the same
unit does not go out twice.
The reverse direction — stitching old posts into an issue — works only if the
issue adds a synthesis the posts did not have. Otherwise subscribers are being
notified about something they already read.
---
## Sources
1. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150
followers/connections threshold, and the existence of unpublished criteria.
2. LinkedIn Help. **"Manage a newsletter on LinkedIn"** and **"Newsletters on
LinkedIn FAQ"** — the five-newsletter limit, cadence declaration, subscriber
notification behaviour.
3. LinkedIn Help. **"Updates to Creator Mode."** — the coupling and decoupling of
creator tools and newsletter access over time.
4. Kleon, A. **Show Your Work!** — the case for a recurring, low-ceremony
publishing habit over occasional set pieces.
5. Handley, A. **Everybody Writes** — editorial calendars, the "bigger, braver"
standard for anything that lands in a notification.
6. Clear, J. **Atomic Habits** — the two-minute rule and habit stacking, applied
here as the reserve low-cost issue format.
7. Doerr, J. **Measure What Matters** — committing the stop condition in advance,
while it is still a decision rather than a defeat.

View file

@ -0,0 +1,118 @@
# Objective to Pillars — turning "grow my LinkedIn" into an editorial constitution
"Build my personal brand" is not an objective. It is a wish with no failure
condition, which means no post can ever be judged against it and no post can ever
be refused. That is why feeds drift: without a stated objective, every idea is
equally on-topic.
---
## The six real objectives
Each one implies a different audience, a different proof requirement, and a
different definition of a good week. `positioning_brief.py` accepts only these
six, on purpose.
| Objective | The audience that matters | Proof it demands | The thing people get wrong |
|---|---|---|---|
| **career-change** | Practitioners and hiring managers in the *target* field | Evidence of doing the new thing, however small | Writing to their current colleagues, who cannot hire them into the new role |
| **consulting** | The person who signs the invoice | One artifact a buyer can evaluate in 60 seconds | Writing for peers, who are flattering and never buy |
| **thought-leadership** | Practitioners who could disagree competently | A position that could be wrong | Writing agreeable summaries nobody argues with |
| **hiring** | The specific person you want to hire | The team's real working conditions, stated publicly | Job-ad language, which repels the candidates worth having |
| **fundraising** | Operators and angels in the category | A dated public track record of the thesis | Starting three weeks before the raise, which is legible |
| **community** | People with the same problem, who would recognise each other | A recurring format they anticipate | Broadcasting instead of convening |
**Pick one.** Two objectives produce a feed that serves neither, because the
audiences do not overlap as much as they appear to. If two genuinely matter, run
one for a quarter and then the other; sequencing works, blending does not.
## Pillars: two to four, and the arithmetic matters
A pillar is a recurring subject you have standing to speak on. The brief requires
each one to carry three fields:
- **name** — what it is about, in the audience's language
- **why you** — the specific standing you have. If anyone could post it, cut it.
- **proof** — the thing that already exists: shipped work, a measurement, a repo,
a hire, a talk. Empty proof marks the pillar experimental.
- **share** — the percentage of your output. This is a budget, and its job is to
decide what gets cut in a busy week.
Rules the tool enforces, and why:
- **Two to four.** Under two is a monologue; over four is a magazine nobody
subscribed to, and it prevents any single association forming.
- **Shares sum to 100.** If they do not, there is no budget, only preferences.
- **At least one pillar proof-backed.** Otherwise every pillar is a claim you
would have to invent evidence for — see the fabrication refusal in
`policy_and_account_safety.md`.
- **At least one pillar at 10-20%.** The experimental slot. Next quarter's main
pillar comes from here, and without it the plan can only shrink.
## The exclusion list is the actual positioning
`positioning_brief.py` refuses a brief with fewer than two exclusions, and this
is the rule people push back on hardest.
A positioning that excludes nothing is not a positioning; it is availability.
Naming two or three topics you will not post about does three things: it settles
the "should I comment on this news cycle" question in advance, it stops the feed
drifting toward whatever is trending, and it makes the pillars mean something by
contrast.
Good exclusions are specific and reasoned:
- "Generic AI commentary — I have no edge there and neither does anyone posting it."
- "Hot takes on other companies' layoffs."
- "Vendor comparisons that would compromise client confidentiality."
Bad exclusions are performative: "politics", "negativity". Nobody was going to
post those anyway.
## Success criteria that someone else could verify
The brief emits observable 90-day criteria per objective. They share one
property: **another person could check them.**
Follower count is deliberately not among them. It moves for reasons unrelated to
whether the objective is being met — one post reaching an adjacent audience adds
followers who will never engage again, and a quarter of excellent, well-targeted
work can add very few. Optimising the number you can see instead of the outcome
you want is the most common way a LinkedIn strategy fails while appearing to work.
Better criteria, by shape:
- **Conversations you did not start.** Inbound is the honest signal.
- **Specific references.** Someone citing a specific post in their first message.
- **Invitations.** Podcast, panel, guest post, referral.
- **The sentence test.** Can you name the one idea people now associate with you?
## Reviewing the brief
Quarterly, not weekly. A brief revised every month is not a constitution; it is a
mood. At the review, three questions:
1. Which pillar produced the outcomes, as opposed to the engagement?
2. Did the experimental pillar earn promotion, or should it be replaced?
3. Has the audience description got vaguer? (It always drifts toward broader.
Drag it back.)
---
## Sources
1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — a position is
defined by what it excludes.
2. Porter, M. **"What Is Strategy?"** *Harvard Business Review*, 1996 — strategy
is the deliberate choice of what not to do; the exclusion list is that idea at
the scale of one feed.
3. Clark, D. **Stand Out** — building a public identity around one distinctive
idea rather than a portfolio of competences.
4. Ibarra, H. **Working Identity** — career transitions proceed by public action
in the new field, not by private decision followed by announcement.
5. Kleon, A. **Show Your Work!** — process as publishable material, which is what
makes an unproven pillar survivable before the proof exists.
6. Doerr, J. **Measure What Matters** — objectives paired with observable key
results; the 90-day criteria are that pattern at personal scale.
7. Godin, S. **This Is Marketing** — "the smallest viable audience", the direct
answer to an audience description that keeps drifting broader.

View file

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""cadence_planner.py — size a LinkedIn week against the hours that actually exist.
Content plans do not fail on ideas. They fail in week five, when the plan assumed
six hours and the person has ninety minutes. This tool prices every activity in
minutes, allocates the real budget by stage, and refuses a target it cannot pay
for naming the overage rather than quietly shrinking the plan.
It also emits a minimum viable week: the subset to keep when the week collapses.
Consistency is the compounding variable, so the fallback matters more than the
ideal plan.
Allocation shifts with stage on purpose. From a standing start, most of the budget
belongs in other people's comment sections — a post published to nobody reaches
nobody, and a substantive comment on a well-read post is the cheapest distribution
available.
Exit codes:
0 plan fits the budget
2 budget is below the floor a comment-only week is returned instead
3 the requested target does not fit; the overage is named
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import sys
# Minutes per unit of work, drafting + revision + posting included.
COST = {
"text-post": 25,
"document-carousel": 90,
"native-video": 120,
"image-post": 30,
"article": 180,
"newsletter-issue": 150,
"comment": 6,
"outreach-message": 5,
"own-post-replies": 20, # per published post, in the first hours after posting
}
FLOOR_MINUTES = 90
STAGES = {
"starting": {
"label": "Under ~1,000 followers, or restarting after a long gap",
"engagement_share": 0.60,
"why": "Your own posts have almost no distribution yet. Comments on posts that already "
"have an audience are the only lever that works from zero.",
"comment_floor_per_day": 5,
},
"rebuilding": {
"label": "An audience exists but has gone quiet",
"engagement_share": 0.45,
"why": "Reach recovers with consistency, not with a big swing. Split the budget while "
"the cadence re-establishes.",
"comment_floor_per_day": 3,
},
"established": {
"label": "Posts reliably reach people who are not your connections",
"engagement_share": 0.30,
"why": "Distribution is working; the constraint is now the quality and frequency of "
"what you publish.",
"comment_floor_per_day": 2,
},
}
def plan(minutes: int, stage: str, target_posts: int, formats: list,
outreach_per_week: int) -> dict:
spec = STAGES[stage]
findings = []
if minutes < FLOOR_MINUTES:
return {
"verdict": "BELOW_FLOOR",
"exit_code": 2,
"minutes_available": minutes,
"floor": FLOOR_MINUTES,
"recommendation": {
"posts_per_week": 0,
"comments_per_week": max(1, minutes // COST["comment"]),
"note": "Under 90 minutes a week, publishing on a schedule will break before it "
"compounds. Spend the whole budget on substantive comments in other "
"people's threads: it builds the same recognition, costs a fraction of "
"the time, and stops cleanly when a week disappears.",
},
"when_to_revisit": "Come back to publishing when you can protect 2 hours a week for "
"eight consecutive weeks.",
"rule": "A cadence you abandon in week five is worse than a cadence you never started, "
"because the abandoned one is visible on your profile.",
}
engagement_budget = round(minutes * spec["engagement_share"])
creation_budget = minutes - engagement_budget
outreach_cost = outreach_per_week * COST["outreach-message"]
if outreach_cost > engagement_budget * 0.5:
findings.append({
"severity": "warning", "area": "outreach",
"finding": f"{outreach_per_week} outreach messages cost {outreach_cost} min — over "
f"half the {engagement_budget}-min engagement budget.",
"fix": "Outreach converts far better after someone has seen you in their feed or "
"comments. Shift the balance toward comments until then.",
})
comment_budget = max(0, engagement_budget - outreach_cost)
comments = comment_budget // COST["comment"]
comments_per_day = round(comments / 7, 1)
chosen = [f for f in formats if f in COST] or ["text-post"]
avg_create = sum(COST[f] for f in chosen) / len(chosen)
cost_per_post = avg_create + COST["own-post-replies"]
affordable = int(creation_budget // cost_per_post)
verdict, code = "FITS", 0
if target_posts:
if target_posts > affordable:
need = round(target_posts * cost_per_post + engagement_budget)
findings.append({
"severity": "blocking", "area": "capacity",
"finding": f"{target_posts} posts/week in {', '.join(chosen)} costs about "
f"{round(target_posts * cost_per_post)} min of creation "
f"({round(cost_per_post)} min each including replying to your own "
f"comments). With engagement, the week needs ~{need} min; you have "
f"{minutes}.",
"fix": f"Either drop to {affordable} post(s)/week, move to a cheaper format "
f"(text-post at {COST['text-post']} min), or find {need - minutes} more "
"minutes. Do not solve it by skipping the reply window — replying to "
"early comments is part of the post, not an extra.",
})
verdict, code = "OVER_BUDGET", 3
else:
affordable = target_posts
if affordable == 0 and verdict == "FITS":
findings.append({
"severity": "warning", "area": "capacity",
"finding": f"The chosen formats ({', '.join(chosen)}) cost more than the "
f"{creation_budget}-min creation budget allows for even one post.",
"fix": "Add text-post to the format mix, or accept a fortnightly cadence for the "
"expensive format.",
})
if comments_per_day < spec["comment_floor_per_day"]:
findings.append({
"severity": "warning", "area": "engagement",
"finding": f"{comments_per_day} comments/day is below the {spec['comment_floor_per_day']} "
f"floor for the '{stage}' stage. {spec['why']}",
"fix": "Shift one post's worth of time into comments. At this stage comments buy "
"more reach per minute than publishing does.",
})
minimum_week = ["1 substantive comment per weekday (30 min total)"]
if affordable >= 1:
minimum_week.insert(0, "1 text post, published on the same day each week")
minimum_week.append("Reply to every comment on your own post within 24 hours")
return {
"verdict": verdict,
"exit_code": code,
"stage": stage,
"stage_label": spec["label"],
"minutes_available": minutes,
"budget_split": {"creation": creation_budget, "engagement": engagement_budget,
"engagement_share": spec["engagement_share"], "why": spec["why"]},
"weekly_plan": {
"posts": affordable,
"formats": chosen,
"minutes_per_post_including_replies": round(cost_per_post),
"comments": int(comments),
"comments_per_day": comments_per_day,
"outreach_messages": outreach_per_week,
},
"minimum_viable_week": minimum_week,
"findings": findings,
"rules": [
"Same day, same time, every week. The schedule is the product; the topic varies.",
"Reply time is post time. Block the 60-90 minutes after publishing — early comments "
"are where a post either travels or dies.",
"A skipped week is fine. A skipped month resets you to the starting stage.",
],
}
def render_human(r: dict) -> str:
if r["verdict"] == "BELOW_FLOOR":
rec = r["recommendation"]
return "\n".join([
f"Cadence: BELOW_FLOOR ({r['minutes_available']} min/week, floor {r['floor']})",
"=" * 56, rec["note"], "",
f"This week: {rec['comments_per_week']} substantive comments, 0 posts.",
f"Revisit : {r['when_to_revisit']}", "", r["rule"]])
w = r["weekly_plan"]
b = r["budget_split"]
lines = [f"Cadence: {r['verdict']} ({r['minutes_available']} min/week, stage: {r['stage']})",
"=" * 56,
f"{r['stage_label']}",
f"Split: {b['creation']} min creating / {b['engagement']} min engaging "
f"({int(b['engagement_share'] * 100)}% engagement)",
f" why: {b['why']}", "",
"Weekly plan:",
f" posts : {w['posts']} ({', '.join(w['formats'])}, "
f"~{w['minutes_per_post_including_replies']} min each incl. replies)",
f" comments : {w['comments']} (~{w['comments_per_day']}/day)",
f" outreach msgs : {w['outreach_messages']}"]
if r["findings"]:
lines.append("\nFindings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
lines.append("\nMinimum viable week (what survives a bad week):")
for m in r["minimum_viable_week"]:
lines.append(f" - {m}")
lines.append("\nRules:")
for rule in r["rules"]:
lines.append(f" - {rule}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Size a sustainable LinkedIn week (fits=0 / below-floor=2 / over-budget=3).")
ap.add_argument("--minutes", type=int, help="Minutes per week you will actually protect.")
ap.add_argument("--stage", choices=sorted(STAGES), default="starting")
ap.add_argument("--target-posts", type=int, default=0,
help="Posts per week you want. Omit to be told what fits.")
ap.add_argument("--format", action="append", default=[], choices=sorted(
k for k in COST if k not in ("comment", "outreach-message", "own-post-replies")),
help="Format(s) you intend to publish. Repeatable. Default text-post.")
ap.add_argument("--outreach", type=int, default=0,
help="Manual outreach messages per week (default 0).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Run a built-in example week.")
args = ap.parse_args()
if args.sample:
minutes, stage, target, formats, outreach = 240, "starting", 3, ["text-post"], 10
else:
if args.minutes is None:
ap.error("--minutes is required (or use --sample)")
minutes, stage = args.minutes, args.stage
target, formats, outreach = args.target_posts, args.format, args.outreach
result = plan(minutes, stage, target, formats, outreach)
result["inputs"] = {"minutes": minutes, "stage": stage, "target_posts": target,
"formats": formats or ["text-post"], "outreach": outreach}
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""newsletter_planner.py — gate a LinkedIn newsletter, then lay out its first arc.
A LinkedIn newsletter notifies every subscriber on every issue. That is a standing
promise about cadence and topic, and it is the reason most of them are abandoned
after four issues: the promise was made against a month the author happened to
have free.
This tool gates the promise before it is made:
ELIGIBILITY LinkedIn evaluates access for members and Pages with more than 150
followers and/or connections, among other criteria it does not
publish in full. Below that, the answer is "not yet".
SUSTAINABILITY Cadence x issue cost, checked against a six-month horizon rather
than a good week. Six months is the horizon because that is roughly
when a newsletter starts to have a returning readership.
SHAPE Issues mapped across the declared pillars with rotating issue types,
so the arc is not twelve variations of the same essay.
STOP RULE The condition under which you slow down or end it, written before
issue one, when it is still a decision rather than a defeat.
Exit codes:
0 green eligible, sustainable, arc emitted
2 eligible but thin (named risks; proceed knowingly)
3 refused not eligible yet, or the cadence cannot be sustained
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import sys
ELIGIBILITY_FLOOR = 150 # LinkedIn Help: newsletters evaluated above 150 followers/connections
THIN_AUDIENCE = 500
ISSUE_MINUTES_DEFAULT = 150
HORIZON_MONTHS = 6
CADENCES = {
"weekly": {"issues_per_month": 4.3, "label": "Weekly"},
"biweekly": {"issues_per_month": 2.15, "label": "Every two weeks"},
"monthly": {"issues_per_month": 1.0, "label": "Monthly"},
}
# Rotating issue shapes so the arc does not become twelve identical essays.
ISSUE_TYPES = [
("framework", "A repeatable way to make one decision. The issue people forward."),
("teardown", "One real artifact examined in public — with permission, or anonymised."),
("field-note", "What you actually did this fortnight, including what failed."),
("counter-take", "The received wisdom in your field, and where it breaks."),
("reader-question", "One question a reader asked, answered at length."),
("roundup", "What you read and what changed your mind. Cheapest issue to produce — "
"keep one in reserve for a bad month."),
]
SAMPLE = {
"followers": 1800,
"cadence": "biweekly",
"minutes_per_month": 420,
"issue_minutes": 150,
"pillars": ["Trust debt in analytics", "dbt and modelling decisions",
"Hiring the first analytics engineer"],
"issues": 12,
}
def build(followers: int, cadence: str, minutes_per_month: int, issue_minutes: int,
pillars: list, issues: int) -> dict:
findings = []
spec = CADENCES[cadence]
if followers < ELIGIBILITY_FLOOR:
return {
"verdict": "NOT_ELIGIBLE",
"exit_code": 3,
"followers": followers,
"floor": ELIGIBILITY_FLOOR,
"finding": f"{followers} followers/connections. LinkedIn evaluates newsletter access "
f"above {ELIGIBILITY_FLOOR}, and applies further criteria it does not "
"publish in full.",
"instead": "Publish the same material as regular posts on the same day each week. "
"It builds the audience the newsletter will need, and it costs less to "
"abandon if the topic turns out to be wrong.",
"recheck_at": ELIGIBILITY_FLOOR,
}
if followers < THIN_AUDIENCE:
findings.append({
"severity": "warning", "area": "audience",
"finding": f"{followers} followers. Eligible, but a newsletter to a small list has a "
"high abandonment rate — the feedback is too sparse to tell you whether "
"the topic is right.",
"fix": "Consider running the format as posts for another quarter. The newsletter "
"converts better when there is already an audience asking for the next one.",
})
monthly_cost = spec["issues_per_month"] * issue_minutes
sustainable = monthly_cost <= minutes_per_month
if not sustainable:
affordable = [c for c, s in CADENCES.items()
if s["issues_per_month"] * issue_minutes <= minutes_per_month]
return {
"verdict": "UNSUSTAINABLE",
"exit_code": 3,
"cadence": cadence,
"monthly_cost_minutes": round(monthly_cost),
"monthly_budget_minutes": minutes_per_month,
"horizon_months": HORIZON_MONTHS,
"finding": f"{spec['label']} at {issue_minutes} min/issue costs about "
f"{round(monthly_cost)} min/month against a {minutes_per_month}-min budget. "
f"Over {HORIZON_MONTHS} months that is a "
f"{round(monthly_cost * HORIZON_MONTHS)}-minute commitment you cannot pay.",
"instead": (f"Drop to {', '.join(affordable)}." if affordable else
"Cut the issue cost — a 60-minute field-note format is a real newsletter; "
"a 150-minute essay you skip is not."),
"rule": "Cadence is the promise. Missing it is more damaging than never having made it, "
"because subscribers opted in to a frequency.",
}
headroom = round((minutes_per_month - monthly_cost) / minutes_per_month * 100)
if headroom < 20:
findings.append({
"severity": "warning", "area": "sustainability",
"finding": f"Only {headroom}% headroom. One busy month breaks the cadence.",
"fix": "Bank two issues before launching, and keep the roundup format in reserve as "
"the low-cost issue for a bad month.",
})
pillars = [p for p in pillars if str(p).strip()] or ["(no pillars declared)"]
if pillars == ["(no pillars declared)"]:
findings.append({
"severity": "warning", "area": "shape",
"finding": "No pillars declared, so the arc cannot be balanced against your positioning.",
"fix": "Run positioning_brief.py first. A newsletter with no pillars drifts within "
"three issues.",
})
arc = []
for i in range(issues):
# Offset the pillar cycle by the type cycle so pillar/type pairs do not
# repeat in lockstep every LCM(pillars, types) issues.
pillar = pillars[(i + i // len(ISSUE_TYPES)) % len(pillars)]
itype, why = ISSUE_TYPES[i % len(ISSUE_TYPES)]
arc.append({"issue": i + 1, "pillar": pillar, "type": itype, "shape": why})
verdict, code = ("THIN", 2) if findings else ("GREEN", 0)
return {
"verdict": verdict,
"exit_code": code,
"eligibility": {"followers": followers, "floor": ELIGIBILITY_FLOOR, "eligible": True,
"note": "LinkedIn also applies criteria it does not publish; eligibility "
"is evaluated by LinkedIn, not by this tool."},
"cadence": {"choice": cadence, "label": spec["label"],
"issues_per_month": spec["issues_per_month"],
"monthly_cost_minutes": round(monthly_cost),
"monthly_budget_minutes": minutes_per_month,
"headroom_pct": headroom},
"arc": arc,
"findings": findings,
"stop_rule": [
"Write it now, before issue one.",
"If three consecutive issues land below half the median engagement of your regular "
"posts, the format is not earning its cost — move the material back to posts.",
"If you miss two scheduled issues in a quarter, drop the cadence one step rather "
"than trying to catch up. Subscribers notice frequency, not effort.",
"Ending it deliberately with a final issue costs nothing. Letting it go quiet is "
"the version people remember.",
],
"naming_rule": "Name the newsletter after the problem it solves, not after yourself. "
"'The Analytics Trust Letter' tells a stranger whether to subscribe; "
"'Alex's Newsletter' does not.",
}
def render_human(r: dict) -> str:
if r["verdict"] in ("NOT_ELIGIBLE", "UNSUSTAINABLE"):
lines = [f"Newsletter: {r['verdict']}", "=" * 52, r["finding"], "",
f"Instead: {r['instead']}"]
if "rule" in r:
lines += ["", r["rule"]]
return "\n".join(lines)
c = r["cadence"]
lines = [f"Newsletter: {r['verdict']}", "=" * 52,
f"Eligibility : {r['eligibility']['followers']} followers "
f"(floor {r['eligibility']['floor']}) — {r['eligibility']['note']}",
f"Cadence : {c['label']}{c['monthly_cost_minutes']} of "
f"{c['monthly_budget_minutes']} min/month, {c['headroom_pct']}% headroom", ""]
if r["findings"]:
lines.append("Findings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
lines.append("")
lines.append("Arc:")
for a in r["arc"]:
lines.append(f" #{a['issue']:<3} {a['type']:<15} {a['pillar']}")
lines.append("\nStop rule:")
for s in r["stop_rule"]:
lines.append(f" - {s}")
lines.append(f"\n{r['naming_rule']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Gate and plan a LinkedIn newsletter (green=0 / thin=2 / refused=3).")
ap.add_argument("--followers", type=int, help="Followers and/or connections.")
ap.add_argument("--cadence", choices=sorted(CADENCES), default="biweekly")
ap.add_argument("--minutes-per-month", type=int, default=300,
help="Minutes per month you will protect for the newsletter.")
ap.add_argument("--issue-minutes", type=int, default=ISSUE_MINUTES_DEFAULT,
help=f"Minutes one issue really takes (default {ISSUE_MINUTES_DEFAULT}).")
ap.add_argument("--pillar", action="append", default=[],
help="A content pillar from the positioning brief. Repeatable.")
ap.add_argument("--issues", type=int, default=12, help="Issues to lay out (default 12).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Run a built-in example.")
args = ap.parse_args()
if args.sample:
s = SAMPLE
followers, cadence = s["followers"], s["cadence"]
mpm, im, pillars, issues = (s["minutes_per_month"], s["issue_minutes"],
s["pillars"], s["issues"])
else:
if args.followers is None:
ap.error("--followers is required (or use --sample)")
followers, cadence = args.followers, args.cadence
mpm, im, pillars, issues = (args.minutes_per_month, args.issue_minutes,
args.pillar, args.issues)
if issues < 1 or issues > 52:
ap.error("--issues must be between 1 and 52")
result = build(followers, cadence, mpm, im, pillars, issues)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""positioning_brief.py — validate a LinkedIn positioning brief before any post is written.
Posting without a brief produces a feed of unrelated observations that reads as
noise even when each individual post is good. This tool does not invent the brief
that is a conversation, and the SKILL.md walks it. It enforces the four things a
brief has to survive:
1. An objective that is one of the six real ones, not "build my brand".
2. An audience specific enough that someone could be excluded from it.
3. Two to four pillars whose shares sum to 100, at least one backed by proof you
already have, and at least one small enough to be an experiment.
4. An exclusion list. A positioning that excludes nothing positions nothing.
It then emits observable 90-day criteria for the declared objective, so "is this
working" has an answer that does not depend on how the week felt.
Exit codes:
0 brief is sound
2 brief has fixable defects (each one named)
3 brief is not usable the objective or audience is too vague to proceed
Stdlib only. No network. Deterministic.
"""
import argparse
import json
import sys
OBJECTIVES = {
"career-change": {
"label": "Move into a different role or field",
"criteria": [
"≥3 inbound conversations with people who hold the target role (not recruiters)",
"≥1 referral or intro offered without you asking",
"Profile headline and About describe the target role, not the current one",
],
"audience_hint": "hiring managers and practitioners in the target field, not your "
"current colleagues",
},
"consulting": {
"label": "Generate consulting or freelance work",
"criteria": [
"≥5 qualified inbound enquiries (they named a budget, timeline, or scoped problem)",
"≥1 closed engagement traceable to a post or a comment thread",
"Featured section contains one artifact a buyer can evaluate in 60 seconds",
],
"audience_hint": "the person who signs the invoice, not the person who does the work",
},
"thought-leadership": {
"label": "Be cited as a credible voice on one specific thing",
"criteria": [
"≥3 posts where practitioners in the field argued with you substantively in comments",
"≥1 invitation (podcast, panel, guest post) that referenced a specific post",
"You can name the one sentence people now associate with you",
],
"audience_hint": "practitioners who could disagree with you competently",
},
"hiring": {
"label": "Attract candidates to a team you are building",
"criteria": [
"≥10 candidate conversations sourced without an agency",
"≥2 candidates who referenced a specific post in their first message",
"The team's actual working conditions are described somewhere public",
],
"audience_hint": "the specific engineer/designer/seller you want, not 'talent'",
},
"fundraising": {
"label": "Build investor and operator awareness ahead of a raise",
"criteria": [
"≥5 investor or operator conversations initiated by them",
"A public track record of the thesis dated before the raise, not after",
"No confidential metric published that the cap table would object to",
],
"audience_hint": "operators and angels in your category, not generalist VC accounts",
},
"community": {
"label": "Build a durable group around a shared problem",
"criteria": [
"≥20 named people who reliably show up in your comments",
"≥1 recurring format the audience anticipates",
"Conversation continues without you starting it",
],
"audience_hint": "people with the same problem, who would recognise each other",
},
}
VAGUE_AUDIENCE = [
"everyone", "professionals", "people", "business leaders", "the industry",
"anyone interested", "my network", "b2b", "companies", "the market",
"decision makers", "tech people", "the community",
]
SAMPLE = {
"objective": "consulting",
"audience": "heads of data at Series A-B SaaS companies who have three analysts and no "
"analytics engineer, and whose CEO does not trust the dashboard",
"pillars": [
{"name": "Trust debt in analytics", "why_you": "I have rebuilt this at three companies",
"proof": "the 6-vs-35-days handoff measurement", "share": 40},
{"name": "dbt and modelling decisions", "why_you": "I maintain two OSS dbt packages",
"proof": "public repos + a conference talk", "share": 35},
{"name": "Hiring the first analytics engineer",
"why_you": "I have written three of these job specs",
"proof": "two hires who are still in role", "share": 15},
{"name": "Field notes / experiments", "why_you": "unproven, testing the appetite",
"proof": "", "share": 10},
],
"exclusions": [
"generic AI commentary — I have no edge there",
"hot takes on other companies' layoffs",
"vendor comparison posts that would compromise client confidentiality",
],
}
def validate(brief: dict) -> dict:
findings, blocking = [], []
def add(sev, field, msg, fix):
entry = {"severity": sev, "field": field, "finding": msg, "fix": fix}
(blocking if sev == "blocking" else findings).append(entry)
obj = (brief.get("objective") or "").strip().lower()
if obj not in OBJECTIVES:
add("blocking", "objective",
f"'{brief.get('objective')}' is not one of the six real objectives "
f"({', '.join(OBJECTIVES)}).",
"Pick the one that would make you stop posting if it were achieved. "
"'Build my brand' is not an objective; it is a side effect of one.")
aud = (brief.get("audience") or "").strip()
aud_low = aud.lower()
if not aud:
add("blocking", "audience", "No audience declared.",
"Name them so specifically that a real person could be excluded.")
elif len(aud.split()) < 6 or any(v == aud_low or aud_low.startswith(v) for v in VAGUE_AUDIENCE):
add("blocking", "audience",
f"'{aud}' is too broad to exclude anyone, which means it cannot guide a single "
"editorial decision.",
"Add the situation they are in, not just their title: role + company stage + the "
"problem they have this quarter."
+ (f" For {obj}, aim at {OBJECTIVES[obj]['audience_hint']}." if obj in OBJECTIVES else ""))
pillars = brief.get("pillars") or []
if not 2 <= len(pillars) <= 4:
add("blocking", "pillars",
f"{len(pillars)} pillar(s). Under two is a monologue; over four is a magazine "
"nobody subscribed to.",
"Two to four. If a fifth matters that much, it is displacing one of the others.")
else:
total = sum(int(p.get("share") or 0) for p in pillars)
if abs(total - 100) > 2:
add("major", "pillars", f"Shares sum to {total}, not 100.",
"Rebalance. The share is a budget: it decides what gets cut in a busy week.")
backed = [p for p in pillars if (p.get("proof") or "").strip()]
if not backed:
add("blocking", "pillars",
"No pillar has a proof asset behind it. Every pillar is a claim you would have "
"to invent evidence for.",
"At least one pillar must rest on something that already exists: a shipped "
"project, a measurement, a repo, a hire, a talk.")
elif len(backed) < len(pillars) - 1:
add("major", "pillars",
f"Only {len(backed)}/{len(pillars)} pillars are proof-backed.",
"One unproven experimental pillar is healthy. Two or more means you are "
"positioning on ambition rather than track record.")
experimental = [p for p in pillars if int(p.get("share") or 0) <= 20]
if not experimental:
add("major", "pillars",
"Every pillar is a major commitment; there is no small slot to test something new.",
"Keep one pillar at 10-20%. It is where next quarter's main pillar comes from.")
for p in pillars:
if not (p.get("why_you") or "").strip():
add("major", "pillars",
f"Pillar '{p.get('name', '?')}' has no 'why you'. Anyone could post it.",
"State the specific standing you have. If there is none, cut the pillar.")
exclusions = [e for e in (brief.get("exclusions") or []) if str(e).strip()]
if len(exclusions) < 2:
add("blocking", "exclusions",
f"{len(exclusions)} exclusion(s). A positioning that excludes nothing is not a "
"positioning — it is availability.",
"Name at least two topics you will not post about, and why. The trending one you "
"have no edge on is usually the first.")
all_findings = blocking + findings
if blocking:
verdict, code = ("NOT_USABLE", 3) if any(
f["field"] in ("objective", "audience") for f in blocking) else ("DEFECTIVE", 2)
elif findings:
verdict, code = "DEFECTIVE", 2
else:
verdict, code = "SOUND", 0
out = {
"verdict": verdict,
"exit_code": code,
"objective": obj if obj in OBJECTIVES else None,
"objective_label": OBJECTIVES[obj]["label"] if obj in OBJECTIVES else None,
"audience": aud,
"pillar_mix": [{"name": p.get("name"), "share": p.get("share"),
"proof_backed": bool((p.get("proof") or "").strip())}
for p in pillars],
"exclusions": exclusions,
"findings": all_findings,
"rule": ("The brief is the editorial constitution. When a post idea does not fit a "
"pillar, the answer is not to add a pillar — it is to not post it, or to "
"put it in the experimental slot and see."),
}
if obj in OBJECTIVES:
out["ninety_day_criteria"] = OBJECTIVES[obj]["criteria"]
out["criteria_rule"] = ("Observable by someone other than you. Follower count is not on "
"this list on purpose — it moves for reasons unrelated to whether "
"the objective is being met.")
return out
def render_human(r: dict) -> str:
lines = [f"Positioning brief: {r['verdict']}", "=" * 52]
if r["objective_label"]:
lines.append(f"Objective : {r['objective']}{r['objective_label']}")
lines.append(f"Audience : {r['audience'] or '(none)'}")
if r["pillar_mix"]:
lines.append("Pillars :")
for p in r["pillar_mix"]:
mark = "proof-backed" if p["proof_backed"] else "UNPROVEN"
lines.append(f" {str(p['share']):>3}% {p['name']} [{mark}]")
if r["exclusions"]:
lines.append("Will not post about:")
for e in r["exclusions"]:
lines.append(f" - {e}")
if r["findings"]:
lines.append("\nFindings:")
for f in r["findings"]:
lines.append(f" [{f['severity'].upper():<8}] {f['field']}: {f['finding']}")
lines.append(f" fix → {f['fix']}")
else:
lines.append("\nNo findings.")
if r.get("ninety_day_criteria"):
lines.append("\n90-day success criteria:")
for c in r["ninety_day_criteria"]:
lines.append(f" [ ] {c}")
lines.append(f" ({r['criteria_rule']})")
lines.append(f"\n{r['rule']}")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(
description="Validate a LinkedIn positioning brief "
"(sound=0 / defective=2 / not-usable=3).")
ap.add_argument("--objective", choices=sorted(OBJECTIVES))
ap.add_argument("--audience", help="Who this is for, specifically.")
ap.add_argument("--pillar", action="append", default=[],
help="name:why_you:proof:share — repeatable. Empty proof = experimental.")
ap.add_argument("--exclude", action="append", default=[],
help="A topic you will not post about. Repeatable.")
ap.add_argument("--input", help="Read the whole brief from a JSON file ('-' for stdin).")
ap.add_argument("--output", choices=["json", "human"], default="json")
ap.add_argument("--sample", action="store_true", help="Validate a built-in sample brief.")
ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.")
args = ap.parse_args()
if args.print_schema:
print(json.dumps(SAMPLE, indent=2))
return 0
if args.sample:
brief = SAMPLE
elif args.input:
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
try:
brief = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr)
return 4
else:
pillars = []
for spec in args.pillar:
parts = spec.split(":")
while len(parts) < 4:
parts.append("")
name, why, proof, share = parts[0], parts[1], parts[2], parts[3]
try:
share_val = int(share) if share.strip() else 0
except ValueError:
print(f"ERROR: pillar share must be an integer: '{spec}'", file=sys.stderr)
return 4
pillars.append({"name": name.strip(), "why_you": why.strip(),
"proof": proof.strip(), "share": share_val})
brief = {"objective": args.objective, "audience": args.audience,
"pillars": pillars, "exclusions": args.exclude}
if not brief["objective"] and not brief["audience"]:
ap.error("provide --objective and --audience (or --input / --sample)")
result = validate(brief)
print(json.dumps(result, indent=2) if args.output == "json" else render_human(result))
return result["exit_code"]
if __name__ == "__main__":
sys.exit(main())