syndicate-article
$
npx mdskill add aaronjmars/aeon/syndicate-article<!-- autoresearch: variation B — sharper output: hook-driven cast + CTR-optimized Dev.to card, with quality gate --> > **${var}** — Filename of a specific article to syndicate (e.g. `repo-article-2026-04-16.md`). If empty, syndicates the most recently written article.
SKILL.md
.github/skills/syndicate-articleView on GitHub ↗
---
name: syndicate-article
description: Cross-post articles to Dev.to and Farcaster with hook-driven copy and click-optimized metadata
var: ""
tags: [content, growth]
---
<!-- autoresearch: variation B — sharper output: hook-driven cast + CTR-optimized Dev.to card, with quality gate -->
> **${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](https://dev.to) (developer audience) and [Farcaster](https://warpcast.com) (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](https://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
```bash
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, use `articles/${var}`.
- Otherwise, most recently modified `.md` in `articles/` (exclude `feed.xml`, `.gitkeep`):
```bash
ls -t articles/*.md 2>/dev/null | grep -v -E '(feed\.xml|\.gitkeep)$' | head -1
```
- If no articles exist, log `SYNDICATE_SKIP: no articles found` and stop.
### 3. Dedup check
Search the last 7 days of `memory/logs/` for:
- `SYNDICATED:` lines containing this filename → Dev.to already posted
- `FARCAST:` 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 frontmatter `title:` 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 has `image:` or `cover:`, use that; otherwise first `` in the body where `url` starts with `http`. 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.to `description` and Farcaster hook fallback.
### 5. Clean the body for syndication
Produce `body_clean` from the raw body:
1. Remove any Jekyll liquid tags (`{% ... %}`, `{{ ... }}`) — they render as literal text on Dev.to.
2. Rewrite relative links/images: any `](/foo)` or `](foo.md)` → absolute `https://aaronjmars.github.io/aeon/foo` (strip `.md` where present). Preserve anchor fragments.
3. Strip the first `# Heading` line (Dev.to shows the title separately — double-heading looks amateur).
4. 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):
1. **Explicit TL;DR** — if the article has a `## TL;DR`, `## Summary`, or `**TL;DR:**` block, use its first sentence.
2. **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
3. **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, agents`
- `token-report`, `token-alert`, `defi-overview`, `defi-monitor` → `crypto, defi, blockchain, trading`
- `changelog`, `push-recap`, `weekly-shiplog` → `opensource, devops, changelog, github`
- `digest`, `rss-digest`, `hacker-news` → `news, tech, ai, digest`
- `deep-research`, `research-brief`, `paper-pick` → `research, ai, machinelearning, papers`
- `technical-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):
```bash
mkdir -p .pending-devto/
```
Payload:
```json
{
"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`:
```json
{
"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.sh` executes 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.sh` injects 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: queued | skipped | already-syndicated
- Farcaster: queued (hook found) | skipped (no hook) | skipped (not configured) | already-syndicated
- Canonical: {canonical_url}
- Files written: .pending-devto/*.json, .pending-farcaster/*.json (as applicable)
```
More from aaronjmars/aeon
- [REPLACE: SKILL_NAME]Daily mention/keyword sweep on social platforms for [REPLACE: KEYWORDS] — trends, sentiment, top posts
- Action Converter5 concrete real-life actions for today, leverage-scored against open loops with specificity and anti-fluff gates
- Agent BuzzCurated AI-agent tweets, clustered into narratives with insight summaries
- agent-displacementWeekly tracker of AI agent substitution signals — which roles, companies, and industries show real headcount displacement. Named roles + real deployments only.
- AI Framework WatchWeekly competitive-intelligence digest on the AI agent framework space — momentum, releases, breaking changes across a curated watchlist
- AIXBT PulseCross-domain market pulse from AIXBT's free grounding endpoint — crypto, macro, tradfi, geopolitics. Refreshes taxonomy references (clusters, chains) as a bonus.
- api-health-probeDaily pre-batch API provider health check — detects credit exhaustion or auth failure for every configured provider key before the morning batch runs, giving the operator a window to act before skills degrade
- Approval AuditList a wallet's live ERC-20 token approvals on Base and flag unlimited / risky spender grants. Keyless via Base RPC (eth_getLogs + eth_call) — no explorer key needed.
- article-queueWeekly article idea synthesizer — ranks signals from topic-momentum, beat-tracker, and narrative-tracker into a prioritized queue the article skill reads on next run
- atrium-catalog-watcherWeekly diff of the Atrium marketplace catalog at https://atriumhermes.tech/.well-known/skills/index.json against the prior snapshot — surfaces newly-published skills, removed skills, and updated descriptions. Supply-side complement to sparkleware-catalog (curated skill-packs.json registry) and skill-update-check (version drift of installed skills).