---
name: social-media-content-pipeline
description: Generate, schedule, and verify weekly LinkedIn/Facebook content batches for Rob's brands (Pipeline Layer, RRR). Covers research → write → image → schedule → verify. Trigger when Rob asks for "LinkedIn posts," "FB posts," "content batch," or "social media scheduling."
---

# Social Media Content Pipeline

Weekly content generation and scheduling for Rob's brands via GHL Social Planner API.

## Brand Assignments (locked 2026-08-03)

| Brand | Platform | Sub-account | Location ID | Voice |
|-------|----------|-------------|-------------|-------|
| **Pipeline Layer** | LinkedIn | Pipeline Layer | `k6PTFz0gXAdNJLDT1aji` | B2B SaaS RevOps, thought leadership, contrarian hooks |
| **RRR** | Facebook | Real Results Ready LLC | `4yTvHUHrmVuP8CgCt1Q9` | Local service SMBs, opportunity-shaped, data-driven |

**Do not cross brands.** Pipeline Layer content ≠ RRR content. Rob flags cross-contamination fast.

## Hard Rules (learned 2026-08-04 — every one of these has bitten us)

1. **Always attach an image.** No exceptions — text-only posts don't go out. If Rob supplies an image, stage it to `/root/.hermes/images/linkedin/` and serve via nginx; otherwise generate one per Step 3.
2. **Enforce the 3,000-char LinkedIn limit BEFORE saving.** GHL **silently truncates** `summary` at exactly 3,000 chars and still returns `success:true` — a post can go out cut off mid-sentence with no error anywhere. Check `len(summary) <= 3000` client-side; if over, trim and show Rob the edited full text for approval before saving.
3. **Always verify the tail after saving.** After every create/update, re-fetch the post and confirm the saved `summary` length matches what was sent AND that the expected ending (hashtags/closer) is present. Never trust a 200 alone.
4. **Always append hashtags** (3–6, mix broad + niche per Step 2) — even if Rob's pasted text has none. Confirm the set with him if adding to his own draft.
5. **PUT updates require the full payload.** `accountIds`, `summary`, `type`, `status`, `scheduleDate`, `userId`, and `media` are all required on update — fetch the existing post first and reuse its values for anything not changing, or the PUT 422s.

## Pre-Flight Checklist

Before generating any content:

1. **Verify GHL token:** `curl -s http://127.0.0.1:9120/ghl/health` — confirm agency token active
2. **Verify social accounts connected:**
   - Pipeline Layer: LinkedIn (Rob Blake profile)
   - RRR: Facebook (Real Results Ready LLC page)
3. **Verify image backend:** OpenAI gpt-image-2 (primary), FAL flux-2-pro (fallback)
4. **Verify nginx image serving:** `curl -s -o /dev/null -w "%{http_code}" https://robblake.cloud/ghl/images/linkedin/test.png` (404 expected, but path must resolve)

## The 5-Step Workflow

### Step 1: Research (optional but recommended)

```bash
# Check LinkedIn engagement trends for the week
web_search: "LinkedIn algorithm changes $(date +%B %Y) engagement"
web_search: "B2B SaaS content marketing trends $(date +%B %Y)"
```

Look for: algorithm updates, format preferences (text vs image vs video), trending topics in RevOps/AI.

### Step 2: Write Posts

**Pipeline Layer LinkedIn structure:**
- **Pillar rotation:** Builder log → MarTech/RevOps → Applied AI → Career/perspective → Industry commentary
- **Hook types:** Pattern-break, Mechanism-reveal, Stat-anchored, Experience-led, Counter-intuitive
- **Length:** 800-1,500 characters (long-form gets 18% more engagement)
- **Format:** Short paragraphs, line breaks, no walls of text
- **Hashtags:** 3-6, mix of broad (#LinkedIn #AI) and niche (#MarketingOps #RevOps)
- **Voice rules:** No fabricated case studies, no engagement bait, "background check" test (would this survive scrutiny?)

**RRR Facebook structure:**
- **Angle rotation:** Discovery → Systems → Trust/Proof → Surprise → Reliability
- **Local focus:** Brighton CO, HVAC/plumbing/electrical/roofing
- **CTA:** $497 Missed Call Diagnostic, credited to first month
- **Tone:** Opportunity-shaped ("here's the revenue you're missing"), not fear-shaped

## Image Source Precedence (learned 2026-08-11 — vault cards were ignored)

1. **Vault-designed cards are authoritative.** Before generating ANY image, check `vault/LinkedIn Posting System/` for a card matching the post's date or pillar (pattern: `YYYY-MM-DD_<pillar>_card.png`). If one exists, stage THAT file to `/root/.hermes/images/linkedin/` and use it. Rob designs these himself (editorial style: headshot, FIG. numbers, headline + body) and they always beat auto-generated quote cards.
2. **Generate only as fallback** when no vault card exists for that post. When generating, use the vault's editorial renderer — `vault/LinkedIn Posting System/visual-assets/generate_card.py` (`build_card(fig_label, tag, eyebrow_white, eyebrow_orange, headline_lines, caption, out_path, photo_path)`) with `photo_path="rob-headshot.png"` (the full chest-to-head shot — NEVER the tight face crop). Continue the FIG. numbering from the existing series. Keep headline lines ≤ ~14 chars at the template font size (BigShoulders-Bold 88) or they collide with the photo zone (max safe width ~500px from x=64). After rendering, vision_analyze the card to confirm no text/face overlap before staging. Plain orange-quote cards (the old auto-design) are retired.
3. **Match images to post CONTENT, not to date labels.** Post text and day assignments can change between drafting and scheduling (e.g. 2026-08-13's text was swapped at schedule time — the image generated against the original draft went out with the replacement text). Before staging/scheduling, verify the card's topic matches the final post text (vision_analyze the card, compare against the post's pillar/headline). If a post's text is swapped after images are made, regenerate or re-match the image for that post.
4. **Write replacements back to the batch .md.** If a drafted post is replaced at schedule time, update the batch file so the vault reflects what actually went out — the vault is the audit trail.

### Step 3: Generate Images (fallback only — see precedence above)

**Backend priority:**
1. **OpenAI gpt-image-2** (primary) — best text rendering, use for quote cards
2. **FAL flux-2-pro** (fallback) — if OpenAI fails or rate-limited

**Pipeline Layer image specs:**
- Background: Dark charcoal `#2D3748`
- Text: Warm amber `#E8893A` (main), white (brand name)
- Font: Bold sans-serif, 600-700 weight
- Layout: Centered text, generous padding, typography-only
- Size: Landscape 1536x1024 (LinkedIn optimal)
- Brand: "Pipeline Layer" in white below main text

**RRR image specs:**
- Photorealistic, local service professionals (HVAC, plumbing, electrical, roofing)
- Warm lighting, positive mood — not stressful/chaotic
- No text overlay (text goes in post body)

**Always vision_analyze before using:**
- Verify text is correct (no "Pirolene" issues)
- Check for typos, garbled letters
- Confirm brand name renders correctly

### Step 4: Schedule via GHL API

**Endpoint:** `POST /social-media-posting/{locationId}/posts`

**Required fields:**
```json
{
  "accountIds": ["{account_id}"],
  "summary": "{post_text}",
  "type": "post",
  "status": "scheduled",
  "scheduleDate": "2026-08-03T09:00:00-06:00",
  "userId": "{user_id}",
  "media": [{"url": "{image_url}", "type": "image/png"}]
}
```

**Gotchas:**
- `scheduleDate` not `scheduledAt`
- `media.type` must be MIME type (`image/png`, not `image`)
- `userId` required — use account's `oauthId` or `createdBy`
- `platform` field not needed in payload (inferred from account)
- Images must be publicly accessible URLs (serve via nginx: `https://robblake.cloud/ghl/images/linkedin/{filename}.png`)

**Schedule pattern:**
- Pipeline Layer LinkedIn: Monday-Friday, 9:00 AM MDT
- RRR Facebook: Tuesday-Thursday, 7-9 AM or 12-1 PM (contractor lunch break)

### Step 5: Verify

```bash
# List scheduled posts
curl -s -X POST "https://services.leadconnectorhq.com/social-media-posting/{locationId}/posts/list" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Version: 2021-07-28" \
  -H "Content-Type: application/json" \
  -d '{"limit": "10"}'
```

Check:
- `status: scheduled`
- `media: true` (non-negotiable — if missing, the post isn't ready)
- `scheduleDate` correct
- `summary` matches final text — **compare exact length and last 60 chars**, not just "looks right." GHL truncates at 3,000 without error; the tail check is the only way to catch it.
- Hashtags present at end of summary

## File Locations

| Asset | Path |
|-------|------|
| Generated images | `/root/.hermes/cache/images/` |
| Web-served images | `/root/.hermes/images/linkedin/` |
| GHL OAuth tokens | `/root/.hermes/secrets/ghl-oauth.json` |
| Abacus AI key (deprecated) | `/root/.hermes/secrets/abacus.env` |
| OpenAI key | `image_gen.openai.api_key` in config.yaml |

## Image Serving

Images must be publicly accessible for GHL to fetch them. The GHL OAuth handler serves them at:

```
https://robblake.cloud/ghl/images/linkedin/{filename}.png
```

**Setup (one-time):**
1. Copy generated image to `/root/.hermes/images/linkedin/{date}.png`
2. GHL OAuth handler serves it via the `/ghl/images/linkedin/` endpoint
3. Use that URL in the post `media` array

## Weekly Cron Template (two-job split, live since 2026-08-10)

The single Sunday draft+schedule job was split into a review-gated pair at Rob's request:

1. **`linkedin-weekly-drafts`** (`de54c923184b`, Sat 14:00 UTC = 8 AM MDT) — drafts the coming week's 5 posts + quote cards, writes the batch .md to the vault + a machine-readable `/root/.hermes/work/linkedin/YYYY-MM-DD_batch.json`, stages images to `/root/.hermes/images/linkedin/`, and delivers the full drafts to Rob in chat (attach_to_session=true so he can reply with edits in-thread). **No GHL writes.** Trend research uses web_search/web_extract, NOT Apify actors (the Cowork Apify path is deprecated — no Apify MCP on this box).
2. **`linkedin-content-batch`** (`8b2ae3275ce1`, Sun 15:00 UTC = 9 AM MDT) — reads the JSON (authoritative), applies any edits Rob requested in-session Saturday, schedules each day for 9:00 AM MDT the coming Mon–Fri, re-lists and verifies status/media/tail, stamps the batch .md STATUS line, and delivers a compact per-day report (no full bodies — Rob saw them Saturday).

If Rob never replies on Saturday, Sunday schedules the drafts as-is — silence = GO. Shell posts are skipped, never fabricated.

Old single-job template (for reference):

## Error Recovery

| Error | Cause | Fix |
|-------|-------|-----|
| 401 "Invalid token" | Token expired or wrong | Check `/ghl/health`, reinstall if needed |
| 401 "authClass not allowed" | Agency token can't write | Use location-level token (or verify agency has write scope) |
| 422 "media.0.Invalid media format" | Wrong media.type | Use `image/png` not `image` |
| 422 "property platform should not exist" | Extra field in payload | Remove `platform` from payload |
| 422 "userId must be a string" | Missing userId | Add `userId` field |
| 422 on PUT listing many missing fields | PUT requires full payload | Fetch post first, resend all fields with edits merged in |
| Post text cut off mid-sentence | GHL silently truncates `summary` at 3,000 chars, still returns success | Trim client-side to ≤3,000, re-save, verify tail on re-fetch |
| Image 404 | Not served by nginx | Check file exists, check handler route |

## Brand Voice Reference

**Pipeline Layer (Rob's voice):**
- "For years I built X... leadership would nod and go back to instinct"
- "The fix wasn't a better chart. It was a different sentence structure."
- "Nobody's account should have been a bet on a loophole staying open."
- Short sentences. Contrarian but earned. No hype.

**RRR:**
- "Most contractors we talk to know they're missing calls..."
- "The reality: 61% of their booked jobs came from the mailer they almost cancelled."
- "You can't fix what you can't see."
- Data-driven, local, opportunity-shaped.

## Related Skills

- `brand-asset-generation` — image iterate loop, vision_analyze checks
- `gohighlevel-ops` — GHL API auth, endpoints, error dictionary
- `ghl-api-integration` — PIT setup, scope debugging
