social-distribute
This skill should be used when distributing a blog article across social platforms (Discord, X/Twitter, IndieHackers, Reddit, Hacker News, LinkedIn). Writes a persistent content file for automated publishing.
Social Distribute
Generate platform-specific content variants from a blog article and write them to a persistent content file for the automated publishing pipeline. Discord can optionally be posted immediately via webhook after approval. The content file feeds into the directory-driven cron pipeline (content-publisher.sh) for scheduled publishing.
Headless Mode Detection
If $ARGUMENTS contains --headless, set HEADLESS_MODE=true and strip --headless from $ARGUMENTS. The remainder is the blog post path.
Argument format: <blog-post-path> [--headless]
Headless defaults for interactive gates:
- Phase 7 (Discord Approval): auto-selects Skip (never auto-post to external platforms without human approval).
channelsis alwaysdiscord, x, bluesky, linkedin-company. - Phase 9 Step 2 (Overwrite Check): auto-selects Overwrite (content is regenerated from the same blog post, so overwriting is idempotent).
Prerequisites
Before generating content, verify all prerequisites. If a hard prerequisite fails, display the error message and stop. Soft prerequisites display a warning and continue.
1. Brand Guide (hard)
Check if knowledge-base/marketing/brand-guide.md exists.
If missing:
No brand guide found. Run the brand architect agent first to establish brand identity:
Use the brand-architect agent to define our brand.
Stop execution.
2. Blog Post Path (hard)
The skill expects a blog post path as an argument (e.g., /soleur:social-distribute plugins/soleur/docs/blog/my-article.md).
If no path provided or file does not exist:
Provide a path to a blog post markdown file:
/soleur:social-distribute <path-to-blog-post.md>
Stop execution.
3. Discord Webhook URL (soft)
Check if DISCORD_BLOG_WEBHOOK_URL or DISCORD_WEBHOOK_URL environment variable is set.
If both missing:
Neither
DISCORD_BLOG_WEBHOOK_URLnorDISCORD_WEBHOOK_URLis set. Discord posting will be skipped (manual output only). To configure: Server Settings > Integrations > Webhooks > Copy URL from the #blog channel >export DISCORD_BLOG_WEBHOOK_URL="..."
Continue execution -- Discord will be included in the content file's channels field for cron publishing instead of immediate webhook posting.
Content Input
Phase 1: Read Blog Post
- Read the blog post markdown file at the provided path
- Parse YAML frontmatter to extract:
title,description,date,tags
Phase 2: Gather Current Stats
Run these shell commands to get current component counts:
# Count agents (recursive .md files under agents/)
find plugins/soleur/agents -name '*.md' | wc -l
# Count skills (directories with SKILL.md)
find plugins/soleur/skills -maxdepth 2 -name 'SKILL.md' | wc -l
# Count commands
ls plugins/soleur/commands/*.md | wc -l
# Count departments (non-empty top-level dirs under agents/)
find plugins/soleur/agents -mindepth 1 -maxdepth 1 -type d | wc -l
# Get site URL
cat plugins/soleur/docs/_data/site.json
Extract: agents count, skills count, commands count, departments count, site.url.
Phase 3: Build Article URL
Construct the article URL from site.url and the blog post path:
- Strip
plugins/soleur/docs/prefix from the path - Replace
.mdextension with/ - Strip any leading
YYYY-MM-DD-date prefix from the filename portion of the path (regex:/\d{4}-\d{2}-\d{2}-(.*)/). Eleventy'spage.fileSlugstrips this prefix, so URLs must match. If no date prefix exists, leave the path unchanged. - Prepend
site.url
Example: plugins/soleur/docs/blog/2026-03-24-vibe-coding-vs-agentic-engineering.md becomes https://soleur.ai/blog/vibe-coding-vs-agentic-engineering/
UTM Tracking: Derive the campaign slug from the article URL path — strip /blog/ prefix and trailing /. Example: /blog/caas-pillar/ → caas-pillar. If the slug contains characters other than a-z, 0-9, hyphens, or underscores, replace them with hyphens.
Construct platform-specific tracked URLs using this mapping:
| Platform | Tracked URL |
|---|---|
| Discord | <base-url>?utm_source=discord&utm_medium=community&utm_campaign=<slug> |
| X/Twitter | <base-url>?utm_source=x&utm_medium=social&utm_campaign=<slug> |
| IndieHackers | <base-url>?utm_source=indiehackers&utm_medium=community&utm_campaign=<slug> |
<base-url>?utm_source=reddit | |
| Hacker News | <base-url>?utm_source=hackernews&utm_medium=community&utm_campaign=<slug> |
| LinkedIn Personal | <base-url>?utm_source=linkedin-personal&utm_medium=social&utm_campaign=<slug> |
| LinkedIn Company Page | <base-url>?utm_source=linkedin-company&utm_medium=social&utm_campaign=<slug> |
| Bluesky | <base-url>?utm_source=bluesky&utm_medium=social&utm_campaign=<slug> |
Reddit gets minimal UTM parameters (utm_source only) to reduce spam filter risk — long marketing-looking URLs can trigger irreversible domain reputation damage on Reddit.
Use each platform's tracked URL in the corresponding Phase 5 variant section instead of the bare base URL.
Content Generation
Phase 4: Read Brand Guide
Read the brand guide sections that inform content generation:
- Read
## Voice-- apply brand voice, tone, do's and don'ts - Read
## Channel Notes > ### Discord-- apply Discord-specific guidelines - Read
## Channel Notes > ### X/Twitter-- apply X/Twitter-specific guidelines - Read
## Channel Notes > ### LinkedIn Personal-- apply LinkedIn personal profile guidelines - Read
## Channel Notes > ### LinkedIn Company Page-- apply LinkedIn company page guidelines
If a channel notes section is missing for a platform, generate content using only the ## Voice section.
Phase 5: Generate All Variants
Using the blog post content, stats values, article URL, and brand guide as context, generate all platform-specific variants. The LLM handles template variable substitution (replace {{ stats.agents }} with actual counts), markup stripping (ignore JSON-LD, HTML tags, FAQ accordions), and content adaptation per platform.
Important: Every variant must contain resolved numbers, not template syntax like {{ stats.agents }}. Use the stats gathered in Phase 2.
5.1 Discord Announcement
- Maximum 2000 characters (UTM-tagged URLs are ~60-80 chars longer than bare URLs — account for this in the character budget)
- Include Discord tracked URL
- Match brand voice from
## Voiceand## Channel Notes > ### Discord - Plain text only (no rich embeds)
- Declarative, concrete, builder-to-builder tone
5.2 X/Twitter Thread
- Hook tweet (standalone value, no "thread" announcement)
- Numbered body tweets (2/ 3/ 4/)
- Final tweet with X/Twitter tracked URL and up to one hashtag
- Each tweet maximum 280 characters
- Match brand voice from
## Voiceand## Channel Notes > ### X/Twitter - Links only in final tweet
- No emojis in hook tweet
5.3 IndieHackers Building Update
- Markdown format
- Transparent metrics and numbers
- Building-in-public framing
- Include IndieHackers tracked URL
- Honest, first-person builder voice
5.4 Reddit Post
- Subreddit-appropriate framing (suggest target subreddits: r/SaaS, r/startups, r/solopreneur, r/artificial)
- Non-promotional title and body
- Value-first: lead with the insight, not the product
- Include Reddit tracked URL naturally in context, not as a CTA (minimal UTM:
?utm_source=redditonly) - Reddit detects and punishes self-promotion -- frame as sharing knowledge
5.5 Hacker News Submission
- Title maximum 80 characters
- No marketing language, no ALL CAPS, no exclamation marks
- Factual, understated, curiosity-driven
- Format:
Title | Hacker News tracked URL - HN titles that work: questions, counterintuitive claims, concrete results
5.6 LinkedIn Personal
- Thought-leadership framing: case studies, reflections, lessons learned
- First-person, authentic founder voice ("I built..." not "We launched...")
- Aim for ~1,300 characters (optimal organic visibility), max 3,000
- Professional but not corporate -- substantive, measured, and direct
- Match brand voice from
## Voiceand## Channel Notes > ### LinkedIn Personal - Hook-first: opening line must deliver a complete, compelling idea that works in the feed preview
- Include LinkedIn Personal tracked URL naturally in context, not as a standalone CTA
- One or two relevant hashtags maximum (#solofounder, #buildinpublic, #AIagents)
- No promotional framing -- "Here's what I learned building X" outperforms "Check out our new feature Y"
- Tuesday-Thursday mornings perform best (note in content, not enforced)
- Section heading:
## LinkedIn Personal
5.7 LinkedIn Company Page
- Official announcement tone, third-person company voice ("Soleur now supports...")
- ~1,300 chars optimal, max 3,000
- Professional framing: product updates, feature announcements, milestones
- Match brand voice from
## Voiceand## Channel Notes > ### LinkedIn Company Page - Include LinkedIn Company Page tracked URL naturally in context
- Minimal hashtags (1-2 max)
- Section heading:
## LinkedIn Company Page
5.8 Bluesky Post
- Maximum 300 characters (grapheme count; Bluesky uses codepoint counting as approximation)
- Standalone value post (no threads -- single posts perform better for distribution)
- Match brand voice from
## Voice - Include Bluesky tracked URL
- No hashtags (Bluesky has no hashtag discovery)
- Conversational, direct tone suited to the developer/indie community
- Note: URLs will render as plain text (facet support for clickable links is a future enhancement)
- Section heading:
## Bluesky
Approval Flow
Phase 5.5: Marker Validation (hard gate)
Before presenting variants (Phase 6), scan every generated section for unresolved Liquid/Jinja template markers: {{, }}, {%, %}.
You MUST run the committed linter on the assembled variant text (write it to a temp file with a minimal ---\n---\n frontmatter preamble, then bash scripts/lint-distribution-content.sh <tmpfile>). Do NOT substitute an LLM visual inspection — the rule is "delegate to the deterministic tool that lefthook and content-publisher.sh both enforce against." Exit code 0 means clean; exit 1 means markers found.
Procedure:
- Assemble each generated variant into a
tmpfilewith a minimal frontmatter preamble. - Run
bash scripts/lint-distribution-content.sh <tmpfile>. - If exit 1, do NOT proceed to Phase 6. Regenerate the offending section with explicit substitution of
site.url(resolved value:https://soleur.ai) and any{{ stats.* }}placeholders using Phase 2 values. Re-run the linter against the new output. - If the re-generation still produces markers, STOP and surface to the user: "Auto-regeneration did not resolve Liquid markers -- manual intervention required." Do not loop further.
Template markers in distribution content files are always a bug.
Phase 6: Present All Variants
Display all variants in a summary view with clear headers and character counts:
## Discord (1847/2000 chars)
[content]
## X/Twitter Thread (4 tweets)
[tweet 1] (267/280 chars)
[tweet 2] (243/280 chars)
...
## IndieHackers
[content]
## Reddit
Suggested subreddits: r/SaaS, r/startups
[title]
[body]
## Hacker News
[title] (72/80 chars)
[url]
## LinkedIn Personal (1247/1300 optimal, 1247/3000 max)
[content]
## LinkedIn Company Page (1247/1300 optimal, 1247/3000 max)
[content]
## Bluesky (287/300 chars)
[content]
Phase 7: Discord Approval
If HEADLESS_MODE=true: auto-select Skip. Discord is deferred to the content file for cron publishing.
If DISCORD_BLOG_WEBHOOK_URL or DISCORD_WEBHOOK_URL is set:
Use the AskUserQuestion tool with three options:
- Accept -- Post this content to Discord
- Edit -- Provide feedback to revise the Discord variant (regenerate with feedback, re-present)
- Skip -- Skip Discord posting, include Discord in content file's
channelsfield for cron publishing
If neither is set:
Skip this phase. Discord will be included in the content file's channels field for cron publishing.
Posting
Phase 8: Post to Discord (conditional)
This phase only runs if the user accepted Discord posting in Phase 7. If the user skipped Discord or no webhook URL is set, skip to Phase 9.
On acceptance, post the Discord content via webhook.
First get the webhook URL with printenv DISCORD_BLOG_WEBHOOK_URL || printenv DISCORD_WEBHOOK_URL, then use the literal URL:
curl -s -o /dev/null -w "%{http_code}" \
-H "Content-Type: application/json" \
-d "{\"content\": \"ESCAPED_CONTENT\", \"username\": \"Sol\", \"avatar_url\": \"AVATAR_URL\", \"allowed_mentions\": {\"parse\": []}}" \
"<webhook-url>"
Replace <webhook-url> with the actual URL from printenv.
Set avatar_url to the hosted logo URL (e.g., the GitHub-hosted logo-mark-512.png). Webhook messages freeze author identity at post time -- these fields ensure consistent branding.
Content must be properly JSON-escaped before inserting into the payload. Escape double quotes, backslashes, and newlines.
Payload format: Plain content field only. No rich embeds.
On success (HTTP 2xx):
Posted to Discord successfully.
Track that Discord was posted successfully -- this affects the channels field in Phase 9.
On failure (HTTP 4xx/5xx):
Failed to post to Discord (HTTP [status_code]).
Draft content (copy-paste manually):
[full draft content]
Display the draft so the user can post it manually. Do not retry automatically. Treat a failed post as "Discord not posted" for Phase 9's channels field.
Content File Output
Phase 9: Write Content File
After content generation and approval, write a persistent content file for the directory-driven publishing pipeline.
Step 1: Derive slug and output path
Derive the slug from the blog post filename: strip path, strip .md, keep kebab-case.
Example: plugins/soleur/docs/blog/why-most-agentic-tools-plateau.md → why-most-agentic-tools-plateau
Output path: knowledge-base/marketing/distribution-content/<slug>.md
Step 2: Check for existing file
Check for an existing content file matching this slug. The check must account for both slug-only filenames and legacy numeric-prefixed filenames:
ls knowledge-base/marketing/distribution-content/*<slug>.md 2>/dev/null
If a match is found (either <slug>.md or NN-<slug>.md), use the matched filename as the output path (preserving the existing naming convention).
If HEADLESS_MODE=true: auto-select Overwrite and continue.
If interactive and a file exists, use the AskUserQuestion tool:
- Overwrite -- Replace the existing file with new content
- Cancel -- Abort file writing and stop. The user can rename or delete the existing file, then re-run.
Step 3: Determine channels field
- If Discord was posted successfully in Phase 8: set
channels: x, bluesky, linkedin-company(Discord already done) - If Discord was skipped, failed, or no webhook configured: set
channels: discord, x, bluesky, linkedin-company
Step 4: Write the content file
Write the file with YAML frontmatter and a section per platform. Use the blog post's frontmatter title for the content file title. If the blog post has no frontmatter title, fall back to the first H1 heading or the filename.
---
title: "<blog post title>"
type: pillar
publish_date: ""
channels: <channels from step 3>
status: draft
---
## Discord
<discord content>
---
## X/Twitter Thread
<tweet 1 with label>
<tweet 2 with label>
...
---
## IndieHackers
**Title:** <ih title>
**Body:**
<ih body content>
---
## Reddit
**Subreddit:** <suggested subreddits>
**Title:** <title>
**Body:**
<body>
---
## Hacker News
**Title:** <title>
**URL:** <Hacker News tracked url>
---
## LinkedIn Personal
<linkedin personal content>
---
## LinkedIn Company Page
<linkedin company page content>
---
## Bluesky
<bluesky content>
Step 5: Pre-write marker validation (belt-and-suspenders)
Before writing the assembled content to disk, scan the full content buffer one final time for {{, }}, {%, %}. This repeats the Phase 5.5 check against the fully assembled file (including headings, frontmatter, and inter-section transitions). If any marker is present, STOP and do not write the file. Regenerate the offending section or escalate to the user.
Writing a content file that contains Liquid markers is a workflow violation -- lefthook's distribution-content-liquid-guard will reject the commit, and if the file is somehow committed, content-publisher.sh's runtime gate will refuse to post it. Catch it here so the skill does not produce a broken artifact in the first place.
Phase 10: Summary & Next Steps
Output the file path, channel status, and instructions:
Content file written: knowledge-base/marketing/distribution-content/<slug>.md
Distribution summary:
- Discord: [Posted now via webhook / Will publish via cron when scheduled]
- X/Twitter: Will publish via cron when scheduled
- Bluesky: Will publish via cron when scheduled
- LinkedIn Company Page: Will publish via cron when scheduled
- IndieHackers: Manual (content in file)
- Reddit: Manual (content in file)
- Hacker News: Manual (content in file)
- LinkedIn Personal: Manual (content in file)
Next steps:
1. Review the content file
2. Set publish_date to the target date (YYYY-MM-DD format)
3. Change status from "draft" to "scheduled"
4. The daily cron will publish to Discord, X, Bluesky, and LinkedIn Company Page on the scheduled date
5. Reddit, IndieHackers, Hacker News, and LinkedIn Personal sections are for manual posting
Important Guidelines
- All Discord posting requires explicit user approval before sending -- no auto-send
- Character limits are enforced during generation, not as a post-hoc check (2000 for Discord, 280 per tweet for X/Twitter, 80 for HN title, 300 for Bluesky, 1300 optimal / 3000 max for LinkedIn Personal and LinkedIn Company Page)
- Discord uses the plain
contentfield, not rich embeds - JSON-escape all Discord content before inserting into the webhook payload
- When posting via webhook, always include
username,avatar_url, andallowed_mentions: {parse: []}fields - If the brand guide's channel notes section is missing for a platform, generate content using only the
## Voicesection (no error) - If the user selects "Edit" for Discord, incorporate their feedback and regenerate -- do not present the same draft
- Template variables in blog source (
{{ stats.agents }}etc.) are resolved by passing current stats as LLM context -- the LLM substitutes actual values during generation - Distribution content files are raw API payloads (Discord webhook
contentfield, X tweet text, LinkedIn share text), NOT Eleventy templates. Liquid/Jinja markers ({{,}},{%,%}) in the body will be posted verbatim to third parties -- a{{ site.url }}becomes a literal{{ site.url }}in the Discord message. Phase 5.5 and Phase 9 Step 5 enforce this mechanically; do NOT weaken these gates - Markup artifacts (JSON-LD scripts, HTML details/summary tags, Nunjucks tags) in the blog source are ignored during generation -- they are meaningless in social posts
- Missing
DISCORD_BLOG_WEBHOOK_URLandDISCORD_WEBHOOK_URLdoes not block execution -- Discord is included in the content file'schannelsfield for cron publishing - New content files use the blog post slug as filename. If an existing file with a numeric prefix matches the slug (e.g.,
06-<slug>.md), the existing filename is preserved