11# publish-linkedin
22
33---
4- description: Publish a Spaarke blog article to LinkedIn as a feed-card post, with chat approval gate
4+ description: Publish a Spaarke piece to LinkedIn (blog promotion or standalone post) , with chat approval gate
55tags: [ linkedin, publishing, marketing, orchestration]
66appliesTo: [ "publish to linkedin", "/publish-linkedin"]
77alwaysApply: false
88---
99
1010## Purpose
1111
12- ** Orchestrator Skill** — The operator's only interface for promoting a
13- published Spaarke blog article to LinkedIn. Drafts a feed-card
14- commentary in the voice appropriate to the target surface (company
15- page or personal account), resolves the 1920×1080 image, gates on a
16- chat approval, then shells out to ` npx tsx scripts/linkedin-publish.ts ` which
17- holds the OAuth credentials and talks to LinkedIn's Posts API.
12+ ** Orchestrator Skill** — The operator's only interface for promoting
13+ Spaarke content to LinkedIn. Two modes:
14+
15+ - ** blog-promo** * (default)* — promotes a published blog article as a
16+ feed-card post (link card with thumbnail and excerpt). Requires a
17+ live ` content/blog/<date>-<slug>.mdx ` and a canonical URL on
18+ ` spaarke.com/why-spaarke/<slug> ` .
19+ - ** standalone** — posts a self-contained piece of content (no
20+ backing blog article). Body comes from
21+ ` content-platform/articles/<slug>/draft.md ` . Image is optional.
22+
23+ Drafts commentary in the voice appropriate to the target surface
24+ (company page or personal account) for blog-promo; uses the
25+ draft.md body verbatim for standalone. Gates on chat approval, then
26+ shells out to ` npx tsx scripts/linkedin-publish.ts ` which holds the
27+ OAuth credentials and talks to LinkedIn's Posts API.
1828
1929Full architectural spec: [ ` projects/linkedin-publishing/spec.md ` ] ( ../../../projects/linkedin-publishing/spec.md ) .
2030The 7-gate workflow this skill implements is §4.2. The two-voice
@@ -50,14 +60,20 @@ evolve independently.
5060
5161- User says "publish to LinkedIn", "publish <slug > to LinkedIn", or
5262 invokes ` /publish-linkedin <slug> ` .
53- - A ` content/blog/<date>-<slug>.mdx ` file exists, has ` draft: false ` ,
54- and the corresponding URL is live on ` spaarke.com/why-spaarke/<slug> ` .
55- - After the article has gone through the ` content-pipeline ` workflow
56- and been merged to ` main ` .
63+ - For ** blog-promo** (default): a ` content/blog/<date>-<slug>.mdx `
64+ file exists, has ` draft: false ` , and the corresponding URL is live
65+ on ` spaarke.com/why-spaarke/<slug> ` . After the article has gone
66+ through the ` content-pipeline ` workflow and been merged to ` main ` .
67+ - For ** standalone** : a ` content-platform/articles/<slug>/draft.md `
68+ file exists with the post body. No blog article required.
69+
70+ Mode dispatch: if ` --mode= ` is not specified, the skill auto-detects
71+ by looking for ` content/blog/<date>-<slug>.mdx ` . If found,
72+ blog-promo; if absent but ` articles/<slug>/draft.md ` exists,
73+ standalone. If both or neither exist, asks the operator.
5774
5875Do ** not** use this skill for:
5976- Native LinkedIn Articles (Pulse) — out of scope per spec §2.
60- - Posts that aren't promoting an existing ` content/blog/ ` piece.
6177- Scheduled future-dated posts — this is a single-click * now*
6278 publisher.
6379
@@ -68,13 +84,23 @@ Do **not** use this skill for:
6884### Step 1 — Validate
6985
7086```
71- PARSE invocation: <slug> [--target=company|personal] [--draft-fresh]
87+ PARSE invocation: <slug> [--target=company|personal] [--mode=blog-promo|standalone] [-- draft-fresh]
7288
7389IF --target is missing:
7490 -> ASK: "Which surface — company page or personal account?"
7591 -> WAIT for "company" | "personal"
7692
77- VALIDATE:
93+ RESOLVE mode:
94+ IF --mode is provided -> use it.
95+ ELSE auto-detect:
96+ blog-mdx-exists = exists(content/blog/<date>-<slug>.mdx)
97+ draft-md-exists = exists(content-platform/articles/<slug>/draft.md)
98+ IF blog-mdx-exists AND NOT draft-md-exists -> mode = blog-promo
99+ IF draft-md-exists AND NOT blog-mdx-exists -> mode = standalone
100+ IF both -> ASK: "Both a blog article and a standalone draft exist. Which mode?"
101+ IF neither -> STOP with helpful error.
102+
103+ VALIDATE (blog-promo):
78104 1. content/blog/<date>-<slug>.mdx exists (glob by slug suffix).
79105 2. Frontmatter `draft:` is missing or false.
80106 3. Frontmatter has `title`, `description`, `summary`. Pull
@@ -86,11 +112,17 @@ VALIDATE:
86112 npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --dry-run
87113 and capture any "secret not found" / "token expired" surface.
88114
115+ VALIDATE (standalone):
116+ 1. content-platform/articles/<slug>/draft.md exists and has a non-empty body.
117+ 2. Skip the "live URL" check entirely — standalone posts have no backing article.
118+ 3. KV credentials for the chosen target exist. Probe by running:
119+ npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone --dry-run
120+
89121IF validation fails:
90122 -> STOP — surface the specific issue.
91123 -> If a 401 / token error: route to Error Handling §"Token expired".
92- -> If 404 on the canonical URL: "The article isn't live yet —
93- check the deploy or wait for SWA to finish."
124+ -> If 404 on the canonical URL (blog-promo only) : "The article
125+ isn't live yet — check the deploy or wait for SWA to finish."
94126```
95127
96128** Output to user** :
@@ -115,6 +147,8 @@ Next: resolve the LinkedIn image.
115147
116148### Step 2 — Resolve image
117149
150+ ** For blog-promo:**
151+
118152```
119153PRIMARY: public/articles/<slug>/linkedin-1920x1080.png
120154 IF exists -> use it.
@@ -139,6 +173,22 @@ NO ASSET:
139173 hero.svg or linkedin-1920x1080.png and re-run."
140174```
141175
176+ ** For standalone:**
177+
178+ ```
179+ DEFAULT: skip image. Standalone posts are typically text-only —
180+ LinkedIn auto-renders an OG link card from the first URL in
181+ commentary, which is usually the desired behavior.
182+
183+ IF operator wants an image:
184+ -> ASK: "Standalone posts default to text-only. Want to attach an
185+ image? Reply with the path (relative to repo root), or
186+ 'skip' to post text-only."
187+ -> If a path: validate the file exists, is PNG/JPG, ≤ 8 MB.
188+
189+ The CLI will pass --image=<path> (or omit) accordingly.
190+ ```
191+
142192** Output to user** :
143193```
144194Image resolved:
@@ -158,6 +208,8 @@ Next: resolve the commentary.
158208
159209### Step 3 — Resolve commentary
160210
211+ ** For blog-promo:**
212+
161213```
162214DRAFT-FRESH PATH:
163215 IF --draft-fresh was passed -> skip to "Draft fresh" below.
@@ -188,6 +240,22 @@ ALWAYS surface to operator the voice doc(s) used to draft, so they
188240can override.
189241```
190242
243+ ** For standalone:**
244+
245+ ```
246+ READ content-platform/articles/<slug>/draft.md.
247+ -> Strip frontmatter; the body becomes the LinkedIn commentary.
248+ -> Surface to operator: "Using draft from
249+ content-platform/articles/<slug>/draft.md (the writer-approved
250+ text — no voice re-drafting in standalone mode)."
251+
252+ Standalone mode does NOT re-draft from frontmatter. The post body
253+ is whatever the writer wrote in draft.md. If the operator wants to
254+ change it, they can:
255+ - Reply `edit "<copy>"` at the approval gate (Step 5).
256+ - Or edit draft.md directly and re-invoke the skill.
257+ ```
258+
191259** Output to user** :
192260```
193261Commentary drafted using voice: <path or "placeholder profile">
@@ -275,7 +343,12 @@ explicit `approve`.
275343
276344```
277345EXECUTE (Bash tool):
278- npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target>
346+ blog-promo:
347+ npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target>
348+ standalone (text-only):
349+ npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone
350+ standalone (with image):
351+ npx tsx scripts/linkedin-publish.ts --slug=<slug> --target=<target> --mode=standalone --image=<path>
279352
280353The CLI:
281354 1. Loads tokens from KV for <target>; refreshes inline if needed.
0 commit comments