
Social Card Gen
- 138 installs
- 390 repo stars
- Updated March 19, 2026
- brianrwagner/ai-marketing-skills
Create polished social promo images from headlines, quotes, and launch details for organic posts during product or content launches.
About
Social-card-gen in ai-marketing-skills converts marketing messages into shareable launch graphics within Claude Code, helping founders ship cohesive visual posts alongside copy for threads, LinkedIn updates, and announcement campaigns.
- Brand-consistent visual briefs
- Multi-network dimension guidance
- Stat-and-quote card formats
- CTA overlay patterns
- Rapid copy-to-creative loops
Social Card Gen by the numbers
- 138 all-time installs (skills.sh)
- Ranked #744 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/brianrwagner/ai-marketing-skills --skill social-card-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 138 |
|---|---|
| repo stars | ★ 390 |
| Last updated | March 19, 2026 |
| Repository | brianrwagner/ai-marketing-skills ↗ |
What it does
Create polished social promo images from headlines, quotes, and launch details for organic posts during product or content launches.
Files
Social Card Generator
Transform one source message into platform-ready social copy for Twitter/X, LinkedIn, and Reddit.
This skill works two ways:
- With Node.js: Use
generate.jsfor deterministic, automated output - Without Node.js: Manual generation path built directly into this skill (no dependencies required)
---
Mode
Detect from context or ask: "One platform, all platforms, or all platforms with variants?"
| Mode | What you get | Best for |
|---|---|---|
quick | 1 platform (user picks), optimized variant | Testing a single channel |
standard | All 3 platforms: Twitter, LinkedIn, Reddit | Standard cross-platform post |
deep | All platforms + 3 variants each + A/B test guidance | Campaign testing, maximizing reach |
Default: `standard` — use quick if they specify a platform. Use deep if they're running a content campaign and want options.
---
⚠️ MANDATORY: 4-Question Intake Before Generating
Ask these before writing a single word of copy:
1. Source message: What's the core idea, update, or story? (Paste raw text or describe it.)
2. Tone goal: Informative? Provocative? Humble brag? Conversational? Educational?
3. Audience: Who needs to see this? (Founders, marketers, developers, general public?)
4. CTA: What do you want readers to do? (Visit link, reply, follow, share, start a conversation?)
Without these answers, you'll get generic output that won't land on any platform.
---
Platform Reasoning Rules (Built In — No External Script Needed)
🐦 Twitter/X
- Character limit: 280 characters (single tweet) or threaded for long-form
- Tone: Punchy, direct, opinionated. Twitter rewards brevity and conviction.
- Hook structure: Lead with the most surprising or specific claim. No warm-up.
- Hashtags: Max 1-2 directly relevant tags. Never stuff.
- CTA style: Short and embedded. "What's your take?" or "Link in reply."
- What kills Twitter posts: Long intros, passive voice, corporate speak, hashtag walls.
- Example hook pattern: "[Counterintuitive claim]. Here's why: [1-2 sentence reason]. [Engaging question]."
- Character limit: 3,000 characters for posts. First 2 lines before "See more" are critical.
- Tone: Professional but personal. Thought leadership with a human angle.
- Hook structure: First line must create curiosity or state a bold truth — not "excited to share."
- Hashtags: 3-5 max, at the end. Never in the body copy.
- CTA style: Invite discussion. "What's your experience with this?" works well.
- Opener rule: NEVER start with self-promotional language. Lead with a lesson, story, or observation.
- What kills LinkedIn posts: Cringe openers ("I'm humbled to announce..."), excessive hashtags, no line breaks.
- Format tip: Short paragraphs (1-2 lines). White space is your friend.
- Character limit: No hard limit, but long posts need clear structure with headers.
- Tone: Authentic, curious, community-first. Reddit readers have a finely tuned BS detector.
- Hook structure: Situational — frame as a question, lesson learned, or genuine experience.
- Hashtags: Never. Not a thing on Reddit.
- CTA style: End with an open question that invites the community to share their take.
- Self-promotion rule: Never lead with your own promotion. Frame everything as a contribution to the community.
- What kills Reddit posts: Obvious marketing, lack of genuine insight, "check out my [thing]" openers.
---
Manual Generation Path (No Node.js Required)
Follow this process exactly:
Step 1: Distill the Core Message
From the source input, extract:
- The ONE key insight or fact
- The "so what" — why does this matter to the audience?
- The evidence or story behind it
Step 2: Generate 3 Platform Variants
Twitter/X variant:
[Bold claim or surprising fact — max 1 sentence]
[1-2 sentences of supporting context]
[Engaging question or CTA]
[1-2 relevant hashtags max]Check: Under 280 chars for single tweet? If not, decide: cut or thread.
LinkedIn variant:
[First line hook — create curiosity, not announcement]
[1-2 line context paragraph]
[Core insight or framework — 2-4 short paragraphs]
[Personal angle or reflection]
[Discussion CTA — question for the audience]
[3-5 hashtags at the end only]Check: Does the first line work before "See more"? Does it NOT start with "I'm excited to..."?
Reddit variant:
[Title: specific, searchable, not clickbaity]
[Opening: frame as experience, question, or lesson — community-first]
[Body: genuine story or insight with specifics]
[Closing question to invite community input]Check: Would this feel at home in the target subreddit? No hashtags?
---
With Node.js (Automated Path)
npm install
# text input
node generate.js --text "We reduced onboarding time by 35% with a checklist." --stdout
# file input
node generate.js --file examples/input-example.md --outdir examples
# URL input (when network is available)
node generate.js --url https://example.com/post --platforms twitter,linkedin --stdoutIf generate.js is unavailable, use the Manual Generation Path above — same quality, just manual.
---
Quality Self-Review Pass (REQUIRED After Generating)
After generating all 3 variants, run this review before delivering:
| Check | |||
|---|---|---|---|
| Hook strong? (Would you stop scrolling?) | ✅/❌ | ✅/❌ | ✅/❌ |
| CTA present and clear? | ✅/❌ | ✅/❌ | ✅/❌ |
| Platform fit? (Tone matches platform norms) | ✅/❌ | ✅/❌ | ✅/❌ |
| Guardrails pass? (See below) | ✅/❌ | ✅/❌ | ✅/❌ |
Flag the weakest variant and explain why: "The Reddit variant is the weakest because it still reads like a LinkedIn post — too polished, not community-first."
---
Guardrails (Never Violate)
❌ No identical copy cross-posted — every platform variant must be meaningfully different in tone and structure ❌ No hashtag spam on LinkedIn — max 5, always at the end, never in the body ❌ No self-promotional openers on Reddit — community value first, always ❌ No vague hooks — "I have some thoughts on X" is not a hook ❌ No copy that assumes platform context — Reddit readers don't know your LinkedIn audience
---
Iteration Loop
After delivering the 3 variants: 1. Ask: "Which variant feels most off? What's not landing?" 2. If hook is weak → rewrite the first line using a different angle (counterintuitive, numerical, story-led) 3. If CTA is weak → try a question instead of a directive 4. If platform fit is off → re-read the platform rules and rewrite from scratch for that platform
---
Output Format
Deliver:
## Twitter/X
[Final copy — ready to paste]
Character count: [X]/280
## LinkedIn
[Final copy — ready to paste]
## Reddit
Title: [Post title]
[Body — ready to paste]
## Quality Review
- Strongest variant: [Platform] — because [reason]
- Weakest variant: [Platform] — [what to improve if needed]
- All guardrails passed: Yes / No — [note any issues]---
Skill by Brian Wagner | AI Marketing Architect | brianrwagner.com
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "social-card-gen",
"installedVersion": "1.0.2",
"installedAt": 1769882955927
}
Product update: onboarding improvements
This week we shipped a guided onboarding checklist for new users. In testing, setup time dropped from 45 minutes to 28 minutes, and week-1 activation improved by 19%.
The biggest win came from replacing long setup docs with short in-app prompts at the exact moment users needed each step.
Next, we're adding role-based onboarding paths so teams can skip irrelevant setup screens.
Product update: onboarding improvements
Product update: onboarding improvements This week we shipped a guided onboarding checklist for new users. In testing, setup time dropped from 45 minutes to 28 minutes, and week-1 activation improved by 19%. The biggest win came from replacing long setup docs with short in-app prompts at the exact moment users needed each step.
#onboarding #setup #week #users
What are you seeing in your team?
I just read this and wanted to sanity-check my take: Product update: onboarding improvements This week we shipped a guided onboarding checklist for new users.
In testing, setup time dropped from 45 minutes to 28 minutes, and week-1 activation improved by 19%. The biggest win came from replacing long setup docs with short in-app prompts at the exact moment users needed each step.
Curious how others here handle this.
Quick take: Product update: onboarding improvements This week we shipped a guided onboarding checklist for new users. In testing, setup time dropped from 45 minutes to 28 minutes, and week-1 activation improved by 19%. The biggest win came...
#onboarding #setup #week
Thoughts?
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { extractKeywords, renderTemplate, PLATFORM_CONFIGS } = require('./templates');
function printHelp() {
console.log(`social-card-gen\n\nUsage:\n node generate.js [options]\n\nInput options (use one):\n --text "..." Raw text input\n --file ./path.md Read content from local file\n --url https://... Read content from URL\n\nOutput options:\n --platforms list Comma-separated: twitter,linkedin,reddit (default: all)\n --outdir ./dir Write files to directory (default: current directory)\n --stdout Print generated cards to stdout instead of files\n --prefix output- Output filename prefix (default: output-)\n --title "Custom title" Override title extraction\n\nExamples:\n node generate.js --text "We reduced deploy time by 42%" --stdout\n node generate.js --file examples/input-example.md --outdir examples\n node generate.js --url https://example.com/post --platforms twitter,linkedin --stdout\n`);
}
function parseArgs(argv) {
const options = {
platforms: ['twitter', 'linkedin', 'reddit'],
outdir: process.cwd(),
stdout: false,
prefix: 'output-'
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
continue;
}
if (arg === '--text') {
options.text = argv[++i];
continue;
}
if (arg === '--file') {
options.file = argv[++i];
continue;
}
if (arg === '--url') {
options.url = argv[++i];
continue;
}
if (arg === '--platforms') {
options.platforms = (argv[++i] || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
continue;
}
if (arg === '--outdir') {
options.outdir = argv[++i];
continue;
}
if (arg === '--stdout') {
options.stdout = true;
continue;
}
if (arg === '--prefix') {
options.prefix = argv[++i] || 'output-';
continue;
}
if (arg === '--title') {
options.title = argv[++i];
continue;
}
if (!arg.startsWith('--') && !options.text && !options.file && !options.url) {
options.text = arg;
continue;
}
throw new Error(`Unknown or incomplete argument: ${arg}`);
}
return options;
}
function stripMarkdownAndHtml(input) {
return (input || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, ' ')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^>\s?/gm, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function toSentences(text) {
return text
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 20);
}
function firstNonEmptyLine(raw) {
const line = (raw || '')
.split(/\r?\n/)
.map((l) => l.trim())
.find(Boolean);
if (!line) return 'Content update';
return line.replace(/^#{1,6}\s+/, '').trim();
}
function composeContext(rawInput, cleanedText, options) {
const sentences = toSentences(cleanedText);
const s1 = sentences[0] || cleanedText;
const s2 = sentences[1] || '';
const s3 = sentences[2] || '';
const title = options.title || firstNonEmptyLine(rawInput).slice(0, 90);
const keywords = extractKeywords(cleanedText, 8);
return {
title,
hook: `Quick take: ${s1}`,
corePoint: [s2, s3].filter(Boolean).join(' '),
professionalSummary: [s1, s2, s3].filter(Boolean).join(' '),
redditIntro: `I just read this and wanted to sanity-check my take: ${s1}`,
redditBody: [s2, s3].filter(Boolean).join(' '),
sourceUrl: options.url || '',
keywords
};
}
async function readFromStdinIfAvailable() {
if (process.stdin.isTTY) return '';
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
async function loadInput(options) {
if (options.text) {
return options.text;
}
if (options.file) {
return fs.readFileSync(path.resolve(options.file), 'utf8');
}
if (options.url) {
const response = await fetch(options.url);
if (!response.ok) {
throw new Error(`Failed to fetch URL (${response.status}): ${options.url}`);
}
return response.text();
}
const stdinValue = await readFromStdinIfAvailable();
if (stdinValue.trim()) {
return stdinValue;
}
throw new Error('No input provided. Use --text, --file, --url, or pipe data via stdin.');
}
function validatePlatforms(platforms) {
const supported = new Set(Object.keys(PLATFORM_CONFIGS));
const invalid = platforms.filter((p) => !supported.has(p));
if (invalid.length > 0) {
throw new Error(`Unsupported platforms: ${invalid.join(', ')}`);
}
if (platforms.length === 0) {
throw new Error('No platforms selected. Use --platforms twitter,linkedin,reddit');
}
}
function writeOutputs(results, options) {
if (options.stdout) {
for (const { platform, text } of results) {
process.stdout.write(`\n=== ${platform.toUpperCase()} ===\n${text}\n`);
}
return;
}
fs.mkdirSync(path.resolve(options.outdir), { recursive: true });
for (const { platform, text } of results) {
const fileName = `${options.prefix}${platform}.txt`;
const filePath = path.join(path.resolve(options.outdir), fileName);
fs.writeFileSync(filePath, `${text}\n`, 'utf8');
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
validatePlatforms(options.platforms);
const rawInput = await loadInput(options);
const cleanedText = stripMarkdownAndHtml(rawInput);
if (!cleanedText) {
throw new Error('Input resolved to empty text after cleaning.');
}
const context = composeContext(rawInput, cleanedText, options);
const results = options.platforms.map((platform) => ({
platform,
text: renderTemplate(platform, context)
}));
writeOutputs(results, options);
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
{
"name": "social-card-gen",
"version": "1.0.0",
"description": "Offline social post generator for Twitter, LinkedIn, and Reddit",
"main": "generate.js",
"bin": {
"social-card-gen": "generate.js"
},
"scripts": {
"generate:examples": "node generate.js --file examples/input-example.md --outdir examples"
},
"keywords": [
"social",
"content",
"twitter",
"linkedin",
"reddit",
"generator"
],
"license": "MIT",
"dependencies": {}
}
Stop rewriting the same post 3 times
You write one update. Then you rewrite it for Twitter, LinkedIn, and Reddit. That repetitive step is where time disappears.
social-card-gen turns one source into platform-ready drafts in one command.
Value
- Save 15-30 minutes per post cycle.
- Keep tone consistent across channels.
- Enforce platform limits automatically.
- Run locally with no AI/API dependency.
Platforms
- Twitter: 280 chars, punchy style, compact hashtag strategy.
- LinkedIn: 3000 chars, professional tone, business CTA.
- Reddit: authentic conversation style, discussion CTA.
Install
npm installUsage
# from raw text
node generate.js --text "We reduced support tickets by 28% after redesigning onboarding." --stdout
# from a markdown file
node generate.js --file examples/input-example.md --outdir examples
# choose specific platforms
node generate.js --file examples/input-example.md --platforms twitter,reddit --stdoutExample source
examples/input-example.md
Example outputs
examples/output-twitter.txtexamples/output-linkedin.txtexamples/output-reddit.txt
Time savings snapshot
Manual approach:
- Draft + rewrite x3: 20-30 minutes
With social-card-gen:
- One source draft + command run + quick edits: 5-10 minutes
Platform: OpenClaw (token-optimized)
Required Intake (4 questions — ask before writing)
1. Source message — core idea, update, or story (paste raw text or describe it) 2. Tone goal — Informative / Provocative / Humble brag / Conversational / Educational 3. Audience — who needs to see this? (Founders, marketers, developers, etc.) 4. CTA — what should readers do? (Visit link, reply, follow, share, start conversation)
Mode
| Mode | Output | Use when |
|---|---|---|
quick | 1 platform (user picks) | Testing single channel |
standard | All 3 platforms: Twitter + LinkedIn + Reddit | Default cross-platform |
deep | All platforms + 3 variants each + A/B guidance | Campaign testing |
Platform Rules
Twitter/X (280 chars):
- Lead with most surprising or specific claim — no warm-up
- Max 1–2 hashtags, directly relevant
- Short CTA embedded in copy
- ❌ Long intros, passive voice, hashtag walls
LinkedIn (3,000 chars, first 2 lines critical):
- First line: bold truth or curiosity hook (NOT "excited to share")
- Short paragraphs (1–2 lines), lots of whitespace
- 3–5 hashtags at end only, never in body
- Discussion CTA: "What's your experience with this?"
- ❌ "I'm humbled to announce," excessive hashtags, no line breaks
Reddit:
- Frame as experience, question, or lesson — community-first
- Title: specific and searchable, not clickbaity
- End with open question inviting community input
- ❌ Never hashtags. Never "check out my [thing]" openers
Generation Template
Twitter:
[Bold claim or surprising fact]
[1-2 sentences supporting context]
[Engaging question or CTA]
[1-2 hashtags max]LinkedIn:
[First line hook — curiosity, not announcement]
[1-2 line context]
[Core insight — 2-4 short paragraphs]
[Personal angle or reflection]
[Discussion CTA question]
[3-5 hashtags at end]Reddit:
Title: [specific, searchable]
[Opening: experience/question/lesson — community-first]
[Body: genuine story with specifics]
[Closing question for community]With Node.js (automated path)
npm install
node generate.js --text "source message" --stdout
node generate.js --file input.md --outdir output/Quality Review (required before delivering)
- [ ] Twitter: under 280 chars? Starts with strongest point?
- [ ] LinkedIn: first line works before "see more"? No "I'm excited"?
- [ ] Reddit: would feel native in target subreddit? Zero hashtags?
- [ ] All: unique to platform — not the same post copy-pasted?
- [ ] All: CTA matches stated goal?
--- Skill by Brian Wagner | AI Marketing Architect
'use strict';
const PLATFORM_CONFIGS = {
twitter: {
name: 'Twitter',
characterLimit: 280,
tone: 'punchy',
toneGuideline: 'Lead with the hook, keep it tight, and make every sentence earn space.',
hashtagStrategy: 'Use 1-3 topical hashtags. Keep them short and specific.',
ctaFormat: 'Short imperative CTA (e.g., Read more:, Try this:, What do you think?)'
},
linkedin: {
name: 'LinkedIn',
characterLimit: 3000,
tone: 'professional',
toneGuideline: 'Use a clear business outcome, practical framing, and credible language.',
hashtagStrategy: 'Use 2-5 industry hashtags at the end. Prefer category tags over memes.',
ctaFormat: 'Professional prompt CTA (e.g., What are you seeing in your team?)'
},
reddit: {
name: 'Reddit',
characterLimit: null,
tone: 'authentic',
toneGuideline: 'Write like a real person: direct, candid, and discussion-friendly.',
hashtagStrategy: 'Avoid hashtag spam. Usually 0 hashtags; optionally one if it adds context.',
ctaFormat: 'Open-ended discussion question (e.g., Curious how others approach this.)'
}
};
const STOPWORDS = new Set([
'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'than', 'that', 'this', 'those', 'these',
'to', 'for', 'of', 'on', 'in', 'at', 'with', 'by', 'from', 'as', 'is', 'are', 'was', 'were',
'be', 'been', 'being', 'it', 'its', 'into', 'about', 'over', 'under', 'after', 'before',
'we', 'you', 'they', 'our', 'your', 'their', 'i', 'he', 'she', 'them', 'us', 'can', 'could',
'should', 'would', 'will', 'just', 'very', 'more', 'most', 'much', 'many', 'also'
]);
function extractKeywords(text, maxKeywords = 6) {
const words = (text || '')
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter((word) => word.length > 2 && !STOPWORDS.has(word));
const counts = new Map();
for (const word of words) {
counts.set(word, (counts.get(word) || 0) + 1);
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, maxKeywords)
.map(([word]) => word);
}
function toHashtag(term) {
const cleaned = term.replace(/[^a-z0-9]/gi, ' ').trim();
if (!cleaned) return '';
const parts = cleaned.split(/\s+/);
const formatted = parts
.map((part, index) => {
if (index === 0) return part.toLowerCase();
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join('');
return `#${formatted}`;
}
function enforceCharacterLimit(text, limit) {
if (!limit || text.length <= limit) return text;
const suffix = '...';
const hardLimit = Math.max(0, limit - suffix.length);
const trimmed = text.slice(0, hardLimit);
const safeTrimmed = trimmed.slice(0, Math.max(0, trimmed.lastIndexOf(' ')));
const output = (safeTrimmed || trimmed).trimEnd();
return `${output}${suffix}`;
}
function buildHashtags(platform, keywords) {
if (!keywords.length) return '';
if (platform === 'twitter') {
return keywords.slice(0, 3).map(toHashtag).filter(Boolean).join(' ');
}
if (platform === 'linkedin') {
return keywords.slice(0, 4).map(toHashtag).filter(Boolean).join(' ');
}
if (platform === 'reddit') {
return ''; // Reddit style avoids hashtag clutter by default.
}
return '';
}
function buildCTA(platform, sourceUrl) {
if (platform === 'twitter') {
return sourceUrl ? `Read more: ${sourceUrl}` : 'Thoughts?';
}
if (platform === 'linkedin') {
return sourceUrl
? `Full details: ${sourceUrl}\n\nWhat are you seeing in your team?`
: 'What are you seeing in your team?';
}
if (platform === 'reddit') {
return sourceUrl
? `If you want the full context: ${sourceUrl}\n\nCurious how others here handle this.`
: 'Curious how others here handle this.';
}
return '';
}
function renderTemplate(platform, context) {
const config = PLATFORM_CONFIGS[platform];
if (!config) {
throw new Error(`Unsupported platform: ${platform}`);
}
const hashtags = buildHashtags(platform, context.keywords || []);
const cta = buildCTA(platform, context.sourceUrl);
let text;
if (platform === 'twitter') {
const tailLines = [hashtags, cta].filter(Boolean);
const tail = tailLines.join('\n');
const reserved = tail ? tail.length + 2 : 0; // account for separating newlines
const maxBodyLength = Math.max(0, (config.characterLimit || 280) - reserved);
const body = enforceCharacterLimit(
[context.hook, context.corePoint].filter(Boolean).join(' ').trim(),
maxBodyLength
);
text = tail ? `${body}\n\n${tail}` : body;
} else if (platform === 'linkedin') {
text = `${context.title}\n\n${context.professionalSummary}\n\n${hashtags}`.trim();
text = `${text}\n\n${cta}`.trim();
} else {
text = `${context.redditIntro}\n\n${context.redditBody}\n\n${cta}`.trim();
}
return enforceCharacterLimit(text, config.characterLimit);
}
module.exports = {
PLATFORM_CONFIGS,
extractKeywords,
enforceCharacterLimit,
renderTemplate
};