snappy-post skill
linkedin textpostslack channel-id textposttelegram textpost/blog/${SLUG}$ npx snappy-skills install snappy-post
$ npx snappy-skills install --all
$ npx snappy-skills update
You are posting content to one or more platforms. This skill distributes -- it does not write content (that's snappy-content).
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md. Required keys: XANO_TOKEN, XANO.
| Channel | Method | Endpoint | Payload |
|---|---|---|---|
| LinkedIn text | POST | api:PB9UH7b9/linkedin/post |
{ text } |
| LinkedIn image | POST | api:PB9UH7b9/linkedin/post-image |
{ text, image_url } |
| LinkedIn carousel | POST | api:PB9UH7b9/linkedin/post-carousel |
{ text, slides[] } |
| LinkedIn video | POST | api:PB9UH7b9/linkedin/post-video |
{ text, video_url } |
| YouTube upload | POST | api:hZB4Dj0c/youtube-video-uploader |
{ title, description, tags, video_url, privacy } |
| Email send | POST | api:PB9UH7b9/emails/send |
{ to_email, subject, body, dry_run } |
| Skool | browser | agent-browser --state ~/.openclaw/workspace/skool-auth.json |
See skool-posting.md |
| Blog | git | Hand off to snappy-publish |
git push origin main triggers Vercel |
| Platform | Max chars | Hashtags | Sweet spot |
|---|---|---|---|
| 3,000 | ZERO | 800-1500 chars | |
| Skool | N/A | ZERO | 400-1200 chars |
| N/A | N/A | < 300 words | |
| YouTube title | 100 | in description only | < 60 chars |
snappy-positioning for voice/brand rulessnappy-content/anti-ai-checklist.mddry_run: true first, review, then flip to falseReturn a distribution log to Robert:
DISTRIBUTION LOG -- YYYY-MM-DD
LinkedIn: POSTED <url> HH:MM ET
Email: SENT "Subject" HH:MM ET
| Need | File |
|---|---|
| LinkedIn posting | linkedin-posting.md |
| YouTube uploading | youtube-uploading.md |
| Skool posting | skool-posting.md |
| Email sending | email-sending.md |
| Blog publishing | blog-publishing.md |
| Multi-platform | multi-platform-workflow.md |
post.sh#Unified distribution router. Handles linkedin, slack, telegram directly. Blog and skool print instructions (they need git/browser respectively).
bash# LinkedIn text post (dry run first)
~/.claude/skills/snappy-post/scripts/post.sh --platform linkedin --content "Hook line here" --dry-run
# LinkedIn image post
~/.claude/skills/snappy-post/scripts/post.sh --platform linkedin --content "Caption" --image "https://cdn.example.com/img.jpg"
# Slack to a specific channel
~/.claude/skills/snappy-post/scripts/post.sh --platform slack --content "Deploy done" --channel bugs-and-issues
# Telegram
~/.claude/skills/snappy-post/scripts/post.sh --platform telegram --content "*Deploy SUCCESS* app v2.1"
Requires: snappy-settings/scripts/load-env.sh (auto-sourced) or pre-exported XANO_TOKEN + XANO. Telegram requires ROBERT_TELEGRAM_CHAT_ID env var.
deploy remote -- always origin mainsnappy-video first)typescriptimport { linkedinPost, slackPost, telegramPost } from "../snappy-post/api.ts";
Or CLI:
bashnpx tsx ~/.claude/skills/snappy-post/api.ts linkedin "Hook line here"
npx tsx ~/.claude/skills/snappy-post/api.ts slack <channel_id> "text"
npx tsx ~/.claude/skills/snappy-post/api.ts telegram "text"
| Function | Purpose |
|---|---|
linkedinPost(text) |
Post text to LinkedIn (re-export from snappy-linkedin) |
slackPost(channelId, text) |
Send message to Slack channel (re-export from snappy-slack) |
telegramPost(text) |
Send text via Telegram (re-export from snappy-telegram) |
snappy-emailsnappy-ffmpeg<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
linkedin |
text |
post |
npx tsx ~/.claude/skills/snappy-post/api.ts linkedin "<text>" |
slack |
channel-id, text |
post |
npx tsx ~/.claude/skills/snappy-post/api.ts slack <channel-id> "<text>" |
telegram |
text |
post |
npx tsx ~/.claude/skills/snappy-post/api.ts telegram "<text>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
If this AGENTS.md does not cover the case you are working on and you had to read other files to find the answer, you have two jobs before you finish:
1. Fix it now (P -- proportional correction). If the missing information fits in 1-5 lines, edit this AGENTS.md directly -- add the line, the table row, the one-liner, whatever you had to go find. Keep edits surgical. Do not restructure sections or rewrite paragraphs. The goal is: the next agent that hits this case won't have to leave the loader.
2. Log it always. Whether or not you fixed it, append one line to the feedback log:
bashecho "[$(date -u +%FT%TZ)] <skill-name>: <what was missing> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
Tag FIXED if you patched the loader in step 1. Tag LOGGED if the gap was too large to patch inline (needs a section rewrite, new workflow, etc.) -- those get picked up by drain-skill-regen.sh for a full regen pass (I -- integral correction).
Do not skip this. A missing report means the same gap will happen next time. A missing fix means you chose to let the next agent stumble on the same thing you just solved.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-post Index]|root: ~/.claude/skills/snappy-post|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,blog-publishing.md,email-sending.md,linkedin-posting.md,multi-platform-workflow.md,scheduling.md,skool-posting.md,youtube-uploading.md}
<!-- SKILL-INDEX-END -->
---
name: snappy-post
role: Unified distribution router for LinkedIn, YouTube, Skool, Email, Blog.
loaded-by: PreToolUse hook (auto-injected when "snappy-post" is mentioned)
---
# snappy-post -- loader
You are posting content to one or more platforms. This skill distributes -- it does not write content (that's `snappy-content`).
## Auth
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see `snappy-settings/SKILL.md`. Required keys: `XANO_TOKEN`, `XANO`.
## Channel endpoints
| Channel | Method | Endpoint | Payload |
|---------|--------|----------|---------|
| LinkedIn text | POST | `api:PB9UH7b9/linkedin/post` | `{ text }` |
| LinkedIn image | POST | `api:PB9UH7b9/linkedin/post-image` | `{ text, image_url }` |
| LinkedIn carousel | POST | `api:PB9UH7b9/linkedin/post-carousel` | `{ text, slides[] }` |
| LinkedIn video | POST | `api:PB9UH7b9/linkedin/post-video` | `{ text, video_url }` |
| YouTube upload | POST | `api:hZB4Dj0c/youtube-video-uploader` | `{ title, description, tags, video_url, privacy }` |
| Email send | POST | `api:PB9UH7b9/emails/send` | `{ to_email, subject, body, dry_run }` |
| Skool | browser | `agent-browser --state ~/.openclaw/workspace/skool-auth.json` | See [skool-posting.md](skool-posting.md) |
| Blog | git | Hand off to `snappy-publish` | `git push origin main` triggers Vercel |
## Format rules
| Platform | Max chars | Hashtags | Sweet spot |
|----------|-----------|----------|------------|
| LinkedIn | 3,000 | ZERO | 800-1500 chars |
| Skool | N/A | ZERO | 400-1200 chars |
| Email | N/A | N/A | < 300 words |
| YouTube title | 100 | in description only | < 60 chars |
## Before posting -- always
1. Read `snappy-positioning` for voice/brand rules
2. Run copy through `snappy-content/anti-ai-checklist.md`
3. For emails: always `dry_run: true` first, review, then flip to false
## After posting -- always
Return a distribution log to Robert:
```
DISTRIBUTION LOG -- YYYY-MM-DD
LinkedIn: POSTED <url> HH:MM ET
Email: SENT "Subject" HH:MM ET
```
## Detailed workflows
| Need | File |
|------|------|
| LinkedIn posting | [linkedin-posting.md](linkedin-posting.md) |
| YouTube uploading | [youtube-uploading.md](youtube-uploading.md) |
| Skool posting | [skool-posting.md](skool-posting.md) |
| Email sending | [email-sending.md](email-sending.md) |
| Blog publishing | [blog-publishing.md](blog-publishing.md) |
| Multi-platform | [multi-platform-workflow.md](multi-platform-workflow.md) |
## CLI script -- `post.sh`
Unified distribution router. Handles linkedin, slack, telegram directly. Blog and skool print instructions (they need git/browser respectively).
```bash
# LinkedIn text post (dry run first)
~/.claude/skills/snappy-post/scripts/post.sh --platform linkedin --content "Hook line here" --dry-run
# LinkedIn image post
~/.claude/skills/snappy-post/scripts/post.sh --platform linkedin --content "Caption" --image "https://cdn.example.com/img.jpg"
# Slack to a specific channel
~/.claude/skills/snappy-post/scripts/post.sh --platform slack --content "Deploy done" --channel bugs-and-issues
# Telegram
~/.claude/skills/snappy-post/scripts/post.sh --platform telegram --content "*Deploy SUCCESS* app v2.1"
```
Requires: `snappy-settings/scripts/load-env.sh` (auto-sourced) or pre-exported `XANO_TOKEN` + `XANO`. Telegram requires `ROBERT_TELEGRAM_CHAT_ID` env var.
## Rules
- Do NOT use browser to post to LinkedIn -- use Xano API
- Do NOT copy-paste the same text to all platforms -- rewrite for each
- Do NOT skip email dry_run
- Do NOT add hashtags to LinkedIn or Skool posts
- Do NOT push to `deploy` remote -- always `origin main`
- Do NOT upload YouTube video before burning captions (run `snappy-video` first)
- Do NOT use the Xano queue for time-critical sends -- queue runs every 5 min
- Do NOT post to Skool without verifying login (snapshot first, confirm "Write something" exists)
## API module
```typescript
import { linkedinPost, slackPost, telegramPost } from "../snappy-post/api.ts";
```
Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-post/api.ts linkedin "Hook line here"
npx tsx ~/.claude/skills/snappy-post/api.ts slack <channel_id> "text"
npx tsx ~/.claude/skills/snappy-post/api.ts telegram "text"
```
## API functions
| Function | Purpose |
|----------|---------|
| `linkedinPost(text)` | Post text to LinkedIn (re-export from snappy-linkedin) |
| `slackPost(channelId, text)` | Send message to Slack channel (re-export from snappy-slack) |
| `telegramPost(text)` | Send text via Telegram (re-export from snappy-telegram) |
## Used by
- `snappy-email`
- `snappy-ffmpeg`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `linkedin` | `text` | `post` | `npx tsx ~/.claude/skills/snappy-post/api.ts linkedin "<text>"` |
| `slack` | `channel-id`, `text` | `post` | `npx tsx ~/.claude/skills/snappy-post/api.ts slack <channel-id> "<text>"` |
| `telegram` | `text` | `post` | `npx tsx ~/.claude/skills/snappy-post/api.ts telegram "<text>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
## Self-correcting loader (PID feedback)
If this AGENTS.md does not cover the case you are working on and you had to read other files to find the answer, you have two jobs before you finish:
**1. Fix it now (P -- proportional correction).** If the missing information fits in 1-5 lines, edit this AGENTS.md directly -- add the line, the table row, the one-liner, whatever you had to go find. Keep edits surgical. Do not restructure sections or rewrite paragraphs. The goal is: the next agent that hits this case won't have to leave the loader.
**2. Log it always.** Whether or not you fixed it, append one line to the feedback log:
```bash
echo "[$(date -u +%FT%TZ)] <skill-name>: <what was missing> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
```
Tag `FIXED` if you patched the loader in step 1. Tag `LOGGED` if the gap was too large to patch inline (needs a section rewrite, new workflow, etc.) -- those get picked up by `drain-skill-regen.sh` for a full regen pass (I -- integral correction).
**Do not skip this.** A missing report means the same gap will happen next time. A missing fix means you chose to let the next agent stumble on the same thing you just solved.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-post Index]|root: ~/.claude/skills/snappy-post|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,blog-publishing.md,email-sending.md,linkedin-posting.md,multi-platform-workflow.md,scheduling.md,skool-posting.md,youtube-uploading.md}
<!-- SKILL-INDEX-END -->
Voice & Brand: Always read snappy-positioning before creating any outbound content. It holds the one-liner, voice rules, banned phrases, and property map. If this skill disagrees with positioning, positioning wins.
The three verbs this hand implements send through the platform's own hand -- linkedin through snappy-linkedin's post, slack through snappy-slack's send, telegram through snappy-telegram's send -- so the preview, the stage row and the doors are that hand's. This skill decides only which platform a piece goes to and how it is shaped for it. Skool, email and blog are named in this document as distribution channels and have no verb here yet: reach them through snappy-skool, snappy-gmail and snappy-publish directly.
snappy-post is the distribution layer for the Snappy content engine. It takes approved content from snappy-content (and visuals from snappy-image / snappy-video) and ships it to every channel: LinkedIn, YouTube, Skool, Email, Blog. It adapts format per platform, enforces voice/format rules, and returns a distribution log. It does not write content -- that's snappy-content.
snappy-content and needs to shipsnappy-ops and a draft is queuedDo NOT use this skill for:
snappy-contentsnappy-imagesnappy-videosnappy-emailsnappy-linkedinsnappy-skoolInputs (skills that feed this one):
snappy-content -- provides the approved copy + voice rules + anti-AI checklist passsnappy-blog -- provides finished MDX drafts ready for blog publishingsnappy-image -- provides hero images, carousel slides, thumbnails (CDN URLs)snappy-video -- provides processed/captioned videos and short clips (CDN URLs)snappy-knowledge -- provides contact context for personalized email sends and @-mentionssnappy-transcripts -- provides raw transcripts as repurposing source materialsnappy-ops -- triggers distribution during the daily/weekly content slotssnappy-positioning -- voice rules and §4a trip-wires (canonical source) <!-- learning from 2026-04-07 session -->Principle: Set
AGENT_BROWSER_SESSIONbefore any agent-browser call (session isolation). <!-- learning from 2026-04-07 session -->
Outputs (skills that consume this one):
snappy-publish -- receives MDX + slug + frontmatter for git-based blog deploysnappy-linkedin -- engagement layer that runs after a post lands (golden-hour comments)snappy-skool -- owns Skool curriculum context that informs Skool postsChannels (where output is delivered):
api:PB9UH7b9/linkedin/*)api:hZB4Dj0c/youtube-video-uploader)agent-browser via ~/.openclaw/workspace/skool-auth.json)api:PB9UH7b9/emails/send, Gmail backend). ActiveCampaign is NOT in use.git push origin main → Vercel)Orchestrator:
snappy-ops triggers this skill during the morning briefing when calendar shows a content slot, and during the weekly content rhythm (Mon LinkedIn, Tue email, Wed YouTube/blog, Thu carousel, Fri Skool, Sat clips)Every distribution session starts here. 3 questions, then route.
| answer | next_step |
|---|---|
| Has content ready (text, link, video, MDX) | Go to question 2 |
| Has a topic but no content | Run snappy-content 4-question interview first, then come back |
| Wants to repurpose existing content | Identify source (blog, video, email), see multi-platform-workflow.md |
| type | platforms_it_works_on |
|---|---|
| Text post | LinkedIn, Skool, Email |
| Image + caption | LinkedIn, Skool |
| Carousel | |
| Video | YouTube, LinkedIn |
| Long-form MDX | Blog (snappy-publish), Email (link from blog) |
| Short clip | YouTube Shorts, LinkedIn video |
| answer | read_this |
|---|---|
| "LinkedIn" | linkedin-posting.md |
| "YouTube" | youtube-uploading.md |
| "Skool" / "Community" | skool-posting.md |
| "Email" / "Newsletter" | email-sending.md |
| "Blog" / "Website" | blog-publishing.md → routes to snappy-publish |
| "Everywhere" / "All platforms" | multi-platform-workflow.md |
| "Schedule it for later" | scheduling.md |
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md. Required keys: XANO_TOKEN, XANO.
| channel | method | endpoint | payload |
|---|---|---|---|
| LinkedIn text | POST | api:PB9UH7b9/linkedin/post |
{ text } |
| LinkedIn image | POST | api:PB9UH7b9/linkedin/post-image |
{ text, image_url } |
| LinkedIn carousel | POST | api:PB9UH7b9/linkedin/post-carousel |
{ text, slides[] } |
| LinkedIn video | POST | api:PB9UH7b9/linkedin/post-video |
{ text, video_url } |
| YouTube upload | POST | api:hZB4Dj0c/youtube-video-uploader |
{ title, description, tags, video_url, thumbnail_url, privacy, publish_at? } |
| Email send | POST | api:PB9UH7b9/emails/send |
{ to_email, subject, body, dry_run } |
| Email queue | POST | api:8wuQ86By/queue/add |
{ to_email, subject, body, send_at? } |
| DEPRECATED -- AC is not in use. Use Xano emails/send. | |||
| Skool feed | browser | agent-browser via skool-auth.json |
see skool-posting.md |
| Blog | git | snappy-publish → git push origin main |
see blog-publishing.md |
| platform | max_chars | fold_point | hashtags | sweet_spot |
|---|---|---|---|---|
| 3,000 | ~150 | ZERO | 800-1500 chars | |
| Skool | N/A | N/A | ZERO | 400-1200 chars |
| N/A | subject ~10 words | N/A | < 300 words | |
| YouTube title | 100 | ~60 | in description only | < 60 chars |
| YouTube description | 5,000 | first 150 = preview | hashtags at the bottom only (max 3) | 1500-2500 chars |
| Blog (MDX) | N/A | first 100 words | N/A | 800-1200 words |
| X / Twitter | 280 | N/A | sparingly, on-brand | 180-250 chars |
Voice rules and anti-AI patterns are owned by snappy-content/anti-ai-checklist.md and snappy-content/quality-rules.md. Always run final copy through the checklist before posting.
WRONG: Use the browser to post to LinkedIn
CORRECT: Xano API (api:PB9UH7b9/linkedin/post) -- instant, reliable
WRONG: Skip the dry_run on emails ("it's just one send")
CORRECT: Always dry_run first, review the rendered HTML, then flip to false
WRONG: Copy-paste the same text from LinkedIn to Skool to X
CORRECT: Rewrite for each platform -- different fold points, different audience
WRONG: Post a YouTube video without burned-in captions
CORRECT: Run snappy-video FIRST to burn captions; most viewers watch muted
WRONG: git push deploy main (legacy remote -- does nothing for snappy.ai)
CORRECT: git push origin main (this is what triggers Vercel)
WRONG: Set publish_at without privacy: "private" on YouTube
CORRECT: privacy: "private" + publish_at: ISO timestamp = scheduled publish
WRONG: Use the Xano queue for time-critical sends
CORRECT: Queue worker runs every 5 min -- use POST emails/send for immediate
WRONG: Add hashtags to a LinkedIn post
CORRECT: Zero hashtags -- LinkedIn algorithm killed hashtag reach in 2024
WRONG: Reuse a temp video URL from an upload API
CORRECT: Always upload to CDN first; temp URLs expire mid-upload
WRONG: Post to Skool without verifying you're logged in
CORRECT: Always agent-browser snapshot first to confirm "Write something" exists
| need_to | read_this |
|---|---|
| Post to LinkedIn (text/image/carousel/video) | linkedin-posting.md |
| Upload a video to YouTube | youtube-uploading.md |
| Post to the Skool community feed | skool-posting.md |
| Send an email (transactional or broadcast) | email-sending.md |
| Publish a blog post (handoff to snappy-publish) | blog-publishing.md |
| Distribute one source piece to multiple platforms | multi-platform-workflow.md |
| Schedule a post for future delivery | scheduling.md |
bash# LinkedIn text post (the most common operation)
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Post content here"}'
# Email dry-run (always before live send)
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"to_email": "robert@snappy.ai", "subject": "Test", "body": "<p>Body</p>", "dry_run": true}'
# YouTube upload
curl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"title": "Title", "description": "Desc", "tags": ["snappy"], "video_url": "https://...", "privacy": "private"}'
# Skool post (browser)
agent-browser --state ~/.openclaw/workspace/skool-auth.json open https://www.skool.com/snappy
agent-browser wait 3000
agent-browser snapshot -i 2>&1 | head -10
agent-browser find text "Write something" click
agent-browser fill "[contenteditable]" "Post content"
agent-browser find text "Post" click
After any multi-platform send, return a log to Robert:
DISTRIBUTION LOG -- 2026-04-07
─────────────────────────────────────────
LinkedIn: POSTED https://linkedin.com/posts/robert-boulos_xxx 08:14 ET
Email: SENT "Subject" → Xano/Gmail 08:15 ET
Skool: POSTED Snappy community feed 08:17 ET
YouTube: PUBLISHED https://youtu.be/xxx 08:20 ET
Blog: LIVE https://snappy.ai/blog/<slug> 08:22 ET
─────────────────────────────────────────
| wrong | right |
|---|---|
| Copy-paste same text to all platforms | Rewrite for each platform's format and audience |
| Post without anti-AI check | Run through snappy-content/anti-ai-checklist.md first |
| Use a browser for LinkedIn posting | Xano API -- instant, reliable |
| Skip dry-run on emails | Always dry-run, review, then send |
| Post at random times | Follow per-platform best times (see linkedin-posting.md, scheduling.md) |
| Forget to log what was posted | Always return a distribution log |
| Add hashtags to LinkedIn or Skool | Zero hashtags |
| Send a 600-word email | Under 300 words; longer content belongs in a blog |
| Restart multi-platform sequence on failure | Resume from the failed step -- log what already shipped |
Push to deploy remote for blog |
origin only -- deploy is a legacy remote that does nothing |
| Upload YouTube video before captions are burned | snappy-video first -- most viewers watch muted |
| skill | why |
|---|---|
snappy-content |
Methodology source -- interview framework, voice rules, anti-AI checklist, repurposing paths |
snappy-blog |
Produces MDX drafts that snappy-post hands off to snappy-publish |
snappy-publish |
Owns the git/MDX/Vercel pipeline for blog publishing |
snappy-image |
Generates and CDN-hosts images for posts (hero, carousel slides, thumbnails) |
snappy-video |
Processes raw video (Whisper transcription, caption burning, clip extraction) |
snappy-linkedin |
Full LinkedIn engagement layer (DMs, comments, outreach) -- runs after posts land |
snappy-youtube |
Channel strategy, video planning, SEO -- feeds into snappy-post for distribution |
snappy-skool |
Skool member ops + curriculum -- provides context for Skool posts |
snappy-email |
Inbox/triage/templates layer -- owns the broader email surface area |
snappy-knowledge |
Contact context for personalization |
snappy-transcripts |
Raw transcript material for repurposing |
snappy-browse |
agent-browser CLI primitive used for Skool, LinkedIn scheduling |
snappy-ops |
Daily/weekly orchestration -- triggers content distribution slots |
snappy-scheduling |
Calendar/planning layer that decides when posts ship |
snappy-infra |
Auth + Xano base URLs (single source of truth) |
Skill Status: COMPLETE
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
snappy-ads |
YouTube advertising for Snappy -- paid acquisition for the mastermind/consulting funnel via G… |
snappy-course |
Orchestrator for the free agentic-building course. |
snappy-deploy |
Meta-deployment skill that orchestrates ALL Snappy project deployments across the four suppor… |
snappy-dom-cartographer |
Master DOM mapping agent for the Snappy swarm. |
snappy-gemini |
Single canonical interface to Google's Gemini family for the Snappy system. |
snappy-github |
Centralized GitHub operations across all Snappy client repos via the gh CLI -- pull request… |
snappy-gmail |
Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gma… |
snappy-inbox-sweep |
Deterministic sweep across every inbox Robert has to check (Slack, Gmail, LinkedIn DMs, Skool… |
snappy-maintenance |
Snappy project maintenance -- keeping all client and internal systems healthy across Vercel… |
snappy-openrouter |
Single canonical interface to OpenRouter for the Snappy system. |
snappy-outbound |
Channel router for outbound messages. |
snappy-slack |
Slack operations channel for Snappy via Xano API (api:hZB4Dj0c + api:XOwEm4wm). |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
snappy-update |
Snappy Update -- dev updates to consulting clients. |
snappy-website |
Snappy website (snappy.ai) operations -- Next.js + Vercel marketing site, VSL conversion funn… |
snappy-whatsapp |
WhatsApp messaging channel for Snappy via Xano API (api:hZB4Dj0c). |
snappy-xano-mcp |
THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API… |
---
name: snappy-post
instruction-only: true
reports_to: growth
head: false
description: >
Unified social media posting and scheduling router for Snappy. One entry point that takes any
approved content (text, image, carousel, video, MDX) and distributes it across LinkedIn, YouTube,
Skool, Email (Xano/Gmail), and Blog (via snappy-publish). Adapts format per platform,
enforces no-hashtag and anti-AI rules from snappy-content, dry-runs emails, and returns a
distribution log. Triggers on: post, schedule post, publish, share on, post to linkedin, post to skool,
schedule content, distribute content, post everywhere, multi-platform post, cross-post,
distribute, post to all, send email, upload video, blog this.
---
# Snappy Post -- Unified Distribution Router
**Voice & Brand:** Always read `snappy-positioning` before creating any outbound content. It holds the one-liner, voice rules, banned phrases, and property map. If this skill disagrees with positioning, positioning wins.
## Where its sends go
The three verbs this hand implements **send through the platform's own hand** -- `linkedin` through `snappy-linkedin`'s `post`, `slack` through `snappy-slack`'s `send`, `telegram` through `snappy-telegram`'s `send` -- so the preview, the stage row and the doors are that hand's. This skill decides only which platform a piece goes to and how it is shaped for it. Skool, email and blog are named in this document as distribution channels and have no verb here yet: reach them through `snappy-skool`, `snappy-gmail` and `snappy-publish` directly.
## Purpose
snappy-post is the **distribution layer** for the Snappy content engine. It takes approved content from `snappy-content` (and visuals from `snappy-image` / `snappy-video`) and ships it to every channel: LinkedIn, YouTube, Skool, Email, Blog. It adapts format per platform, enforces voice/format rules, and returns a distribution log. It does not write content -- that's `snappy-content`.
## When to Use This Skill
- Robert says "post on LinkedIn" / "post to Skool" / "send the email" / "upload to YouTube" / "blog this"
- Robert says "share this everywhere" / "cross-post" / "distribute this"
- Robert says "schedule this for [day/time]"
- A content piece is finished in `snappy-content` and needs to ship
- The daily content slot fires from `snappy-ops` and a draft is queued
Do NOT use this skill for:
- Writing the content itself → `snappy-content`
- Generating images → `snappy-image`
- Processing video → `snappy-video`
- Inbox triage / replying to emails → `snappy-email`
- LinkedIn engagement (commenting, DMs) → `snappy-linkedin`
- Skool member ops / curriculum → `snappy-skool`
---
## Workflow
**Inputs (skills that feed this one):**
- `snappy-content` -- provides the approved copy + voice rules + anti-AI checklist pass
- `snappy-blog` -- provides finished MDX drafts ready for blog publishing
- `snappy-image` -- provides hero images, carousel slides, thumbnails (CDN URLs)
- `snappy-video` -- provides processed/captioned videos and short clips (CDN URLs)
- `snappy-knowledge` -- provides contact context for personalized email sends and @-mentions
- `snappy-transcripts` -- provides raw transcripts as repurposing source material
- `snappy-ops` -- triggers distribution during the daily/weekly content slots
- `snappy-positioning` -- voice rules and §4a trip-wires (canonical source) <!-- learning from 2026-04-07 session -->
> **Principle**: Set `AGENT_BROWSER_SESSION` before any agent-browser call (session isolation). <!-- learning from 2026-04-07 session -->
**Outputs (skills that consume this one):**
- `snappy-publish` -- receives MDX + slug + frontmatter for git-based blog deploy
- `snappy-linkedin` -- engagement layer that runs after a post lands (golden-hour comments)
- `snappy-skool` -- owns Skool curriculum context that informs Skool posts
**Channels (where output is delivered):**
- LinkedIn (Xano API: `api:PB9UH7b9/linkedin/*`)
- YouTube (Xano API: `api:hZB4Dj0c/youtube-video-uploader`)
- Skool (`agent-browser` via `~/.openclaw/workspace/skool-auth.json`)
- Email -- transactional (Xano: `api:PB9UH7b9/emails/send`, Gmail backend). ActiveCampaign is NOT in use.
- Blog (snappy-publish → `git push origin main` → Vercel)
**Orchestrator:**
- `snappy-ops` triggers this skill during the morning briefing when calendar shows a content slot, and during the weekly content rhythm (Mon LinkedIn, Tue email, Wed YouTube/blog, Thu carousel, Fri Skool, Sat clips)
---
## Quick Start Interview
Every distribution session starts here. 3 questions, then route.
### 1. "What do you want to post?"
|answer|next_step|
|------|---------|
|Has content ready (text, link, video, MDX)|Go to question 2|
|Has a topic but no content|Run `snappy-content` 4-question interview first, then come back|
|Wants to repurpose existing content|Identify source (blog, video, email), see [`multi-platform-workflow.md`](multi-platform-workflow.md)|
### 2. "What type of content is it?"
|type|platforms_it_works_on|
|----|---------------------|
|Text post|LinkedIn, Skool, Email|
|Image + caption|LinkedIn, Skool|
|Carousel|LinkedIn|
|Video|YouTube, LinkedIn|
|Long-form MDX|Blog (snappy-publish), Email (link from blog)|
|Short clip|YouTube Shorts, LinkedIn video|
### 3. "Where should it go?"
|answer|read_this|
|------|---------|
|"LinkedIn"|[`linkedin-posting.md`](linkedin-posting.md)|
|"YouTube"|[`youtube-uploading.md`](youtube-uploading.md)|
|"Skool" / "Community"|[`skool-posting.md`](skool-posting.md)|
|"Email" / "Newsletter"|[`email-sending.md`](email-sending.md)|
|"Blog" / "Website"|[`blog-publishing.md`](blog-publishing.md) → routes to `snappy-publish`|
|"Everywhere" / "All platforms"|[`multi-platform-workflow.md`](multi-platform-workflow.md)|
|"Schedule it for later"|[`scheduling.md`](scheduling.md)|
---
## Auth Quick Reference
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see `snappy-settings/SKILL.md`. Required keys: `XANO_TOKEN`, `XANO`.
---
## Channel Endpoints (Quick Reference)
|channel|method|endpoint|payload|
|-------|------|--------|-------|
|LinkedIn text|POST|`api:PB9UH7b9/linkedin/post`|`{ text }`|
|LinkedIn image|POST|`api:PB9UH7b9/linkedin/post-image`|`{ text, image_url }`|
|LinkedIn carousel|POST|`api:PB9UH7b9/linkedin/post-carousel`|`{ text, slides[] }`|
|LinkedIn video|POST|`api:PB9UH7b9/linkedin/post-video`|`{ text, video_url }`|
|YouTube upload|POST|`api:hZB4Dj0c/youtube-video-uploader`|`{ title, description, tags, video_url, thumbnail_url, privacy, publish_at? }`|
|Email send|POST|`api:PB9UH7b9/emails/send`|`{ to_email, subject, body, dry_run }`|
|Email queue|POST|`api:8wuQ86By/queue/add`|`{ to_email, subject, body, send_at? }`|
|~~AC campaign~~|~~POST~~|~~ActiveCampaign~~|**DEPRECATED -- AC is not in use. Use Xano emails/send.**|
|Skool feed|browser|agent-browser via `skool-auth.json`|see [`skool-posting.md`](skool-posting.md)|
|Blog|git|`snappy-publish` → `git push origin main`|see [`blog-publishing.md`](blog-publishing.md)|
---
## Format Rules Cheat Sheet
|platform|max_chars|fold_point|hashtags|sweet_spot|
|--------|---------|---------|--------|----------|
|LinkedIn|3,000|~150|ZERO|800-1500 chars|
|Skool|N/A|N/A|ZERO|400-1200 chars|
|Email|N/A|subject ~10 words|N/A|< 300 words|
|YouTube title|100|~60|in description only|< 60 chars|
|YouTube description|5,000|first 150 = preview|hashtags at the bottom only (max 3)|1500-2500 chars|
|Blog (MDX)|N/A|first 100 words|N/A|800-1200 words|
|X / Twitter|280|N/A|sparingly, on-brand|180-250 chars|
Voice rules and anti-AI patterns are owned by `snappy-content/anti-ai-checklist.md` and `snappy-content/quality-rules.md`. Always run final copy through the checklist before posting.
---
## What AI Agents Get Wrong
```
WRONG: Use the browser to post to LinkedIn
CORRECT: Xano API (api:PB9UH7b9/linkedin/post) -- instant, reliable
WRONG: Skip the dry_run on emails ("it's just one send")
CORRECT: Always dry_run first, review the rendered HTML, then flip to false
WRONG: Copy-paste the same text from LinkedIn to Skool to X
CORRECT: Rewrite for each platform -- different fold points, different audience
WRONG: Post a YouTube video without burned-in captions
CORRECT: Run snappy-video FIRST to burn captions; most viewers watch muted
WRONG: git push deploy main (legacy remote -- does nothing for snappy.ai)
CORRECT: git push origin main (this is what triggers Vercel)
WRONG: Set publish_at without privacy: "private" on YouTube
CORRECT: privacy: "private" + publish_at: ISO timestamp = scheduled publish
WRONG: Use the Xano queue for time-critical sends
CORRECT: Queue worker runs every 5 min -- use POST emails/send for immediate
WRONG: Add hashtags to a LinkedIn post
CORRECT: Zero hashtags -- LinkedIn algorithm killed hashtag reach in 2024
WRONG: Reuse a temp video URL from an upload API
CORRECT: Always upload to CDN first; temp URLs expire mid-upload
WRONG: Post to Skool without verifying you're logged in
CORRECT: Always agent-browser snapshot first to confirm "Write something" exists
```
---
## Navigation Guide
|need_to|read_this|
|-------|---------|
|Post to LinkedIn (text/image/carousel/video)|[`linkedin-posting.md`](linkedin-posting.md)|
|Upload a video to YouTube|[`youtube-uploading.md`](youtube-uploading.md)|
|Post to the Skool community feed|[`skool-posting.md`](skool-posting.md)|
|Send an email (transactional or broadcast)|[`email-sending.md`](email-sending.md)|
|Publish a blog post (handoff to snappy-publish)|[`blog-publishing.md`](blog-publishing.md)|
|Distribute one source piece to multiple platforms|[`multi-platform-workflow.md`](multi-platform-workflow.md)|
|Schedule a post for future delivery|[`scheduling.md`](scheduling.md)|
---
## Quick Reference -- Common One-Liners
```bash
# LinkedIn text post (the most common operation)
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Post content here"}'
# Email dry-run (always before live send)
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"to_email": "robert@snappy.ai", "subject": "Test", "body": "<p>Body</p>", "dry_run": true}'
# YouTube upload
curl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"title": "Title", "description": "Desc", "tags": ["snappy"], "video_url": "https://...", "privacy": "private"}'
# Skool post (browser)
agent-browser --state ~/.openclaw/workspace/skool-auth.json open https://www.skool.com/snappy
agent-browser wait 3000
agent-browser snapshot -i 2>&1 | head -10
agent-browser find text "Write something" click
agent-browser fill "[contenteditable]" "Post content"
agent-browser find text "Post" click
```
---
## Distribution Log Format
After any multi-platform send, return a log to Robert:
```
DISTRIBUTION LOG -- 2026-04-07
─────────────────────────────────────────
LinkedIn: POSTED https://linkedin.com/posts/robert-boulos_xxx 08:14 ET
Email: SENT "Subject" → Xano/Gmail 08:15 ET
Skool: POSTED Snappy community feed 08:17 ET
YouTube: PUBLISHED https://youtu.be/xxx 08:20 ET
Blog: LIVE https://snappy.ai/blog/<slug> 08:22 ET
─────────────────────────────────────────
```
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Copy-paste same text to all platforms|Rewrite for each platform's format and audience|
|Post without anti-AI check|Run through `snappy-content/anti-ai-checklist.md` first|
|Use a browser for LinkedIn posting|Xano API -- instant, reliable|
|Skip dry-run on emails|Always dry-run, review, then send|
|Post at random times|Follow per-platform best times (see [`linkedin-posting.md`](linkedin-posting.md), [`scheduling.md`](scheduling.md))|
|Forget to log what was posted|Always return a distribution log|
|Add hashtags to LinkedIn or Skool|Zero hashtags|
|Send a 600-word email|Under 300 words; longer content belongs in a blog|
|Restart multi-platform sequence on failure|Resume from the failed step -- log what already shipped|
|Push to `deploy` remote for blog|`origin` only -- `deploy` is a legacy remote that does nothing|
|Upload YouTube video before captions are burned|`snappy-video` first -- most viewers watch muted|
---
## Related Skills
|skill|why|
|-----|---|
|`snappy-content`|Methodology source -- interview framework, voice rules, anti-AI checklist, repurposing paths|
|`snappy-blog`|Produces MDX drafts that snappy-post hands off to snappy-publish|
|`snappy-publish`|Owns the git/MDX/Vercel pipeline for blog publishing|
|`snappy-image`|Generates and CDN-hosts images for posts (hero, carousel slides, thumbnails)|
|`snappy-video`|Processes raw video (Whisper transcription, caption burning, clip extraction)|
|`snappy-linkedin`|Full LinkedIn engagement layer (DMs, comments, outreach) -- runs after posts land|
|`snappy-youtube`|Channel strategy, video planning, SEO -- feeds into snappy-post for distribution|
|`snappy-skool`|Skool member ops + curriculum -- provides context for Skool posts|
|`snappy-email`|Inbox/triage/templates layer -- owns the broader email surface area|
|`snappy-knowledge`|Contact context for personalization|
|`snappy-transcripts`|Raw transcript material for repurposing|
|`snappy-browse`|agent-browser CLI primitive used for Skool, LinkedIn scheduling|
|`snappy-ops`|Daily/weekly orchestration -- triggers content distribution slots|
|`snappy-scheduling`|Calendar/planning layer that decides when posts ship|
|`snappy-infra`|Auth + Xano base URLs (single source of truth)|
---
**Skill Status**: COMPLETE
## Near neighbours
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
| `snappy-ads` | YouTube advertising for Snappy -- paid acquisition for the mastermind/consulting funnel via G… |
| `snappy-course` | Orchestrator for the free agentic-building course. |
| `snappy-deploy` | Meta-deployment skill that orchestrates ALL Snappy project deployments across the four suppor… |
| `snappy-dom-cartographer` | Master DOM mapping agent for the Snappy swarm. |
| `snappy-gemini` | Single canonical interface to Google's Gemini family for the Snappy system. |
| `snappy-github` | Centralized GitHub operations across all Snappy client repos via the `gh` CLI -- pull request… |
| `snappy-gmail` | Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gma… |
| `snappy-inbox-sweep` | Deterministic sweep across every inbox Robert has to check (Slack, Gmail, LinkedIn DMs, Skool… |
| `snappy-maintenance` | Snappy project maintenance -- keeping all client and internal systems healthy across Vercel… |
| `snappy-openrouter` | Single canonical interface to OpenRouter for the Snappy system. |
| `snappy-outbound` | Channel router for outbound messages. |
| `snappy-slack` | Slack operations channel for Snappy via Xano API (`api:hZB4Dj0c` + `api:XOwEm4wm`). |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
| `snappy-update` | Snappy Update -- dev updates to consulting clients. |
| `snappy-website` | Snappy website (snappy.ai) operations -- Next.js + Vercel marketing site, VSL conversion funn… |
| `snappy-whatsapp` | WhatsApp messaging channel for Snappy via Xano API (`api:hZB4Dj0c`). |
| `snappy-xano-mcp` | THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API… |
#!/usr/bin/env npx tsx
/**
* snappy-post/api.ts -- Unified distribution router for all snappy-* skills.
*
* Re-exports posting functions from each channel skill so agents have
* a single import surface for multi-platform distribution.
*
* Usage:
* npx tsx api.ts linkedin "Hook line.\n\nBody."
* npx tsx api.ts slack C09DD2D0S07 "Hello"
* npx tsx api.ts telegram "Deploy done"
*
* Or import as module:
* import { linkedinPost, slackPost, telegramPost } from "../snappy-post/api.ts";
*/
import { realpathSync } from "node:fs";
import { delegateToHand } from "../hand-delegate.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// --- Re-exports from channel skills ---
// ⟨lane CONTRACTS, 2026-09-07⟩ THESE NAMES WERE WRONG AND THIS HAND HAD NEVER
// LOADED. It re-exported `createPost` and `sendMessage`, which neither channel
// skill has ever exported, so every import of snappy-post — and its own CLI —
// died at module instantiation with "does not provide an export named". The
// names below are read from each skill's own `export async function` lines.
export { createLinkedInPost as linkedinPost } from "../snappy-linkedin/api.ts";
export { sendSlackMessage as slackPost } from "../snappy-slack/api.ts";
export { sendText as telegramPost } from "../snappy-telegram/api.ts";
// --- CLI ---
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-post",
description: "Unified social media posting and scheduling router for Snappy. One entry point that takes any approved content (text, image, carousel, video, MDX) and distributes it across LinkedIn, YouTube, Skool, Email (Xano/Gmail), and Blog (via snappy-publish). Adapts format per platform, enforces no-hashtag and anti-AI rules from snappy-content, dry-runs emails, and returns a distribution log. Triggers on: post, schedule post, publish, share on, post to linkedin, post to skool, schedule content, distribute content, post everywhere, multi-platform post, cross-post, distribute, post to all, send email, upload video, blog this.",
managed: false,
requires: ["LINKEDIN_CLIENT_ID","LINKEDIN_CLIENT_SECRET","SLACK_BOT_TOKEN","TELEGRAM_BOT_TOKEN","TELEGRAM_ROBERT_CHAT_ID","TYPEFULLY_API_KEY"] as string[],
refusals: refusalTable("missing_credential", "missing_argument", "unknown_verb"),
verbs: {
linkedin: {
args: ["text"], effect: "post", class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" } } },
},
slack: {
args: ["channel-id","text"], effect: "post", class: "send-to-a-person", target: "channel-id", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { "channel-id": { type: "string", description: "Destination channel id" }, text: { type: "string", description: "The words to use, verbatim" } } },
},
telegram: {
args: ["text"], effect: "post", class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
/* ── THE DESTINATIONS OWN THEIR DOORS ⟨lane doors-everywhere, 2026-09-09⟩ ─────
*
* MEASURED, and both halves were true at once: this hand's CLI destructured
* `createPost` from snappy-linkedin and `sendMessage` from snappy-slack, and
* NEITHER EXPORT EXISTS — the file's own header says so about the module-level
* re-exports, which were fixed while the dynamic imports twenty lines below
* were not. So every verb died at instantiation and no post was ever made from
* here. And had they resolved, they would have posted STRAIGHT TO THE VENDOR:
* no preview, no stage row, no door, no receipt — the one thing CLAUDE.md §6
* forbids, from a hand whose whole contract is `class: "send-to-a-person"`.
*
* A ROUTER IS AN ADAPTER, NOT A FOURTH IMPLEMENTATION ⟨CLAUDE.md §4: DUPLICATE
* ROADS ARE BANNED; if two must exist, one is a thin adapter over the other and
* says so at its top⟩. snappy-linkedin, snappy-slack and snappy-telegram each
* already read the context, print the decision in it with its two doors, stage
* without `--now` and execute the owner's decision. Giving this hand a fourth
* decision face would be a fourth set of words for one act, drifting the first
* time one of them changed. So each verb RUNS THE DESTINATION'S OWN ARM, with
* the caller's flags passed through untouched: `--json` gets that family's real
* preview and doors, the bare verb takes that family's stage road, and `--now`
* is that family's one bypass. Nothing about the act is decided here.
*
* THE SPAWN ITSELF IS ONE ROAD ⟨lane doors-2, 2026-09-09⟩. This file carried
* the plumbing inline and was right to; then eight more hands needed the same
* three lines, and eight copies of "resolve the sibling's api.ts, carry this
* process's loader down, exit with the child's status" is the duplication the
* pattern was applied to remove. It lives in `skills/hand-delegate.ts` now and
* this hand is its first caller — which is what a pattern becoming a road looks
* like, and why the file below got shorter rather than longer. */
const DESTINATIONS: Record<string, { skill: string; verb: string; usage: string }> = {
linkedin: { skill: "snappy-linkedin", verb: "post", usage: "api.ts linkedin <text> [--now] [--json]" },
slack: { skill: "snappy-slack", verb: "send", usage: "api.ts slack <channel_id> <text> [--now] [--json]" },
telegram: { skill: "snappy-telegram", verb: "send", usage: "api.ts telegram <text> [--now] [--json]" },
};
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [, , cmd, ...args] = process.argv;
const to = cmd === undefined ? undefined : DESTINATIONS[cmd];
if (to === undefined) {
console.log("Usage: npx tsx api.ts [linkedin|slack|telegram] ...\nEvery verb runs the destination hand's own arm: --json previews the decision in its context with that family's doors, the bare verb stages it, --now is the one bypass.");
process.exit(cmd === undefined ? 0 : 2);
}
if (args.filter((word) => !word.startsWith("--")).length === 0) {
console.error(`Usage: ${to.usage}`);
process.exit(1);
}
delegateToHand({ skill: to.skill, verb: to.verb, args });
}
#!/usr/bin/env npx tsx
/**
* snappy-post/api.ts -- Unified distribution router for all snappy-* skills.
*
* Re-exports posting functions from each channel skill so agents have
* a single import surface for multi-platform distribution.
*
* Usage:
* npx tsx api.ts linkedin "Hook line.\n\nBody."
* npx tsx api.ts slack C09DD2D0S07 "Hello"
* npx tsx api.ts telegram "Deploy done"
*
* Or import as module:
* import { linkedinPost, slackPost, telegramPost } from "../snappy-post/api.ts";
*/
import { realpathSync } from "node:fs";
import { delegateToHand } from "../hand-delegate.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// --- Re-exports from channel skills ---
// ⟨lane CONTRACTS, 2026-09-07⟩ THESE NAMES WERE WRONG AND THIS HAND HAD NEVER
// LOADED. It re-exported `createPost` and `sendMessage`, which neither channel
// skill has ever exported, so every import of snappy-post — and its own CLI —
// died at module instantiation with "does not provide an export named". The
// names below are read from each skill's own `export async function` lines.
export { createLinkedInPost as linkedinPost } from "../snappy-linkedin/api.ts";
export { sendSlackMessage as slackPost } from "../snappy-slack/api.ts";
export { sendText as telegramPost } from "../snappy-telegram/api.ts";
// --- CLI ---
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-post",
description: "Unified social media posting and scheduling router for Snappy. One entry point that takes any approved content (text, image, carousel, video, MDX) and distributes it across LinkedIn, YouTube, Skool, Email (Xano/Gmail), and Blog (via snappy-publish). Adapts format per platform, enforces no-hashtag and anti-AI rules from snappy-content, dry-runs emails, and returns a distribution log. Triggers on: post, schedule post, publish, share on, post to linkedin, post to skool, schedule content, distribute content, post everywhere, multi-platform post, cross-post, distribute, post to all, send email, upload video, blog this.",
managed: false,
requires: ["LINKEDIN_CLIENT_ID","LINKEDIN_CLIENT_SECRET","SLACK_BOT_TOKEN","TELEGRAM_BOT_TOKEN","TELEGRAM_ROBERT_CHAT_ID","TYPEFULLY_API_KEY"] as string[],
refusals: refusalTable("missing_credential", "missing_argument", "unknown_verb"),
verbs: {
linkedin: {
args: ["text"], effect: "post", class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" } } },
},
slack: {
args: ["channel-id","text"], effect: "post", class: "send-to-a-person", target: "channel-id", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { "channel-id": { type: "string", description: "Destination channel id" }, text: { type: "string", description: "The words to use, verbatim" } } },
},
telegram: {
args: ["text"], effect: "post", class: "send-to-a-person", openWorld: true,
annotations: annotationsForClass("send-to-a-person", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
/* ── THE DESTINATIONS OWN THEIR DOORS ⟨lane doors-everywhere, 2026-09-09⟩ ─────
*
* MEASURED, and both halves were true at once: this hand's CLI destructured
* `createPost` from snappy-linkedin and `sendMessage` from snappy-slack, and
* NEITHER EXPORT EXISTS — the file's own header says so about the module-level
* re-exports, which were fixed while the dynamic imports twenty lines below
* were not. So every verb died at instantiation and no post was ever made from
* here. And had they resolved, they would have posted STRAIGHT TO THE VENDOR:
* no preview, no stage row, no door, no receipt — the one thing CLAUDE.md §6
* forbids, from a hand whose whole contract is `class: "send-to-a-person"`.
*
* A ROUTER IS AN ADAPTER, NOT A FOURTH IMPLEMENTATION ⟨CLAUDE.md §4: DUPLICATE
* ROADS ARE BANNED; if two must exist, one is a thin adapter over the other and
* says so at its top⟩. snappy-linkedin, snappy-slack and snappy-telegram each
* already read the context, print the decision in it with its two doors, stage
* without `--now` and execute the owner's decision. Giving this hand a fourth
* decision face would be a fourth set of words for one act, drifting the first
* time one of them changed. So each verb RUNS THE DESTINATION'S OWN ARM, with
* the caller's flags passed through untouched: `--json` gets that family's real
* preview and doors, the bare verb takes that family's stage road, and `--now`
* is that family's one bypass. Nothing about the act is decided here.
*
* THE SPAWN ITSELF IS ONE ROAD ⟨lane doors-2, 2026-09-09⟩. This file carried
* the plumbing inline and was right to; then eight more hands needed the same
* three lines, and eight copies of "resolve the sibling's api.ts, carry this
* process's loader down, exit with the child's status" is the duplication the
* pattern was applied to remove. It lives in `skills/hand-delegate.ts` now and
* this hand is its first caller — which is what a pattern becoming a road looks
* like, and why the file below got shorter rather than longer. */
const DESTINATIONS: Record<string, { skill: string; verb: string; usage: string }> = {
linkedin: { skill: "snappy-linkedin", verb: "post", usage: "api.ts linkedin <text> [--now] [--json]" },
slack: { skill: "snappy-slack", verb: "send", usage: "api.ts slack <channel_id> <text> [--now] [--json]" },
telegram: { skill: "snappy-telegram", verb: "send", usage: "api.ts telegram <text> [--now] [--json]" },
};
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
const [, , cmd, ...args] = process.argv;
const to = cmd === undefined ? undefined : DESTINATIONS[cmd];
if (to === undefined) {
console.log("Usage: npx tsx api.ts [linkedin|slack|telegram] ...\nEvery verb runs the destination hand's own arm: --json previews the decision in its context with that family's doors, the bare verb stages it, --now is the one bypass.");
process.exit(cmd === undefined ? 0 : 2);
}
if (args.filter((word) => !word.startsWith("--")).length === 0) {
console.error(`Usage: ${to.usage}`);
process.exit(1);
}
delegateToHand({ skill: to.skill, verb: to.verb, args });
}
snappy-post is the distribution router -- for blog posts, it hands off to snappy-publish, which owns the git/MDX/Vercel pipeline. This file documents the handoff contract; the actual publishing logic lives in snappy-publish.
snappy-blog (writes content)
│
│ hands off MDX + slug + frontmatter
│
▼
snappy-post (router) ──── delegates to ────▶ snappy-publish (git workflow)
│
│ push to origin/main
│ trigger Vercel deploy
│ verify live URL
▼
https://snappy.ai/blog/<slug>
If snappy-post is asked to "blog this", route to snappy-publish rather than trying to do the git work locally. snappy-publish owns:
origin (NOT deploy)When handing off to snappy-publish, snappy-post must provide:
| field | source | ||||
|---|---|---|---|---|---|
slug |
Generated from title (lowercase, hyphens, no punctuation, < 60 chars) | ||||
title |
From the user / interview / blog draft | ||||
description |
1-2 sentences, under 160 chars | ||||
mdx_body |
The post body (markdown + approved MDX components) | ||||
category |
Strategy \ |
Engineering \ |
AI \ |
Business \ |
Case Study |
tags |
3-5 lowercase-hyphenated tags | ||||
hero_image_url |
Optional. CDN URL from snappy-image |
||||
featured |
Default false. Only flip with explicit instruction. |
These are NOT snappy-post's responsibility. If asked, route to snappy-publish:
| task | owner |
|---|---|
| Clone the snappy-website repo | snappy-publish/git-workflow.md |
Validate frontmatter against gray-matter |
snappy-publish/mdx-validation.md |
| Write the MDX file | snappy-publish (uses Write tool, NOT echo/heredoc) |
| Commit + push | snappy-publish (git add <file>, git push origin main) |
| Verify Vercel deploy | snappy-publish (poll the live URL until 200) |
| Rollback if deploy fails | snappy-publish/git-workflow.md |
| Hero/og image specs | snappy-publish/images-and-frontmatter.md |
If the user explicitly says "publish this blog now" and the MDX is already validated, the inline path is fine -- but it's just a thin wrapper around the snappy-publish workflow.
bash# Repo location (per snappy-publish/SKILL.md)
SNAPPY_WEB="/Users/robertboulos/Projects/v0-prototypes/v0-snappy-website-0c"
SLUG="why-agents-matter"
TITLE="Why Agents Matter"
# 1. Sync main
[ -d "$SNAPPY_WEB" ] || git clone https://github.com/robert-boulos/snappy-website.git "$SNAPPY_WEB"
cd "$SNAPPY_WEB"
git checkout main
git pull --ff-only origin main
# 2. Write the MDX file using the Write tool (not echo/heredoc)
# File: content/blog/${SLUG}.mdx
# Required frontmatter: title, description, date, author, authorRole, category, featured, tags
# 3. Validate (gray-matter check from snappy-publish/mdx-validation.md)
node -e "const m=require('gray-matter');const f=require('fs');const p=m(f.readFileSync('content/blog/${SLUG}.mdx','utf8'));const r=['title','description','date','author','authorRole','category','featured','tags'];const miss=r.filter(k=>!(k in p.data));if(miss.length){console.error('MISSING:',miss);process.exit(1)}else console.log('OK')"
# 4. Commit + push (specific file only)
git add "content/blog/${SLUG}.mdx"
git commit -m "blog: ${TITLE}"
git push origin main
# 5. Verify deploy
sleep 90
curl -sI "https://snappy.ai/blog/${SLUG}" | head -1 # Expect: HTTP/2 200
For everything else (frontmatter rules, MDX components, image specs, slug collision handling, rollback), see snappy-publish/SKILL.md.
| wrong | right |
|---|---|
Push to deploy remote |
origin only -- deploy is a legacy remote |
git add . or git add -A |
git add content/blog/<slug>.mdx |
| Skip frontmatter validation | Run gray-matter check before commit |
| Use heredoc/echo to write MDX | Use the Write tool -- preserves frontmatter and special chars |
Local image path in frontmatter (/images/x.png) |
CDN URL from snappy-image |
Set featured: true without demoting the current featured post |
Default false |
| Skip the snappy-publish handoff | Route there for the canonical workflow; this file is a thin wrapper |
# Blog Publishing -- snappy-post
snappy-post is the **distribution router** -- for blog posts, it hands off to `snappy-publish`, which owns the git/MDX/Vercel pipeline. This file documents the handoff contract; the actual publishing logic lives in `snappy-publish`.
## Table of Contents
- [The Handoff Contract](#the-handoff-contract)
- [What snappy-post Provides](#what-snappy-post-provides)
- [What snappy-publish Handles](#what-snappy-publish-handles)
- [Quick Inline Path](#quick-inline-path)
- [Anti-Patterns](#anti-patterns)
---
## The Handoff Contract
```
snappy-blog (writes content)
│
│ hands off MDX + slug + frontmatter
│
▼
snappy-post (router) ──── delegates to ────▶ snappy-publish (git workflow)
│
│ push to origin/main
│ trigger Vercel deploy
│ verify live URL
▼
https://snappy.ai/blog/<slug>
```
If `snappy-post` is asked to "blog this", route to `snappy-publish` rather than trying to do the git work locally. snappy-publish owns:
- Repo state (clone, sync, branch)
- Frontmatter validation (gray-matter)
- Slug collision handling
- Image references (CDN URLs only)
- Commit + push to `origin` (NOT `deploy`)
- Vercel deploy verification + rollback
---
## What snappy-post Provides
When handing off to snappy-publish, snappy-post must provide:
|field|source|
|-----|------|
|`slug`|Generated from title (lowercase, hyphens, no punctuation, < 60 chars)|
|`title`|From the user / interview / blog draft|
|`description`|1-2 sentences, under 160 chars|
|`mdx_body`|The post body (markdown + approved MDX components)|
|`category`|`Strategy` \| `Engineering` \| `AI` \| `Business` \| `Case Study`|
|`tags`|3-5 lowercase-hyphenated tags|
|`hero_image_url`|Optional. CDN URL from `snappy-image`|
|`featured`|Default `false`. Only flip with explicit instruction.|
---
## What snappy-publish Handles
These are NOT snappy-post's responsibility. If asked, route to snappy-publish:
|task|owner|
|----|-----|
|Clone the snappy-website repo|`snappy-publish/git-workflow.md`|
|Validate frontmatter against `gray-matter`|`snappy-publish/mdx-validation.md`|
|Write the MDX file|`snappy-publish` (uses Write tool, NOT echo/heredoc)|
|Commit + push|`snappy-publish` (`git add <file>`, `git push origin main`)|
|Verify Vercel deploy|`snappy-publish` (poll the live URL until 200)|
|Rollback if deploy fails|`snappy-publish/git-workflow.md`|
|Hero/og image specs|`snappy-publish/images-and-frontmatter.md`|
---
## Quick Inline Path
If the user explicitly says "publish this blog now" and the MDX is already validated, the inline path is fine -- but it's just a thin wrapper around the snappy-publish workflow.
```bash
# Repo location (per snappy-publish/SKILL.md)
SNAPPY_WEB="/Users/robertboulos/Projects/v0-prototypes/v0-snappy-website-0c"
SLUG="why-agents-matter"
TITLE="Why Agents Matter"
# 1. Sync main
[ -d "$SNAPPY_WEB" ] || git clone https://github.com/robert-boulos/snappy-website.git "$SNAPPY_WEB"
cd "$SNAPPY_WEB"
git checkout main
git pull --ff-only origin main
# 2. Write the MDX file using the Write tool (not echo/heredoc)
# File: content/blog/${SLUG}.mdx
# Required frontmatter: title, description, date, author, authorRole, category, featured, tags
# 3. Validate (gray-matter check from snappy-publish/mdx-validation.md)
node -e "const m=require('gray-matter');const f=require('fs');const p=m(f.readFileSync('content/blog/${SLUG}.mdx','utf8'));const r=['title','description','date','author','authorRole','category','featured','tags'];const miss=r.filter(k=>!(k in p.data));if(miss.length){console.error('MISSING:',miss);process.exit(1)}else console.log('OK')"
# 4. Commit + push (specific file only)
git add "content/blog/${SLUG}.mdx"
git commit -m "blog: ${TITLE}"
git push origin main
# 5. Verify deploy
sleep 90
curl -sI "https://snappy.ai/blog/${SLUG}" | head -1 # Expect: HTTP/2 200
```
For everything else (frontmatter rules, MDX components, image specs, slug collision handling, rollback), see `snappy-publish/SKILL.md`.
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Push to `deploy` remote|`origin` only -- `deploy` is a legacy remote|
|`git add .` or `git add -A`|`git add content/blog/<slug>.mdx`|
|Skip frontmatter validation|Run gray-matter check before commit|
|Use heredoc/echo to write MDX|Use the Write tool -- preserves frontmatter and special chars|
|Local image path in frontmatter (`/images/x.png`)|CDN URL from `snappy-image`|
|Set `featured: true` without demoting the current featured post|Default `false`|
|Skip the snappy-publish handoff|Route there for the canonical workflow; this file is a thin wrapper|
One path: Xano API for all email sends (Gmail backend). ActiveCampaign is NOT in use. Always dry-run first.
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md. Required keys: XANO_TOKEN, XANO.
Use for: one-off sends to a specific recipient (sales follow-ups, client updates, manual broadcasts to small groups).
POST api:PB9UH7b9/emails/send
| field | type | required | notes |
|---|---|---|---|
to_email |
string | yes | Recipient address |
subject |
string | yes | Under 10 words |
body |
string | yes | HTML format with <p> tags |
dry_run |
boolean | no | true = preview only, false = actually send. Default false |
bash# Step 1: Dry run (preview only -- nothing sent)
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "robert@snappy.ai",
"subject": "Test: 3-email sequence that booked 7 calls",
"body": "<p>Hey John,</p><p>Most people send one email and wonder why nobody books.</p><p>Last month I ran a 3-email sequence for a client...</p><p>-- Robert</p>",
"dry_run": true
}'
# Step 2: Review the response. If the body looks right, flip dry_run and send for real
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "john@company.com",
"subject": "The 3-email sequence that booked 7 calls",
"body": "<p>Hey John,</p>...<p>-- Robert</p>",
"dry_run": false
}'
For full inbox/triage/templates workflow see snappy-email.
ActiveCampaign is NOT in use. For full-list broadcasts, loop over Xano contacts with Path 1, or use Loops.so (
LOOPS_API_KEY).
For full inbox/triage/templates workflow see snappy-email.
Use for: queueing a send for later, batching, or fire-and-forget when the recipient list is dynamic.
bashcurl -s -X POST "$XANO/api:8wuQ86By/queue/add" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "recipient@example.com",
"subject": "Subject",
"body": "<p>Body</p>",
"send_at": "2026-04-10T08:00:00-04:00"
}'
| field | notes |
|---|---|
send_at |
Optional ISO timestamp. Omit to send immediately when the queue worker picks it up. |
The Xano queue worker runs every 5 minutes -- sends are not exact-time; budget ±5 min.
| rule | why |
|---|---|
| Write to ONE person, not "your list" | "Hey John" beats "Hey everyone" |
Plain-text style HTML (<p> tags only) |
Looks like a real email, not a marketing template |
| Short paragraphs (1-3 sentences each) | Mobile-readable; the only kind of email that gets read |
| ONE CTA per email | Multiple CTAs split attention and kill clicks |
| Under 300 words total | Anything longer should be a blog post linked from a short email |
| First-person, conversational | Same voice as a Slack DM, not a press release |
| Sign off with "-- Robert" or first name | Personal, signed, real |
| No banner image | Banners scream "marketing email" and reduce open rates |
Voice rules and anti-AI patterns are owned by snappy-content -- see snappy-content/anti-ai-checklist.md.
| rule | why |
|---|---|
| Under 10 words | Truncated otherwise on mobile |
| Specific > clever | "3-email sequence that booked 7 calls" beats "Quick question" |
| No ALL CAPS | Triggers spam filters |
| No emoji unless on-brand | Most look fake; one well-placed emoji is fine occasionally |
| Lowercase first word OK | Looks more personal: "quick thought on your launch" |
| No "RE:" or "Fwd:" fakes | Trust killer when discovered |
| pattern | example |
|---|---|
| Number + outcome | The 3-email sequence that booked 7 calls |
| Personal observation | Saw your launch -- one thought |
| Question | Are you still using Webflow for this? |
| Curiosity gap | The mistake every founder makes at 50 customers |
| First name | John, quick thought on your tech stack |
| wrong | right |
|---|---|
| Skip dry_run | Always dry_run first, review, then send |
| HTML email template with banner | Plain <p> tags only -- looks personal |
| Multi-CTA email | One CTA per email |
| Subject line over 10 words | Under 10 |
| "Hi everyone" | "Hey John" -- write to one person |
| Email longer than 300 words | Move long content to a blog and link |
| Send without checking subject | Subject decides whether the email gets opened |
| Use ActiveCampaign for anything | AC is NOT in use, email goes through Xano/Gmail |
| Use the queue for time-critical sends | Queue worker runs every 5 min -- use Path 1 for immediate |
# Email Sending -- snappy-post
One path: **Xano API** for all email sends (Gmail backend). ActiveCampaign is NOT in use. Always dry-run first.
## Table of Contents
- [Auth](#auth)
- [Path 1: Xano Transactional Send](#path-1-xano-transactional-send)
- [Path 2: ActiveCampaign Broadcast](#path-2-activecampaign-broadcast)
- [Path 3: Xano Queue (Async / Delayed)](#path-3-xano-queue-async--delayed)
- [Format Rules](#format-rules)
- [Subject Line Rules](#subject-line-rules)
- [Anti-Patterns](#anti-patterns)
---
## Auth
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see `snappy-settings/SKILL.md`. Required keys: `XANO_TOKEN`, `XANO`.
---
## Path 1: Xano Transactional Send
Use for: one-off sends to a specific recipient (sales follow-ups, client updates, manual broadcasts to small groups).
### Endpoint
`POST api:PB9UH7b9/emails/send`
|field|type|required|notes|
|-----|----|--------|-----|
|`to_email`|string|yes|Recipient address|
|`subject`|string|yes|Under 10 words|
|`body`|string|yes|HTML format with `<p>` tags|
|`dry_run`|boolean|no|`true` = preview only, `false` = actually send. Default `false`|
### Always Dry-Run First
```bash
# Step 1: Dry run (preview only -- nothing sent)
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "robert@snappy.ai",
"subject": "Test: 3-email sequence that booked 7 calls",
"body": "<p>Hey John,</p><p>Most people send one email and wonder why nobody books.</p><p>Last month I ran a 3-email sequence for a client...</p><p>-- Robert</p>",
"dry_run": true
}'
# Step 2: Review the response. If the body looks right, flip dry_run and send for real
curl -s -X POST "$XANO/api:PB9UH7b9/emails/send" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "john@company.com",
"subject": "The 3-email sequence that booked 7 calls",
"body": "<p>Hey John,</p>...<p>-- Robert</p>",
"dry_run": false
}'
```
For full inbox/triage/templates workflow see `snappy-email`.
---
## Path 2: Broadcast (Full List)
> **ActiveCampaign is NOT in use.** For full-list broadcasts, loop over Xano contacts with Path 1, or use Loops.so (`LOOPS_API_KEY`).
For full inbox/triage/templates workflow see `snappy-email`.
---
## Path 3: Xano Queue (Async / Delayed)
Use for: queueing a send for later, batching, or fire-and-forget when the recipient list is dynamic.
```bash
curl -s -X POST "$XANO/api:8wuQ86By/queue/add" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "recipient@example.com",
"subject": "Subject",
"body": "<p>Body</p>",
"send_at": "2026-04-10T08:00:00-04:00"
}'
```
|field|notes|
|-----|-----|
|`send_at`|Optional ISO timestamp. Omit to send immediately when the queue worker picks it up.|
The Xano queue worker runs every 5 minutes -- sends are not exact-time; budget ±5 min.
---
## Format Rules
|rule|why|
|----|---|
|Write to ONE person, not "your list"|"Hey John" beats "Hey everyone"|
|Plain-text style HTML (`<p>` tags only)|Looks like a real email, not a marketing template|
|Short paragraphs (1-3 sentences each)|Mobile-readable; the only kind of email that gets read|
|ONE CTA per email|Multiple CTAs split attention and kill clicks|
|Under 300 words total|Anything longer should be a blog post linked from a short email|
|First-person, conversational|Same voice as a Slack DM, not a press release|
|Sign off with "-- Robert" or first name|Personal, signed, real|
|No banner image|Banners scream "marketing email" and reduce open rates|
Voice rules and anti-AI patterns are owned by `snappy-content` -- see `snappy-content/anti-ai-checklist.md`.
---
## Subject Line Rules
|rule|why|
|----|---|
|Under 10 words|Truncated otherwise on mobile|
|Specific > clever|"3-email sequence that booked 7 calls" beats "Quick question"|
|No ALL CAPS|Triggers spam filters|
|No emoji unless on-brand|Most look fake; one well-placed emoji is fine occasionally|
|Lowercase first word OK|Looks more personal: "quick thought on your launch"|
|No "RE:" or "Fwd:" fakes|Trust killer when discovered|
### Subject Line Patterns
|pattern|example|
|-------|-------|
|Number + outcome|`The 3-email sequence that booked 7 calls`|
|Personal observation|`Saw your launch -- one thought`|
|Question|`Are you still using Webflow for this?`|
|Curiosity gap|`The mistake every founder makes at 50 customers`|
|First name|`John, quick thought on your tech stack`|
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Skip dry_run|Always dry_run first, review, then send|
|HTML email template with banner|Plain `<p>` tags only -- looks personal|
|Multi-CTA email|One CTA per email|
|Subject line over 10 words|Under 10|
|"Hi everyone"|"Hey John" -- write to one person|
|Email longer than 300 words|Move long content to a blog and link|
|Send without checking subject|Subject decides whether the email gets opened|
|Use ActiveCampaign for anything|AC is NOT in use, email goes through Xano/Gmail|
|Use the queue for time-critical sends|Queue worker runs every 5 min -- use Path 1 for immediate|
LinkedIn distribution via the Xano API. NEVER use a browser for posting -- Xano is instant and reliable. Browser automation is reserved for engagement, scheduling, and DM workflows (those live in snappy-linkedin).
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md. Required keys: XANO_TOKEN, XANO.
| method | endpoint | payload |
|---|---|---|
| POST | api:PB9UH7b9/linkedin/post |
{ text } |
| POST | api:PB9UH7b9/linkedin/post-image |
{ text, image_url } |
| POST | api:PB9UH7b9/linkedin/post-carousel |
{ text, slides[] } |
| POST | api:PB9UH7b9/linkedin/post-video |
{ text, video_url } |
bashcurl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Post content here"}'
bashcurl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-image" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Caption text", "image_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/social/2026-04-07-post.jpg"}'
LinkedIn carousels are document posts. The Xano endpoint takes a slides array; the worker generates the PDF and uploads as a document.
bashcurl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-carousel" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"text": "Carousel intro hook here",
"slides": [
{"title": "Slide 1 headline", "body": "Slide 1 body"},
{"title": "Slide 2 headline", "body": "Slide 2 body"},
{"title": "Slide 3 headline", "body": "Slide 3 body"}
]
}'
For full carousel design (cream-paper editorial style with ink illustrations), see snappy-content/carousel-method.md.
bashcurl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-video" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Video description with hook line", "video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-07-clip.mp4"}'
| rule | why |
|---|---|
| Hook in first ~150 chars | Everything after the fold ("...see more") is hidden until clicked |
| 3-5 short paragraphs, line breaks between | LinkedIn renders single line breaks, so paragraph spacing is the only structure cue |
| 800-1500 chars total | Sweet spot -- long enough for substance, short enough to read in one breath |
| ZERO hashtags | LinkedIn algorithm stopped rewarding hashtags in 2024; they look automated |
| Question or invitation as final line | Drives comments, which drive reach |
| Max 1 post per day | Posting more than once cannibalizes reach |
| No links inside the body | LinkedIn down-ranks posts with external links -- put any link in the first comment |
Voice rules and anti-AI patterns are owned by snappy-content -- see snappy-content/anti-ai-checklist.md and snappy-content/quality-rules.md.
| pattern | example |
|---|---|
| Specific number | "We cut deploy time from 45 minutes to 90 seconds." |
| Contrarian take | "Stop writing unit tests for your API layer." |
| Vivid scene | "The Slack message came in at 2am: 'prod is down.'" |
| Confession | "I mass-deleted 400 database columns last Tuesday." |
| Before/after | "6 months ago, every deploy was a prayer." |
| pattern | why_it_fails |
|---|---|
| "Have you ever..." | Generic rhetorical question |
| "I'm thrilled to share..." | Corporate AI slop |
| "In today's rapidly evolving..." | Banned phrase, screams AI |
| "Hot take:" / "Unpopular opinion:" | Overused format |
| Starting with a hashtag | Automated posting giveaway |
| day_window | times_ET | notes |
|---|---|---|
| Tue-Thu morning | 7:30-8:30 AM | Pre-coffee scroll, highest engagement |
| Tue-Thu midday | 12:00-1:00 PM | Lunch break scroll |
| Mon/Fri morning | 8:00-9:00 AM | Slightly lower but still strong |
| Weekends | Avoid | Low engagement; save the post |
If posting outside Robert's timezone, schedule via the LinkedIn UI workflow in scheduling.md.
snappy-image (Nano Banana → Grok edits → CDN upload)linkedin/post-image as image_url| spec | value |
|---|---|
| aspect | 1:1 (square) |
| pixels | 1080x1080 |
| format | JPEG |
| max_filesize | 2 MB |
LinkedIn carousels are PDFs uploaded as document posts. The recommended flow:
snappy-content/carousel-method.md to storyboard slides as ASCII firstsnappy-image using the editorial cream-paper templatelinkedin/post-carousel (the Xano worker assembles the PDF)| spec | value |
|---|---|
| aspect | 3:4 (portrait) |
| pixels | 1080x1350 per slide |
| slide_count | 8-10 |
| max_chars_per_slide | 25-50 words |
snappy-video (Whisper transcription → caption burning)linkedin/post-video| spec | value |
|---|---|
| max_length | 10 minutes (LinkedIn limit) |
| sweet_spot | 30-90 seconds |
| aspect | 16:9 (landscape) or 9:16 (square/portrait for native upload) |
| format | MP4, H.264 |
| captions | Burn in via snappy-video (LinkedIn auto-captions are unreliable) |
| wrong | right |
|---|---|
| Use a browser to post | Xano API -- instant, reliable, no auth churn |
| Copy-paste the same text from Twitter | Rewrite for LinkedIn fold and audience |
| Add 10 hashtags at the bottom | Zero hashtags |
| Put the link in the body | Drop the link in the first comment after posting |
| Skip the hook line | First 150 chars decide whether anyone reads |
| Post 3 times in one day | Max 1 post/day; cadence is 3-5 posts/week |
| Use an AI image with text overlays | Hand-crafted ink illustration via carousel-method or photo |
| Generate a long video without captions | Always burn captions before upload (most viewers watch muted) |
# LinkedIn Posting -- snappy-post
LinkedIn distribution via the Xano API. **NEVER use a browser for posting** -- Xano is instant and reliable. Browser automation is reserved for engagement, scheduling, and DM workflows (those live in `snappy-linkedin`).
## Table of Contents
- [Auth](#auth)
- [API Endpoints](#api-endpoints)
- [Format Rules](#format-rules)
- [Best Posting Times](#best-posting-times)
- [Image Pipeline](#image-pipeline)
- [Carousel Pipeline](#carousel-pipeline)
- [Video Pipeline](#video-pipeline)
- [Anti-Patterns](#anti-patterns)
---
## Auth
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see `snappy-settings/SKILL.md`. Required keys: `XANO_TOKEN`, `XANO`.
---
## API Endpoints
|method|endpoint|payload|
|------|--------|-------|
|POST|`api:PB9UH7b9/linkedin/post`|`{ text }`|
|POST|`api:PB9UH7b9/linkedin/post-image`|`{ text, image_url }`|
|POST|`api:PB9UH7b9/linkedin/post-carousel`|`{ text, slides[] }`|
|POST|`api:PB9UH7b9/linkedin/post-video`|`{ text, video_url }`|
### Text Post
```bash
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Post content here"}'
```
### Image Post
```bash
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-image" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Caption text", "image_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/social/2026-04-07-post.jpg"}'
```
### Carousel Post (PDF)
LinkedIn carousels are document posts. The Xano endpoint takes a `slides` array; the worker generates the PDF and uploads as a document.
```bash
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-carousel" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"text": "Carousel intro hook here",
"slides": [
{"title": "Slide 1 headline", "body": "Slide 1 body"},
{"title": "Slide 2 headline", "body": "Slide 2 body"},
{"title": "Slide 3 headline", "body": "Slide 3 body"}
]
}'
```
For full carousel design (cream-paper editorial style with ink illustrations), see `snappy-content/carousel-method.md`.
### Video Post
```bash
curl -s -X POST "$XANO/api:PB9UH7b9/linkedin/post-video" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{"text": "Video description with hook line", "video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-07-clip.mp4"}'
```
---
## Format Rules
|rule|why|
|----|---|
|Hook in first ~150 chars|Everything after the fold ("...see more") is hidden until clicked|
|3-5 short paragraphs, line breaks between|LinkedIn renders single line breaks, so paragraph spacing is the only structure cue|
|800-1500 chars total|Sweet spot -- long enough for substance, short enough to read in one breath|
|ZERO hashtags|LinkedIn algorithm stopped rewarding hashtags in 2024; they look automated|
|Question or invitation as final line|Drives comments, which drive reach|
|Max 1 post per day|Posting more than once cannibalizes reach|
|No links inside the body|LinkedIn down-ranks posts with external links -- put any link in the first comment|
Voice rules and anti-AI patterns are owned by `snappy-content` -- see `snappy-content/anti-ai-checklist.md` and `snappy-content/quality-rules.md`.
### LinkedIn Hook Patterns That Work
|pattern|example|
|-------|-------|
|Specific number|"We cut deploy time from 45 minutes to 90 seconds."|
|Contrarian take|"Stop writing unit tests for your API layer."|
|Vivid scene|"The Slack message came in at 2am: 'prod is down.'"|
|Confession|"I mass-deleted 400 database columns last Tuesday."|
|Before/after|"6 months ago, every deploy was a prayer."|
### LinkedIn Hook Patterns to Avoid
|pattern|why_it_fails|
|-------|-----------|
|"Have you ever..."|Generic rhetorical question|
|"I'm thrilled to share..."|Corporate AI slop|
|"In today's rapidly evolving..."|Banned phrase, screams AI|
|"Hot take:" / "Unpopular opinion:"|Overused format|
|Starting with a hashtag|Automated posting giveaway|
---
## Best Posting Times
|day_window|times_ET|notes|
|----------|--------|-----|
|Tue-Thu morning|7:30-8:30 AM|Pre-coffee scroll, highest engagement|
|Tue-Thu midday|12:00-1:00 PM|Lunch break scroll|
|Mon/Fri morning|8:00-9:00 AM|Slightly lower but still strong|
|Weekends|Avoid|Low engagement; save the post|
If posting outside Robert's timezone, schedule via the LinkedIn UI workflow in [`scheduling.md`](scheduling.md).
---
## Image Pipeline
1. Generate the image via `snappy-image` (Nano Banana → Grok edits → CDN upload)
2. Receive the CDN URL
3. Pass to `linkedin/post-image` as `image_url`
|spec|value|
|----|-----|
|aspect|1:1 (square)|
|pixels|1080x1080|
|format|JPEG|
|max_filesize|2 MB|
---
## Carousel Pipeline
LinkedIn carousels are PDFs uploaded as document posts. The recommended flow:
1. Run `snappy-content/carousel-method.md` to storyboard slides as ASCII first
2. Get user approval on the storyboard before any image generation
3. Generate slides via `snappy-image` using the editorial cream-paper template
4. Pass the slides array to `linkedin/post-carousel` (the Xano worker assembles the PDF)
|spec|value|
|----|-----|
|aspect|3:4 (portrait)|
|pixels|1080x1350 per slide|
|slide_count|8-10|
|max_chars_per_slide|25-50 words|
---
## Video Pipeline
1. Process the source video through `snappy-video` (Whisper transcription → caption burning)
2. Upload the rendered MP4 to the CDN
3. Pass the CDN URL to `linkedin/post-video`
|spec|value|
|----|-----|
|max_length|10 minutes (LinkedIn limit)|
|sweet_spot|30-90 seconds|
|aspect|16:9 (landscape) or 9:16 (square/portrait for native upload)|
|format|MP4, H.264|
|captions|Burn in via `snappy-video` (LinkedIn auto-captions are unreliable)|
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Use a browser to post|Xano API -- instant, reliable, no auth churn|
|Copy-paste the same text from Twitter|Rewrite for LinkedIn fold and audience|
|Add 10 hashtags at the bottom|Zero hashtags|
|Put the link in the body|Drop the link in the first comment after posting|
|Skip the hook line|First 150 chars decide whether anyone reads|
|Post 3 times in one day|Max 1 post/day; cadence is 3-5 posts/week|
|Use an AI image with text overlays|Hand-crafted ink illustration via carousel-method or photo|
|Generate a long video without captions|Always burn captions before upload (most viewers watch muted)|
The end-to-end choreography for taking ONE source piece and distributing it everywhere it should go. Never copy-paste between platforms -- each platform gets its own pipeline pass per snappy-content.
SOURCE CONTENT (snappy-content interview OR existing video/blog/email)
│
├─ adapt for LinkedIn ──▶ snappy-post → linkedin/post
├─ adapt for Skool ──▶ snappy-post → skool browser
├─ adapt for Email ──▶ snappy-post → email/send (or AC)
├─ adapt for YouTube ──▶ snappy-post → youtube uploader
└─ adapt for Blog ──▶ snappy-post → snappy-publish
│
▼
DISTRIBUTION LOG
Each branch is a full pipeline pass: rewrite, anti-AI check, format, post.
| source_type | how_to_get_it |
|---|---|
| New idea | Run snappy-content 4-question interview, then come back |
| Existing video | snappy-video Whisper transcript becomes the source material |
| Existing blog | Pull MDX body from ~/Projects/v0-prototypes/v0-snappy-website-0c/content/blog/ |
| Existing email | Pull from sent items via snappy-email |
| Sales call | Permission first via snappy-testimonials, then transcript via snappy-video |
| Workshop replay | Skool recording via snappy-skool, transcript via snappy-video |
The full repurposing path diagrams (YouTube → all, Blog → all, Sales call → case study, Workshop → drip) live in snappy-content/repurposing-paths.md.
The same insight gets rewritten for each platform's format and audience expectations. Rewrite. Don't resize.
| platform | format_adaptation | length_target |
|---|---|---|
| Hook (under 150 chars) + 3-5 short paragraphs + question CTA. NO hashtags. | 800-1500 chars | |
| Skool | Discussion-oriented. Hook + value + open question. Address the community. | 400-1200 chars |
| Subject (under 10 words) + 1 key insight + 2-3 supporting points + ONE CTA. | Under 300 words | |
| YouTube | Title (under 60 chars) + description with timestamps + 5-15 tags. Video must be processed first. | Title 60 chars; description 1500-2500 chars |
| Blog | Full long-form MDX. Headers, lists, optional code blocks. SEO keyword in first paragraph. | 800-1200 words |
| X / Twitter | Hook tweet + thread (3-5 tweets) OR single sharp post. | 180-250 chars per tweet |
Run each adapted draft through the snappy-content anti-AI checklist before posting (snappy-content/anti-ai-checklist.md).
Source insight: "Cut a client's deploy time from 45 minutes to 90 seconds by replacing 7 sequential GitHub Actions with a single parallelized job."
| platform | adapted_version |
|---|---|
| Hook + story arc with the 45→90 number front-loaded. 800 chars. Question: "What's the worst CI/CD bottleneck you've seen?" | |
| Skool | "Hey crew -- anyone wrestling with slow GitHub Actions? Here's what worked for a client this week..." discussion-led |
| Subject: "From 45 minutes to 90 seconds". Body: 2 paragraphs + a single CTA to book a discovery call. | |
| YouTube | Full 8-minute walkthrough video -- title Cut Deploy Time From 45 Min to 90 Sec, with timestamps + screen recording. |
| Blog | 800-word post: Problem → root cause → diff → result → takeaway. Embed the YAML config. Tags: ci-cd, github-actions. |
| X | Single tweet: "Cut a deploy from 45 min → 90 sec by parallelizing 7 GitHub Actions jobs into 1. The fix was a 12-line YAML diff." |
Execute fastest to slowest. Slow ops (video upload, blog deploy) start first; fast ops fill in around them.
| order | platform | tool | duration |
|---|---|---|---|
| 1 | YouTube (if video) | youtube-uploading.md Xano API |
1-5 min upload + processing |
| 2 | Blog | blog-publishing.md → snappy-publish git workflow |
~90s deploy |
| 3 | linkedin-posting.md Xano API |
< 1s | |
| 4 | email-sending.md Xano transactional or AC |
< 5s | |
| 5 | Skool | skool-posting.md agent-browser |
30-60s |
| 6 | X / Twitter | Manual or via X API (no current Xano endpoint) | < 1s |
If multiple are happening, run the slow ones (YouTube, Blog) first and let the fast ones (LinkedIn, Email) fill the gap.
After posting, return a distribution log to Robert with confirmation links/IDs for each platform.
DISTRIBUTION LOG -- 2026-04-07
─────────────────────────────────────────
LinkedIn: POSTED https://linkedin.com/posts/robert-boulos_xxx 08:14 ET
Email: SENT "From 45 min to 90 sec" → Xano/Gmail 08:15 ET
Skool: POSTED Snappy community feed 08:17 ET
YouTube: PUBLISHED https://youtu.be/xxx 08:20 ET
Blog: LIVE https://snappy.ai/blog/cut-deploy-time 08:22 ET
─────────────────────────────────────────
SOURCE: 1 video → 5 derivatives, 8 min total
This log feeds back into snappy-ops for the daily/weekly content review.
snappy-content (writes content via interview)
│
▼
snappy-image (adds visuals -- thumbnails, graphics, carousel slides)
│
▼
snappy-post (adapts + distributes)
│
├─▶ LinkedIn API (text/image/carousel/video posts)
├─▶ YouTube API (video upload)
├─▶ Skool browser (community feed posts)
├─▶ Email API (Xano transactional, Gmail backend)
└─▶ snappy-publish (git → Vercel for blog)
│
▼
snappy.ai/blog/<slug>
| source_skill | provides | snappy-post_action |
|---|---|---|
snappy-content |
Written content via interview methodology | Adapt format per platform, post |
snappy-image |
Thumbnails, graphics, carousel slides | Attach to image/carousel/YouTube posts |
snappy-video |
Processed/captioned video, short clips | Upload to YouTube, post as LinkedIn video |
snappy-blog |
MDX blog draft | Hand off to snappy-publish |
snappy-knowledge |
Contact context | Personalize email sends, community @-mentions |
snappy-transcripts |
Raw meeting/video transcripts | Source material for repurposing |
snappy-ops |
Daily rhythm + content calendar | Tells snappy-post what to distribute today |
| wrong | right |
|---|---|
| Copy-paste the same text to all platforms | Rewrite for each platform's format and audience |
| Post without anti-AI check | Run through snappy-content/anti-ai-checklist.md first |
| Use the browser for LinkedIn | Xano API -- instant, reliable |
| Skip the dry-run on emails | Always dry-run first, then send |
| Post at random times | Follow per-platform best times |
| Forget to log what was posted | Always return a distribution log |
| Add hashtags to LinkedIn or Skool | Zero hashtags (LinkedIn algorithm killed them in 2024; Skool doesn't use them) |
| Send a 600-word email | Under 300 words; longer content belongs in a blog |
| Restart the multi-platform sequence on failure | Resume from the failed step -- log what already shipped |
# Multi-Platform Workflow -- snappy-post
The end-to-end choreography for taking ONE source piece and distributing it everywhere it should go. **Never copy-paste between platforms** -- each platform gets its own pipeline pass per `snappy-content`.
## Table of Contents
- [The Multi-Platform Arc](#the-multi-platform-arc)
- [Step 1: Source the Content](#step-1-source-the-content)
- [Step 2: Adapt Per Platform](#step-2-adapt-per-platform)
- [Step 3: Post in Order](#step-3-post-in-order)
- [Step 4: Log Distribution](#step-4-log-distribution)
- [Cross-Skill Flow](#cross-skill-flow)
- [Anti-Patterns](#anti-patterns)
---
## The Multi-Platform Arc
```
SOURCE CONTENT (snappy-content interview OR existing video/blog/email)
│
├─ adapt for LinkedIn ──▶ snappy-post → linkedin/post
├─ adapt for Skool ──▶ snappy-post → skool browser
├─ adapt for Email ──▶ snappy-post → email/send (or AC)
├─ adapt for YouTube ──▶ snappy-post → youtube uploader
└─ adapt for Blog ──▶ snappy-post → snappy-publish
│
▼
DISTRIBUTION LOG
```
Each branch is a full pipeline pass: rewrite, anti-AI check, format, post.
---
## Step 1: Source the Content
|source_type|how_to_get_it|
|-----------|-------------|
|New idea|Run `snappy-content` 4-question interview, then come back|
|Existing video|`snappy-video` Whisper transcript becomes the source material|
|Existing blog|Pull MDX body from `~/Projects/v0-prototypes/v0-snappy-website-0c/content/blog/`|
|Existing email|Pull from sent items via `snappy-email`|
|Sales call|Permission first via `snappy-testimonials`, then transcript via `snappy-video`|
|Workshop replay|Skool recording via `snappy-skool`, transcript via `snappy-video`|
The full repurposing path diagrams (YouTube → all, Blog → all, Sales call → case study, Workshop → drip) live in `snappy-content/repurposing-paths.md`.
---
## Step 2: Adapt Per Platform
The same insight gets rewritten for each platform's format and audience expectations. **Rewrite. Don't resize.**
|platform|format_adaptation|length_target|
|--------|-----------------|-------------|
|LinkedIn|Hook (under 150 chars) + 3-5 short paragraphs + question CTA. NO hashtags.|800-1500 chars|
|Skool|Discussion-oriented. Hook + value + open question. Address the community.|400-1200 chars|
|Email|Subject (under 10 words) + 1 key insight + 2-3 supporting points + ONE CTA.|Under 300 words|
|YouTube|Title (under 60 chars) + description with timestamps + 5-15 tags. Video must be processed first.|Title 60 chars; description 1500-2500 chars|
|Blog|Full long-form MDX. Headers, lists, optional code blocks. SEO keyword in first paragraph.|800-1200 words|
|X / Twitter|Hook tweet + thread (3-5 tweets) OR single sharp post.|180-250 chars per tweet|
Run each adapted draft through the `snappy-content` anti-AI checklist before posting (`snappy-content/anti-ai-checklist.md`).
### Adaptation Examples (Same Source, Different Outputs)
**Source insight:** "Cut a client's deploy time from 45 minutes to 90 seconds by replacing 7 sequential GitHub Actions with a single parallelized job."
|platform|adapted_version|
|--------|--------------|
|LinkedIn|Hook + story arc with the 45→90 number front-loaded. 800 chars. Question: "What's the worst CI/CD bottleneck you've seen?"|
|Skool|"Hey crew -- anyone wrestling with slow GitHub Actions? Here's what worked for a client this week..." discussion-led|
|Email|Subject: "From 45 minutes to 90 seconds". Body: 2 paragraphs + a single CTA to book a discovery call.|
|YouTube|Full 8-minute walkthrough video -- title `Cut Deploy Time From 45 Min to 90 Sec`, with timestamps + screen recording.|
|Blog|800-word post: Problem → root cause → diff → result → takeaway. Embed the YAML config. Tags: `ci-cd`, `github-actions`.|
|X|Single tweet: "Cut a deploy from 45 min → 90 sec by parallelizing 7 GitHub Actions jobs into 1. The fix was a 12-line YAML diff."|
---
## Step 3: Post in Order
Execute fastest to slowest. Slow ops (video upload, blog deploy) start first; fast ops fill in around them.
|order|platform|tool|duration|
|-----|--------|----|--------|
|1|YouTube (if video)|`youtube-uploading.md` Xano API|1-5 min upload + processing|
|2|Blog|`blog-publishing.md` → `snappy-publish` git workflow|~90s deploy|
|3|LinkedIn|`linkedin-posting.md` Xano API|< 1s|
|4|Email|`email-sending.md` Xano transactional or AC|< 5s|
|5|Skool|`skool-posting.md` agent-browser|30-60s|
|6|X / Twitter|Manual or via X API (no current Xano endpoint)|< 1s|
If multiple are happening, run the slow ones (YouTube, Blog) first and let the fast ones (LinkedIn, Email) fill the gap.
---
## Step 4: Log Distribution
After posting, return a distribution log to Robert with confirmation links/IDs for each platform.
```
DISTRIBUTION LOG -- 2026-04-07
─────────────────────────────────────────
LinkedIn: POSTED https://linkedin.com/posts/robert-boulos_xxx 08:14 ET
Email: SENT "From 45 min to 90 sec" → Xano/Gmail 08:15 ET
Skool: POSTED Snappy community feed 08:17 ET
YouTube: PUBLISHED https://youtu.be/xxx 08:20 ET
Blog: LIVE https://snappy.ai/blog/cut-deploy-time 08:22 ET
─────────────────────────────────────────
SOURCE: 1 video → 5 derivatives, 8 min total
```
This log feeds back into `snappy-ops` for the daily/weekly content review.
---
## Cross-Skill Flow
```
snappy-content (writes content via interview)
│
▼
snappy-image (adds visuals -- thumbnails, graphics, carousel slides)
│
▼
snappy-post (adapts + distributes)
│
├─▶ LinkedIn API (text/image/carousel/video posts)
├─▶ YouTube API (video upload)
├─▶ Skool browser (community feed posts)
├─▶ Email API (Xano transactional, Gmail backend)
└─▶ snappy-publish (git → Vercel for blog)
│
▼
snappy.ai/blog/<slug>
```
|source_skill|provides|snappy-post_action|
|------------|--------|------------------|
|`snappy-content`|Written content via interview methodology|Adapt format per platform, post|
|`snappy-image`|Thumbnails, graphics, carousel slides|Attach to image/carousel/YouTube posts|
|`snappy-video`|Processed/captioned video, short clips|Upload to YouTube, post as LinkedIn video|
|`snappy-blog`|MDX blog draft|Hand off to snappy-publish|
|`snappy-knowledge`|Contact context|Personalize email sends, community @-mentions|
|`snappy-transcripts`|Raw meeting/video transcripts|Source material for repurposing|
|`snappy-ops`|Daily rhythm + content calendar|Tells snappy-post what to distribute today|
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Copy-paste the same text to all platforms|Rewrite for each platform's format and audience|
|Post without anti-AI check|Run through `snappy-content/anti-ai-checklist.md` first|
|Use the browser for LinkedIn|Xano API -- instant, reliable|
|Skip the dry-run on emails|Always dry-run first, then send|
|Post at random times|Follow per-platform best times|
|Forget to log what was posted|Always return a distribution log|
|Add hashtags to LinkedIn or Skool|Zero hashtags (LinkedIn algorithm killed them in 2024; Skool doesn't use them)|
|Send a 600-word email|Under 300 words; longer content belongs in a blog|
|Restart the multi-platform sequence on failure|Resume from the failed step -- log what already shipped|
/**
* COVERAGE FOR SNAPPY-POST'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-post declares. */
const DECLARED = [
"missing_credential",
"missing_argument",
"unknown_verb",
] as const;
test("snappy-post declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_credential is grounded: this hand declares credential keys", () => {
assert.ok(HAND_CONTRACT.requires.length > 0);
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
/**
* COVERAGE FOR SNAPPY-POST'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-post declares. */
const DECLARED = [
"missing_credential",
"missing_argument",
"unknown_verb",
] as const;
test("snappy-post declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_credential is grounded: this hand declares credential keys", () => {
assert.ok(HAND_CONTRACT.requires.length > 0);
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
How to schedule posts for future delivery on each platform. Each platform has a different mechanism -- there is no unified scheduler. snappy-scheduling owns the calendar/planning layer; this file owns the per-platform delivery primitives.
| platform | native_scheduling | tool | notes |
|---|---|---|---|
| Yes | Browser (LinkedIn UI clock) | No public API for scheduling | |
| YouTube | Yes | Xano API (publish_at + private) |
Or Studio browser fallback |
| Skool | Limited | Browser draft + reminder | Often no native; falls back to draft + calendar reminder |
| Email (Xano) | Yes | Xano queue (send_at ISO) |
Worker runs every 5 min, ±5 min accuracy |
| ActiveCampaign is NOT in use. Use Xano queue. | |||
| Blog | Yes (date-gated) | Future date in frontmatter |
Vercel deploys immediately, but lib/blog.ts filters by date |
The high-level "what to schedule when" lives in snappy-scheduling. This file is the low-level "how do I actually schedule on platform X" reference.
LinkedIn has native scheduling in the post composer (clock icon). Use agent-browser to access it -- there is no public API for scheduling.
bash# Cleanup
pkill -9 -f "daemon.js" 2>/dev/null
pkill -9 -f "Chrome for Testing" 2>/dev/null
sleep 1
# Launch with auth
agent-browser --state ~/.openclaw/workspace/linkedin-auth.json \
open "https://www.linkedin.com/feed"
agent-browser wait 3000
# Open the post composer
agent-browser find text "Start a post" click
agent-browser wait 1000
# Write the post body
agent-browser fill "[contenteditable][role='textbox']" "Post content here"
agent-browser wait 500
# Snapshot to find the schedule clock icon (next to the Post button)
agent-browser snapshot -i 2>&1 | head -30
# Click the schedule icon (use the ref from the snapshot)
agent-browser find text "Schedule" click
agent-browser wait 1000
# Snapshot to find date and time inputs
agent-browser snapshot -i 2>&1 | head -30
# Fill date and time per the snapshot refs
# (specific selectors vary; use the ref values returned by the snapshot)
# Confirm scheduling
agent-browser find text "Schedule" click
agent-browser wait 2000
| best_window_ET | notes |
|---|---|
| Tue-Thu 7:30-8:30 AM | Highest engagement |
| Tue-Thu 12:00-1:00 PM | Lunch scroll |
| Mon/Fri 8:00-9:00 AM | Slightly lower but consistent |
Set publish time via the Xano upload payload (preferred), or via YouTube Studio browser as a fallback.
publish_at#bashcurl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"title": "Video Title",
"description": "Description with timestamps...",
"tags": ["ai-automation", "snappy"],
"video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-10-video.mp4",
"thumbnail_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/youtube/2026-04-10-thumb.jpg",
"privacy": "private",
"publish_at": "2026-04-10T08:00:00-04:00"
}'
privacy: "private" + publish_at = scheduled publish. YouTube auto-flips visibility to public at the scheduled time.
bashagent-browser --state ~/.openclaw/workspace/youtube-studio-auth.json \
open "https://studio.youtube.com"
agent-browser wait 5000
# Navigate to the video, set visibility to "Scheduled", pick date/time
# Specific UI flow varies -- snapshot to find inputs
agent-browser snapshot -i
Skool has limited native scheduling -- many post types do not support it. Two strategies:
bashpkill -9 -f "daemon.js" 2>/dev/null
pkill -9 -f "Chrome for Testing" 2>/dev/null
sleep 1
agent-browser --state ~/.openclaw/workspace/skool-auth.json \
open "https://www.skool.com/snappy"
agent-browser wait 3000
agent-browser find text "Write something" click
agent-browser wait 500
agent-browser fill "[contenteditable]" "Post content here"
# Snapshot to check for schedule/clock icon in the composer
agent-browser snapshot -i 2>&1 | head -30
# If schedule icon present: click, set date/time, confirm
If the native scheduler is not available for the post type:
snappy-calendar for the target post timeskool-posting.md flow to publish the draftsend_at#bashcurl -s -X POST "$XANO/api:8wuQ86By/queue/add" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "recipient@example.com",
"subject": "Subject line",
"body": "<p>Email body</p>",
"send_at": "2026-04-10T08:00:00-04:00"
}'
| note | value |
|---|---|
| Worker interval | Runs every 5 min -- accuracy is ±5 min |
| Use case | Single-recipient or small-batch sends |
| Not for | Full list newsletters -- use Loops.so or loop Xano sends |
ActiveCampaign is NOT in use. For scheduled broadcasts, use Xano queue with
send_at(above) or Loops.so scheduling.
Vercel deploys immediately when you push, but the blog index can filter by date. Two strategies:
Write the MDX with date: 2026-04-10 (the future date). lib/blog.ts (the website's blog parser) can filter posts where date > now() so they don't appear on the live blog index until the date arrives.
yaml---
title: "Post Title"
description: "Description"
date: 2026-04-10 # future date -- post hidden until this date
author: Robert Boulos
authorRole: "AI Developer & Technical Partner"
category: AI
featured: false
tags:
- ai
- automation
---
Verify lib/blog.ts actually filters by date in the current codebase before relying on this -- see snappy-publish/mdx-validation.md. If filtering is not active, use Strategy B.
Write the MDX, commit on a scheduled/<slug> branch, push. On the target date, merge the branch into main.
bashSNAPPY_WEB="/Users/robertboulos/Projects/v0-prototypes/v0-snappy-website-0c"
SLUG="why-agents-matter"
cd "$SNAPPY_WEB"
git checkout -b scheduled/$SLUG
# Write MDX, commit
git add content/blog/$SLUG.mdx
git commit -m "blog: $SLUG (scheduled for 2026-04-10)"
git push -u origin scheduled/$SLUG
# On the target date:
git checkout main
git pull --ff-only origin main
git merge scheduled/$SLUG
git push origin main
git branch -d scheduled/$SLUG
git push origin --delete scheduled/$SLUG
Pair with a snappy-calendar reminder for the merge date.
| wrong | right |
|---|---|
| Use the Xano queue for time-critical sends | Worker runs every 5 min -- use the immediate send for time-critical |
| Schedule LinkedIn posts via API | No public API exists -- use the browser composer |
| Forget timezone offsets | Always use ISO with offset (-04:00 for ET) |
| Schedule Skool with no native option | Save draft + reminder, NOT a hardcoded sleep |
| Push a future-dated blog and assume it's hidden | Verify lib/blog.ts filters by date first; otherwise hold on a branch |
| Use ActiveCampaign for scheduling | AC is NOT in use, use Xano queue or Loops.so |
Use dry_run: true on a scheduled queue send |
Dry-run is for previewing only; scheduled sends never trigger |
# Scheduling -- snappy-post
How to schedule posts for future delivery on each platform. Each platform has a different mechanism -- there is no unified scheduler. snappy-scheduling owns the calendar/planning layer; this file owns the per-platform delivery primitives.
## Table of Contents
- [Scheduling Mechanism Per Platform](#scheduling-mechanism-per-platform)
- [LinkedIn Scheduling](#linkedin-scheduling)
- [YouTube Scheduling](#youtube-scheduling)
- [Skool Scheduling](#skool-scheduling)
- [Email Scheduling](#email-scheduling)
- [Blog Scheduling](#blog-scheduling)
- [Anti-Patterns](#anti-patterns)
---
## Scheduling Mechanism Per Platform
|platform|native_scheduling|tool|notes|
|--------|----------------|----|-----|
|LinkedIn|Yes|Browser (LinkedIn UI clock)|No public API for scheduling|
|YouTube|Yes|Xano API (`publish_at` + `private`)|Or Studio browser fallback|
|Skool|Limited|Browser draft + reminder|Often no native; falls back to draft + calendar reminder|
|Email (Xano)|Yes|Xano queue (`send_at` ISO)|Worker runs every 5 min, ±5 min accuracy|
|~~Email (AC)~~|~~Yes~~|~~AC deprecated~~|**ActiveCampaign is NOT in use. Use Xano queue.**|
|Blog|Yes (date-gated)|Future `date` in frontmatter|Vercel deploys immediately, but `lib/blog.ts` filters by date|
The high-level "what to schedule when" lives in `snappy-scheduling`. This file is the low-level "how do I actually schedule on platform X" reference.
---
## LinkedIn Scheduling
LinkedIn has native scheduling in the post composer (clock icon). Use agent-browser to access it -- there is no public API for scheduling.
```bash
# Cleanup
pkill -9 -f "daemon.js" 2>/dev/null
pkill -9 -f "Chrome for Testing" 2>/dev/null
sleep 1
# Launch with auth
agent-browser --state ~/.openclaw/workspace/linkedin-auth.json \
open "https://www.linkedin.com/feed"
agent-browser wait 3000
# Open the post composer
agent-browser find text "Start a post" click
agent-browser wait 1000
# Write the post body
agent-browser fill "[contenteditable][role='textbox']" "Post content here"
agent-browser wait 500
# Snapshot to find the schedule clock icon (next to the Post button)
agent-browser snapshot -i 2>&1 | head -30
# Click the schedule icon (use the ref from the snapshot)
agent-browser find text "Schedule" click
agent-browser wait 1000
# Snapshot to find date and time inputs
agent-browser snapshot -i 2>&1 | head -30
# Fill date and time per the snapshot refs
# (specific selectors vary; use the ref values returned by the snapshot)
# Confirm scheduling
agent-browser find text "Schedule" click
agent-browser wait 2000
```
|best_window_ET|notes|
|---------------|-----|
|Tue-Thu 7:30-8:30 AM|Highest engagement|
|Tue-Thu 12:00-1:00 PM|Lunch scroll|
|Mon/Fri 8:00-9:00 AM|Slightly lower but consistent|
---
## YouTube Scheduling
Set publish time via the Xano upload payload (preferred), or via YouTube Studio browser as a fallback.
### Path A: Xano upload with `publish_at`
```bash
curl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"title": "Video Title",
"description": "Description with timestamps...",
"tags": ["ai-automation", "snappy"],
"video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-10-video.mp4",
"thumbnail_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/youtube/2026-04-10-thumb.jpg",
"privacy": "private",
"publish_at": "2026-04-10T08:00:00-04:00"
}'
```
`privacy: "private"` + `publish_at` = scheduled publish. YouTube auto-flips visibility to public at the scheduled time.
### Path B: YouTube Studio browser fallback
```bash
agent-browser --state ~/.openclaw/workspace/youtube-studio-auth.json \
open "https://studio.youtube.com"
agent-browser wait 5000
# Navigate to the video, set visibility to "Scheduled", pick date/time
# Specific UI flow varies -- snapshot to find inputs
agent-browser snapshot -i
```
---
## Skool Scheduling
Skool has limited native scheduling -- many post types do not support it. Two strategies:
### Strategy A: Use the native scheduler if available
```bash
pkill -9 -f "daemon.js" 2>/dev/null
pkill -9 -f "Chrome for Testing" 2>/dev/null
sleep 1
agent-browser --state ~/.openclaw/workspace/skool-auth.json \
open "https://www.skool.com/snappy"
agent-browser wait 3000
agent-browser find text "Write something" click
agent-browser wait 500
agent-browser fill "[contenteditable]" "Post content here"
# Snapshot to check for schedule/clock icon in the composer
agent-browser snapshot -i 2>&1 | head -30
# If schedule icon present: click, set date/time, confirm
```
### Strategy B: Draft + reminder fallback
If the native scheduler is not available for the post type:
1. Save the post as a draft (Skool composer has a "Save draft" option)
2. Create a reminder via `snappy-calendar` for the target post time
3. When the reminder fires, run the standard `skool-posting.md` flow to publish the draft
---
## Email Scheduling
### Xano queue with `send_at`
```bash
curl -s -X POST "$XANO/api:8wuQ86By/queue/add" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"to_email": "recipient@example.com",
"subject": "Subject line",
"body": "<p>Email body</p>",
"send_at": "2026-04-10T08:00:00-04:00"
}'
```
|note|value|
|----|-----|
|Worker interval|Runs every 5 min -- accuracy is ±5 min|
|Use case|Single-recipient or small-batch sends|
|Not for|Full list newsletters -- use Loops.so or loop Xano sends|
### Broadcast scheduling
> **ActiveCampaign is NOT in use.** For scheduled broadcasts, use Xano queue with `send_at` (above) or Loops.so scheduling.
---
## Blog Scheduling
Vercel deploys immediately when you push, but the blog index can filter by date. Two strategies:
### Strategy A: Future date in frontmatter
Write the MDX with `date: 2026-04-10` (the future date). `lib/blog.ts` (the website's blog parser) can filter posts where `date > now()` so they don't appear on the live blog index until the date arrives.
```yaml
---
title: "Post Title"
description: "Description"
date: 2026-04-10 # future date -- post hidden until this date
author: Robert Boulos
authorRole: "AI Developer & Technical Partner"
category: AI
featured: false
tags:
- ai
- automation
---
```
**Verify** `lib/blog.ts` actually filters by date in the current codebase before relying on this -- see `snappy-publish/mdx-validation.md`. If filtering is not active, use Strategy B.
### Strategy B: Hold the commit on a branch
Write the MDX, commit on a `scheduled/<slug>` branch, push. On the target date, merge the branch into `main`.
```bash
SNAPPY_WEB="/Users/robertboulos/Projects/v0-prototypes/v0-snappy-website-0c"
SLUG="why-agents-matter"
cd "$SNAPPY_WEB"
git checkout -b scheduled/$SLUG
# Write MDX, commit
git add content/blog/$SLUG.mdx
git commit -m "blog: $SLUG (scheduled for 2026-04-10)"
git push -u origin scheduled/$SLUG
# On the target date:
git checkout main
git pull --ff-only origin main
git merge scheduled/$SLUG
git push origin main
git branch -d scheduled/$SLUG
git push origin --delete scheduled/$SLUG
```
Pair with a `snappy-calendar` reminder for the merge date.
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Use the Xano queue for time-critical sends|Worker runs every 5 min -- use the immediate send for time-critical|
|Schedule LinkedIn posts via API|No public API exists -- use the browser composer|
|Forget timezone offsets|Always use ISO with offset (`-04:00` for ET)|
|Schedule Skool with no native option|Save draft + reminder, NOT a hardcoded sleep|
|Push a future-dated blog and assume it's hidden|Verify `lib/blog.ts` filters by date first; otherwise hold on a branch|
|Use ActiveCampaign for scheduling|AC is NOT in use, use Xano queue or Loops.so|
|Use `dry_run: true` on a scheduled queue send|Dry-run is for previewing only; scheduled sends never trigger|
#!/usr/bin/env bash
set -euo pipefail
# snappy-post unified distribution script
# Routes content to: linkedin, slack, telegram, blog, skool
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Defaults ──────────────────────────────────────────────
PLATFORM=""
CONTENT=""
IMAGE=""
DRY_RUN=false
CHANNEL="" # slack channel override (default: #all-snappy)
# ── Telegram config ───────────────────────────────────────
# TELEGRAM_BOT_TOKEN loads from snappy-settings/.env.cache via load-env.sh
# (resolved lazily in post_telegram after load_xano_auth sources the env)
TG_BOT_TOKEN=""
TG_BASE=""
TG_CHAT_ID=""
# ── Slack pre-cached channel IDs ──────────────────────────
declare -A SLACK_CHANNELS=(
["all-snappy"]="C09DD2D0S07"
["social"]="C09DD2D0T7H"
["bugs-and-issues"]="C09KKEYAH1V"
["snappy_channel"]="C0A1981GEMN"
["proj-total-crm"]="C0AHMKPTY1M"
)
# ── Usage ─────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: post.sh --platform <platform> --content "text" [options]
Platforms: linkedin, slack, telegram, blog, skool
Options:
--platform linkedin|slack|telegram|blog|skool (required)
--content "Post text content" (required)
--image /path/to/image or URL (optional, linkedin/telegram)
--channel Slack channel name without # (optional, default: all-snappy)
--dry-run Show what would be posted (optional)
-h, --help Show this help
Examples:
post.sh --platform linkedin --content "Hook line here" --dry-run
post.sh --platform slack --content "Deploy done" --channel bugs-and-issues
post.sh --platform telegram --content "Pipeline complete"
EOF
exit 0
}
# ── Parse args ────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) PLATFORM="$2"; shift 2 ;;
--content) CONTENT="$2"; shift 2 ;;
--image) IMAGE="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# ── Validate ──────────────────────────────────────────────
if [[ -z "$PLATFORM" ]]; then
echo "ERROR: --platform is required"
usage
fi
if [[ -z "$CONTENT" ]]; then
echo "ERROR: --content is required"
usage
fi
# ── Load auth (Xano) ─────────────────────────────────────
load_xano_auth() {
if [[ -n "${XANO_TOKEN:-}" && -n "${XANO:-}" ]]; then
return 0
fi
if [[ -f "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh" ]]; then
SNAPPY_SETTINGS_QUIET=1 source "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
fi
if [[ -z "${XANO_TOKEN:-}" || -z "${XANO:-}" ]]; then
echo "ERROR: XANO_TOKEN and XANO must be set. Source snappy-settings or export them."
exit 1
fi
}
# ── Helpers ───────────────────────────────────────────────
json_escape() {
printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()), end="")'
}
timestamp() {
date '+%H:%M ET'
}
# ── Platform: LinkedIn ────────────────────────────────────
post_linkedin() {
load_xano_auth
local endpoint payload
if [[ -n "$IMAGE" ]]; then
endpoint="$XANO/api:PB9UH7b9/linkedin/post-image"
payload="{\"text\": $(json_escape "$CONTENT"), \"image_url\": $(json_escape "$IMAGE")}"
else
endpoint="$XANO/api:PB9UH7b9/linkedin/post"
payload="{\"text\": $(json_escape "$CONTENT")}"
fi
if $DRY_RUN; then
# Xano endpoints support dry_run: true
if [[ -n "$IMAGE" ]]; then
payload="{\"text\": $(json_escape "$CONTENT"), \"image_url\": $(json_escape "$IMAGE"), \"dry_run\": true}"
else
payload="{\"text\": $(json_escape "$CONTENT"), \"dry_run\": true}"
fi
echo "[DRY RUN] LinkedIn"
echo "Endpoint: $endpoint"
echo "Payload: $payload"
echo ""
local result
result=$(curl -s --max-time 30 -X POST "$endpoint" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "Response: $result"
return 0
fi
echo "Posting to LinkedIn..."
local result
result=$(curl -s --max-time 30 -X POST "$endpoint" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "LinkedIn: POSTED $(timestamp)"
echo "Response: $result"
}
# ── Platform: Slack ───────────────────────────────────────
post_slack() {
load_xano_auth
local channel_name="${CHANNEL:-all-snappy}"
local channel_id="${SLACK_CHANNELS[$channel_name]:-}"
if [[ -z "$channel_id" ]]; then
echo "ERROR: Unknown Slack channel '$channel_name'"
echo "Known channels: ${!SLACK_CHANNELS[*]}"
exit 1
fi
local payload="{\"channel_id\": \"$channel_id\", \"text\": $(json_escape "$CONTENT")}"
if $DRY_RUN; then
echo "[DRY RUN] Slack → #$channel_name ($channel_id)"
echo "Endpoint: $XANO/api:hZB4Dj0c/slack/bot-message"
echo "Payload: $payload"
return 0
fi
echo "Posting to Slack #$channel_name..."
local result
result=$(curl -s --max-time 30 -X POST "$XANO/api:hZB4Dj0c/slack/bot-message" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "Slack: POSTED #$channel_name $(timestamp)"
echo "Response: $result"
}
# ── Platform: Telegram ────────────────────────────────────
post_telegram() {
# Load env from snappy-settings/.env.cache
if [[ -f "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh" ]]; then
SNAPPY_SETTINGS_QUIET=1 source "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
fi
TG_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TG_BASE="https://api.telegram.org/bot${TG_BOT_TOKEN}"
TG_CHAT_ID="${ROBERT_TELEGRAM_CHAT_ID:-${TELEGRAM_ROBERT_CHAT_ID:-}}"
if [[ -z "$TG_BOT_TOKEN" ]]; then
echo "ERROR: TELEGRAM_BOT_TOKEN not set in .env.cache."
exit 1
fi
if [[ -z "$TG_CHAT_ID" ]]; then
echo "ERROR: Telegram chat ID not set."
echo "Discover it: curl -s '${TG_BASE}/getUpdates' | jq '.result[-1].message.chat.id'"
echo "Then export ROBERT_TELEGRAM_CHAT_ID=<id> or add TELEGRAM_ROBERT_CHAT_ID to .env.cache."
exit 1
fi
if [[ -n "$IMAGE" ]]; then
# Photo send
if $DRY_RUN; then
echo "[DRY RUN] Telegram sendPhoto"
echo "Chat ID: $TG_CHAT_ID"
echo "Photo: $IMAGE"
echo "Caption: $CONTENT"
return 0
fi
echo "Sending photo to Telegram..."
local result
if [[ "$IMAGE" == http* ]]; then
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendPhoto" \
-H "Content-Type: application/json" \
-d "{\"chat_id\": \"${TG_CHAT_ID}\", \"photo\": $(json_escape "$IMAGE"), \"caption\": $(json_escape "$CONTENT"), \"parse_mode\": \"Markdown\"}")
else
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendPhoto" \
-F "chat_id=${TG_CHAT_ID}" \
-F "photo=@${IMAGE}" \
-F "caption=${CONTENT}" \
-F "parse_mode=Markdown")
fi
echo "Telegram: SENT photo $(timestamp)"
echo "Response: $result"
else
# Text send
if $DRY_RUN; then
echo "[DRY RUN] Telegram sendMessage"
echo "Chat ID: $TG_CHAT_ID"
echo "Text: $CONTENT"
return 0
fi
echo "Sending to Telegram..."
local result
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendMessage" \
-H "Content-Type: application/json" \
-d "{\"chat_id\": \"${TG_CHAT_ID}\", \"text\": $(json_escape "$CONTENT"), \"parse_mode\": \"Markdown\"}")
echo "Telegram: SENT $(timestamp)"
echo "Response: $result"
fi
}
# ── Platform: Blog ────────────────────────────────────────
post_blog() {
echo "Blog posting requires snappy-publish (git-based MDX pipeline)."
echo "This script does not handle blog -- hand off to snappy-publish."
echo ""
echo "Content preview:"
echo "---"
echo "$CONTENT"
echo "---"
echo ""
echo "Next: create MDX file, commit, git push origin main → Vercel deploys."
exit 0
}
# ── Platform: Skool ───────────────────────────────────────
post_skool() {
echo "Skool posting requires agent-browser (browser automation)."
echo "This script does not handle Skool -- use agent-browser directly."
echo ""
echo "Content preview:"
echo "---"
echo "$CONTENT"
echo "---"
echo ""
echo "Next steps:"
echo " agent-browser --state ~/.openclaw/workspace/skool-auth.json open https://www.skool.com/snappy"
echo " agent-browser wait 3000"
echo " agent-browser snapshot -i # verify logged in"
echo " agent-browser find text \"Write something\" click"
echo " agent-browser fill \"[contenteditable]\" \"<content>\""
echo " agent-browser find text \"Post\" click"
exit 0
}
# ── Route ─────────────────────────────────────────────────
case "$PLATFORM" in
linkedin) post_linkedin ;;
slack) post_slack ;;
telegram) post_telegram ;;
blog) post_blog ;;
skool) post_skool ;;
*)
echo "ERROR: Unknown platform '$PLATFORM'"
echo "Supported: linkedin, slack, telegram, blog, skool"
exit 1
;;
esac
#!/usr/bin/env bash
set -euo pipefail
# snappy-post unified distribution script
# Routes content to: linkedin, slack, telegram, blog, skool
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Defaults ──────────────────────────────────────────────
PLATFORM=""
CONTENT=""
IMAGE=""
DRY_RUN=false
CHANNEL="" # slack channel override (default: #all-snappy)
# ── Telegram config ───────────────────────────────────────
# TELEGRAM_BOT_TOKEN loads from snappy-settings/.env.cache via load-env.sh
# (resolved lazily in post_telegram after load_xano_auth sources the env)
TG_BOT_TOKEN=""
TG_BASE=""
TG_CHAT_ID=""
# ── Slack pre-cached channel IDs ──────────────────────────
declare -A SLACK_CHANNELS=(
["all-snappy"]="C09DD2D0S07"
["social"]="C09DD2D0T7H"
["bugs-and-issues"]="C09KKEYAH1V"
["snappy_channel"]="C0A1981GEMN"
["proj-total-crm"]="C0AHMKPTY1M"
)
# ── Usage ─────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: post.sh --platform <platform> --content "text" [options]
Platforms: linkedin, slack, telegram, blog, skool
Options:
--platform linkedin|slack|telegram|blog|skool (required)
--content "Post text content" (required)
--image /path/to/image or URL (optional, linkedin/telegram)
--channel Slack channel name without # (optional, default: all-snappy)
--dry-run Show what would be posted (optional)
-h, --help Show this help
Examples:
post.sh --platform linkedin --content "Hook line here" --dry-run
post.sh --platform slack --content "Deploy done" --channel bugs-and-issues
post.sh --platform telegram --content "Pipeline complete"
EOF
exit 0
}
# ── Parse args ────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) PLATFORM="$2"; shift 2 ;;
--content) CONTENT="$2"; shift 2 ;;
--image) IMAGE="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# ── Validate ──────────────────────────────────────────────
if [[ -z "$PLATFORM" ]]; then
echo "ERROR: --platform is required"
usage
fi
if [[ -z "$CONTENT" ]]; then
echo "ERROR: --content is required"
usage
fi
# ── Load auth (Xano) ─────────────────────────────────────
load_xano_auth() {
if [[ -n "${XANO_TOKEN:-}" && -n "${XANO:-}" ]]; then
return 0
fi
if [[ -f "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh" ]]; then
SNAPPY_SETTINGS_QUIET=1 source "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
fi
if [[ -z "${XANO_TOKEN:-}" || -z "${XANO:-}" ]]; then
echo "ERROR: XANO_TOKEN and XANO must be set. Source snappy-settings or export them."
exit 1
fi
}
# ── Helpers ───────────────────────────────────────────────
json_escape() {
printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()), end="")'
}
timestamp() {
date '+%H:%M ET'
}
# ── Platform: LinkedIn ────────────────────────────────────
post_linkedin() {
load_xano_auth
local endpoint payload
if [[ -n "$IMAGE" ]]; then
endpoint="$XANO/api:PB9UH7b9/linkedin/post-image"
payload="{\"text\": $(json_escape "$CONTENT"), \"image_url\": $(json_escape "$IMAGE")}"
else
endpoint="$XANO/api:PB9UH7b9/linkedin/post"
payload="{\"text\": $(json_escape "$CONTENT")}"
fi
if $DRY_RUN; then
# Xano endpoints support dry_run: true
if [[ -n "$IMAGE" ]]; then
payload="{\"text\": $(json_escape "$CONTENT"), \"image_url\": $(json_escape "$IMAGE"), \"dry_run\": true}"
else
payload="{\"text\": $(json_escape "$CONTENT"), \"dry_run\": true}"
fi
echo "[DRY RUN] LinkedIn"
echo "Endpoint: $endpoint"
echo "Payload: $payload"
echo ""
local result
result=$(curl -s --max-time 30 -X POST "$endpoint" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "Response: $result"
return 0
fi
echo "Posting to LinkedIn..."
local result
result=$(curl -s --max-time 30 -X POST "$endpoint" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "LinkedIn: POSTED $(timestamp)"
echo "Response: $result"
}
# ── Platform: Slack ───────────────────────────────────────
post_slack() {
load_xano_auth
local channel_name="${CHANNEL:-all-snappy}"
local channel_id="${SLACK_CHANNELS[$channel_name]:-}"
if [[ -z "$channel_id" ]]; then
echo "ERROR: Unknown Slack channel '$channel_name'"
echo "Known channels: ${!SLACK_CHANNELS[*]}"
exit 1
fi
local payload="{\"channel_id\": \"$channel_id\", \"text\": $(json_escape "$CONTENT")}"
if $DRY_RUN; then
echo "[DRY RUN] Slack → #$channel_name ($channel_id)"
echo "Endpoint: $XANO/api:hZB4Dj0c/slack/bot-message"
echo "Payload: $payload"
return 0
fi
echo "Posting to Slack #$channel_name..."
local result
result=$(curl -s --max-time 30 -X POST "$XANO/api:hZB4Dj0c/slack/bot-message" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XANO_TOKEN" \
-d "$payload")
echo "Slack: POSTED #$channel_name $(timestamp)"
echo "Response: $result"
}
# ── Platform: Telegram ────────────────────────────────────
post_telegram() {
# Load env from snappy-settings/.env.cache
if [[ -f "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh" ]]; then
SNAPPY_SETTINGS_QUIET=1 source "$HOME/.claude/skills/snappy-settings/scripts/load-env.sh"
fi
TG_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
TG_BASE="https://api.telegram.org/bot${TG_BOT_TOKEN}"
TG_CHAT_ID="${ROBERT_TELEGRAM_CHAT_ID:-${TELEGRAM_ROBERT_CHAT_ID:-}}"
if [[ -z "$TG_BOT_TOKEN" ]]; then
echo "ERROR: TELEGRAM_BOT_TOKEN not set in .env.cache."
exit 1
fi
if [[ -z "$TG_CHAT_ID" ]]; then
echo "ERROR: Telegram chat ID not set."
echo "Discover it: curl -s '${TG_BASE}/getUpdates' | jq '.result[-1].message.chat.id'"
echo "Then export ROBERT_TELEGRAM_CHAT_ID=<id> or add TELEGRAM_ROBERT_CHAT_ID to .env.cache."
exit 1
fi
if [[ -n "$IMAGE" ]]; then
# Photo send
if $DRY_RUN; then
echo "[DRY RUN] Telegram sendPhoto"
echo "Chat ID: $TG_CHAT_ID"
echo "Photo: $IMAGE"
echo "Caption: $CONTENT"
return 0
fi
echo "Sending photo to Telegram..."
local result
if [[ "$IMAGE" == http* ]]; then
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendPhoto" \
-H "Content-Type: application/json" \
-d "{\"chat_id\": \"${TG_CHAT_ID}\", \"photo\": $(json_escape "$IMAGE"), \"caption\": $(json_escape "$CONTENT"), \"parse_mode\": \"Markdown\"}")
else
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendPhoto" \
-F "chat_id=${TG_CHAT_ID}" \
-F "photo=@${IMAGE}" \
-F "caption=${CONTENT}" \
-F "parse_mode=Markdown")
fi
echo "Telegram: SENT photo $(timestamp)"
echo "Response: $result"
else
# Text send
if $DRY_RUN; then
echo "[DRY RUN] Telegram sendMessage"
echo "Chat ID: $TG_CHAT_ID"
echo "Text: $CONTENT"
return 0
fi
echo "Sending to Telegram..."
local result
result=$(curl -s --max-time 30 -X POST "${TG_BASE}/sendMessage" \
-H "Content-Type: application/json" \
-d "{\"chat_id\": \"${TG_CHAT_ID}\", \"text\": $(json_escape "$CONTENT"), \"parse_mode\": \"Markdown\"}")
echo "Telegram: SENT $(timestamp)"
echo "Response: $result"
fi
}
# ── Platform: Blog ────────────────────────────────────────
post_blog() {
echo "Blog posting requires snappy-publish (git-based MDX pipeline)."
echo "This script does not handle blog -- hand off to snappy-publish."
echo ""
echo "Content preview:"
echo "---"
echo "$CONTENT"
echo "---"
echo ""
echo "Next: create MDX file, commit, git push origin main → Vercel deploys."
exit 0
}
# ── Platform: Skool ───────────────────────────────────────
post_skool() {
echo "Skool posting requires agent-browser (browser automation)."
echo "This script does not handle Skool -- use agent-browser directly."
echo ""
echo "Content preview:"
echo "---"
echo "$CONTENT"
echo "---"
echo ""
echo "Next steps:"
echo " agent-browser --state ~/.openclaw/workspace/skool-auth.json open https://www.skool.com/snappy"
echo " agent-browser wait 3000"
echo " agent-browser snapshot -i # verify logged in"
echo " agent-browser find text \"Write something\" click"
echo " agent-browser fill \"[contenteditable]\" \"<content>\""
echo " agent-browser find text \"Post\" click"
exit 0
}
# ── Route ─────────────────────────────────────────────────
case "$PLATFORM" in
linkedin) post_linkedin ;;
slack) post_slack ;;
telegram) post_telegram ;;
blog) post_blog ;;
skool) post_skool ;;
*)
echo "ERROR: Unknown platform '$PLATFORM'"
echo "Supported: linkedin, slack, telegram, blog, skool"
exit 1
;;
esac
Skool has no public API. All posting goes through agent-browser (the Snappy browser automation CLI). Auth lives in ~/.openclaw/workspace/skool-auth.json and is refreshed by the snappy-browse skill.
bash# Auth file (managed by snappy-browse / snappy-skool)
SKOOL_AUTH="$HOME/.openclaw/workspace/skool-auth.json"
SKOOL_URL="https://www.skool.com/snappy"
If skool-auth.json is missing or expired, route to snappy-browse to refresh it (interactive browser login flow).
Always verify you're logged in BEFORE attempting to post -- otherwise the browser will land on the public landing page and the post sequence will silently fail.
bash# Kill stale browsers (avoid daemon collisions)
pkill -9 -f "daemon.js" 2>/dev/null
pkill -9 -f "Chrome for Testing" 2>/dev/null
sleep 1
# Launch with auth state
agent-browser --state "$SKOOL_AUTH" open "$SKOOL_URL"
agent-browser wait 3000
# Snapshot to verify the feed is rendered (logged-in marker: "Write something" textbox)
agent-browser snapshot -i 2>&1 | head -20
If the snapshot shows "Sign in" / "Log in" instead of "Write something", auth is broken -- fix in snappy-browse first.
bash# Open the composer
agent-browser find text "Write something" click
agent-browser wait 500
# Fill the body
agent-browser fill "[contenteditable]" "Post content here. Hook line first.
Body in 3-5 short paragraphs.
End with a question."
# Submit
agent-browser find text "Post" click
agent-browser wait 2000
# Verify the post landed by snapshotting the feed
agent-browser snapshot -i 2>&1 | head -10
bashagent-browser find text "Write something" click
agent-browser wait 500
agent-browser fill "[contenteditable]" "Post body here"
# Attach image (look for the camera icon ref in the snapshot)
agent-browser snapshot -i # Find image-attach button
# Click the attachment button by its ref, upload the file
agent-browser find text "Post" click
agent-browser wait 3000
For lesson content (course material, not feed posts):
bash# Navigate to the classroom
agent-browser open "https://www.skool.com/snappy/classroom"
agent-browser wait 3000
# Snapshot to identify the target module
agent-browser snapshot -i 2>&1 | head -40
# Click the target module by its title text
agent-browser find text "Module Name Here" click
agent-browser wait 2000
# Add lesson content via the module editor (UI varies -- snapshot to find inputs)
agent-browser snapshot -i
Classroom edits are higher-stakes than feed posts -- always preview before saving and route through snappy-skool for the canonical workflow if available.
Skool posts are community discussions, not broadcasts. Voice is closer to a Slack channel than a LinkedIn feed.
| rule | why |
|---|---|
| Hook → value → CTA | Same arc as LinkedIn but more conversational |
| Zero hashtags | Skool's discovery is search + categories, not hashtags |
| End with a question | Discussion-driven engagement is the platform's whole point |
| Tag the relevant category | Use Skool's category dropdown (Wins, Tips, Questions, etc.) |
| Address the community by name | "Hey Snappy crew" / "Devs in here" -- first-person plural works |
| Mention specific members when relevant | @-mentions drive notifications and feel personal |
| Link to long-form content sparingly | Skool down-ranks link-out posts; bring the value into the post |
Voice rules and anti-AI patterns are owned by snappy-content -- see snappy-content/anti-ai-checklist.md.
Run consistent rhythm to keep the community alive without burning out the writer.
| day | topic | format |
|---|---|---|
| Mon | Goals / weekly intent | Open question -- "What are you shipping this week?" |
| Tue | Tip / quick value | 1-paragraph tip from this week's work |
| Wed | Q&A / poll | Ask the community a question, run a poll |
| Thu | Case study / wins | Showcase a member's win or a Snappy client outcome |
| Fri | Wins thread | "Drop your wins from this week ⬇️" |
| Sat | Resource share | Tool, article, repo worth sharing -- with context |
| Sun | Reflective / behind-the-scenes | Personal note, lessons, longer-form |
The full Skool member ops + engagement workflow lives in snappy-skool. snappy-post just owns the post primitive -- pull cadence and curriculum context from snappy-skool/SKILL.md and snappy-skool/SKILL.md.
| wrong | right |
|---|---|
| Skip the verify-login snapshot | Always snapshot first to confirm auth |
| Post broadcasts instead of discussions | End with a question, frame as community conversation |
| Cross-post from LinkedIn verbatim | Rewrite for Skool -- different audience expectations |
| Use hashtags | Zero hashtags |
| Spam links to the website | Bring the value into the post; link sparingly |
| Skip browser cleanup | pkill daemon + Chrome before launch -- daemon collisions are silent killers |
| Post during low-activity hours | Skool community is most active 8-11am ET and 7-10pm ET |
| Forget to verify the post landed | Always snapshot the feed after submit to confirm |
# Skool Posting -- snappy-post Skool has no public API. All posting goes through `agent-browser` (the Snappy browser automation CLI). Auth lives in `~/.openclaw/workspace/skool-auth.json` and is refreshed by the `snappy-browse` skill. ## Table of Contents - [Auth Setup](#auth-setup) - [Verify Login](#verify-login) - [Post to Community Feed](#post-to-community-feed) - [Post to Classroom Module](#post-to-classroom-module) - [Format Rules](#format-rules) - [Daily Topic Rotation](#daily-topic-rotation) - [Anti-Patterns](#anti-patterns) --- ## Auth Setup ```bash # Auth file (managed by snappy-browse / snappy-skool) SKOOL_AUTH="$HOME/.openclaw/workspace/skool-auth.json" SKOOL_URL="https://www.skool.com/snappy" ``` If `skool-auth.json` is missing or expired, route to `snappy-browse` to refresh it (interactive browser login flow). --- ## Verify Login Always verify you're logged in BEFORE attempting to post -- otherwise the browser will land on the public landing page and the post sequence will silently fail. ```bash # Kill stale browsers (avoid daemon collisions) pkill -9 -f "daemon.js" 2>/dev/null pkill -9 -f "Chrome for Testing" 2>/dev/null sleep 1 # Launch with auth state agent-browser --state "$SKOOL_AUTH" open "$SKOOL_URL" agent-browser wait 3000 # Snapshot to verify the feed is rendered (logged-in marker: "Write something" textbox) agent-browser snapshot -i 2>&1 | head -20 ``` If the snapshot shows "Sign in" / "Log in" instead of "Write something", auth is broken -- fix in `snappy-browse` first. --- ## Post to Community Feed ```bash # Open the composer agent-browser find text "Write something" click agent-browser wait 500 # Fill the body agent-browser fill "[contenteditable]" "Post content here. Hook line first. Body in 3-5 short paragraphs. End with a question." # Submit agent-browser find text "Post" click agent-browser wait 2000 # Verify the post landed by snapshotting the feed agent-browser snapshot -i 2>&1 | head -10 ``` ### With Image Attachment ```bash agent-browser find text "Write something" click agent-browser wait 500 agent-browser fill "[contenteditable]" "Post body here" # Attach image (look for the camera icon ref in the snapshot) agent-browser snapshot -i # Find image-attach button # Click the attachment button by its ref, upload the file agent-browser find text "Post" click agent-browser wait 3000 ``` --- ## Post to Classroom Module For lesson content (course material, not feed posts): ```bash # Navigate to the classroom agent-browser open "https://www.skool.com/snappy/classroom" agent-browser wait 3000 # Snapshot to identify the target module agent-browser snapshot -i 2>&1 | head -40 # Click the target module by its title text agent-browser find text "Module Name Here" click agent-browser wait 2000 # Add lesson content via the module editor (UI varies -- snapshot to find inputs) agent-browser snapshot -i ``` Classroom edits are higher-stakes than feed posts -- always preview before saving and route through `snappy-skool` for the canonical workflow if available. --- ## Format Rules Skool posts are community discussions, not broadcasts. Voice is closer to a Slack channel than a LinkedIn feed. |rule|why| |----|---| |Hook → value → CTA|Same arc as LinkedIn but more conversational| |Zero hashtags|Skool's discovery is search + categories, not hashtags| |End with a question|Discussion-driven engagement is the platform's whole point| |Tag the relevant category|Use Skool's category dropdown (Wins, Tips, Questions, etc.)| |Address the community by name|"Hey Snappy crew" / "Devs in here" -- first-person plural works| |Mention specific members when relevant|@-mentions drive notifications and feel personal| |Link to long-form content sparingly|Skool down-ranks link-out posts; bring the value into the post| Voice rules and anti-AI patterns are owned by `snappy-content` -- see `snappy-content/anti-ai-checklist.md`. --- ## Daily Topic Rotation Run consistent rhythm to keep the community alive without burning out the writer. |day|topic|format| |---|-----|------| |Mon|Goals / weekly intent|Open question -- "What are you shipping this week?"| |Tue|Tip / quick value|1-paragraph tip from this week's work| |Wed|Q&A / poll|Ask the community a question, run a poll| |Thu|Case study / wins|Showcase a member's win or a Snappy client outcome| |Fri|Wins thread|"Drop your wins from this week ⬇️"| |Sat|Resource share|Tool, article, repo worth sharing -- with context| |Sun|Reflective / behind-the-scenes|Personal note, lessons, longer-form| The full Skool member ops + engagement workflow lives in `snappy-skool`. snappy-post just owns the *post* primitive -- pull cadence and curriculum context from `snappy-skool/SKILL.md` and `snappy-skool/SKILL.md`. --- ## Anti-Patterns |wrong|right| |-----|-----| |Skip the verify-login snapshot|Always snapshot first to confirm auth| |Post broadcasts instead of discussions|End with a question, frame as community conversation| |Cross-post from LinkedIn verbatim|Rewrite for Skool -- different audience expectations| |Use hashtags|Zero hashtags| |Spam links to the website|Bring the value into the post; link sparingly| |Skip browser cleanup|`pkill` daemon + Chrome before launch -- daemon collisions are silent killers| |Post during low-activity hours|Skool community is most active 8-11am ET and 7-10pm ET| |Forget to verify the post landed|Always snapshot the feed after submit to confirm|
/**
* THE ROUTER TAKES THE DESTINATION'S ROAD, AND NOTHING LEAVES THIS MACHINE.
*
* MEASURED RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩. This hand's CLI
* destructured `createPost` from snappy-linkedin and `sendMessage` from
* snappy-slack; neither export has ever existed, so every verb died at module
* instantiation and this test's first assertion — that `telegram <text> --json`
* prints a decision — failed on a crash, not a wrong shape. And had the names
* resolved, the arms called the vendor DIRECTLY: no preview, no stage row, no
* door, from a hand whose contract declares every verb `send-to-a-person`.
*
* SO BOTH ARTIFACTS ARE COUNTED, not just the happy one ⟨CLAUDE.md §10⟩: the
* decision that was printed, AND the absence of any call that would have left
* this Mac. A test that asserted only the first would pass over a router that
* previewed the post and published it too — the worse failure, because the
* person then decides on something the world already saw.
*
* NOTHING REACHES A VENDOR: `driveHand` replaces `fetch` in the child, and the
* child passes its own loader down to the destination hand it spawns, so the
* stub covers the grandchild as well. The credentials are invented.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { driveHand } from "../hand-stage-probe.ts";
/** Telegram is the destination driven here because its whole road — the chat
* read, the face, the stage call — is one arm behind one bot token, so the
* probe proves the delegation without a second credential road. */
const TELEGRAM = {
TELEGRAM_BOT_TOKEN: "0000000000:invented-for-this-test",
TELEGRAM_ROBERT_CHAT_ID: "5550001",
};
/** getUpdates answers an empty mailbox: the chat has no history this bot can
* read, which is the honest `thread: []` case and still a real decision. */
const NO_UPDATES = JSON.stringify({ ok: true, result: [] });
test("post telegram --json prints telegram's own decision, with telegram's doors", () => {
const run = driveHand({
skill: "snappy-post",
argv: ["telegram", "The staged-write road shipped today.", "--to", "5550002", "--json"],
credentials: TELEGRAM,
vendorAnswer: NO_UPDATES,
});
assert.equal(run.status, 0, `snappy-post telegram --json exited ${run.status}: ${run.stderr.slice(0, 400)}`);
const face = run.json as Record<string, unknown>;
assert.ok(face, `nothing JSON was printed; stdout was ${run.stdout.slice(0, 300)}`);
// THE DESTINATION'S OWN KIND, not a fourth one invented here.
assert.equal(face.kind, "telegram-draft");
assert.deepEqual(face.thread, []);
// THE DOORS ARRIVE ALREADY THOUGHT OF — this hand printed none at all before.
const doors = face.doors as { label: string; verb: string; primary?: boolean }[];
assert.deepEqual(doors.map((door) => door.label), ["Send", "Later"]);
assert.equal(doors[0].primary, true);
// AND THE PRESS IS BUILDABLE: telegram's own contract verb and its argument.
const act = face.act as { verb: string; arguments: Record<string, unknown> };
assert.equal(act.verb, "send");
assert.equal(act.arguments.text, "The staged-write road shipped today.");
// A PREVIEW TOUCHES NOTHING: no stage row either.
assert.equal(run.staged, null);
});
test("the bare verb stages through the destination's stage door and posts nothing", () => {
const run = driveHand({
skill: "snappy-post",
argv: ["telegram", "Deploy done.", "--to", "5550002"],
credentials: TELEGRAM,
vendorAnswer: NO_UPDATES,
});
// THE OPERATION WENT TO THE STAGE DOOR, under the DESTINATION's name — the
// router decides nothing about the act, so the row says who really runs it.
assert.ok(run.staged, `nothing was staged; stdout ${run.stdout.slice(0, 200)} stderr ${run.stderr.slice(0, 300)}`);
assert.equal(run.staged?.skill, "snappy-telegram");
assert.equal(run.staged?.verb, "send");
// AND NOTHING LEFT THIS MACHINE. This is the assertion the old arm could
// never have passed: it called the Bot API the moment it was invoked.
const posts = run.vendorCalls.filter((call) => call.method === "POST");
assert.deepEqual(posts, [], `the router reached a vendor: ${JSON.stringify(posts).slice(0, 400)}`);
});
/**
* THE ROUTER TAKES THE DESTINATION'S ROAD, AND NOTHING LEAVES THIS MACHINE.
*
* MEASURED RED FIRST ⟨lane doors-everywhere, 2026-09-09⟩. This hand's CLI
* destructured `createPost` from snappy-linkedin and `sendMessage` from
* snappy-slack; neither export has ever existed, so every verb died at module
* instantiation and this test's first assertion — that `telegram <text> --json`
* prints a decision — failed on a crash, not a wrong shape. And had the names
* resolved, the arms called the vendor DIRECTLY: no preview, no stage row, no
* door, from a hand whose contract declares every verb `send-to-a-person`.
*
* SO BOTH ARTIFACTS ARE COUNTED, not just the happy one ⟨CLAUDE.md §10⟩: the
* decision that was printed, AND the absence of any call that would have left
* this Mac. A test that asserted only the first would pass over a router that
* previewed the post and published it too — the worse failure, because the
* person then decides on something the world already saw.
*
* NOTHING REACHES A VENDOR: `driveHand` replaces `fetch` in the child, and the
* child passes its own loader down to the destination hand it spawns, so the
* stub covers the grandchild as well. The credentials are invented.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { driveHand } from "../hand-stage-probe.ts";
/** Telegram is the destination driven here because its whole road — the chat
* read, the face, the stage call — is one arm behind one bot token, so the
* probe proves the delegation without a second credential road. */
const TELEGRAM = {
TELEGRAM_BOT_TOKEN: "0000000000:invented-for-this-test",
TELEGRAM_ROBERT_CHAT_ID: "5550001",
};
/** getUpdates answers an empty mailbox: the chat has no history this bot can
* read, which is the honest `thread: []` case and still a real decision. */
const NO_UPDATES = JSON.stringify({ ok: true, result: [] });
test("post telegram --json prints telegram's own decision, with telegram's doors", () => {
const run = driveHand({
skill: "snappy-post",
argv: ["telegram", "The staged-write road shipped today.", "--to", "5550002", "--json"],
credentials: TELEGRAM,
vendorAnswer: NO_UPDATES,
});
assert.equal(run.status, 0, `snappy-post telegram --json exited ${run.status}: ${run.stderr.slice(0, 400)}`);
const face = run.json as Record<string, unknown>;
assert.ok(face, `nothing JSON was printed; stdout was ${run.stdout.slice(0, 300)}`);
// THE DESTINATION'S OWN KIND, not a fourth one invented here.
assert.equal(face.kind, "telegram-draft");
assert.deepEqual(face.thread, []);
// THE DOORS ARRIVE ALREADY THOUGHT OF — this hand printed none at all before.
const doors = face.doors as { label: string; verb: string; primary?: boolean }[];
assert.deepEqual(doors.map((door) => door.label), ["Send", "Later"]);
assert.equal(doors[0].primary, true);
// AND THE PRESS IS BUILDABLE: telegram's own contract verb and its argument.
const act = face.act as { verb: string; arguments: Record<string, unknown> };
assert.equal(act.verb, "send");
assert.equal(act.arguments.text, "The staged-write road shipped today.");
// A PREVIEW TOUCHES NOTHING: no stage row either.
assert.equal(run.staged, null);
});
test("the bare verb stages through the destination's stage door and posts nothing", () => {
const run = driveHand({
skill: "snappy-post",
argv: ["telegram", "Deploy done.", "--to", "5550002"],
credentials: TELEGRAM,
vendorAnswer: NO_UPDATES,
});
// THE OPERATION WENT TO THE STAGE DOOR, under the DESTINATION's name — the
// router decides nothing about the act, so the row says who really runs it.
assert.ok(run.staged, `nothing was staged; stdout ${run.stdout.slice(0, 200)} stderr ${run.stderr.slice(0, 300)}`);
assert.equal(run.staged?.skill, "snappy-telegram");
assert.equal(run.staged?.verb, "send");
// AND NOTHING LEFT THIS MACHINE. This is the assertion the old arm could
// never have passed: it called the Bot API the moment it was invoked.
const posts = run.vendorCalls.filter((call) => call.method === "POST");
assert.deepEqual(posts, [], `the router reached a vendor: ${JSON.stringify(posts).slice(0, 400)}`);
});
YouTube uploads via the Xano API. The video must be processed and captioned (via snappy-video) before this step.
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md. Required keys: XANO_TOKEN, XANO.
bashcurl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"title": "How I Cut Deploy Time From 45 Minutes to 90 Seconds",
"description": "Full description with timestamps and links...",
"tags": ["ai-automation", "devops", "ci-cd", "snappy"],
"video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-07-deploy.mp4",
"thumbnail_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/youtube/2026-04-07-deploy-thumb.jpg",
"privacy": "public"
}'
| field | required | notes | ||
|---|---|---|---|---|
title |
yes | Under 60 chars, keyword front-loaded | ||
description |
yes | See Description Format | ||
tags |
yes | 5-15 tags, lowercase, hyphenated | ||
video_url |
yes | Must be a CDN URL -- temp URLs expire mid-upload | ||
thumbnail_url |
no | If omitted, YouTube auto-picks a frame (usually wrong) | ||
privacy |
no | public \ |
unlisted \ |
private. Default private for staging. |
publish_at |
no | ISO timestamp for scheduled publish (use private + publish_at together) |
| step | owner | action |
|---|---|---|
| 1 | snappy-video |
Whisper transcription complete |
| 2 | snappy-video |
Captions burned in (most viewers watch muted) |
| 3 | snappy-video |
Best clips extracted (for Shorts repurposing) |
| 4 | user | Title finalized (under 60 chars) |
| 5 | snappy-image |
Thumbnail designed (1280x720, high contrast, face if possible) |
| 6 | user | Description with timestamps + CTAs ready |
| 7 | user | Tags list (5-15) ready |
| 8 | snappy-video |
Final MP4 uploaded to CDN |
If any step is missing, route back to the owning skill before calling the upload endpoint.
| rule | why |
|---|---|
| Under 60 chars | YouTube truncates titles in search results around 60-70 chars |
| Front-load the keyword | First 3-5 words carry the most ranking weight |
| Specific result > generic claim | "Cut deploy time from 45 min to 90 sec" beats "Speed up your CI/CD" |
| Number in the title when possible | "3 things..." / "From 45 to 90..." -- numbers boost CTR |
| No clickbait punctuation | "You won't BELIEVE this!!!!" gets demoted |
| No ALL CAPS | YouTube treats all-caps as spam |
| pattern | example |
|---|---|
| Result-first | Cut Deploy Time From 45 Min to 90 Sec |
| How-to | How I Built a 50-Skill Agent System |
| Mistake | The 1 Mistake That Killed My SaaS |
| Comparison | Cursor vs Claude Code: Honest Review |
| Question | Is Vercel Worth It in 2026? |
[1-2 sentence hook that mirrors the title]
[2-3 paragraphs of context -- what the video covers, who it's for, what they'll get]
🔗 LINKS:
- Snappy: https://snappy.ai
- Subscribe: https://www.youtube.com/@robertboulos?sub_confirmation=1
- Skool community: https://www.skool.com/snappy
⏱️ TIMESTAMPS:
00:00 Intro
01:30 The problem
04:20 The solution
08:15 Live demo
14:00 Wrap-up
🛠️ TOOLS MENTIONED:
- Tool 1: https://...
- Tool 2: https://...
#tag1 #tag2 #tag3 ← only here, NOT in the body
| rule | why |
|---|---|
| First 150 chars are the preview | That's all that shows in search; make it count |
| Always include subscribe link | Easy ask, low friction |
| Always include timestamps | Boosts watch time and gives YouTube section data |
| Hashtags ONLY at the bottom | Top of description hashtags look spammy and limit to 3 |
| Include 1-2 internal links | To the website and the Skool community |
| rule | value |
|---|---|
| count | 5-15 tags |
| format | lowercase, hyphenated multi-word tags (ai-automation not AI Automation) |
| order | Most specific first, broader last |
| first 3 tags | These weigh the most -- make them keyword-rich |
| brand tags | Always include snappy, robert-boulos as the last 1-2 tags |
snappy-image (Nano Banana → Grok edits)snappy-images/youtube/<YYYY-MM-DD>-<slug>-thumb.jpgthumbnail_url in the upload payload| rule | why |
|---|---|
| Test against the actual title | Thumbnail + title together must tell the story in 2 seconds |
| No tiny text | Mobile viewers see thumbs at ~120px wide; if text is unreadable there, redo it |
| Bright background | Dark thumbs disappear in YouTube's dark feed |
| Express emotion | Surprise, focus, anger > neutral |
| value | use_case |
|---|---|
public |
Default for finished videos |
unlisted |
Share via direct link only -- for client previews, drafts, replays |
private |
Only the channel owner -- staging area while finalizing |
private + publish_at |
Scheduled publish -- set ISO timestamp |
Always upload as private first, verify the upload, then flip to public (or schedule). This prevents broken thumbnails or wrong descriptions going live.
| wrong | right |
|---|---|
| Upload before captions are burned | Captions first -- most viewers watch muted |
| Auto-generated thumbnail | Always provide a designed thumbnail via snappy-image |
| All-caps title | Title case |
| Hashtags in title | Hashtags at the bottom of the description only |
| No timestamps | Always provide timestamps for videos > 3 min |
| No subscribe link | First link in the description, every time |
Forget to set publish_at for scheduling |
private + publish_at = scheduled publish |
| Upload from a temp video URL | Always upload to CDN first; temp URLs expire |
# YouTube Uploading -- snappy-post
YouTube uploads via the Xano API. The video must be processed and captioned (via `snappy-video`) before this step.
## Table of Contents
- [Auth](#auth)
- [API Endpoint](#api-endpoint)
- [Pre-Upload Checklist](#pre-upload-checklist)
- [Title Rules](#title-rules)
- [Description Format](#description-format)
- [Tags](#tags)
- [Thumbnail Pipeline](#thumbnail-pipeline)
- [Privacy / Visibility](#privacy--visibility)
- [Anti-Patterns](#anti-patterns)
---
## Auth
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see `snappy-settings/SKILL.md`. Required keys: `XANO_TOKEN`, `XANO`.
---
## API Endpoint
```bash
curl -s -X POST "$XANO/api:hZB4Dj0c/youtube-video-uploader" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_TOKEN" \
-d '{
"title": "How I Cut Deploy Time From 45 Minutes to 90 Seconds",
"description": "Full description with timestamps and links...",
"tags": ["ai-automation", "devops", "ci-cd", "snappy"],
"video_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-videos/2026-04-07-deploy.mp4",
"thumbnail_url": "https://snappy-cdn.nyc3.cdn.digitaloceanspaces.com/snappy-images/youtube/2026-04-07-deploy-thumb.jpg",
"privacy": "public"
}'
```
|field|required|notes|
|-----|--------|-----|
|`title`|yes|Under 60 chars, keyword front-loaded|
|`description`|yes|See [Description Format](#description-format)|
|`tags`|yes|5-15 tags, lowercase, hyphenated|
|`video_url`|yes|Must be a CDN URL -- temp URLs expire mid-upload|
|`thumbnail_url`|no|If omitted, YouTube auto-picks a frame (usually wrong)|
|`privacy`|no|`public` \| `unlisted` \| `private`. Default `private` for staging.|
|`publish_at`|no|ISO timestamp for scheduled publish (use `private` + `publish_at` together)|
---
## Pre-Upload Checklist
|step|owner|action|
|----|-----|------|
|1|`snappy-video`|Whisper transcription complete|
|2|`snappy-video`|Captions burned in (most viewers watch muted)|
|3|`snappy-video`|Best clips extracted (for Shorts repurposing)|
|4|user|Title finalized (under 60 chars)|
|5|`snappy-image`|Thumbnail designed (1280x720, high contrast, face if possible)|
|6|user|Description with timestamps + CTAs ready|
|7|user|Tags list (5-15) ready|
|8|`snappy-video`|Final MP4 uploaded to CDN|
If any step is missing, route back to the owning skill before calling the upload endpoint.
---
## Title Rules
|rule|why|
|----|---|
|Under 60 chars|YouTube truncates titles in search results around 60-70 chars|
|Front-load the keyword|First 3-5 words carry the most ranking weight|
|Specific result > generic claim|"Cut deploy time from 45 min to 90 sec" beats "Speed up your CI/CD"|
|Number in the title when possible|"3 things..." / "From 45 to 90..." -- numbers boost CTR|
|No clickbait punctuation|"You won't BELIEVE this!!!!" gets demoted|
|No ALL CAPS|YouTube treats all-caps as spam|
### Title Patterns
|pattern|example|
|-------|-------|
|Result-first|`Cut Deploy Time From 45 Min to 90 Sec`|
|How-to|`How I Built a 50-Skill Agent System`|
|Mistake|`The 1 Mistake That Killed My SaaS`|
|Comparison|`Cursor vs Claude Code: Honest Review`|
|Question|`Is Vercel Worth It in 2026?`|
---
## Description Format
```
[1-2 sentence hook that mirrors the title]
[2-3 paragraphs of context -- what the video covers, who it's for, what they'll get]
🔗 LINKS:
- Snappy: https://snappy.ai
- Subscribe: https://www.youtube.com/@robertboulos?sub_confirmation=1
- Skool community: https://www.skool.com/snappy
⏱️ TIMESTAMPS:
00:00 Intro
01:30 The problem
04:20 The solution
08:15 Live demo
14:00 Wrap-up
🛠️ TOOLS MENTIONED:
- Tool 1: https://...
- Tool 2: https://...
#tag1 #tag2 #tag3 ← only here, NOT in the body
```
|rule|why|
|----|---|
|First 150 chars are the preview|That's all that shows in search; make it count|
|Always include subscribe link|Easy ask, low friction|
|Always include timestamps|Boosts watch time and gives YouTube section data|
|Hashtags ONLY at the bottom|Top of description hashtags look spammy and limit to 3|
|Include 1-2 internal links|To the website and the Skool community|
---
## Tags
|rule|value|
|----|-----|
|count|5-15 tags|
|format|lowercase, hyphenated multi-word tags (`ai-automation` not `AI Automation`)|
|order|Most specific first, broader last|
|first 3 tags|These weigh the most -- make them keyword-rich|
|brand tags|Always include `snappy`, `robert-boulos` as the last 1-2 tags|
---
## Thumbnail Pipeline
1. Generate via `snappy-image` (Nano Banana → Grok edits)
2. Spec: 1280x720, JPEG, < 2 MB
3. High contrast, large readable text (3-5 words max)
4. Face if possible -- faces increase CTR significantly
5. Upload to CDN under `snappy-images/youtube/<YYYY-MM-DD>-<slug>-thumb.jpg`
6. Pass URL as `thumbnail_url` in the upload payload
|rule|why|
|----|---|
|Test against the actual title|Thumbnail + title together must tell the story in 2 seconds|
|No tiny text|Mobile viewers see thumbs at ~120px wide; if text is unreadable there, redo it|
|Bright background|Dark thumbs disappear in YouTube's dark feed|
|Express emotion|Surprise, focus, anger > neutral|
---
## Privacy / Visibility
|value|use_case|
|-----|--------|
|`public`|Default for finished videos|
|`unlisted`|Share via direct link only -- for client previews, drafts, replays|
|`private`|Only the channel owner -- staging area while finalizing|
|`private` + `publish_at`|Scheduled publish -- set ISO timestamp|
**Always upload as `private` first, verify the upload, then flip to `public`** (or schedule). This prevents broken thumbnails or wrong descriptions going live.
---
## Anti-Patterns
|wrong|right|
|-----|-----|
|Upload before captions are burned|Captions first -- most viewers watch muted|
|Auto-generated thumbnail|Always provide a designed thumbnail via `snappy-image`|
|All-caps title|Title case|
|Hashtags in title|Hashtags at the bottom of the description only|
|No timestamps|Always provide timestamps for videos > 3 min|
|No subscribe link|First link in the description, every time|
|Forget to set `publish_at` for scheduling|`private` + `publish_at` = scheduled publish|
|Upload from a temp video URL|Always upload to CDN first; temp URLs expire|