syndicate-article
Cross-post articles to Dev.to and Farcaster with hook-driven copy and click-optimized metadata
Install
mkdir -p .claude/skills/syndicate-article-dawsonblock && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16097" && unzip -o skill.zip -d .claude/skills/syndicate-article-dawsonblock && rm skill.zipInstalls to .claude/skills/syndicate-article-dawsonblock
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Cross-post articles to Dev.to and Farcaster with hook-driven copy and click-optimized metadataAbout this skill
${var} — Filename of a specific article to syndicate (e.g.
repo-article-2026-04-16.md). If empty, syndicates the most recently written article.
Cross-post Aeon articles to Dev.to (developer audience) and Farcaster (crypto-native audience) for organic discovery. Articles are published with a canonical URL pointing back to the GitHub Pages gallery, preserving SEO attribution.
Each channel is opt-in — set the relevant secrets and it activates. If neither is configured, the skill logs a skip and exits silently.
Thesis: The biggest lever in syndication is not "did we post" but "will anyone click." Generic "New post: X\n\nURL" casts and body-only Dev.to articles waste the channel's attention budget. This skill extracts a real hook from the article, adds a cover image and description for Dev.to, and refuses to post if no hook exists.
Prerequisites
DEVTO_API_KEY— Dev.to API key. Generate at https://dev.to/settings/extensions (scroll to "DEV Community API Keys").NEYNAR_API_KEY+NEYNAR_SIGNER_UUID— Neynar credentials for Farcaster posting. Get an API key at neynar.com and create a managed signer to obtain the signer UUID.
If none of DEVTO_API_KEY or NEYNAR_SIGNER_UUID are set, the skill logs a skip and exits silently — no error, no notification.
Steps
1. Channel check
if [ -z "$DEVTO_API_KEY" ] && [ -z "$NEYNAR_SIGNER_UUID" ]; then
echo "SYNDICATE_SKIP: no syndication channels configured"
exit 0
fi
Log SYNDICATE_SKIP: no syndication channels configured to memory/logs/${today}.md and stop. Do NOT send any notification.
2. Select the article
- If
${var}is set, usearticles/${var}. - Otherwise, most recently modified
.mdinarticles/(excludefeed.xml,.gitkeep):ls -t articles/*.md 2>/dev/null | grep -v -E '(feed\.xml|\.gitkeep)$' | head -1 - If no articles exist, log
SYNDICATE_SKIP: no articles foundand stop.
3. Dedup check
Search the last 7 days of memory/logs/ for:
SYNDICATED:lines containing this filename → Dev.to already postedFARCAST:lines containing this filename → Farcaster already queued/posted
Track per-channel. If both already posted, log SYNDICATE_SKIP: already syndicated {filename} to all channels and stop. Otherwise proceed with only the missing channels.
4. Parse the article
- Title: first
# Heading. If Jekyll frontmattertitle:exists, use that. - Body (raw): everything after the first heading (or after frontmatter).
- Date: regex
([0-9]{4}-[0-9]{2}-[0-9]{2})on filename. - Slug: filename prefix before the date, trailing hyphens stripped.
- Cover image (
cover_url): if Jekyll frontmatter hasimage:orcover:, use that; otherwise firstin the body whereurlstarts withhttp. If none found, leave empty. - Description (
meta_description): first paragraph of the body after the title — stripped of markdown, trimmed to 140 chars, ending on a word boundary. Used for Dev.todescriptionand Farcaster hook fallback.
5. Clean the body for syndication
Produce body_clean from the raw body:
- Remove any Jekyll liquid tags (
{% ... %},{{ ... }}) — they render as literal text on Dev.to. - Rewrite relative links/images: any
](/foo)or](foo.md)→ absolutehttps://aaronjmars.github.io/aeon/foo(strip.mdwhere present). Preserve anchor fragments. - Strip the first
# Headingline (Dev.to shows the title separately — double-heading looks amateur). - Trim leading/trailing whitespace.
Keep the pre-cleaned body around as a source for step 6's hook extraction.
6. Extract the Farcaster hook (quality gate)
Farcaster's feed rewards specificity. "New post: Title\nURL" produces near-zero engagement. Extract a real hook from the article:
Hook candidates (try in order, stop at first that passes):
- Explicit TL;DR — if the article has a
## TL;DR,## Summary, or**TL;DR:**block, use its first sentence. - First claim paragraph — the first paragraph of the body that is NOT:
- A question title (ends with
?and <60 chars) - Boilerplate ("In this article...", "Today we'll...", "This post covers...")
- A frontmatter echo (repeats the title)
- A code block, table, list, or image
- Shorter than 40 chars or longer than 400 chars
- A question title (ends with
- Strongest line — scan the first 800 chars of the body for the most specific sentence: contains a number, a proper noun, OR a concrete claim verb ("shipped", "found", "broke", "dropped", "crossed", "beat"). Use that line.
Trim the chosen hook to 240 chars, ending on a word boundary. This leaves ~60 chars for the URL within Farcaster's 320-char limit.
Quality gate: If none of the three strategies produce a hook ≥40 chars, set hook_found=false. Skip the Farcaster step entirely and log FARCAST_SKIP: no hook extractable from {filename}. Do not fall back to "New post: X" — a weak cast is worse than no cast (burns attention, trains followers to scroll past).
7. Build the canonical URL
https://aaronjmars.github.io/aeon/articles/YYYY/MM/DD/<slug>/
Where <slug> matches update-gallery's Jekyll post filename convention: title lowercased, spaces → hyphens, non-alphanumerics stripped, truncated to 50 chars.
8. Dev.to post (if enabled + not already syndicated)
a. Derive tags (max 4, Dev.to hard limit) from the filename slug:
repo-article,article→ai, github, automation, agentstoken-report,token-alert,defi-overview,defi-monitor→crypto, defi, blockchain, tradingchangelog,push-recap,shiplog→opensource, devops, changelog, githubdigest,rss-digest,hacker-news→news, tech, ai, digestdeep-research,research-brief,paper-pick→research, ai, machinelearning, paperstechnical-explainer→tutorial, ai, explainer, programming- Everything else →
ai, automation, agents, programming
b. Write the payload to .pending-devto/<slug>-<date>.json (always use the post-process path; WebFetch cannot reliably pass api-key headers from the sandbox):
mkdir -p .pending-devto/
Payload:
{
"article": {
"title": "<extracted title>",
"body_markdown": "<body_clean>",
"published": true,
"tags": ["tag1", "tag2", "tag3", "tag4"],
"canonical_url": "<canonical_url>",
"description": "<meta_description>",
"main_image": "<cover_url or empty>",
"series": "Aeon"
}
}
Omit main_image from the JSON entirely if cover_url is empty (Dev.to rejects empty-string URLs). Omit description if <20 chars (better to let Dev.to auto-excerpt than feed it garbage).
c. scripts/postprocess-devto.sh POSTs to https://dev.to/api/articles and records the URL on success.
d. Record in memory/logs/${today}.md:
SYNDICATED: {filename} → {canonical_url} (queued for Dev.to, see postprocess log for dev.to URL)
(The Dev.to URL is only known after the postprocess run — the log line matches filename for dedup; a future reconciliation skill or manual check picks up the live URL.)
9. Farcaster cast (if enabled + hook_found + not already syndicated)
a. Build the cast text (320-byte Farcaster limit):
<hook>
<canonical_url>
No "New post:" prefix, no emoji, no hashtags — the hook IS the value. Verify total byte length ≤ 310 (leave 10 bytes buffer for embed unfurl metadata). If over, trim the hook further on a word boundary.
b. Write the payload to .pending-farcaster/<slug>-<date>.json — do NOT include NEYNAR_SIGNER_UUID:
{
"text": "<cast text>",
"embeds": [{"url": "<canonical_url>"}]
}
Use mkdir -p .pending-farcaster/ first.
c. scripts/postprocess-farcaster.sh reads each payload, injects NEYNAR_SIGNER_UUID from env, POSTs to https://api.neynar.com/v2/farcaster/cast with x-api-key: $NEYNAR_API_KEY, removes on success.
d. Record in memory/logs/${today}.md:
FARCAST: {filename} → queued (hook: "{first 60 chars of hook}...")
10. Notification
Send via ./notify only if at least one channel was actually queued (not skipped). Match operator voice — direct, concrete, no hype.
If both Dev.to + Farcaster queued:
Syndicated "{title}"
Dev.to: queued with cover image and description.
Farcaster: hook ready — "{first 80 chars of hook}..."
Canonical: {canonical_url}
If only Dev.to (Farcaster skipped on quality gate or missing secret):
Syndicated "{title}" to Dev.to
Farcaster skipped ({reason: no hook extractable / not configured}).
Canonical: {canonical_url}
If only Farcaster (Dev.to skipped or missing secret):
Cast queued for "{title}"
Hook: "{first 80 chars}..."
Canonical: {canonical_url}
If nothing queued (both already syndicated, or neither passed gates), do NOT notify.
Sandbox note
- Dev.to: Always writes to
.pending-devto/.scripts/postprocess-devto.shexecutes the actual API call after Claude finishes, outside the sandbox. Avoids the env-var-in-headers problem entirely. - Farcaster: Writes
.pending-farcaster/<slug>-<date>.json(no signer_uuid on disk);scripts/postprocess-farcaster.shinjects the signer UUID from env at post time and POSTs to Neynar.
Why the quality gate matters
Dropping weak casts is a feature, not a bug. Each low-effort cast trains followers to scroll past the next one — the compounding cost of "new post: X" over 100 posts is worse than posting 60 with hooks and skipping 40. If the article doesn't yield an extractable hook, that's signal the article needs a stronger opener; fix the article, don't launder the cast.
Output (summary block)
End with:
## Summary
- Article: {filename}
- Dev.to
---
*Content truncated.*