Install
openclaw skills install @norbertlevente/postyPosty schedules and publishes social posts from the command line to Facebook, Instagram, X, LinkedIn, TikTok, YouTube, Threads, Bluesky, Telegram, Discord and Slack.
openclaw skills install @norbertlevente/postyThe npm package is posty-cli. The command it provides is posty — the
package name and the command name are deliberately different, because
npm install -g posty installs somebody else's package: the bare name
posty on npmjs.com belongs to an unrelated UK-postcode library. Never run it
and never tell a user to.
Install it from npm:
npm install -g posty-cli
posty auth:login # opens the browser to approve the login
To work from a clone instead — only if you are changing the CLI itself:
git clone https://github.com/norbertlevente/posty-agent.git
cd posty-agent
pnpm install && pnpm run build
npm link # provides the `posty` command
official website: https://posty.hu API base: https://api.posty.hu (also served at https://posty.hu/api)
| Property | Value |
|---|---|
| name | posty |
| description | CLI for scheduling and publishing posts through the Posty public API |
| allowed-tools | Bash(posty:*) |
Only these. Do not offer a user anything else, and do not try to post to it. Posty's server carries inherited code for many more providers; that code is not enabled and not supported.
| Channel | identifier |
|---|---|
| X (Twitter) | x |
| Facebook (Pages) | facebook |
instagram | |
| LinkedIn (member feed) | linkedin |
| TikTok | tiktok |
| YouTube | youtube |
| Threads | threads |
| Bluesky | bluesky |
| Telegram | telegram |
| Discord | discord |
| Slack | slack |
integrations:list is the truth. The table says what Posty supports; that
command says what THIS user has actually connected. Check it before you
schedule anything, and never post to an id that is not in its output.
Never assume another provider exists. If a user asks for Mastodon, Reddit,
Pinterest, Google Business Profile, Medium, Dev.to, WordPress, Tumblr,
Farcaster or anything else, the answer is that Posty does not offer it. Do not
build a --settings payload for it.
Rule 1 — Authenticate before anything. All commands fail without valid credentials.
Rule 2 — Every file passed to -m (or to image/media fields in JSON mode) MUST first go through posty upload. Raw filesystem paths (image.jpg, video.mp4) and external URLs (https://example.com/...) are NOT accepted by the publishing pipeline. TikTok, Instagram, YouTube, and most other providers reject anything that isn't a Posty-verified URL. Always:
RESULT=$(posty upload <file>)
URL=$(echo "$RESULT" | jq -r '.path')
posty posts:create ... -m "$URL" ...
If you see -m "something.jpg" anywhere below, treat it as shorthand for "the .path you got back from posty upload something.jpg" — never a raw local file.
Every command prints its JSON result — and nothing else — to stdout;
status lines, warnings and errors go to stderr, and every failure exits 1. So
posty upload x.jpg | jq -r '.path' is always safe, with no stripping needed.
Unknown flags are an error, not silently ignored — if a flag is rejected, it
does not exist; do not retry with variations.
You MUST authenticate before running any Posty CLI command. All commands will fail without valid credentials.
Before doing anything else, check auth status:
posty auth:status
If not authenticated, either:
posty auth:login — opens a browser, the human approves itexport POSTY_API_KEY=your_api_keyDo NOT proceed with any other commands until authentication is confirmed.
Keys are per person, named, and scoped. They are stored hashed and shown exactly once, at creation, in the web app under Settings → Developers. A lost key cannot be recovered — only rotated.
Two limits apply to every request, and both explain 403s that look like bugs:
posts:read, posts:draft,
posts:publish, channels:read, channels:write, media:write,
analytics:read. A route refuses a key that lacks its scope.A key can also be granted only some workspaces and only some channels. A request naming a channel outside the grant is refused even when the scope is right.
When you hit a 403, read the message. It names the missing scope. Do not retry, and do not try a different route to get around it — ask the user to mint a key with the scope, or to have someone with the right role do it.
The fundamental pattern for using Posty CLI:
audioSearch; Discord and Slack channels){"missing": true}, run posts:missing to list provider content, then posts:connect to link it# 1. Authenticate
posty auth:status
# If not authenticated: posty auth:login (no client id/secret — it is a device flow)
# 2. Discover
posty integrations:list
posty integrations:settings <integration-id>
# 3. Fetch (if needed)
posty integrations:trigger <integration-id> <method> -d '{"key":"value"}'
# 4. Prepare
posty upload image.jpg
# or, when the file is on the person's phone / not on this machine:
posty upload:link # show the url to the person, they drop the files
posty upload:files <id> # what arrived; use each .path in -m
# 5. Post
posty posts:create -c "Content" -m "image.jpg" -i "<integration-id>"
# 6. Analyze
posty analytics:platform <integration-id> -d 30
posty analytics:post <post-id> -d 7
# 7. Resolve (if analytics returns {"missing": true})
posty posts:missing <post-id>
posty posts:connect <post-id> --release-id "<content-id>"
Option 1: Device login (recommended)
# Opens a browser. No client ID or secret — the human approves the code shown.
posty auth:login
# Check auth status (verifies credentials are still valid)
posty auth:status
# Logout (remove stored credentials)
posty auth:logout
How it works, so you can explain it if the user asks: the CLI calls
POST /device/code, prints a short user code and opens the approval page in
the browser, then polls POST /device/token until the human — signed in to
Posty — approves it and chooses which workspace and which channels the
resulting key may reach. This runs inside Posty's own backend; there is no
separate auth service.
Credentials land in ~/.posty/credentials.json (0600, in a 0700
directory) and take priority over POSTY_API_KEY. The token response also
carries the API base, so the CLI points itself at the right host without a
release.
On a TTY, a successful login also offers to save a scheduling timezone
(detected from the machine, confirmed by the human) into
~/.posty/config.json. Off-TTY — i.e. when you, an agent, run it — no prompt
appears: pass --timezone on date-taking commands, or run
posty config:set timezone Europe/Budapest once. See "Date Handling".
Option 2: API Key
export POSTY_API_KEY=your_api_key_here
Mint it in the web app under Settings → Developers. It is shown once.
Optional custom API URL:
export POSTY_API_URL=https://api.posty.hu
https://api.posty.hu is live. The same API is also served under
https://posty.hu/api, which is the CLI's built-in fallback. Anything else you
may have read about docs.posty.hu, cdn.posty.hu or mcp.posty.hu is
wrong — those hostnames do not resolve.
posty upload needs the file on disk. When the person has it on their phone,
or you cannot see their filesystem, do NOT ask them to host it somewhere
public. Mint a link:
posty upload:link
# → {"id":"kR3mQ7xP2nLa","url":"https://posty.hu/feltoltes/…","expiresAt":"…"}
Show the url to the person verbatim and say: open it, drop the image or
video, come back and tell me it is done. It works signed out and on a phone.
When they say done:
posty upload:files kR3mQ7xP2nLa
# → {"status":"ready","count":1,"files":[{"id":"…","name":"clip.mp4","path":"https://…","type":"video"}]}
Pass each path to posts:create -m. status: "empty" is not an error: they
have not finished, or spoke before the last file landed. Ask them to check the
page says "uploaded" and run it again. The link lives two hours; mint a new one
if it expires.
Tiered, per key, per route, per hour. Each route keeps its own bucket, so a
polling loop on posts:list cannot starve posts:create.
| Routes | Limit |
|---|---|
Reads — posts:list, integrations:list, integrations:groups, integrations:settings, posts:missing, posts:find-slot, notifications | 600/h (the API_LIMIT default; production sets 600) |
Publish and delete — posts:create, posts:delete, posts:status, integrations:trigger | 60/h |
Analytics and channel refresh — analytics:platform, analytics:post, /social/:integration | 30/h |
Uploads — upload, upload-from-url | 30/h |
Upload links — upload:link 60/h, upload:files 600/h |
The bucket is keyed on the hash of the presented key, so rotating a key starts
a fresh allowance and the old secret's allowance dies with it. Exceeding a
limit returns 429. Back off; do not retry in a tight loop — a retry
storm just burns the next hour's allowance too.
# List all connected integrations
posty integrations:list
# List integrations belonging to a specific group (customer)
posty integrations:list --group <group-id>
# List all groups (customers) as {id, name}
posty integrations:groups
# Get settings schema for specific integration
posty integrations:settings <integration-id>
# Trigger integration tool to fetch dynamic data
posty integrations:trigger <integration-id> <method-name>
posty integrations:trigger <integration-id> <method-name> -d '{"param":"value"}'
# Simple post (date is REQUIRED)
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "integration-id"
# Draft post
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" -t draft -i "integration-id"
# Post with media (upload each file FIRST — see Rule 2)
IMG1=$(posty upload img1.jpg | jq -r '.path')
IMG2=$(posty upload img2.jpg | jq -r '.path')
posty posts:create -c "Content" -m "$IMG1,$IMG2" -s "2026-12-31T12:00:00Z" -i "integration-id"
# Post with comments (each with own media — every file uploaded first)
MAIN=$(posty upload main.jpg | jq -r '.path')
C1=$(posty upload comment1.jpg | jq -r '.path')
C2A=$(posty upload comment2.jpg | jq -r '.path')
C2B=$(posty upload comment3.jpg | jq -r '.path')
posty posts:create \
-c "Main post" -m "$MAIN" \
-c "First comment" -m "$C1" \
-c "Second comment" -m "$C2A,$C2B" \
-s "2026-12-31T12:00:00Z" \
-i "integration-id"
# Multi-platform post
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "$X_ID,$FB_ID,$THREADS_ID"
# Platform-specific settings
posty posts:create \
-c "Content" \
-s "2026-12-31T12:00:00Z" \
--settings '{"who_can_reply_post":"everyone"}' \
-i "$X_ID"
# Complex post from JSON file
posty posts:create --json post.json
# List posts (defaults to last 30 days to next 30 days)
posty posts:list
# List posts in date range
posty posts:list --startDate "2026-01-01T00:00:00Z" --endDate "2026-12-31T23:59:59Z"
# Delete post
posty posts:delete <post-id>
# Change post status (draft ↔ schedule)
posty posts:status <post-id> --status draft # Move back to draft, terminates any running publish workflow
posty posts:status <post-id> --status schedule # Promote a draft into the publishing queue (uses the post's stored date)
# Get platform analytics (default: last 7 days)
posty analytics:platform <integration-id>
# Get platform analytics for last 30 days
posty analytics:platform <integration-id> -d 30
# Get post analytics (default: last 7 days)
posty analytics:post <post-id>
# Get post analytics for last 30 days
posty analytics:post <post-id> -d 30
Returns an array of metrics (e.g. Followers, Impressions, Likes, Comments) with daily data points and percentage change over the period.
⚠️ IMPORTANT: Missing Release ID Handling
If analytics:post returns {"missing": true} instead of an analytics array, the post was published but the platform didn't return a usable post ID. You must resolve this before analytics will work:
# 1. analytics:post returns {"missing": true}
posty analytics:post <post-id>
# 2. Get available content from the provider
posty posts:missing <post-id>
# Returns: [{"id": "7321456789012345678", "url": "https://...cover.jpg"}, ...]
# 3. Connect the correct content to the post
posty posts:connect <post-id> --release-id "7321456789012345678"
# 4. Now analytics will work
posty analytics:post <post-id>
Some platforms (e.g. TikTok) don't return a post ID immediately after publishing. When this happens, the post's releaseId is set to "missing" and analytics are unavailable until resolved.
# List recent content from the provider for a post with missing release ID
posty posts:missing <post-id>
# Connect a post to its published content
posty posts:connect <post-id> --release-id "<content-id>"
Returns an empty array if the provider doesn't support this feature or if the post doesn't have a missing release ID.
⚠️ IMPORTANT: Always upload files to Posty before using them in posts. Many platforms (TikTok, Instagram, YouTube) require verified URLs and will reject external links.
# Upload file and get URL
posty upload image.jpg
# EXACTLY EIGHT TYPES ARE ACCEPTED. Anything else returns 400 "Unsupported
# file type." — the server sniffs the magic bytes, so renaming the file does
# not help.
#
# image/jpeg image/png image/gif image/webp
# image/avif image/bmp image/tiff (max 10 MB)
# video/mp4 (max 1 GB)
#
# NOT accepted: MOV, MKV, WEBM, AVI, SVG, PDF, DOC/DOCX, and every audio
# format. Convert first: ffmpeg -i clip.mov -c:v libx264 -c:a aac clip.mp4
# See SUPPORTED_FILE_TYPES.md.
# Workflow: Upload → Extract URL → Use in post
VIDEO=$(posty upload video.mp4)
VIDEO_PATH=$(echo "$VIDEO" | jq -r '.path')
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$VIDEO_PATH" -i "tiktok-id"
Most supported channels have no tools at all. Of the channels in the table
at the top of this file, three expose a tool: Instagram (audioSearch),
Discord (channels) and Slack (channels). X, Facebook, Threads, Bluesky,
YouTube, LinkedIn, TikTok and Telegram expose none — integrations:settings
returns an empty tools array for them, and integrations:trigger on them
returns 404 Tool not found.
Never guess a method name. Read the tools array from
integrations:settings first; the methodName there is the only string
integrations:trigger will accept.
IG_ID=$(posty integrations:list | jq -r '.[] | select(.identifier=="instagram") | .id')
# What can this channel actually do?
posty integrations:settings "$IG_ID" | jq '.output.tools'
# [ { "methodName": "audioSearch", "description": "Search audio …", "dataSchema": [ … ] } ]
Instagram — find audio for a Reel:
# Empty query returns trending audio. type is "music" (default) or "original_sound".
AUDIO=$(posty integrations:trigger "$IG_ID" audioSearch -d '{"q":"lofi","type":"music"}')
AUDIO_ID=$(echo "$AUDIO" | jq -r '.output[0].id')
VIDEO=$(posty upload reel.mp4 | jq -r '.path')
posty posts:create \
-c "Reel caption" \
-s "2026-12-31T12:00:00Z" \
--settings "{\"post_type\":\"post\",\"audio\":{\"id\":\"$AUDIO_ID\"}}" \
-m "$VIDEO" \
-i "$IG_ID"
# Upload multiple files
VIDEO_RESULT=$(posty upload video.mp4)
VIDEO_PATH=$(echo "$VIDEO_RESULT" | jq -r '.path')
THUMB_RESULT=$(posty upload thumbnail.jpg)
THUMB_PATH=$(echo "$THUMB_RESULT" | jq -r '.path')
# Use in post
posty posts:create \
-c "Check out my video!" \
-s "2026-12-31T12:00:00Z" \
-m "$VIDEO_PATH" \
-i "tiktok-id"
# Upload every image first (Rule 2)
INTRO=$(posty upload intro.jpg | jq -r '.path')
P1=$(posty upload point1.jpg | jq -r '.path')
P2=$(posty upload point2.jpg | jq -r '.path')
OUTRO=$(posty upload outro.jpg | jq -r '.path')
posty posts:create \
-c "🧵 Thread starter (1/4)" -m "$INTRO" \
-c "Point one (2/4)" -m "$P1" \
-c "Point two (3/4)" -m "$P2" \
-c "Conclusion (4/4)" -m "$OUTRO" \
-s "2026-12-31T12:00:00Z" \
-d 5 \
-i "$X_ID"
# -d is the delay between comments in MINUTES (here: 5 minutes)
JSON mode takes the API's real request body — type, date, shortLink,
tags, and a posts array where each element names an integration, its
value thread and its settings. There is no "integrations" key and no
"provider" key; a full working file is
examples/multi-platform-post.json.
IMG=$(posty upload sale.jpg | jq -r '.path')
cat > campaign.json << EOF
{
"type": "schedule",
"date": "2026-12-01T09:00:00Z",
"shortLink": false,
"tags": [],
"posts": [
{
"integration": { "id": "<x-id>" },
"value": [
{ "content": "Holiday sale! 🎄 20% off every plan. #sale", "image": [ { "id": "a", "path": "$IMG" } ] }
],
"settings": { "who_can_reply_post": "everyone" }
},
{
"integration": { "id": "<facebook-id>" },
"value": [
{ "content": "Our holiday sale is live — 20% off every plan until the end of December. Details on the website!", "image": [ { "id": "b", "path": "$IMG" } ] }
],
"settings": { "post_type": "post" }
}
]
}
EOF
posty posts:create --json campaign.json
#!/bin/bash
INTEGRATION_ID="$X_ID"
CONTENT="Your post content here"
# Get integration settings and extract max length
SETTINGS_JSON=$(posty integrations:settings "$INTEGRATION_ID")
MAX_LENGTH=$(echo "$SETTINGS_JSON" | jq '.output.maxLength')
# Check character limit and truncate if needed
if [ ${#CONTENT} -gt "$MAX_LENGTH" ]; then
echo "Content exceeds $MAX_LENGTH chars, truncating..."
CONTENT="${CONTENT:0:$((MAX_LENGTH - 3))}..."
fi
# Create post with settings
posty posts:create \
-c "$CONTENT" \
-s "2026-12-31T12:00:00Z" \
--settings '{"key": "value"}' \
-i "$INTEGRATION_ID"
#!/bin/bash
# Schedule posts for the week
DATES=(
"2026-02-14T09:00:00Z"
"2026-02-15T09:00:00Z"
"2026-02-16T09:00:00Z"
)
CONTENT=(
"Monday motivation 💪"
"Tuesday tips 💡"
"Wednesday wisdom 🧠"
)
for i in "${!DATES[@]}"; do
# Rule 2: upload each file before passing to -m
IMG=$(posty upload "post-${i}.jpg" | jq -r '.path')
posty posts:create \
-c "${CONTENT[$i]}" \
-s "${DATES[$i]}" \
-i "$X_ID" \
-m "$IMG"
echo "Scheduled: ${CONTENT[$i]} for ${DATES[$i]}"
done
#!/bin/bash
CONTENT="Your post content"
INTEGRATION_ID="$X_ID"
DATE="2026-12-31T12:00:00Z"
MAX_RETRIES=3
for attempt in $(seq 1 $MAX_RETRIES); do
if posty posts:create -c "$CONTENT" -s "$DATE" -i "$INTEGRATION_ID"; then
echo "Post created successfully"
break
else
echo "Attempt $attempt failed"
if [ "$attempt" -lt "$MAX_RETRIES" ]; then
DELAY=$((2 ** attempt))
echo "Retrying in ${DELAY}s..."
sleep "$DELAY"
else
echo "Failed after $MAX_RETRIES attempts"
exit 1
fi
fi
done
Some integrations expose a tool for data that cannot be hard-coded. Among supported channels Instagram, Discord and Slack do. The workflow:
integrations:settings returns a tools arraymethodName, description, and dataSchemaintegrations:trigger with required parametersTools on supported channels, exhaustively:
| Channel | Tools |
|---|---|
Instagram (instagram) | audioSearch |
Discord (discord) | channels (the server's channels; the id goes into settings.channel) |
Slack (slack) | channels (the workspace's channels; the id goes into settings.channel) |
| X, Facebook, Threads, Bluesky, YouTube, LinkedIn, TikTok, Telegram | (none) |
That is the whole list. There is no getPlaylists, no getCompanies, no
getBoards, no getFlairs — earlier versions of this file invented all four,
and integrations:trigger answers 404 Tool not found for every one of them.
Platform-specific settings use a discriminator pattern with __type field:
{
"posts": [
{
"provider": "youtube",
"post": [{ "content": "Video description", "image": [...] }],
"settings": {
"__type": "youtube",
"title": "Video title",
"type": "public",
"selfDeclaredMadeForKids": "no",
"tags": [{ "value": "tech", "label": "Tech" }]
}
}
]
}
Pass settings directly:
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" --settings '{"title":"…","type":"public"}' -i "youtube-id"
# Backend automatically adds "__type" based on integration ID
Channels with no settings of their own — Threads and Bluesky — take an empty object. Do not invent fields for them.
You never have to write __type yourself. The server stamps it from the
integration's provider before validating, overwriting whatever you sent. An
example file carrying "__type": "EmptySettings" still works for that reason —
it is being replaced, not accepted.
1. A key without posts:publish silently produces a draft. posts:create
requires only posts:draft. If the key (or its owner's current role — a
Közreműködő/DRAFTER) lacks posts:publish, the server coerces type to
draft and creates a draft instead of refusing. It is not silent in the
response: read back the type you got, do not assume the one you sent.
If the user wanted it published, tell them the key cannot, rather than
reporting success.
2. One X channel per post, always. A single post may not target two X channels, whatever the content. X's Developer Policy forbids substantially similar content from multiple accounts through one developer app, and the account at risk is Posty's, shared by every customer. Split it into two posts. X alongside Facebook, Instagram, Threads, Bluesky, YouTube and the rest is unaffected — that combination is the entire point.
Posts can have comments (threads on Twitter/X, replies elsewhere). Each comment can have its own media:
# Upload every file first (Rule 2)
I1=$(posty upload image1.jpg | jq -r '.path')
I2=$(posty upload image2.jpg | jq -r '.path')
CI=$(posty upload comment-img.jpg | jq -r '.path')
A1=$(posty upload another.jpg | jq -r '.path')
A2=$(posty upload more.jpg | jq -r '.path')
posty posts:create \
-c "Main post" -m "$I1,$I2" \
-c "Comment 1" -m "$CI" \
-c "Comment 2" -m "$A1,$A2" \
-s "2026-12-31T12:00:00Z" \
-d 5 \ # Delay between comments in minutes
-i "integration-id"
Internally creates (note: every URL is a Posty-uploaded .path, not a raw filename):
{
"posts": [{
"value": [
{ "content": "Main post", "image": ["<uploaded image1>", "<uploaded image2>"] },
{ "content": "Comment 1", "image": ["<uploaded comment-img>"], "delay": 5 },
{ "content": "Comment 2", "image": ["<uploaded another>", "<uploaded more>"], "delay": 5 }
]
}]
}
The contract: ALWAYS pass an explicit offset, or pass --timezone with an
IANA name. The CLI never guesses a timezone. The server reads a naive
datetime as UTC, which silently shifts a Hungarian "12:00" by one or two
hours — so the CLI refuses ambiguity instead.
The two safe forms:
-s "2026-12-31T12:00:00Z" # explicit UTC
-s "2026-12-31 12:00" --timezone Europe/Budapest # wall-clock + IANA zone
How a date is resolved, in priority order:
Z, +01:00) — used as written.--timezone <IANA name> on posts:create / posts:list.POSTY_TIMEZONE environment variable (IANA name).posty config:set timezone Europe/Budapest
(also offered interactively during posty auth:login on a TTY).Rules and behaviors:
Europe/Budapest, UTC). Numeric
offsets (+02:00, UTC+2, Etc/GMT+2) are rejected as timezone values
— never hand-compute DST; put an offset inside the date string only when
you are certain of it, otherwise use the IANA name and let the CLI convert
(DST-aware).ℹ️ Interpreted "2026-12-24 18:00" as Europe/Budapest (via --timezone) → 2026-12-24T17:00:00.000Z"2026-12-31") means midnight in the resolved timezone.posts:list --startDate/--endDate accept the same shapes and the same
--timezone flag; the defaults (30 days back / 30 days forward) never
need a timezone.posty config:get / posty config:get timezone shows what is saved;
settings live in ~/.posty/config.json and survive auth:logout.posty posts:find-slot <integration-id> returns {"date": "..."} — the
next free slot on that channel's schedule; pass it straight back to
posts:create -s.-t now publishes immediately and needs no --date.Upload returns JSON with path and metadata:
{
"path": "https://posty.hu/uploads/2026/08/abc123.jpg",
"size": 123456,
"type": "image/jpeg"
}
Extract path for use in posts:
RESULT=$(posty upload image.jpg)
PATH=$(echo "$RESULT" | jq -r '.path')
posty posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$PATH" -i "integration-id"
CLI flags - Quick posts:
posty posts:create -c "Content" -m "$(posty upload img.jpg | jq -r '.path')" -s "2026-12-31T12:00:00Z" -i "$X_ID"
JSON mode - Complex posts with multiple platforms and settings:
posty posts:create --json post.json
JSON mode supports:
Every field below is taken from the server's validation DTOs. A field that is
not listed does not exist, and sending it is a 400.
xposty posts:create \
-c "Tweet content" \
-s "2026-12-31T12:00:00Z" \
--settings '{"who_can_reply_post":"everyone"}' \
-i "$X_ID"
who_can_reply_post is required: everyone | following |
mentionedUsers | subscribers | verified. Optional: community (must
match https://x.com/i/communities/<digits>), made_with_ai (bool),
paid_partnership (bool).
facebookIMG=$(posty upload photo.jpg | jq -r '.path')
posty posts:create \
-c "Post text" \
-s "2026-12-31T12:00:00Z" \
--settings '{"post_type":"post"}' \
-m "$IMG" \
-i "$FB_ID"
All optional: post_type (post | story), url, text_format_preset_id
(a background for a text-only post; Pages only, ~130 characters max).
instagramIMG=$(posty upload image.jpg | jq -r '.path')
# Feed post
posty posts:create -c "Caption #hashtag" -s "2026-12-31T12:00:00Z" \
--settings '{"post_type":"post"}' -m "$IMG" -i "$IG_ID"
# Story
STORY=$(posty upload story.jpg | jq -r '.path')
posty posts:create -c "" -s "2026-12-31T12:00:00Z" \
--settings '{"post_type":"story"}' -m "$STORY" -i "$IG_ID"
post_type is required: post | story. Optional: collaborators (array
of {label}), audio ({id, title?, artist?, image?, audio_volume?, video_volume?} — get the id from the audioSearch tool), is_trial_reel,
graduation_strategy (MANUAL | SS_PERFORMANCE).
threads, and Bluesky — blueskyposty posts:create -c "Post text" -s "2026-12-31T12:00:00Z" -i "$THREADS_ID"
No provider settings. Omit --settings entirely.
State these plainly rather than attempting a workaround.
/generate-video and /video/function
routes were removed from the API entirely (2026-08-06). Do not tell a user
Posty can generate video, and do not call those routes — they 404.posty upload.DELETE /integrations/:id was deliberately removed
from the public API. Disconnecting a channel is a signed-in, admin action in
the web app.This file is the command reference. Everything the CLI accepts is documented above; there is no second syntax guide to consult.
Deep-dive documentation (shipped in the npm package, and in the repo):
Ready-to-use --json payloads (all verified against the current CLI):
posty auth:login or export POSTY_API_KEY=key before using CLIintegrations:list to get current IDsintegrations:settings for required fields-m or to an image/media field in JSON mode must be a .path returned by posty upload. Raw local filenames (image.jpg) and external URLs (https://...) will be rejected — TikTok, Instagram, YouTube and most other providers only accept Posty-verified URLs. No exceptions: even a "quick test post" needs the upload step.--settings '{...}'"2026-12-31T12:00:00Z"), or a naive datetime plus --timezone <IANA name>. A naive datetime with no timezone configured is a hard error. Required except with -t now or --json.integrations:settings outputmaxLength in settingstitle and type; X requires who_can_reply_post; Instagram requires post_type; TikTok requires privacy_level and content_posting_method; Discord and Slack require channel (an id from their channels tool). Threads, Bluesky, LinkedIn and Telegram require nothing.{"missing": true} - The post was published but the platform didn't return a post ID. Run posts:missing <post-id> to get available content, then posts:connect <post-id> --release-id "<id>" to link it. Analytics will work after connecting.# ⚠️ AUTHENTICATE FIRST - required before any other command
posty auth:status # Check if authenticated
posty auth:login # OAuth2 device flow login
posty auth:logout # Remove credentials
export POSTY_API_KEY=key # Or use API key
# Settings (~/.posty/config.json — survives logout)
posty config:set timezone Europe/Budapest # Timezone for dates without an offset
posty config:get # Show saved settings as JSON
# Discovery (only after auth is confirmed)
posty integrations:list # Get integration IDs
posty integrations:list --group <group-id> # Get integration IDs in a group
posty integrations:groups # List groups (customers)
posty integrations:settings <id> # Get settings schema
posty integrations:trigger <id> <method> -d '{}' # Fetch dynamic data
# Posting (date required unless -t now or --json; explicit offset or --timezone — never naive)
posty posts:create -c "text" -s "2026-12-31T12:00:00Z" -i "id" # Simple (UTC)
posty posts:create -c "Good morning!" -s "2026-12-31 08:00" --timezone Europe/Budapest -i "id" # Local wall-clock
posty posts:create -c "text" -t now -i "id" # Publish immediately
posty posts:create -c "text" -s "2026-12-31T12:00:00Z" -t draft -i "id" # Draft
posty posts:create -c "text" -m "$(posty upload img.jpg | jq -r '.path')" -s "2026-12-31T12:00:00Z" -i "id" # With media (upload first — Rule 2)
posty posts:create -c "main" -c "comment" -s "2026-12-31T12:00:00Z" -i "id" # With comment
posty posts:create -c "text" -s "2026-12-31T12:00:00Z" --settings '{}' -i "id" # Platform-specific
posty posts:create --json file.json # Complex
# Management
posty posts:list # List posts
posty posts:find-slot <integration-id> # Next free slot: {"date": "..."}
posty posts:delete <id> # Delete post
posty posts:status <id> --status draft # Move to draft (stops workflow)
posty posts:status <id> --status schedule # Queue draft for publishing
posty upload <file> # Upload media
# Analytics
posty analytics:platform <id> # Platform analytics (7 days)
posty analytics:platform <id> -d 30 # Platform analytics (30 days)
posty analytics:post <id> # Post analytics (7 days)
posty analytics:post <id> -d 30 # Post analytics (30 days)
# If analytics:post returns {"missing": true}, resolve it:
posty posts:missing <id> # List provider content
posty posts:connect <id> --release-id "<rid>" # Connect content to post
# Help
posty --help # Show help
posty posts:create --help # Command help