Skip to content
Back to skills

Postiz

ASecurity

Schedule social posts to any of 30+ providers via a running postiz instance (hosted at api.postiz.com or self-hosted). Use this skill whenever someone wants to post, schedule, or publish a draft to LinkedIn, Reddit, Bluesky, Mastodon, Threads, Instagram, TikTok, YouTube, Pinterest, Discord, Slack, or any non-Twitter social channel. ALWAYS chain content-atomizer first for platform-specific copy — this skill is a distribution layer only, it does not rewrite copy. For Twitter/X threads, use type...

  • 31 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 19, 2026
content-marketinggobashrailsapidatabasebackend

Works with

  • api

Security analysis

A100/100

Scanned September 19, 2026

npx -y skills add MoizIbnYousaf/marketing-cli --skill postiz --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Postiz?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Postiz
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/moizibnyousaf-postiz/badge)](https://www.skillsdirectory.com/skills/moizibnyousaf-postiz)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: postiz
description: >-
  Schedule social posts to any of 30+ providers via a running postiz instance
  (hosted at api.postiz.com or self-hosted). Use this skill whenever someone
  wants to post, schedule, or publish a draft to LinkedIn, Reddit, Bluesky,
  Mastodon, Threads, Instagram, TikTok, YouTube, Pinterest, Discord, Slack, or
  any non-Twitter social channel. ALWAYS chain content-atomizer first for
  platform-specific copy — this skill is a distribution layer only, it does
  not rewrite copy. For Twitter/X threads, use typefully instead (better
  thread UX). Triggers: "post to linkedin", "post to reddit", "post to
  bluesky", "post to mastodon", "post to threads", "schedule via postiz".
category: distribution
tier: must-have
layer: distribution
reads:
  - brand/voice-profile.md
depends-on:
  - content-atomizer
env_vars:
  - POSTIZ_API_KEY
  - POSTIZ_API_BASE
triggers:
  - post to linkedin
  - post to reddit
  - post to bluesky
  - post to mastodon
  - post to threads
  - schedule via postiz
allowed-tools:
  - Bash(mktg publish *)
  - Bash(mktg catalog *)
  - Bash(mktg doctor *)
---

# Postiz — Social Distribution Layer

You schedule pre-written social posts to LinkedIn, Reddit, Bluesky, Mastodon, Threads, Instagram, TikTok, YouTube, Pinterest, Discord, Slack, and any other provider a running postiz instance supports. You do NOT write copy. You do NOT tailor per platform. Upstream skills (content-atomizer, social-campaign) deliver platform-specific text; you turn it into drafts in postiz.

For Twitter/X threads, defer to `typefully` — its thread UX is canonical. You handle everything else.

## North Star

Postiz is a distribution execution layer in the mktg playbook:

1. You never generate or rewrite copy.
2. You never choose the platform mix — that's the user's ask or social-campaign's plan.
3. You never retry a failed post without first consulting the sent-marker file at `.mktg/publish/{campaign}-postiz.json` — postiz has no idempotency key, and retries duplicate.
4. You always use `mktg publish --adapter postiz` — never call the postiz API directly from the skill. The adapter owns rate-limit handling, 401/429 envelopes, and the two-step integration.id resolution.

## On Activation

Run these 4 steps before anything else. Each step has a fallback that keeps the skill useful even when postiz is absent.

### Step 1 — Verify the catalog is registered and configured

```bash
mktg catalog info postiz --json --fields configured,missing_envs,auth.credential_envs,resolved_base
```

`catalog info <name>` returns the full `CatalogEntry` plus computed `configured`/`missing_envs`/`resolved_base` fields. The command errors with exit code 1 (NOT_FOUND) if the catalog is not in `catalogs-manifest.json` — that's the "not registered" signal.

- Exit code 1 → postiz catalog is not registered. Tell the user: `mktg catalog add postiz --confirm`. Stop.
- `configured: false` → env vars are missing. Build the fix string from `missing_envs` rather than hardcoding. Canonical expectation: `POSTIZ_API_KEY` (required) and `POSTIZ_API_BASE` (defaults to `https://api.postiz.com` if unset; the catalog loader returns `resolved_base: null` in that case and the skill applies the default). Stop.
- `configured: true` → proceed to Step 2.

### Step 2 — Resolve connected provider identifiers (cached)

```bash
mktg publish --adapter postiz --list-integrations --json
```

Returns `{ adapter, integrations: [{ id, identifier, name, profile, disabled, picture, customer }, ...] }` from `GET /public/v1/integrations`. Cache in `.mktg/cache/postiz-integrations.json` (TTL 15 minutes). Use this to validate the user's requested providers before publish — fail fast if they ask for `"pinterest"` and it isn't connected.

**Critical: `identifier` is a postiz-native provider-module key, NOT a platform display name.** Single-variant providers collapse to the expected string (`"reddit"`, `"bluesky"`, `"mastodon"`, `"threads"`), but some platforms ship multiple identifiers:

| Platform | Possible postiz `identifier` values |
|---|---|
| LinkedIn | `linkedin` (personal profile), `linkedin-page` (company page) — distinct integrations |
| YouTube | may vary by postiz version (channel vs shorts flavors) |
| Other | any string postiz's provider module exports |

The adapter does **exact-match** on `identifier` (no alias layer) — if the user says *"post to LinkedIn"* and the instance only has `linkedin-page` connected, the adapter will refuse `"linkedin"`. Alias handling is your job in Step 2b.

### Step 2b — Handle user friendly-name aliasing (skill-layer only)

When the agent hears *"post to LinkedIn"* (a platform name, not a postiz identifier), map the friendly name to the right postiz identifier(s):

1. Look up the cached integrations from Step 2.
2. Find all identifiers whose display name or known-alias matches the user's phrase. For LinkedIn, both `linkedin` and `linkedin-page` match.
3. If exactly one matches → use it.
4. If multiple match → present the options via AskUserQuestion. *"You have both `linkedin` (personal) and `linkedin-page` (company) connected. Which?"* Let the user pick; cache their choice in `.mktg/cache/postiz-aliases.json` for future sessions.
5. If zero match → error: *"'LinkedIn' is not connected in this postiz instance. Connected: {enumeration}. Connect it in the postiz UI first."*

**Alias resolution lives here and NEVER in the adapter** — the adapter takes whatever you pass as `metadata.providers` and resolves against the live integrations verbatim.

### Step 3 — Chain to content-atomizer FIRST if content is raw

If the user's input is a blog post, article, or long-form source (length > 500 chars or file extension `.md` in `marketing/content/`), you MUST chain `/content-atomizer` first to produce platform-native copy. Feed the atomizer's output files (`linkedin-posts.md`, `reddit-posts.md`, etc.) into this skill.

If the user's input is already platform-tailored (short-form text targeting one platform), skip atomization and proceed.

### Step 4 — Tone-check against voice-profile (sanity guard only)

Read `brand/voice-profile.md` if it exists. Before building the publish manifest, verify each post:
- Does the tone spectrum match (not wildly off-brand)?
- Is the length within the platform's character limit **and** within the voice profile's stated sentence-rhythm range?

If a check fails, surface a warning to the user and ask before proceeding. **Never rewrite.** That's content-atomizer's job — bounce back there.

## How to Use

This skill runs in one of two modes. Pick by looking at the input.

### Mode A — Single post to one or more platforms

User says: *"Post this to LinkedIn and Bluesky"* + paste text.

1. Run the On Activation steps above. Step 2b resolves any user-said platform names ("LinkedIn") to postiz identifiers (`linkedin` or `linkedin-page` or both).
2. Build a single-item `publish.json`. The manifest's top-level `name` is the campaign identifier (the adapter threads this into sent-marker keys — do NOT also put `campaign` in `metadata`; the adapter ignores it):
   ```json
   {
     "name": "ad-hoc-{timestamp}",
     "items": [{
       "type": "social",
       "adapter": "postiz",
       "content": "<the user's text verbatim>",
       "metadata": { "providers": ["linkedin", "bluesky"] }
     }]
   }
   ```
3. `mktg publish --adapter postiz --dry-run --input '<manifest>' --json` — preview.
4. On user confirm: `mktg publish --adapter postiz --confirm --input '<manifest>' --json`.
5. Report per-provider status from the adapter's response envelope.

### Mode B — Campaign from content-atomizer output

User says: *"Schedule my atomized blog post to LinkedIn, Reddit, and Threads"*.

1. Run On Activation.
2. Read `marketing/social/{source-slug}/linkedin-posts.md`, `reddit-posts.md`, `threads-posts.md`. Each file contains one or more posts with YAML frontmatter matching the atomizer's contract.
3. Build a multi-item `publish.json` with one item per (file × post). Each item's `metadata.providers` is a single-element array matching the file's platform.
4. Same dry-run → confirm → report flow as Mode A.

### Example 1 — LinkedIn-only draft

```bash
mktg publish --adapter postiz --confirm --input '{
  "name": "linkedin-announcement",
  "items": [{
    "type": "social",
    "adapter": "postiz",
    "content": "We shipped v2.0 today. Here is what changed...",
    "metadata": { "providers": ["linkedin"] }
  }]
}' --json
```

### Example 2 — Cross-platform (atomizer output + postiz distribution)

```bash
/content-atomizer marketing/content/blog/v2-announcement.md
# Atomizer writes marketing/social/v2-announcement/{linkedin,reddit,threads}-posts.md

# Build a publish.json with one item per atomized post, then:
mktg publish --adapter postiz --dry-run --json < <tmp_publish_manifest.json>
# Review → confirm:
mktg publish --adapter postiz --confirm --json < <tmp_publish_manifest.json>
```

### Example 3 — Mixed batch (postiz + typefully)

This is the social-campaign Phase 5 path. You do NOT handle this standalone — the orchestrator in social-campaign routes per-post per the Phase 5 routing table.

## Inputs

| Input | Required | Shape | Notes |
|---|---|---|---|
| `content` | yes | string | Platform-tailored post text. Must fit the target platform's character limit (LinkedIn 3000, Reddit no limit, Bluesky 300, Mastodon 500, Threads 500). Failing this → Step 4 tone-check surfaces a warning. |
| `metadata.providers` | yes | string[] | **Postiz identifiers, not platform display names.** Canonical examples: `"linkedin"` (personal), `"linkedin-page"` (company page), `"reddit"`, `"bluesky"`, `"mastodon"`, `"threads"`, `"instagram"`, `"tiktok"`, `"youtube"`, `"pinterest"`, `"discord"`, `"slack"`. Must match `identifier` exactly from `GET /public/v1/integrations`. Unknown identifiers fail fast per-item with the connected list in the error. Alias resolution (user says "LinkedIn" → you pick `linkedin` vs `linkedin-page`) happens in Step 2b, never in the adapter. |
| `metadata.mediaPaths` | no | string[] | v2 stub — **ignored in v1** (draft-only, images hardcoded to `[]` in the adapter). Don't populate this field in v1 manifests; it has no effect. |

**Campaign identifier does NOT live in `metadata`.** The adapter reads it from the publish manifest's top-level `name` field. If you set `metadata.campaign`, the adapter silently ignores it. Never emit `metadata.campaign`.

**Validation performed at publish-time, before the network:**
- Control chars in `content` are rejected via the base pipeline's `rejectControlChars`.
- For each item, any identifier in `providers` that isn't in the cached `/public/v1/integrations` result produces a per-item `failed` result with `detail: "Unconnected provider(s): X. Connected: {list}. Connect in the postiz UI first."`. The adapter does NOT short-circuit the whole batch — items with fully-connected providers still publish.
- Missing `metadata.providers` fails per-item with `detail: "Missing item.metadata.providers[] — add at least one postiz identifier"`.

## Outputs

The adapter returns the standard `AdapterResult` envelope:

```json
{
  "adapter": "postiz",
  "items": 3,
  "published": 3,
  "failed": 0,
  "errors": [],
  "results": [
    { "item": 0, "status": "published", "detail": "draft id=abc123 provider=linkedin" },
    { "item": 0, "status": "published", "detail": "draft id=abc124 provider=reddit" },
    { "item": 0, "status": "skipped",   "detail": "dedupe: sent-marker match" }
  ]
}
```

**Per-provider fan-out:** one input item with N providers becomes N entries in `results[]`, each with its own status. A partial failure (Reddit rate-limited, LinkedIn succeeded) does NOT fail the whole item.

**Exit-code aggregation:** the top-level `publish` command exits **0** whenever at least one item succeeds. Top-level non-zero exit only when ZERO items succeed. This matches how `typefully` and `resend` adapters already behave. Inspect `results[]` for per-item outcomes — a successful publish run can still have failed providers inside it.

**Sent-marker side-effect:** successful publishes append to `.mktg/publish/<campaign>-postiz.json` with a stable hash key. Re-running the same campaign is a no-op for already-sent items.

**Platform coverage table (UI hint):** after a run, render a small confirmation table showing per-provider status, draft ID, and notes.

## Anti-Patterns

| Anti-Pattern | Why it fails | Instead |
|---|---|---|
| Generate copy in this skill | Voice drift — each skill that rewrites copy diverges from `voice-profile.md`. We have one generation path (content-atomizer) for a reason: platform-specific copy rules live there. If you inline paraphrasing here, the voice profile's guardrails don't fire. | Chain `/content-atomizer` first. Feed its file output into this skill. |
| Retry a failed post without checking the sent-marker | Postiz has no idempotency key and no webhooks. A retry without checking `.mktg/publish/<campaign>-postiz.json` duplicates the draft — if the user accepted the first one before the adapter crashed, you just ship the post twice. | Always call the adapter. The adapter consults the sent-marker *before* POSTing. Never construct your own HTTP request. |
| Call the postiz API from within this skill | Every HTTP caller reinvents error handling. The adapter owns 401 (bad key), 429 (rate limit with Retry-After), 400 (bad provider), and network timeouts — each with structured `fix` fields. Skipping the adapter loses these. | `mktg publish --adapter postiz ...`. Always. |
| Pass postiz `integration.id` DB primary keys in `metadata.providers` | `integration.id` values are database rows — they change between environments (hosted vs self-host vs fresh install). A hardcoded `"42"` breaks the moment the user rotates their instance. | Pass postiz **identifiers** (`"linkedin"`, `"linkedin-page"`, `"bluesky"`) verbatim — these are provider-module keys that are stable across instances. The adapter does the identifier → id lookup. |
| Translate user-said platform names to identifiers in the adapter | Alias drift: the moment postiz ships a new provider module, any in-adapter alias layer becomes wrong. The adapter does exact-match on `identifier`; aliasing is a SKILL-layer concern. | Handle alias resolution in Step 2b of On Activation (this skill). Pass exact postiz identifiers to the adapter. |
| Populate `metadata.campaign` in publish items | Redundant: the adapter threads `manifest.name` into sent-marker keys. If you also set `metadata.campaign`, the adapter ignores it — confusion for the agent about which wins. | Use the top-level `manifest.name` field as the campaign identifier. Don't duplicate into `metadata`. |
| Populate `metadata.mediaPaths` in v1 | v1 is draft-only; the adapter hardcodes images to `[]`. Setting `mediaPaths` silently no-ops and wastes tokens. | Leave `mediaPaths` out of v1 manifests. Image support lands in v2 alongside `schedule` / `now` post types. |
| Skip dry-run and go straight to `--confirm` | 30 drafts/hour is a tight rate limit; burning quota on broken manifests is wasteful. Dry-run validates providers and env vars without touching the network. | Always dry-run first. |
| Use this skill for X threads | Typefully owns threads — better UX, per-tweet character validation, and thread-unroll view. Postiz can post to X but thread authoring is weak. | Twitter/X single post OR thread → `typefully`. Everything else → this skill. |

## Progressive Enhancement

| Level | State | Experience |
|---|---|---|
| L0 | No brand files, no postiz configured | Activation step 1 fails with `configured: false` and emits a clean fix: set `POSTIZ_API_KEY` and optionally `POSTIZ_API_BASE`. No network calls, no partial state, no crash. Exit code 3 (dependency missing). |
| L1 | `POSTIZ_API_KEY` set, `POSTIZ_API_BASE` defaulted to `https://api.postiz.com` | The skill works for every platform the hosted instance supports. `voice-profile.md` absent → tone-check is a no-op. User feeds raw copy → warning suggests chaining content-atomizer but doesn't block. |
| L2 | + `voice-profile.md` | Tone-check fires. Posts that violate length or tone spectrum surface warnings before the publish confirm prompt. Still functional without atomization. |
| L3 | + `content-atomizer` has run and written `marketing/social/{slug}/` | Mode B is available. Batch-publish N atomized posts across M platforms with one confirm. |
| L4 | + full brand + postiz configured + content-atomizer chain | End-to-end: user says "launch my blog post on LinkedIn/Reddit/Bluesky", orchestrator routes, atomizer writes, postiz publishes. Voice honored; sent-markers prevent dupes; partial failures roll forward. |

This skill never fails silently. At every level, the user either gets structured output (publish succeeded/failed) or a structured error with a `fix` field. Absent postiz, the skill errors cleanly — no half-finished drafts.

## Related Skills

- **content-atomizer** — generates platform-specific copy. MUST run before this skill for long-form source content.
- **typefully** — X/Twitter thread handling. Complements this skill (X threads → typefully; everything else → this skill).
- **social-campaign** — orchestrator that chains strategy/write/review/design/schedule. In Phase 5, it picks the scheduling backend per platform.
- **brand-voice** — writes `voice-profile.md`, which this skill reads as a sanity guard in Step 4.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.