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:
- You never generate or rewrite copy.
- You never choose the platform mix — that's the user's ask or social-campaign's plan.
- 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. - 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
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 frommissing_envsrather than hardcoding. Canonical expectation:POSTIZ_API_KEY(required) andPOSTIZ_API_BASE(defaults tohttps://api.postiz.comif unset; the catalog loader returnsresolved_base: nullin that case and the skill applies the default). Stop.configured: true→ proceed to Step 2.
Step 2 — Resolve connected provider identifiers (cached)
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 (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):
- Look up the cached integrations from Step 2.
- Find all identifiers whose display name or known-alias matches the user's phrase. For LinkedIn, both
linkedinandlinkedin-pagematch. - If exactly one matches → use it.
- If multiple match → present the options via AskUserQuestion. "You have both
linkedin(personal) andlinkedin-page(company) connected. Which?" Let the user pick; cache their choice in.mktg/cache/postiz-aliases.jsonfor future sessions. - 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.
- Run the On Activation steps above. Step 2b resolves any user-said platform names ("LinkedIn") to postiz identifiers (
linkedinorlinkedin-pageor both). - Build a single-item
publish.json. The manifest's top-levelnameis the campaign identifier (the adapter threads this into sent-marker keys — do NOT also putcampaigninmetadata; the adapter ignores it):{ "name": "ad-hoc-{timestamp}", "items": [{ "type": "social", "adapter": "postiz", "content": "<the user's text verbatim>", "metadata": { "providers": ["linkedin", "bluesky"] } }] } mktg publish --adapter postiz --dry-run --input '<manifest>' --json— preview.- On user confirm:
mktg publish --adapter postiz --confirm --input '<manifest>' --json. - 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".
- Run On Activation.
- 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. - Build a multi-item
publish.jsonwith one item per (file × post). Each item'smetadata.providersis a single-element array matching the file's platform. - Same dry-run → confirm → report flow as Mode A.
Example 1 — LinkedIn-only draft
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)
/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 p