Publish pipeline skill

Publish pipeline — Marketing templates (from the marcosmodly/marketing-skill README)

Illustration · not a resultFrom the marcosmodly/marketing-skill README — shows the whole collection, not only this skill.source ↗

Publishes approved marketing content: packages finished posts and asset references into JSON and sends them to n8n, Make, or any other incoming-webhook automation, or optionally straight to a platform API with your own credentials, only after showing the exact payload and getting explicit human approval.

by marcosmodly·MIT license·★ 42 Stars on the repo·GitHub ↗

Use now
Publish pipeline — Marketing templates (from the marcosmodly/marketing-skill README)

Illustration · not a resultFrom the marcosmodly/marketing-skill README — shows the whole collection, not only this skill.view on GitHub

Files of Publish pipeline

marcosmodly/main1 file shown
SKILL.md
Show the full text366 lines

Publish Pipeline

Purpose

Final handoff step: package finished content and any asset references into a structured JSON payload, then send it to the user's own automation system (n8n, Make, or anything else that accepts an incoming webhook) so it can take over scheduling and posting. By default this skill hands off to automation the user already owns rather than posting directly — direct platform posting exists only as an explicit, optional alternate path (see step 5) for users who've set up their own platform API credentials.

Step-by-step process

Plugin root: if a path below still shows an unexpanded CLAUDE_PLUGIN_ROOT placeholder (Claude Code fills it in; other agents such as Codex don't), resolve it before using any of them: take the output of printenv CLAUDE_PLUGIN_ROOT if it prints a path, otherwise walk up from this file's folder to the first folder that contains references/brand-voice.md, and if neither works, ask the user where this plugin lives. Use that absolute path in every file path and shell command below.

  1. Check onboarding status. Read ${CLAUDE_PLUGIN_ROOT}/references/brand-voice.md. If it doesn't exist, or its first line is <!-- MARKETING-SKILL:UNCONFIGURED -->, ask the user this plugin's 4 setup questions (priority task; content types to produce; target audience + tone; default output format — same as /marketing-skill:marketing-setup) before continuing, then save the answers and flip the marker to CONFIGURED with today's date.

  2. Confirm scope.

    • Which content pieces are ready to send (from content-repurposer, visual-brief-generator, short-form-video, community-post-generator, a row in state/content-calendar.md, or pasted directly)?
    • If the source is community-post-generator, confirm its Go/No-Go section actually said go — never send a draft that skill flagged as blocked by the target community's own rules.
    • If the user is pointing at a calendar entry (e.g. "send the queued post for Tuesday"), read ${CLAUDE_PLUGIN_ROOT}/state/content-calendar.md, find the matching row, then read the post file named in that row's Notes column (state/posts/<date>-<platform-slug>.md) — that file, not the calendar table, holds the actual content to send. A row with no post-file path in Notes, or whose named file doesn't exist, isn't a usable source — say so and stop rather than improvising content from the short Topic/Hook label. Refuse to re-send a row already marked Sent without the user explicitly confirming they want to send it again.
    • Destination webhook URL, if not already set via MARKETING_WEBHOOK_URL.
    • Is this a real send, or a dry run to inspect the payload first?
  3. Assemble the JSON payload using the exact structure below.

  4. Show a clear, human-readable confirmation before any real send — never a raw JSON dump as the only thing shown — and get explicit user go-ahead. This step fires an external side effect the user may not be able to easily undo — never skip it, even if the original request sounded like blanket authorization (e.g. "just publish it"). Silence, a timeout, or no reply in this conversation is never a yes — only an actual affirmative reply counts, and if none arrives, stop here without sending. This applies exactly the same way whether an interactive chat or something else (a scheduled or automated trigger) invoked this skill — there is no framing of "the run itself is standing authorization" that substitutes for a real reply.

    • Single item: show four labeled lines before anything else — Account (which identity this goes out as: the destination automation itself for a webhook handoff, e.g. "your n8n webhook," or the specific platform identity for a direct post, e.g. "dev.to — personal account, not the Organization" or "Instagram Business account linked to [Page]"), Destination (URL, subreddit, channel, or recipient), Attachments (each asset's type and source, or "none"), and Final Text (the actual post/message body, in full, not a summary) — then the JSON payload or script command underneath as the technical detail, not the primary thing being confirmed.
    • Batch (more than one item ready at once): never loop through items one at a time asking "send this? ... send this? ..." — that pattern gets a "yes" to everything without anyone actually reading past the first item or two. Instead list every ready item together, numbered, each with its own Account/Destination/Attachments/Final Text breakout, and require the reply to name specific numbers (e.g., "1 and 3, yes — skip 2") rather than accepting an unqualified "approve all"/"send them" with no numbers attached — treat an unqualified blanket reply to a numbered list the same as silence for any item it doesn't actually name. Cap a single batch at 10 items; if more than 10 are ready, present them in waves of 10 or fewer instead of one long list, so the format itself never invites skimming.
    • Staleness. For any item sourced from a state/content-calendar.md or state/outreach-log.md row, compare that row's Date to today. Flag anything more than 14 days old (7 days for an outreach-log row, since personalized prospect research ages faster) as Stale right in the listing, not in a separate report — a batch mixing fresh and stale items must show the flag per-row so it can't be missed inside a larger "yes to all." Don't refuse to send a stale item outright; just make its age impossible to approve past without seeing it.
    • If the source is a state/content-calendar.md row, this is the point where — and only where — its Status may move to Approved, immediately after the affirmative reply and immediately before sending, and only for the specific numbers actually named. No skill in this plugin sets Approved at any other time; content-calendar in particular never writes it.
  5. Send it:

    • Write the JSON payload to a temporary file (e.g. via mktemp).
    • Resolve the webhook URL in this order: an explicit URL the user gave

      the MARKETING_WEBHOOK_URL environment variable > ask the user for one if neither is available.

    • Run via Bash: python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file <tmp-path> --dry-run to preview, then, only after step 4's confirmation, python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file <tmp-path> --confirmed to actually send. The script refuses to run without one of those two flags — there is no default-sends behavior to be careful about.
    • Remove the temporary file afterward regardless of outcome.
    • If the source was a calendar row, update it to Sent in state/content-calendar.md immediately after a successful send (keep the row — this is the history log, don't delete it).
    • Alternate path (optional, not the default): Make MCP. If a Make MCP connector is connected in this session (tools like scenarios_run, scenarios_list), you may call scenarios_run directly instead of the webhook script, if the user prefers that. Don't assume it's connected — check available tools first.
    • Alternate path (optional, not the default): direct platform posting. If the user wants to post straight to LinkedIn, X, Meta, Reddit, Discord, Slack, Telegram, or dev.to rather than handing off to their own automation, see ${CLAUDE_PLUGIN_ROOT}/scripts/publish_direct.py --help. It only works if the user has already set up real API credentials for that platform (see the plugin's docs/publishing.md) — check with --dry-run first, same confirmation rules as above apply, and be explicit that this path is less proven than the webhook path since it talks to live platform APIs this plugin's author can't verify from here. For Reddit specifically, a successful API response doesn't guarantee the post survives that subreddit's AutoModerator — confirm the content actually came from a community-post-generator go (not a no-go) before sending. For Discord, Slack, or Telegram specifically, confirm the content came from a go too — and since a Discord or Slack go is always User-Supplied confidence (this skill never independently verified that server's/workspace's rules), and a Telegram go is User-Supplied for a private channel/group but can be Primary/Secondary/Mixed for a public one, check which tier this specific draft actually rests on rather than assuming — that's one more reason not to skip step 4's confirmation just because the request "sounds routine." For Slack in particular, also confirm the user actually has (or can get) the workspace permission a webhook needs before treating the send as a quick step — it isn't guaranteed self-serve the way Discord's is. For Telegram in particular, confirm the bot has actually been added to the target chat by one of its admins — creating the bot itself needs no approval from anyone, but that's only the first of two gates, not the whole thing. For dev.to in particular, confirm the content came from a go too, and if community-post-generator's research couldn't confirm the Code of Conduct's self-promotion wording directly, say so before sending — a 2xx from dev.to's API means the article was accepted, not that a Tag Moderator won't strip a tag from it afterward. Product Hunt, Hacker News, and Indie Hackers have no equivalent direct-send path, for different reasons (see docs/publishing.md): Product Hunt's write API requires special approval from Product Hunt itself; Hacker News's API has no write/submit endpoint at all, for anyone; and Indie Hackers' API situation is unverified rather than confirmed either way, so it's treated as manual-only too — all three always go out by pasting the draft in manually (producthunt.com, news.ycombinator.com, or indiehackers.com), never through this script, and that's permanent for Hacker News, not a "not yet approved" situation. Discord, Slack, Telegram, and dev.to each have a real send path but don't share one friction profile — Discord's webhook needs no approval step, Slack's app often needs Workspace Owner/Admin approval before creation, Telegram's bot needs no approval to create but does need a chat admin to add it before it can post, and dev.to's API key needs no approval process found in this skill's research at all (the simplest of the four, though that's about access, not about whether a Tag Moderator is happy with the result) — so don't lump any of the four into "every non-Reddit platform here is manual-only," and don't lump them into each other's ease either.
    • YouTube, Instagram, and TikTok (--platform youtube/instagram/ tiktok) are a different shape from every platform above — all three post actual video, and confirm the content came from short-form-video, not this skill improvising a caption. Don't present any of the three with borrowed ease from another:
      • YouTube is the most self-serve of the three: the user's own Google Cloud OAuth client, authorized against their own channel, is enough — no platform-side approval queue the way Reddit or TikTok have. It's also the one platform here that uploads a local file (--video-path) rather than pointing at a hosted URL; confirm the asset entry has a real path, not just a url, before offering this path. --privacy-status defaults to private in the script itself — flag that plainly so "just post it" doesn't quietly mean "post it where only you can see it."
      • Instagram Reels needs the heaviest setup of the three: a Business or Creator IG account linked to a Facebook Page, a Meta app with instagram_business_content_publish actually approved (not just Development Mode), and — unlike YouTube — the video already hosted at a public URL, since Instagram's API fetches from a URL rather than accepting an upload; confirm the asset entry has a real url before offering this path, and that step 4's confirmation makes clear this is a three-step, several-second-to-minutes process (container creation, processing, then publish), not instant.
      • TikTok needs an app approved for the video.publish scope, and — critically — until the app passes TikTok's own audit, TikTok only accepts a private (SELF_ONLY) post to a creator account that is itself set to private, and rejects anything else outright — it doesn't quietly downgrade a public request. The script checks the requested privacy level against the creator's allowed options (TikTok's required creator_info step) before posting, and stops if it isn't one of them. Never present a TikTok send as reaching a public audience without the user confirming their app's audit status directly in TikTok's developer portal first; a 2xx response only means TikTok accepted and queued the request, not that the video is live, since TikTok fetches and processes it asynchronously afterward. Also needs the video at a public URL, same as Instagram, with that URL's domain pre-verified in TikTok's developer portal.
  6. Report the result plainly: exit code, HTTP status if a real send was made, and a one-line human-readable summary of what went where. Never report success if the script exited non-zero.

When to use this skill

Trigger on requests like:

  • "Publish this"
  • "Send this to n8n / Make"
  • "Trigger the automation"
  • "Fire the webhook"
  • "Send the queued post for [date]" / "approve and send calendar item"

Payload structure (required)

{
  "run_id": "<uuid or timestamp-based id>",
  "timestamp": "<ISO 8601>",
  "source_skill": "publish-pipeline",
  "content": {
    "linkedin_post": "...",
    "twitter_thread": ["...", "..."],
    "newsletter_blurb": "...",
    "reddit_post": { "subreddit": "...", "title": "...", "body": "..." },
    "discord_message": "...",
    "slack_message": "...",
    "telegram_message": "...",
    "devto_post": { "title": "...", "body": "...", "tags": ["...", "..."] },
    "youtube_short": { "title": "...", "description": "...", "tags": ["...", "..."], "privacy_status": "private|unlisted|public" },
    "instagram_reel": { "caption": "..." },
    "tiktok_video": { "caption": "..." }
  },
  "assets": [
    { "type": "image|video", "description": "...", "url": "...or null", "path": "...or null", "prompt_reference": "...", "platform": "youtube_short|instagram_reel|tiktok_video|..." }
  ],
  "metadata": { "campaign": "...", "brand_voice_version": "..." }
}

Omit fields that don't apply (e.g. no assets if none were generated), but keep the top-level shape stable so downstream n8n/Make workflows can rely on it. For a youtube_short/instagram_reel/tiktok_video entry, the matching assets item needs either path (a local file — the only form YouTube's direct-post option in step 5 can use) or url (a publicly-reachable hosted link — required for Instagram's and TikTok's direct-post options in step 5, which fetch the video themselves rather than accepting an upload); which one is available depends on whether short-form-video generated the clip via a connected tool (may produce either) or the user is supplying their own already-hosted video.

Formatting rules

  • Never claim a send succeeded if the script returned a non-zero exit code — report the actual failure and exit code instead.
  • Never fire a real send without showing the exact payload and getting explicit confirmation first — and never pass --confirmed without having received an actual affirmative reply in this conversation first.
  • Keep the payload's top-level keys stable across runs; add new content under content rather than renaming existing keys.
  • Never write Approved to state/content-calendar.md except in step 4, immediately after a real human reply, immediately before sending.
  • Never present a raw JSON payload as the only confirmation surface — always break out Account, Destination, Attachments, and Final Text as labeled lines first, for both a single item and every item in a batch.
  • Never list more than 10 items in one batch confirmation, and never treat an unqualified "approve all"/"send them" reply to a numbered list as approval for items it doesn't specifically name — only named numbers move to Approved.
  • Never let an item more than 14 days old (7 for an outreach-log row) pass through a confirmation listing without a visible Stale flag on that row.
  • For youtube/instagram/tiktok sends specifically: never report a 2xx response as "the video is live" — Instagram and TikTok both process the video asynchronously after that response, so report exactly what the script itself reported (queued/processing vs. confirmed published), and never claim a TikTok send reached a public audience without the user having confirmed their app's audit status.

Example output

Confirmation display, single item (shown before the dry run below, never replaced by it):

Account: your n8n webhook (automation handoff, not a direct platform post)
Destination: https://your-n8n-host/webhook/abc123
Attachments: none
Final Text:
  Speed got us here. It won't be the only thing that keeps us here...
  (full LinkedIn post text)

Reply to confirm this exact send, or say what to change first.

Confirmation display, batch of three, one stale:

Three rows are ready to send. Reply with the numbers to approve (e.g.
"1 and 3, skip 2") — an unqualified "send them all" won't be treated as
approving any of these.

1. [Stale — queued 19 days ago] Account: your n8n webhook · Destination:
   https://your-n8n-host/webhook/abc123 · Attachments: none · Final Text:
   "Most teams lose an afternoon a month to manual exports..."
2. Account: dev.to — personal account · Destination: dev.to (tags:
   showdev, saas) · Attachments: none · Final Text: "I built this because
   our own export flow was the thing customers complained about most..."
3. Account: your n8n webhook · Destination: https://your-n8n-host/webhook/abc123
   · Attachments: 1 image (state/assets/export-diagram.png) · Final Text:
   "One-click export is live today for every customer..."

Reported this way regardless of how the request was phrased ("send the ready ones," "go ahead with today's batch") — a reply naming specific numbers is the only thing that moves those rows to Approved.

Dry run (the technical payload behind a single confirmed item):

$ python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file /tmp/payload.json --dry-run
=== DRY RUN: no request sent ===
Method: POST
URL: https://your-n8n-host/webhook/abc123
Headers:
  Content-Type: application/json
Body (application/json, 412 bytes):
{
  "run_id": "2026-09-25T14-30-00",
  "timestamp": "2026-09-25T14:30:00Z",
  ...
}
=== END DRY RUN ===

Reported to the user as: "Dry run only — nothing was sent. Here's the exact payload and destination above; confirm to send for real."

Real send, success:

Sent to https://your-n8n-host/webhook/abc123
Status: 200 OK
Response: (empty body)

Reported as: "Sent successfully (200 OK) to your n8n webhook."

Real send, failure:

Webhook returned non-2xx status
Status: 500 Internal Server Error
Response: {"error": "workflow disabled"}

Reported as: "Send failed — the endpoint returned 500 (workflow disabled). Nothing further was attempted."

1---
2name: publish-pipeline
3description: "Publishes approved marketing content: packages finished posts and asset references into JSON and sends them to n8n, Make, or any other incoming-webhook automation, or optionally straight to a platform API with your own credentials, only after showing the exact payload and getting explicit human approval. Use when the user asks to publish, post, ship, or send content live, trigger the automation, fire the webhook, or send a queued calendar post."
4allowed-tools: Read, Write, Edit, Bash
5---
6 
7# Publish Pipeline
8 
9## Purpose
10 
11Final handoff step: package finished content and any asset references into
12a structured JSON payload, then send it to the user's own automation
13system (n8n, Make, or anything else that accepts an incoming webhook) so
14it can take over scheduling and posting. By default this skill hands off
15to automation the user already owns rather than posting directly — direct
16platform posting exists only as an explicit, optional alternate path (see
17step 5) for users who've set up their own platform API credentials.
18 
19## Step-by-step process
20 
21**Plugin root:** if a path below still shows an unexpanded
22`CLAUDE_PLUGIN_ROOT` placeholder (Claude Code fills it in; other agents
23such as Codex don't), resolve it before using any of them: take the
24output of `printenv CLAUDE_PLUGIN_ROOT` if it prints a path, otherwise
25walk up from this file's folder to the first folder that contains
26`references/brand-voice.md`, and if neither works, ask the user where
27this plugin lives. Use that absolute path in every file path and shell
28command below.
29 
301. **Check onboarding status.** Read
31 `${CLAUDE_PLUGIN_ROOT}/references/brand-voice.md`. If it doesn't exist,
32 or its first line is `<!-- MARKETING-SKILL:UNCONFIGURED -->`, ask the
33 user this plugin's 4 setup questions (priority task; content types to
34 produce; target audience + tone; default output format — same as
35 `/marketing-skill:marketing-setup`) before continuing, then save the
36 answers and flip the marker to `CONFIGURED` with today's date.
37 
382. **Confirm scope.**
39 - Which content pieces are ready to send (from `content-repurposer`,
40 `visual-brief-generator`, `short-form-video`, `community-post-generator`,
41 a row in `state/content-calendar.md`, or pasted directly)?
42 - If the source is `community-post-generator`, confirm its Go/No-Go
43 section actually said go — never send a draft that skill flagged as
44 blocked by the target community's own rules.
45 - If the user is pointing at a calendar entry (e.g. "send the queued
46 post for Tuesday"), read
47 `${CLAUDE_PLUGIN_ROOT}/state/content-calendar.md`, find the matching
48 row, then **read the post file named in that row's Notes column**
49 (`state/posts/<date>-<platform-slug>.md`) — that file, not the
50 calendar table, holds the actual content to send. A row with no
51 post-file path in Notes, or whose named file doesn't exist, isn't a
52 usable source — say so and stop rather than improvising content
53 from the short Topic/Hook label. Refuse to re-send a row already
54 marked `Sent` without the user explicitly confirming they want to
55 send it again.
56 - Destination webhook URL, if not already set via `MARKETING_WEBHOOK_URL`.
57 - Is this a real send, or a dry run to inspect the payload first?
58 
593. **Assemble the JSON payload** using the exact structure below.
60 
614. **Show a clear, human-readable confirmation before any real send —
62 never a raw JSON dump as the only thing shown — and get explicit user
63 go-ahead.** This step fires an external side effect the user may not
64 be able to easily undo — never skip it, even if the original request
65 sounded like blanket authorization (e.g. "just publish it"). **Silence,
66 a timeout, or no reply in this conversation is never a yes** — only an
67 actual affirmative reply counts, and if none arrives, stop here without
68 sending. This applies exactly the same way whether an interactive chat
69 or something else (a scheduled or automated trigger) invoked this
70 skill — there is no framing of "the run itself is standing
71 authorization" that substitutes for a real reply.
72 - **Single item:** show four labeled lines before anything else —
73 **Account** (which identity this goes out as: the destination
74 automation itself for a webhook handoff, e.g. "your n8n webhook," or
75 the specific platform identity for a direct post, e.g. "dev.to —
76 personal account, not the Organization" or "Instagram Business
77 account linked to [Page]"), **Destination** (URL, subreddit, channel,
78 or recipient), **Attachments** (each asset's type and source, or
79 "none"), and **Final Text** (the actual post/message body, in full,
80 not a summary) — then the JSON payload or script command underneath
81 as the technical detail, not the primary thing being confirmed.
82 - **Batch (more than one item ready at once):** never loop through
83 items one at a time asking "send this? ... send this? ..." — that
84 pattern gets a "yes" to everything without anyone actually reading
85 past the first item or two. Instead list every ready item together,
86 numbered, each with its own Account/Destination/Attachments/Final
87 Text breakout, and require the reply to name specific numbers (e.g.,
88 "1 and 3, yes — skip 2") rather than accepting an unqualified
89 "approve all"/"send them" with no numbers attached — treat an
90 unqualified blanket reply to a numbered list the same as silence for
91 any item it doesn't actually name. Cap a single batch at 10 items; if
92 more than 10 are ready, present them in waves of 10 or fewer instead
93 of one long list, so the format itself never invites skimming.
94 - **Staleness.** For any item sourced from a `state/content-calendar.md`
95 or `state/outreach-log.md` row, compare that row's `Date` to today.
96 Flag anything more than 14 days old (7 days for an outreach-log row,
97 since personalized prospect research ages faster) as **Stale** right
98 in the listing, not in a separate report — a batch mixing fresh and
99 stale items must show the flag per-row so it can't be missed inside a
100 larger "yes to all." Don't refuse to send a stale item outright; just
101 make its age impossible to approve past without seeing it.
102 - If the source is a `state/content-calendar.md` row, this is the
103 point where — and only where — its Status may move to `Approved`,
104 immediately after the affirmative reply and immediately before
105 sending, and only for the specific numbers actually named. No skill
106 in this plugin sets `Approved` at any other time; `content-calendar`
107 in particular never writes it.
108 
1095. **Send it:**
110 - Write the JSON payload to a temporary file (e.g. via `mktemp`).
111 - Resolve the webhook URL in this order: an explicit URL the user gave
112 > the `MARKETING_WEBHOOK_URL` environment variable > ask the user for
113 one if neither is available.
114 - Run via Bash:
115 `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file <tmp-path> --dry-run`
116 to preview, then, only after step 4's confirmation,
117 `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file <tmp-path> --confirmed`
118 to actually send. The script refuses to run without one of those two
119 flags — there is no default-sends behavior to be careful about.
120 - Remove the temporary file afterward regardless of outcome.
121 - If the source was a calendar row, update it to `Sent` in
122 `state/content-calendar.md` immediately after a successful send
123 (keep the row — this is the history log, don't delete it).
124 - **Alternate path (optional, not the default): Make MCP.** If a Make
125 MCP connector is connected in this session (tools like
126 `scenarios_run`, `scenarios_list`), you may call `scenarios_run`
127 directly instead of the webhook script, if the user prefers that.
128 Don't assume it's connected — check available tools first.
129 - **Alternate path (optional, not the default): direct platform
130 posting.** If the user wants to post straight to LinkedIn, X, Meta,
131 Reddit, Discord, Slack, Telegram, or dev.to rather than handing off to
132 their own automation, see `${CLAUDE_PLUGIN_ROOT}/scripts/publish_direct.py
133 --help`. It only works if the user has already set up real API
134 credentials for that platform (see the plugin's `docs/publishing.md`) — check with
135 `--dry-run` first, same confirmation rules as above apply, and be
136 explicit that this path is less proven than the webhook path since it
137 talks to live platform APIs this plugin's author can't verify from
138 here. For Reddit specifically, a successful API response doesn't
139 guarantee the post survives that subreddit's AutoModerator — confirm
140 the content actually came from a `community-post-generator` go (not a
141 no-go) before sending. For Discord, Slack, or Telegram specifically,
142 confirm the content came from a go too — and since a Discord or Slack
143 go is always `User-Supplied` confidence (this skill never
144 independently verified that server's/workspace's rules), and a
145 Telegram go is `User-Supplied` for a private channel/group but can be
146 `Primary`/`Secondary`/`Mixed` for a public one, check which tier this
147 specific draft actually rests on rather than assuming — that's one
148 more reason not to skip step 4's confirmation just because the
149 request "sounds routine." For Slack in particular, also confirm the
150 user actually has (or can get) the workspace permission a webhook
151 needs before treating the send as a quick step — it isn't guaranteed
152 self-serve the way Discord's is. For Telegram in particular, confirm
153 the bot has actually been added to the target chat by one of its
154 admins — creating the bot itself needs no approval from anyone, but
155 that's only the first of two gates, not the whole thing. For dev.to in
156 particular, confirm the content came from a go too, and if
157 `community-post-generator`'s research couldn't confirm the Code of
158 Conduct's self-promotion wording directly, say so before sending — a
159 2xx from dev.to's API means the article was accepted, not that a Tag
160 Moderator won't strip a tag from it afterward. **Product Hunt, Hacker
161 News, and Indie Hackers have no equivalent direct-send path, for
162 different reasons** (see `docs/publishing.md`): Product Hunt's write API requires
163 special approval from Product Hunt itself; Hacker News's API has no
164 write/submit endpoint at all, for anyone; and Indie Hackers' API
165 situation is unverified rather than confirmed either way, so it's
166 treated as manual-only too — all three always go out by pasting the
167 draft in manually (producthunt.com, news.ycombinator.com, or
168 indiehackers.com), never through this script, and that's permanent
169 for Hacker News, not a "not yet approved" situation. Discord, Slack,
170 Telegram, and dev.to each have a real send path but don't share one
171 friction profile — Discord's webhook needs no approval step, Slack's
172 app often needs Workspace Owner/Admin approval before creation,
173 Telegram's bot needs no approval to create but does need a chat admin
174 to add it before it can post, and dev.to's API key needs no approval
175 process found in this skill's research at all (the simplest of the
176 four, though that's about access, not about whether a Tag Moderator
177 is happy with the result) — so don't lump any of the four into "every
178 non-Reddit platform here is manual-only," and don't lump them into
179 each other's ease either.
180 - **YouTube, Instagram, and TikTok (`--platform youtube`/`instagram`/
181 `tiktok`)** are a different shape from every platform above — all
182 three post actual video, and confirm the content came from
183 `short-form-video`, not this skill improvising a caption. Don't
184 present any of the three with borrowed ease from another:
185 - **YouTube** is the most self-serve of the three: the user's own
186 Google Cloud OAuth client, authorized against their own channel, is
187 enough — no platform-side approval queue the way Reddit or TikTok
188 have. It's also the one platform here that uploads a local file
189 (`--video-path`) rather than pointing at a hosted URL; confirm the
190 asset entry has a real `path`, not just a `url`, before offering this
191 path. `--privacy-status` defaults to `private` in the script itself —
192 flag that plainly so "just post it" doesn't quietly mean "post it
193 where only you can see it."
194 - **Instagram Reels** needs the heaviest setup of the three: a Business
195 or Creator IG account linked to a Facebook Page, a Meta app with
196 `instagram_business_content_publish` actually approved (not just
197 Development Mode), and — unlike YouTube — the video already hosted
198 at a public URL, since Instagram's API fetches from a URL rather than
199 accepting an upload; confirm the asset entry has a real `url` before
200 offering this path, and that step 4's confirmation makes clear this
201 is a three-step, several-second-to-minutes process (container
202 creation, processing, then publish), not instant.
203 - **TikTok** needs an app approved for the `video.publish` scope, and —
204 critically — **until the app passes TikTok's own audit, TikTok only
205 accepts a private (`SELF_ONLY`) post to a creator account that is
206 itself set to private, and rejects anything else outright** — it
207 doesn't quietly downgrade a public request. The script checks the
208 requested privacy level against the creator's allowed options
209 (TikTok's required creator_info step) before posting, and stops if
210 it isn't one of them. Never present a TikTok send as reaching a public
211 audience without the user confirming their app's audit status
212 directly in TikTok's developer portal first; a 2xx response only
213 means TikTok accepted and queued the request, not that the video is
214 live, since TikTok fetches and processes it asynchronously
215 afterward. Also needs the video at a public URL, same as Instagram,
216 with that URL's domain pre-verified in TikTok's developer portal.
217 
2186. **Report the result** plainly: exit code, HTTP status if a real send
219 was made, and a one-line human-readable summary of what went where.
220 Never report success if the script exited non-zero.
221 
222## When to use this skill
223 
224Trigger on requests like:
225- "Publish this"
226- "Send this to n8n / Make"
227- "Trigger the automation"
228- "Fire the webhook"
229- "Send the queued post for [date]" / "approve and send calendar item"
230 
231## Payload structure (required)
232 
233```json
234{
235 "run_id": "<uuid or timestamp-based id>",
236 "timestamp": "<ISO 8601>",
237 "source_skill": "publish-pipeline",
238 "content": {
239 "linkedin_post": "...",
240 "twitter_thread": ["...", "..."],
241 "newsletter_blurb": "...",
242 "reddit_post": { "subreddit": "...", "title": "...", "body": "..." },
243 "discord_message": "...",
244 "slack_message": "...",
245 "telegram_message": "...",
246 "devto_post": { "title": "...", "body": "...", "tags": ["...", "..."] },
247 "youtube_short": { "title": "...", "description": "...", "tags": ["...", "..."], "privacy_status": "private|unlisted|public" },
248 "instagram_reel": { "caption": "..." },
249 "tiktok_video": { "caption": "..." }
250 },
251 "assets": [
252 { "type": "image|video", "description": "...", "url": "...or null", "path": "...or null", "prompt_reference": "...", "platform": "youtube_short|instagram_reel|tiktok_video|..." }
253 ],
254 "metadata": { "campaign": "...", "brand_voice_version": "..." }
255}
256```
257 
258Omit fields that don't apply (e.g. no `assets` if none were generated),
259but keep the top-level shape stable so downstream n8n/Make workflows can
260rely on it. For a `youtube_short`/`instagram_reel`/`tiktok_video` entry,
261the matching `assets` item needs either `path` (a local file — the only
262form YouTube's direct-post option in step 5 can use) or `url` (a
263publicly-reachable hosted link — required for Instagram's and TikTok's
264direct-post options in step 5, which fetch the video themselves rather
265than accepting an upload); which one is available depends on whether
266`short-form-video` generated the clip via a connected tool (may produce
267either) or the user is supplying their own already-hosted video.
268 
269## Formatting rules
270 
271- Never claim a send succeeded if the script returned a non-zero exit
272 code — report the actual failure and exit code instead.
273- Never fire a real send without showing the exact payload and getting
274 explicit confirmation first — and never pass `--confirmed` without
275 having received an actual affirmative reply in this conversation first.
276- Keep the payload's top-level keys stable across runs; add new content
277 under `content` rather than renaming existing keys.
278- Never write `Approved` to `state/content-calendar.md` except in step 4,
279 immediately after a real human reply, immediately before sending.
280- Never present a raw JSON payload as the only confirmation surface —
281 always break out Account, Destination, Attachments, and Final Text as
282 labeled lines first, for both a single item and every item in a batch.
283- Never list more than 10 items in one batch confirmation, and never treat
284 an unqualified "approve all"/"send them" reply to a numbered list as
285 approval for items it doesn't specifically name — only named numbers
286 move to `Approved`.
287- Never let an item more than 14 days old (7 for an outreach-log row) pass
288 through a confirmation listing without a visible Stale flag on that row.
289- For `youtube`/`instagram`/`tiktok` sends specifically: never report a
290 2xx response as "the video is live" — Instagram and TikTok both process
291 the video asynchronously after that response, so report exactly what the
292 script itself reported (queued/processing vs. confirmed published), and
293 never claim a TikTok send reached a public audience without the user
294 having confirmed their app's audit status.
295 
296## Example output
297 
298**Confirmation display, single item** (shown before the dry run below,
299never replaced by it):
300```
301Account: your n8n webhook (automation handoff, not a direct platform post)
302Destination: https://your-n8n-host/webhook/abc123
303Attachments: none
304Final Text:
305 Speed got us here. It won't be the only thing that keeps us here...
306 (full LinkedIn post text)
307 
308Reply to confirm this exact send, or say what to change first.
309```
310 
311**Confirmation display, batch of three, one stale:**
312```
313Three rows are ready to send. Reply with the numbers to approve (e.g.
314"1 and 3, skip 2") — an unqualified "send them all" won't be treated as
315approving any of these.
316 
3171. [Stale — queued 19 days ago] Account: your n8n webhook · Destination:
318 https://your-n8n-host/webhook/abc123 · Attachments: none · Final Text:
319 "Most teams lose an afternoon a month to manual exports..."
3202. Account: dev.to — personal account · Destination: dev.to (tags:
321 showdev, saas) · Attachments: none · Final Text: "I built this because
322 our own export flow was the thing customers complained about most..."
3233. Account: your n8n webhook · Destination: https://your-n8n-host/webhook/abc123
324 · Attachments: 1 image (state/assets/export-diagram.png) · Final Text:
325 "One-click export is live today for every customer..."
326```
327Reported this way regardless of how the request was phrased ("send the
328ready ones," "go ahead with today's batch") — a reply naming specific
329numbers is the only thing that moves those rows to `Approved`.
330 
331**Dry run** (the technical payload behind a single confirmed item):
332```
333$ python3 ${CLAUDE_PLUGIN_ROOT}/scripts/publish_webhook.py --payload-file /tmp/payload.json --dry-run
334=== DRY RUN: no request sent ===
335Method: POST
336URL: https://your-n8n-host/webhook/abc123
337Headers:
338 Content-Type: application/json
339Body (application/json, 412 bytes):
340{
341 "run_id": "2026-09-25T14-30-00",
342 "timestamp": "2026-09-25T14:30:00Z",
343 ...
344}
345=== END DRY RUN ===
346```
347Reported to the user as: "Dry run only — nothing was sent. Here's the
348exact payload and destination above; confirm to send for real."
349 
350**Real send, success:**
351```
352Sent to https://your-n8n-host/webhook/abc123
353Status: 200 OK
354Response: (empty body)
355```
356Reported as: "Sent successfully (200 OK) to your n8n webhook."
357 
358**Real send, failure:**
359```
360Webhook returned non-2xx status
361Status: 500 Internal Server Error
362Response: {"error": "workflow disabled"}
363```
364Reported as: "Send failed — the endpoint returned 500 (workflow
365disabled). Nothing further was attempted."
366 

Discussion

Alternatives

API and interface designGuides stable API and interface design. Use when designing APIs, module boundaries, or any public interface. Use when creating REST or GraphQL endpoints, defining type contracts between modules, or establishing boundaries between frontend and backend.Coding · MITContext7Pulls up-to-date, version-specific library docs and code examples into the prompt so the AI stops inventing old APIs.Coding · MITContext7 Documentation LookupFetch up-to-date documentation and code examples for any library, framework, SDK, CLI tool, or cloud service. Use whenever the user asks about a specific library — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot — because training data may not reflect recent API changes or version updates. Always use for: API syntax questions, configuration options, version migration issues, "how do I" questions mentioning a library name, debugging that involves library-specific behavior, setup instructions, and CLI tool usage. Use even when you think you know the answer. Do not rely on training data for API details, signatures, or configuration options — they are frequently out of date. Prefer this over web search for library documentation.Coding · MITAdaptyv Bio Foundry APIHow to use the Adaptyv Bio Foundry API and Python SDK for protein experiment design, submission, and results retrieval. Use this skill whenever the user mentions Adaptyv, Foundry API, protein binding assays, protein screening experiments, BLI/SPR assays, thermostability assays, or wants to submit protein sequences for experimental characterization. Also trigger when code imports `adaptyv`, `adaptyv_sdk`, or `FoundryClient`, or references `foundry-api-public.adaptyvbio.com`.Science · MIT