Write, preview and schedule social media posts to 37 platforms and apps from Cursor. Postemi's MCP server, writing and template skills, and commands to schedule a post or announce a release.
Grow an audience and get results from social posting with Postemi. Covers choosing networks and a first niche, a 30-day posting test, reading the numbers (Postemi Insights and analytics), posting cadence per network, turning one topic into 30 post angles and scheduling them as a content plan, repurposing one idea across networks, bios and profile links that turn followers into sign-ups or customers, and a weekly review loop. Use when the user asks how to grow followers or reach, what or how often to post, for a content plan or calendar, why posts do not perform, or how to get customers from social media.
# Postemi growth: from posting to results
This skill is the plan; `postemi-writing` is how each post is written,
`postemi-templates` has ready drafts, and the main `postemi` skill has the
tools and commands. If the writing skills are not installed, write the posts
with the rules of the main skill and offer `postemi skills add writing` (ask
the user first). Reference files:
- [reference/30-angles.md](./reference/30-angles.md): 30 ways to post about
one topic, with a worked example.
- [reference/review.md](./reference/review.md): the post log and the weekly
review, step by step.
## Principles
1. **Focus.** One or two networks done well beat seven done badly. Add more
once something works.
2. **Every post is a test.** Growth early on is learning what this audience
wants. Post, read the numbers, keep what works, drop what does not.
3. **Consistency beats bursts.** A steady rhythm the user can keep for months
wins over a busy week followed by silence. Long gaps cost reach.
4. **Big changes, not tiny tweaks.** With a small audience, a new topic, angle
or format teaches more than changing one word.
5. **Real engagement only.** No bought followers, no engagement swaps, no
bots. Comments, shares, saves and replies from real readers are the signal.
6. **What Postemi does and does not do.** Postemi writes nothing on its own,
schedules and publishes, and shows numbers. It does not reply to comments,
follow, connect, like or send messages for the user. Those are the user's
part, and they matter.
## 1. Choose where to start
Ask who the user wants to reach and what they sell or share, then suggest one
main network and at most one second one:
| The user | Start with |
|---|---|
| Sells to businesses, consultant, founder, job market | LinkedIn (personal profile), then X |
| Ideas, tech, writing, building in public | X or Threads, then LinkedIn; Bluesky for tech crowds |
| Visual products, food, fashion, beauty, travel, places | Instagram (Reels and carousels), then TikTok |
| Local business (shop, cafe, salon, trades) | Facebook and Instagram |
| Teaching with video | YouTube (Shorts too) and TikTok |
| An existing community | Telegram, Discord or Slack, next to one public network |
Postemi can send the same idea to several networks at once later (section 6),
so starting narrow costs nothing.
## 2. The first niche and the 30-day test
A niche is not for life; it is the first topic the user goes deep on. The
skill of building an audience carries over when the topic changes. Find it
with data, not by thinking harder:
1. **Pick one network** (usually X or LinkedIn for text, Instagram or TikTok
for visuals).
2. **Post at least once a day for 30 days.** Any idea from the user's
experience: use the idea builder and the look-back test in
`postemi-writing`. Vary topic, angle (how-to, analysis, inspiration, human
nature) and format on purpose, so the data has something to compare.
3. **Schedule it with Postemi a week at a time.** Write seven posts with the
user, preview them, and create them in one go (one `socialPost` entry per
day in `create_post`, or a `--json` file with the CLI). The user only has
to show up for the replies.
4. **Log every post** (topic, angle, format, hook, time): see
[reference/review.md](./reference/review.md).
5. **After 30 days, read the numbers** (section 3) and pick the topic and the
angle with the strongest early signal.
6. **Go deep on that topic** for the next weeks. Writing about the same topic
again and again is not repetition; it is why people follow someone. The 30
angles (section 5) make it easy.
Small numbers still count. If 20 posts got no likes and 10 got one, those 10
did infinitely better: that is the signal. The method is the same at 10
followers and at 10,000; only the zeros change.
## 3. Read the numbers
Where the numbers are:
- **Postemi Insights**, in the web app (postemi.com/app/insights): reach,
engagement and followers per channel over time.
- **The CLI:** `postemi analytics:platform <integration-id> -d 30` for a
channel, `postemi analytics:post <post-id> -d 7` for one post. If a post
answers `{"missing": true}`, link it first (`posts:missing`, then
`posts:connect`; see the main skill).
- **The MCP tools** have no analytics tool. Ask the user to open Insights, or
to paste or screenshot the numbers, and work from that. `list_posts` gives
the post ids, dates and channels to match them to.
- The network's own analytics, when the user has it open.
What to look at, in this order:
1. **Conversations:** comments and replies (and how long they are). The
strongest sign a post landed.
2. **Shares, reposts and saves:** people found it worth passing on or keeping.
3. **Follows and profile visits** after a post: it made people want more.
4. **Engagement rate:** engagements divided by impressions. Compares posts
fairly when reach differs.
5. **Reach and impressions:** how far it went. Useful, but a post can travel
far and change nothing.
6. **Clicks and sign-ups** from the profile link, when the user tracks them.
Likes alone are the weakest signal. Compare posts with each other, not with
other people's accounts.
## 4. How often, and when
Starting points, not laws. The best cadence is the one the user can keep.
| Network | Suggested rhythm | Notes |
|---|---|---|
| X | At least 2 short posts a day, 5 days a week; to grow faster, 3 to 5 a day, 7 days a week, plus 2 or more threads or long posts a week. Text with an image or video does best. | Every post and thread entry counts toward the account's monthly X allowance in Postemi, so tell the user how many are left when the plan needs more. |
| LinkedIn | 1 post a day at most, 3 to 5 a week is plenty. | Posting more does not grow faster here. Mornings in the audience's timezone work well. Do not go weeks without posting. |
| Instagram | 3 to 5 feed posts or Reels a week, Stories in between. | Reels for reach, carousels for saves. |
| TikTok | Up to 1 or 2 a day. | Postemi allows about 15 posts per account in 24 hours. |
| Threads, Bluesky, Mastodon | 1 to 3 a day. | Conversation matters more than volume. |
| Facebook | 3 to 5 a week. | Local and community content. |
| YouTube | 1 a week; Shorts more often. | Consistency of the day helps viewers. |
| Blog channels | 1 to 4 a month. | The long versions of the best posts. |
Timing: start with morning and early evening in the audience's own timezone
(take the user's timezone from Postemi, and ask where the audience is), then
compare times in the log. `postemi posts:find-slot <id>` suggests the next
free slot on a channel's schedule.
## 5. One topic, 30 posts
Once the user has a topic, turn it into a month of posts with the 30 angles
in [reference/30-angles.md](./reference/30-angles.md): tips, mistakes, tools,
steps, stories, lessons, myths, questions and more. Each angle becomes a
question, each answer becomes a post.
Mix the 30 across three to five **content buckets** (themes the audience
expects from the user, for example "how-to", "behind the scenes", "customer
results", "opinions") and refuse ideas that fit no bucket. That keeps the
account recognisable.
### Turning the plan into a Postemi schedule
1. Agree the network(s), the number of posts per week and the times.
2. Write the first week in full (with `postemi-writing`), show it, adjust.
3. Get the clock and timezone (`get_workspace_context`, or the CLI's saved
timezone), and spread the posts over the days at the agreed local times.
4. Preview all of them in one `preview_post` call (one `socialPost` entry per
post), or show the list from the CLI; fix what the preview flags.
5. On the user's yes, create them in one call with an `idempotencyKey`. Use
`type: "draft"` (CLI `-t draft`) if the user wants to review them in the
Postemi calendar before they go live.
6. Repeat weekly, folding in what the numbers say. Writing a month ahead is
fine, but leave room for timely posts and for what you learn.
## 6. Repurpose: one idea, every network
A post that worked is the best raw material for the next ones:
- **Across networks, adapted, not copied.** The same idea as an X short post,
an X thread, a LinkedIn long post with an image, an Instagram carousel, a
Threads post, and later a blog article. Each gets its own hook, length and
format (see `postemi-writing`). In Postemi each channel gets its own entry
with its own text in the same request.
- **Never the same post on two X accounts:** one X channel per post.
- **Up and down in size.** A thread becomes an article; an article becomes
five short posts; a popular reply or comment becomes its own post.
- **Again, later.** Re-share a winner after a few months with a new hook and
a fresh example; most followers did not see it the first time.
- **Collaborations.** Instagram posts can name collaborators (a setting in
Postemi); a post together with a partner brand or creator reaches both
audiences.
## 7. Profile, bio and the path to a customer
Posts bring people to the profile; the profile decides whether they follow
and click. Help the user write it:
- **Bio:** "I help [who] [get what]" or "I write about [topic]", plus one line
of proof: "Baker for 12 years. 3 shops." Clear beats clever, as with hooks.
No list of ten roles.
- **One link,** to one next step: an e-mail list, a free guide or checklist,
a booking page, a product page. On X the bio is the place for links, since
Postemi removes links from X posts. On Instagram point to the link in the
bio. On LinkedIn, feature the free resource or product in the Featured
section.
- **Name and keywords:** put the topic word people search for in the display
name or headline (Instagram, LinkedIn) so the profile can be found.
- **The path:** post, profile, link, e-mail or sign-up, offer. Most posts just
give value; about one in five invites people to the next step (a free
resource, a waitlist, the product), with a clear reason to click. On
LinkedIn and Facebook that link can go in the first comment.
- **Pinned post:** the user's best post, or the one that explains what they do
and where to start.
## 8. The user's part every day
Share this with the user plainly; Postemi cannot do it for them:
- Reply to every comment in the first hours after a post goes out; replies
bring more reach and turn readers into followers.
- Leave two or three thoughtful comments a day on posts by bigger accounts in
the same field. A good comment is a small post of its own, and a comment
that does well is an idea for a full post.
- Thank people who share.
## 9. Review and iterate
Once a week (15 minutes), with the user:
1. Pull the numbers for the week (section 3) into the log.
2. Name the top three and the bottom three posts, and what they had in common
(topic, angle, hook pattern, format, time).
3. Decide one bigger change for next week: more of the winning angle, a new
format, a different time, dropping a topic that never lands.
4. Write and schedule next week with that change.
Once a month: is the main network working? Is the niche still the right
first one? Is the profile link getting clicks? Keep, adjust, or try the
second network. Details and a log template: [reference/review.md](./reference/review.md).Postemi's library of ready post templates and hooks, the same ones the Postemi composer offers under Templates. Everyday posts, product launches, LinkedIn post types, X formats, conversation starters and more than 100 hooks (stories, contrarian takes, how-to, lists, books, habits, questions), each with [placeholders] to fill. Use when the user wants a quick draft of a common post type, a hook or opening line, a post idea for a goal (launch, engagement, teaching, story), or asks for templates.
# Postemi templates and hooks
The templates in [reference/](./reference/index.md) are generated from the
Postemi app, so they match what the user sees in the composer's Templates
column. Each has a title, a line on when to use it, and a body with
[placeholders] in square brackets.
Pair with `postemi-writing` (how to make the filled template good) and the
main `postemi` skill (how to preview and schedule it).
## Files
- [reference/index.md](./reference/index.md): every group and every template
in one table (id, title, network, when to use it). Read this first, then
open only the group file you need.
- Group files, with the full bodies:
- [everyday.md](./reference/everyday.md): launch, behind the scenes, tip,
roundup, customer story, event, question, milestone.
- [launch.md](./reference/launch.md): announcing and explaining what the
user builds.
- [linkedin.md](./reference/linkedin.md): 30 LinkedIn post types in six
sections (building in public, founder story, industry, team, teaching,
conversation).
- [x.md](./reference/x.md): short X formats.
- Hooks, by what the post does:
[hook-stories.md](./reference/hook-stories.md) (lessons and stories),
[hook-contrarian.md](./reference/hook-contrarian.md) (contrarian takes),
[hook-how-to.md](./reference/hook-how-to.md) (frameworks and how-to),
[hook-lists.md](./reference/hook-lists.md) (lists and tools),
[hook-learning.md](./reference/hook-learning.md) (books and learning),
[hook-growth.md](./reference/hook-growth.md) (habits and growth),
[hook-talk.md](./reference/hook-talk.md) (problems and questions).
- [engagement.md](./reference/engagement.md): questions, polls and prompts
that get replies.
## Pick a template
Start from the user's goal, then the network:
| Goal | Look in |
|---|---|
| Announce a product, feature or event | `launch.md`, `everyday.md` (`launch`, `eventReminder`) |
| Build trust as a founder or expert | `linkedin.md` (founder story, building in public), `hook-stories.md` |
| Teach something useful | `hook-how-to.md`, `everyday.md` (`quickTip`), `linkedin.md` (teach) |
| Start a debate, stand out | `hook-contrarian.md`, `linkedin.md` (`liHotTake`), `x.md` |
| Get comments and replies | `engagement.md`, `hook-talk.md` |
| Share tools, books, resources | `hook-lists.md`, `hook-learning.md` |
| Motivate, habits, personal growth | `hook-growth.md` |
| Show the team and culture | `linkedin.md` (team), `everyday.md` (`behindScenes`) |
| Social proof | `everyday.md` (`customerStory`, `milestone`) |
Network fit:
- Ids starting with `li` were written for LinkedIn and ids starting with `x`
for X; the index says so in its Network column. The rest fit anywhere.
- A LinkedIn template on X: shorten it to the hook and one or two lines, or
turn it into a thread (one point per entry). Check the account's limit.
- An X template on LinkedIn: add the context and a story; the hook must sit
in the first two or three lines.
- For Instagram, TikTok and YouTube the template becomes the caption; the
picture or video still carries the post.
- Remove any link from text going to X (Postemi strips links from X posts).
Offer two or three templates that fit and let the user choose, or pick one
and say why in a sentence.
## Fill it in
1. Ask the user for what the placeholders need, in one short message: the
facts, the numbers, the story. Never invent them. A template with made-up
details is worse than no post.
2. Replace every `[placeholder]`. Remove a line whose placeholder the user
cannot fill, rather than leaving it vague.
3. Number placeholders (`[number]`) must be true numbers, and a list must
have as many items as its hook promises.
4. Rewrite in the user's voice. The template is a skeleton, not a script:
change words, order and length freely. Keep the shape that makes it work
(the hook, the turn, the takeaway).
5. Hooks are openings: a hook template gives the first lines; write the rest
with `postemi-writing` (keep the promise of the hook, lead with the best
part, end with one takeaway or question).
6. Run the check in `postemi-writing` (section 7), then preview and schedule
with the main `postemi` skill.
## Example
User: "I want to post on LinkedIn that we finally shipped dark mode."
1. Goal: announce a feature; network: LinkedIn. Candidates: `liFeature`,
`liBuiltWrong` (if customer feedback changed the plan).
2. `liFeature` body: "We just shipped [feature]. It took us [time], longer than
we planned. The reason: [...]".
3. Ask: how long did it take, what was harder than expected, what can people
do now, one lesson.
4. Fill, rewrite in the user's voice, check, `preview_post` (or show the text
in the CLI flow), schedule on the user's yes.
## Keeping this in sync
These files are generated. In the agent repository:
`pnpm sync:templates <path to the app's templates folder>`, then
`pnpm sync:skills`, then commit. Do not edit the reference files by hand.Write social media posts people stop for and read to the end, then schedule them with Postemi. Covers finding post ideas, hooks and headlines, opening lines, formatting and rhythm, threads, the writing rules of each network (X, LinkedIn, Instagram, Threads, Bluesky, Facebook, TikTok, YouTube and more) and a check before publishing. Use when the user asks to write, rewrite or improve a post, thread, caption, hook or headline, asks what to post about, or wants a post to reach more people.
# Postemi writing: posts that get read
This skill is about the words. Other Postemi skills sit next to it:
- `postemi` (the main skill): the tools or commands that preview, schedule and
publish. Its rules win over anything here.
- `postemi-templates`: ready post templates and hooks with [placeholders],
the same ones the Postemi composer shows. Start there when the user wants a
quick draft of a known type (a launch, a tip, a poll).
- `postemi-growth` (optional, may not be installed): the plan over weeks: a
niche, a 30-day test, cadence per network, reading the numbers,
repurposing. If it is missing and the user wants a plan, tell them how to
add it (`postemi skills add growth`, or the `postemi-growth` plugin); ask
first, never install it yourself.
Reference files in this skill:
- [reference/headline-patterns.md](./reference/headline-patterns.md): 25 hook
and headline patterns with examples.
- [reference/networks.md](./reference/networks.md): what works on each
network, and what Postemi does there.
## Ground rules
1. Write in the user's voice, about the user's real experience. Ask for facts
you do not have; never invent a number, a customer, a quote, a result or a
credential. A made-up detail is the fastest way to lose a reader's trust.
2. A strong hook is fine only when the post pays it off. If the body cannot
keep the promise, make the hook smaller, never the truth bigger.
3. Plain words, short sentences, one idea per post.
4. Show the user the draft before anything is scheduled (the main skill's
preview step). The user decides; you suggest.
## 1. Find the idea
### The look-back test
People who "have nothing to write about" are usually writing for the wrong
reader. Ask the user three questions:
- What did you not know two years ago that you know now?
- What have you been through since then that you had not been through before?
- Where were you two years ago, and where are you today?
Every answer is a post. The reader is the person the user was two years ago:
someone a step or two behind, who wants to hear from someone who just did it,
not from a distant expert.
### The idea builder: topic, proof, angle
Build each idea from three choices.
**Topic, made narrow.** Start broad, then go down at least two levels, then
add who it is for and what they get:
- Broad: cooking.
- Narrower: cooking on weeknights.
- Narrower still: cooking dinner in 20 minutes after work.
- For whom, so that: for parents who get home at 6, so that the family eats
together without takeaway every night.
The narrower the topic, the easier the post is to write and the quicker the
right reader knows it is for them.
**Proof: why should anyone listen?** Two kinds:
- Own proof: "This is what happened to me." Lived experience needs no title;
say plainly that it is your experience.
- Borrowed proof: what someone with the experience says or showed: a study,
a well-known practitioner, a customer, a book. Name the source honestly. On
X, links are removed, so name it in words.
If the user has neither for a topic, pick another topic or borrow proof.
**Angle: which road do you take the reader down?**
| Angle | The post says | Example idea |
|---|---|---|
| How-to | Here is how to do it, step by step. | 5 steps to plan a week of dinners in 15 minutes |
| Analysis | Here is what the data or the trend shows, and why. | Why grocery bills rose more for small households this year |
| Inspiration | I did it, so you can too. | How I went from takeaway five nights a week to one |
| Human nature | Here is the hidden human reason behind it. | Why we buy vegetables we never cook |
Readers come back for the angle they liked. Try all four early; once the
numbers show which one the user's audience prefers, lean on it. Mixing is
fine later.
### A short interview when the user has no idea yet
Ask one question at a time, then offer three ideas, each with a hook:
1. What do you do, and for whom?
2. What question do people ask you most?
3. What did you get wrong at the start?
4. What do most people in your field believe that you disagree with?
5. What happened this week that taught you something?
For a whole month of ideas from one topic, use `postemi-growth` (30 angles)
if it is installed.
## 2. The hook
The first line (or the title, on YouTube and blogs) decides whether anyone
reads the rest. A good hook has these five qualities:
1. **Clear, not clever.** A pun makes a friend smile and a stranger scroll
past. The reader must see at once what this is about and who it is for.
2. **A promise.** Not only "this is about X" but "after reading, you will
have Y". No promise, no reason to give it 30 seconds.
3. **Specific.** Name the problem, the reader, or both. Vague reads as
"not for me".
4. **A stance.** Lean one way. The right reader thinks "this is exactly my
problem"; the wrong one thinks "not for me", and that is fine. A post for
everyone is a post for no one.
5. **An open loop.** Say what they will get, hold back the answer. "Coffee
makes you anxious" gives it away; "The one coffee habit behind my 3 pm
anxiety" makes them read on.
### Build the hook from six parts
| Part | Question | Example |
|---|---|---|
| Number | How many things? | 4 |
| Kind | Tips, mistakes, lessons, reasons, steps? | mistakes |
| Topic | What exactly, two or three levels deep? | new runners make in their first month |
| Reader | Who is it for? (often implied by the topic) | new runners |
| Payoff | What does it get them, or save them from? | that lead to shin pain |
| More stakes | What else is at risk or to win? | missed races, quitting by spring |
Write the long version first, with every part:
> 4 mistakes new runners make in their first month that lead to shin pain,
> missed races and quitting by spring
Then cut to fit the network: drop extra stakes first, then adjectives.
> 4 first-month running mistakes that end in shin pain
Starting long and cutting is easier than trying to be short and sharp at once.
Ready patterns: [reference/headline-patterns.md](./reference/headline-patterns.md)
and the hook groups in the `postemi-templates` skill. Offer the user two or
three hooks for the same post and let them pick.
### Where the hook has to land
| Network | The hook is |
|---|---|
| X, Threads, Bluesky, Mastodon | the first line, and the first entry of a thread on its own |
| LinkedIn | the first two or three lines, before "see more" |
| Instagram | the first sentence of the caption (about 125 characters show), plus the image or the first second of the Reel |
| TikTok, Reels, Shorts | the first one to three seconds and the on-screen text |
| YouTube | the title (and the thumbnail the user makes) |
| Facebook | the first line; long posts fold after a few lines |
| WordPress, Ghost, Dev.to, Tumblr | the title |
## 3. The first sentence: six openers
Open with one sentence that does one of these:
1. **A bold statement.** "Most small shops do not need a website. They need a
good photo on their map listing."
2. **A question that makes them think.** "Why do the best bakers in town
never run out of bread at noon?"
3. **A contrary opinion.** "Posting every day is bad advice for most
businesses."
4. **A moment in time.** "At 6:40 on a Monday, our only oven died with 200
orders due."
5. **An honest admission.** "For three years I priced my work too low and
called it being kind."
6. **A surprising fact or detail.** "Our slowest product sells best on rainy
Tuesdays."
Facts in openers must be true and, where it matters, sourced.
## 4. Shape the post
**Outline before writing.** Four lines, then fill them in:
1. Working hook.
2. One line of proof (why the user can say this).
3. The main points (two to five).
4. The takeaway (the one thing to remember or do).
**Lead with the best part.** Do not make the reader wade through setup to
reach the useful bit. Every line should earn the next.
**Points that tell the story.** In a long post, a thread or a carousel, make
each main point a line that reads well on its own, so a skimmer gets the
whole story from the points alone. Use the points as section lines or as the
first line of each thread entry.
**The 1-3-1 rhythm.** Alternate a one-sentence paragraph with a paragraph of
about three sentences, then a single line again. Vary sentence length the same
way. It gives the reader a breath between ideas. On LinkedIn and Instagram,
read mostly on phones, keep most paragraphs to one or two sentences.
**End on purpose.** One of: a takeaway line, a question that is easy to
answer ("Which of these did you make?"), or one call to action. Never three.
**Text plus a picture or a video** usually beats text alone. Use the user's
own photos and videos (Postemi uploads them; it does not generate images or
video). A screenshot, a photo of the work or a short clip is enough.
**Format.** Postemi post content is HTML: each line in `<p>`, plus `strong`,
`ul`/`li` and `h1` to `h3` (never `u` and `strong` together). Networks show
formatting differently; line breaks and short lists are safe everywhere. The
preview shows the result.
## 5. Formats
- **Short post:** one idea, one to five lines. The daily bread on X, Threads
and Bluesky.
- **Long post:** a hook, three to seven short paragraphs or a list, a
takeaway. Strong on LinkedIn and Facebook, and on X for accounts with a
higher character limit (the limit comes from the account, never guess it).
- **Thread** (X, Threads, Bluesky): the first entry must work alone and make
people open the rest. One point per entry. The last entry sums up and asks
for a reply or a follow. Ask the user whether they want a thread or one long
post. On X, every thread entry counts toward the account's monthly X post
allowance in Postemi.
- **Carousel** (LinkedIn image carousel, Instagram multi-image): one point per
slide, the hook on the first slide, the takeaway on the last. The user
supplies the images.
- **Video caption** (TikTok, Reels, Shorts, YouTube): the caption supports
the video; the hook lives in the first seconds of the video itself.
- **Post plus first comment** (LinkedIn, Facebook): put the main text in the
post and a link or extra detail in the first comment. In Postemi, the
entries after the first are comments on these networks.
## 6. Network by network
Short version; details in [reference/networks.md](./reference/networks.md).
- **X:** short and direct; one X channel per post; no links (Postemi removes
them, so put the link in the bio or tell the user to post it on X
themselves); the character limit depends on the account; threads and every
entry count toward the monthly X allowance.
- **LinkedIn:** the hook before "see more", short paragraphs, a question at the
end to draw comments; long text with an image, or an image carousel, does
well. Personal profiles only today (company pages are coming soon).
- **Instagram:** the picture or Reel first, then a caption whose first sentence
hooks; a clear call to action; a few relevant hashtags. Reels want 9:16.
- **Threads, Bluesky, Mastodon:** conversational, short; threads work.
- **Facebook:** a friendly, local voice; questions and photos; links are fine.
- **TikTok, YouTube:** the video carries it; write a caption and (YouTube) a
title that make a clear promise. TikTok takes about 15 posts per account per
day.
- **Blog channels (WordPress, Ghost, Dev.to, Tumblr; beta):** a home for the
long version of the user's best posts, with a title.
Never offer a network Postemi does not support (the main skill has the list;
Pinterest, Google Business Profile and LinkedIn company pages are not
schedulable yet).
## 7. Check before publishing
Go through this list for every draft, then show the user:
1. The hook is clear, specific, promises something and leaves a question open.
2. The body keeps the promise of the hook.
3. The best part comes first; nothing is padding.
4. Every fact, number and quote is true and came from the user or a named
source.
5. One idea, one call to action at most.
6. Short paragraphs; the 1-3-1 rhythm; it reads well on a phone.
7. Every [placeholder] from a template is filled or removed.
8. It fits the account's character limit (from the schema, not from memory).
9. Network rules: no link in X posts; the LinkedIn hook sits above "see more";
media where the network needs it (Instagram, TikTok, YouTube).
10. It sounds like the user, not like an advert. Read it aloud.
11. Hashtags, if any: few and relevant. Emojis, if any: where the user would
use them.
## 8. From draft to scheduled, with Postemi
The workflow is always: **draft, preview, confirm, schedule.** The exact
calls are in the main `postemi` skill; in short:
Use whichever your client has. If the Postemi MCP tools are connected, use
them and never install the CLI; the CLI is for agents with a terminal.
With the Postemi MCP tools (Claude, ChatGPT, Cursor, Grok and other chat
clients):
1. `get_workspace_context` for the clock and the user's timezone.
2. `list_integrations`; pick channels by id; say the `@handle` back.
3. `get_integration_schema` for each channel: the real character limit and
the required settings. Write within them.
4. `preview_post` with one `socialPost` entry per channel (each with its own
text, so every network gets its own version) and show the result.
5. After the user says yes: `create_post` with the same payload and a new
`idempotencyKey`. `type: "draft"` saves without scheduling.
With the `postemi` CLI (coding agents with a terminal):
1. `postemi auth:status`, then `postemi integrations:list`.
2. `postemi integrations:settings <id>` for the limit and the settings.
3. Show the user the text per channel and the local time; wait for a yes.
4. `postemi posts:create ... -t draft` to save, or with `-s "<date>"
--timezone <IANA>` to schedule; `--json` for different text per channel.
`postemi posts:find-slot <id>` suggests the next free slot.
After it goes out, the numbers tell you which hooks and angles work: see
`postemi-growth` (optional).Postemi schedules and publishes social posts from the command line to 37 platforms and apps, including Facebook, Instagram, X, LinkedIn, TikTok, YouTube, Threads, Bluesky, Pinterest and Mastodon.
## Install
The npm package and the command are both **`postemi`**.
```bash
npm install -g postemi
postemi auth:login # opens the browser to approve the login
```
official website: https://postemi.com
API base: https://api.postemi.com
MCP server, for chat clients without a terminal: https://mcp.postemi.com/mcp
(the user signs in to Postemi in the browser; same account, same channels)
---
| Property | Value |
|----------|-------|
| **name** | postemi |
| **description** | CLI for scheduling and publishing posts through the Postemi public API |
| **allowed-tools** | Bash(postemi:*) |
---
## Which channels you can publish to
**Only these. Do not offer a user anything else, and do not try to post to it.**
Postemi's server carries inherited code for many more providers; that code is not
enabled and not supported. Postemi adds channels over time, so
`integrations:list` (below) can show one that is not in this table yet.
| Channel | `identifier` | Status |
|---|---|---|
| X (Twitter) | `x` | Live |
| Facebook (Pages) | `facebook` | Live |
| Instagram (business account through a Facebook Page) | `instagram` | Live |
| Instagram (professional account, no Facebook Page) | `instagram-standalone` | Live |
| LinkedIn (member feed) | `linkedin` | Live |
| TikTok | `tiktok` | Live |
| YouTube | `youtube` | Live |
| Threads | `threads` | Live |
| Bluesky | `bluesky` | Live |
| Telegram | `telegram` | Live |
| Discord | `discord` | Live |
| Slack | `slack` | Live |
| Pinterest | `pinterest` | Coming soon, not schedulable yet |
| Mastodon (mastodon.social) | `mastodon` | BETA |
| Mastodon (any other server) | `mastodon-custom` | BETA |
| Pixelfed (any server) | `pixelfed` | BETA |
| WordPress (your own site) | `wordpress` | BETA |
| Tumblr | `tumblr` | BETA |
| Dev.to | `devto` | BETA |
| Ghost (your own site) | `ghost` | BETA |
| Google Chat (a space, by webhook) | `google-chat` | BETA |
| Microsoft Teams (a channel, by webhook) | `microsoft-teams` | BETA |
| Google Business Profile | `gmb` | Coming soon, not schedulable yet |
| LinkedIn Pages (company pages) | `linkedin-page` | Coming soon, not schedulable yet |
**Pinterest is coming soon.** Postemi customers cannot connect it until Pinterest
approves Postemi's app, so it never appears in `integrations:list`. Tell the user
it is not available yet. (When it opens, a pin needs a `board` id in
`--settings`; see PROVIDER_SETTINGS.md.)
**The other BETA channels** connect and publish, but Postemi has not tried them
with many real accounts yet. Say so when the user picks one. Most need
settings (a title for WordPress, Dev.to and Ghost; a picture for Pixelfed);
see PROVIDER_SETTINGS.md.
**Google Business Profile and LinkedIn Pages are coming soon.** Those platforms
have not approved Postemi yet, so the user cannot connect them. Do not schedule
for them; personal LinkedIn profiles (`linkedin`) work today.
**`integrations:list` is the truth.** The table says what Postemi 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 Reddit, Medium,
Farcaster, Hashnode, Lemmy, Nostr, VK, a newsletter service or anything else
that is not in the table, the answer is that Postemi does not offer it today.
Do not build a `--settings` payload for it.
---
## ⚠️ Two Hard Rules (Read First)
**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 `postemi 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 Postemi-verified URL. Always:
```bash
RESULT=$(postemi upload <file>)
URL=$(echo "$RESULT" | jq -r '.path')
postemi posts:create ... -m "$URL" ...
```
If you see `-m "something.jpg"` anywhere below, treat it as shorthand for "the `.path` you got back from `postemi upload something.jpg`", never a raw local file.
---
## Output contract
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
`postemi 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.
---
## ⚠️ Authentication Required
**You MUST authenticate before running any Postemi CLI command.** All commands will fail without valid credentials.
Before doing anything else, check auth status:
```bash
postemi auth:status
```
If not authenticated:
1. **Sign in:** `postemi auth:login` opens a browser, and the human approves it.
You never see or handle a key.
2. **No account yet:** `postemi auth:signup` creates one, see below. Only when
the user asks you to create a Postemi account for them. They can also sign
up at https://postemi.com/app/signup, which starts a 7-day free trial, and
then run `postemi auth:login`.
### Creating an account for the user (no free trial)
When the user asks you to make them a Postemi account and they have none:
```bash
postemi auth:signup --email anna@example.com
# → {codeSent:true, terms, privacy, timezone, language, next} (no terminal: the code is on its way; `next` is the exact second command, every option kept)
postemi auth:signup --email anna@example.com --code 123456 --accept-terms [--workspace-name "Anna's Cafe"] [--timezone Europe/London]
# → {created:true, workspaceId, workspaceName, trial:false, credentials, next}
```
1. Run it with `--email` only. Postemi e-mails the user a six-digit code
(valid 10 minutes). Ask the user to read the code to you.
2. Show the user the terms (https://postemi.com/terms) and the privacy notice
(https://postemi.com/privacy) and ask whether they agree. Pass
`--accept-terms` ONLY after they said yes; never decide it for them.
3. Run it again with `--code` and `--accept-terms`. The CLI keeps the
sign-in the same way `auth:login` does and never prints it.
4. **There is no free trial on an account made this way**, and until the
user pays, only the `billing:*` commands work. Next: `billing:plans`,
confirm a plan with the user, `billing:subscribe`, give them the link.
Channels are connected by the user in the web app after paying.
5. **You keep working for the user only on a plan with API access**
(`limits.apiAndMcpAccess: true` in `billing:plans`). Basic has none: if
the user picks it, tell them BEFORE they pay that you will not be able to
schedule for them afterwards. `billing:subscribe` answers with
`apiAndMcpAccess: false` and prints the warning on stderr for such a plan.
Exit 1 with "already has a Postemi account" (HTTP 409) means the address is
taken: the user signs in with `postemi auth:login` instead. A wrong or expired
code: run step 1 again for a new one.
`postemi auth:status` reports a valid key of a workspace that has not paid yet
as `authenticated: true, apiAccess: false, reason: "no-plan"` (exit 0). It is
not an invalid key; do not run `auth:login` for it. Only the `billing:*`
commands work until the workspace is on a plan with API access.
**Do NOT proceed with any other commands until authentication is confirmed.**
### What a key can and cannot do
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. It can only be rotated.
Two limits apply to every request, and both explain 403s that look like bugs:
1. **Scopes.** A key carries a subset of `posts:read`, `posts:draft`,
`posts:publish`, `channels:read`, `channels:write`, `media:write`,
`analytics:read`. A route refuses a key that lacks its scope.
2. **The owner's current role, re-checked on every request.** A key can never
do more than the person who created it can do *right now*. If that person is
demoted, their live keys narrow on the next request, with nobody revoking
anything. So a key that worked yesterday can legitimately start returning
403 today with no change to the key.
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.** The CLI names the missing scope; the key is valid, so `auth:login` does not fix it. 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.
---
## Core Workflow
The fundamental pattern for using Postemi CLI:
1. **Authenticate** - Verify or set up authentication (see above)
2. **Workspace** - `postemi workspaces:list`; if more than one and none is `current`, ask the user which, then `postemi workspaces:use <id>`. Channel ids belong to a workspace.
3. **Discover** - List integrations and get their settings
4. **Fetch** - Use integration tools to retrieve dynamic data (Instagram `audioSearch`; Discord and Slack `channels`)
5. **Prepare** - Upload media files if needed
6. **Post** - Create posts with content, media, and platform-specific settings
7. **Analyze** - Track performance with platform and post-level analytics
8. **Resolve** - If analytics returns `{"missing": true}`, run `posts:missing` to list provider content, then `posts:connect` to link it
```bash
# 1. Authenticate
postemi auth:status
# If not authenticated: postemi auth:login (no client id/secret, it is a device flow)
# 2. Discover
postemi integrations:list
postemi integrations:settings <integration-id>
# 3. Fetch (if needed)
postemi integrations:trigger <integration-id> <method> -d '{"key":"value"}'
# 4. Prepare
postemi upload image.jpg
# or, when the file is on the person's phone / not on this machine:
postemi upload:link # show the url to the person, they drop the files
postemi upload:files <id> # what arrived; use each .path in -m
# 5. Post
postemi posts:create -c "Content" -m "image.jpg" -i "<integration-id>"
# 6. Analyze
postemi analytics:platform <integration-id> -d 30
postemi analytics:post <post-id> -d 7
# 7. Resolve (if analytics returns {"missing": true})
postemi posts:missing <post-id>
postemi posts:connect <post-id> --release-id "<content-id>"
```
---
## Essential Commands
### Workspaces
A credential may reach several workspaces (the user ticked "All my
workspaces" at login, or made a multi-workspace key). The API refuses every
request from such a key that does not name one, with 403 and the message
"This API key spans several workspaces", so check first:
```bash
postemi workspaces:list # [{id, name, role, current}]
postemi workspaces:use <id> # persists; every later command acts there
postemi workspaces:current
postemi integrations:list --workspace <id> # one command elsewhere, without switching
```
Say the workspace name back to the user when they have more than one, and
never pick one for them. After a switch, run `integrations:list` again:
channel ids from the other workspace are invalid here.
### Authentication
**Sign in: device login**
```bash
# Opens a browser. No client ID or secret, the human approves the code shown.
postemi auth:login
# Check auth status (verifies credentials are still valid)
postemi auth:status
# Logout (remove stored credentials)
postemi 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
Postemi) approves it and chooses which workspace and which channels the
resulting key may reach. This runs inside Postemi's own backend; there is no
separate auth service.
The CLI keeps its own sign-in after that; do not read, print or copy it.
Off a terminal (when you, an agent, run it) the CLI does not ask for a
timezone: pass `--timezone` on date-taking commands, or run
`postemi config:set timezone Europe/London` once. See "Date Handling".
The CLI talks to `https://api.postemi.com`. The MCP server, for chat clients,
is `https://mcp.postemi.com/mcp`. Other hostnames you may have read about do
not exist.
### Upload link: when the file is not on this machine
`postemi 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:
```bash
postemi upload:link
# → {"id":"kR3mQ7xP2nLa","url":"https://…","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:
```bash
postemi 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.
### Subscription (only when the user asks)
Four commands, the only place a plan is named. Call them only when the user
asks about plans, asks to subscribe, or asks to manage their subscription;
never suggest a plan or an upgrade otherwise.
```bash
postemi billing:plans # → {current:{tier,plan}, plans:[{plan,slug,monthly,yearly,limits}], trial, trialDays}
postemi billing:subscribe --tier pro --period yearly # → {status:"requires_payment", checkoutUrl, checkoutId, plan, amount, currency, trial, expiresAt, apiAndMcpAccess}
postemi billing:status # → {state, plan, period, currentPeriodEnd, payerIsCaller, pendingCheckout, apiAndMcpAccess}
postemi billing:manage # → {portalUrl}
```
The flow:
1. Confirm the plan (Basic, Pro, Creator or Team; `--tier basic`, `pro`,
`creator` or `team`, or the `slug` that `billing:plans` prints) and the
period (monthly or yearly) with the user. Basic has no API access: if the
user picks it, tell them BEFORE they pay that you will not be able to
schedule for them afterwards.
2. Run `billing:subscribe` once and give the user `checkoutUrl`. **The user
opens it and pays with their OWN Link wallet or card.** Do NOT pay with a
one-time or agent-issued card, a virtual card number or any credential of
your own: a Postemi subscription renews monthly or yearly, and a one-time
credential fails at the first renewal, which ends the plan. `--open` opens
the link in the browser of this machine; use it only when this machine is
the user's own.
3. After the user says they have paid, run `billing:status` and check that
`state` is `trialing` or `active` before continuing setup. It changes
within seconds of the payment. If `billing:status` shows a
`pendingCheckout`, hand that link back instead of running
`billing:subscribe` again.
4. A live subscription is changed, cancelled or invoiced in the Stripe
portal: `billing:manage` prints the link. Do not describe plans there.
These commands work before the workspace has a plan with API access (they
are how it gets one). `billing:subscribe` and `billing:manage` need a
full-workspace key or an OAuth token held by the person who pays (or a
workspace owner when nobody pays yet); a scoped key is refused with a
message that says so.
### Rate limits
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:delete-group`, `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.
### Integration Discovery
```bash
# List all connected integrations
postemi integrations:list
# List integrations belonging to a specific group (customer)
postemi integrations:list --group <group-id>
# List all groups (customers) as {id, name}
postemi integrations:groups
# Get settings schema for specific integration
postemi integrations:settings <integration-id>
# Trigger integration tool to fetch dynamic data
postemi integrations:trigger <integration-id> <method-name>
postemi integrations:trigger <integration-id> <method-name> -d '{"param":"value"}'
```
### Creating Posts
```bash
# Simple post (date is REQUIRED)
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "integration-id"
# Draft post
postemi 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=$(postemi upload img1.jpg | jq -r '.path')
IMG2=$(postemi upload img2.jpg | jq -r '.path')
postemi 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=$(postemi upload main.jpg | jq -r '.path')
C1=$(postemi upload comment1.jpg | jq -r '.path')
C2A=$(postemi upload comment2.jpg | jq -r '.path')
C2B=$(postemi upload comment3.jpg | jq -r '.path')
postemi 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
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "$X_ID,$FB_ID,$THREADS_ID"
# Platform-specific settings
postemi posts:create \
-c "Content" \
-s "2026-12-31T12:00:00Z" \
--settings '{"who_can_reply_post":"everyone"}' \
-i "$X_ID"
# Complex post from JSON file
postemi posts:create --json post.json
```
### Managing Posts
```bash
# List posts (defaults to last 30 days to next 30 days)
postemi posts:list
# List posts in date range
postemi posts:list --startDate "2026-01-01T00:00:00Z" --endDate "2026-12-31T23:59:59Z"
# Delete one post (one channel, with its thread or comments); other channels of its group stay
postemi posts:delete <post-id>
# Delete a whole group: the post on every channel it went to (group from posts:list)
postemi posts:delete-group <group>
# Change post status (draft ↔ schedule)
postemi posts:status <post-id> --status draft # Move back to draft, terminates any running publish workflow
postemi posts:status <post-id> --status schedule # Promote a draft into the publishing queue (uses the post's stored date)
```
### Analytics
```bash
# Get platform analytics (default: last 7 days)
postemi analytics:platform <integration-id>
# Get platform analytics for last 30 days
postemi analytics:platform <integration-id> -d 30
# Get post analytics (default: last 7 days)
postemi analytics:post <post-id>
# Get post analytics for last 30 days
postemi 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:
```bash
# 1. analytics:post returns {"missing": true}
postemi analytics:post <post-id>
# 2. Get available content from the provider
postemi posts:missing <post-id>
# Returns: [{"id": "7321456789012345678", "url": "https://...cover.jpg"}, ...]
# 3. Connect the correct content to the post
postemi posts:connect <post-id> --release-id "7321456789012345678"
# 4. Now analytics will work
postemi analytics:post <post-id>
```
### Connecting Missing Posts
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.
```bash
# List recent content from the provider for a post with missing release ID
postemi posts:missing <post-id>
# Connect a post to its published content
postemi 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.
### Media Upload
**⚠️ IMPORTANT:** Always upload files to Postemi before using them in posts. Many platforms (TikTok, Instagram, YouTube) **require verified URLs** and will reject external links.
```bash
# Upload file and get URL
postemi 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=$(postemi upload video.mp4)
VIDEO_PATH=$(echo "$VIDEO" | jq -r '.path')
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$VIDEO_PATH" -i "tiktok-id"
```
---
## Common Patterns
### Pattern 1: Discover & Use Integration Tools
**Most supported channels have no tools at all.** Of the channels in the table
at the top of this file, six expose a tool: Instagram (`audioSearch`),
Discord (`channels`), Slack (`channels`), Pinterest (`boards`), WordPress
(`postTypes`, `categoriesList`, `tagsList`) and Dev.to (`tags`,
`organizations`). The others 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.
```bash
IG_ID=$(postemi integrations:list | jq -r '.[] | select(.identifier=="instagram") | .id')
# What can this channel actually do?
postemi integrations:settings "$IG_ID" | jq '.output.tools'
# [ { "methodName": "audioSearch", "description": "Search audio …", "dataSchema": [ … ] } ]
```
**Instagram, find audio for a Reel:**
```bash
# Empty query returns trending audio. type is "music" (default) or "original_sound".
AUDIO=$(postemi integrations:trigger "$IG_ID" audioSearch -d '{"q":"lofi","type":"music"}')
AUDIO_ID=$(echo "$AUDIO" | jq -r '.output[0].id')
VIDEO=$(postemi upload reel.mp4 | jq -r '.path')
postemi posts:create \
-c "Reel caption" \
-s "2026-12-31T12:00:00Z" \
--settings "{\"post_type\":\"post\",\"audio\":{\"id\":\"$AUDIO_ID\"}}" \
-m "$VIDEO" \
-i "$IG_ID"
```
### Pattern 2: Upload Media Before Posting
```bash
# Upload multiple files
VIDEO_RESULT=$(postemi upload video.mp4)
VIDEO_PATH=$(echo "$VIDEO_RESULT" | jq -r '.path')
THUMB_RESULT=$(postemi upload thumbnail.jpg)
THUMB_PATH=$(echo "$THUMB_RESULT" | jq -r '.path')
# Use in post
postemi posts:create \
-c "Check out my video!" \
-s "2026-12-31T12:00:00Z" \
-m "$VIDEO_PATH" \
-i "tiktok-id"
```
### Pattern 3: Twitter Thread
```bash
# Upload every image first (Rule 2)
INTRO=$(postemi upload intro.jpg | jq -r '.path')
P1=$(postemi upload point1.jpg | jq -r '.path')
P2=$(postemi upload point2.jpg | jq -r '.path')
OUTRO=$(postemi upload outro.jpg | jq -r '.path')
postemi 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)
```
### Pattern 4: Multi-Platform Campaign (different content per channel)
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](./examples/multi-platform-post.json).
```bash
IMG=$(postemi 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
postemi posts:create --json campaign.json
```
### Pattern 5: Validate Settings Before Posting
```bash
#!/bin/bash
INTEGRATION_ID="$X_ID"
CONTENT="Your post content here"
# Get integration settings and extract max length
SETTINGS_JSON=$(postemi 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
postemi posts:create \
-c "$CONTENT" \
-s "2026-12-31T12:00:00Z" \
--settings '{"key": "value"}' \
-i "$INTEGRATION_ID"
```
### Pattern 6: Batch Scheduling
```bash
#!/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=$(postemi upload "post-${i}.jpg" | jq -r '.path')
postemi posts:create \
-c "${CONTENT[$i]}" \
-s "${DATES[$i]}" \
-i "$X_ID" \
-m "$IMG"
echo "Scheduled: ${CONTENT[$i]} for ${DATES[$i]}"
done
```
### Pattern 7: Error Handling & Retry
`posts:create` already retries for you: every run sends an `Idempotency-Key`
(a new UUID per run) and, after a network failure or a 5xx, retries ONCE with
the same key, so the server hands back the post it already made instead of
making a second one. Running the command again is a NEW key and a NEW post.
So never wrap `posts:create` in a retry loop; after a failure, look first:
```bash
#!/bin/bash
CONTENT="Your post content"
INTEGRATION_ID="$X_ID"
DATE="2026-12-31T12:00:00Z"
if postemi posts:create -c "$CONTENT" -s "$DATE" -i "$INTEGRATION_ID"; then
echo "Post created successfully"
else
# Did it get through anyway? Check before running it again.
postemi posts:list --startDate "$DATE" --endDate "$DATE"
fi
```
A 403 that names a missing permission is not retryable: the key is valid
and lacks the scope. A 401 saying the workspace has no plan with API access
is not retryable either: see `billing:plans`.
---
## Technical Concepts
### Integration Tools Workflow
Some integrations expose a tool for data that cannot be hard-coded. Among supported channels Instagram, Discord and Slack do. The workflow:
1. **Check available tools** - `integrations:settings` returns a `tools` array
2. **Review tool schema** - Each tool has `methodName`, `description`, and `dataSchema`
3. **Trigger tool** - Call `integrations:trigger` with required parameters
4. **Use output** - Tool returns data to use in post settings
**Tools 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.
### Provider Settings Structure
Platform-specific settings use a discriminator pattern with `__type` field:
```json
{
"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:
```bash
postemi 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.
### Two behaviours that surprise people
**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
Contributor/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 Postemi'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.
### Comments and Threading
Posts can have comments (threads on Twitter/X, replies elsewhere). Each comment can have its own media:
```bash
# Upload every file first (Rule 2)
I1=$(postemi upload image1.jpg | jq -r '.path')
I2=$(postemi upload image2.jpg | jq -r '.path')
CI=$(postemi upload comment-img.jpg | jq -r '.path')
A1=$(postemi upload another.jpg | jq -r '.path')
A2=$(postemi upload more.jpg | jq -r '.path')
postemi 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 Postemi-uploaded `.path`, not a raw filename):
```json
{
"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 }
]
}]
}
```
### Date Handling
**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 local "12:00" by the user's UTC
offset. The CLI refuses ambiguity instead.
The two safe forms:
```bash
-s "2026-12-31T12:00:00Z" # explicit UTC
-s "2026-12-31 12:00" --timezone Europe/London # wall-clock + IANA zone
```
How a date is resolved, in priority order:
1. **Explicit offset in the string** (`Z`, `+01:00`), used as written.
2. **`--timezone <IANA name>`** on `posts:create` / `posts:list`.
3. **The saved config**: `postemi config:set timezone Europe/London`
(also offered interactively during `postemi auth:login` on a TTY).
4. **None of the above and the date is naive → hard error, exit 1.** The
error names the fixes. Do not retry the same naive date; add a
timezone.
Rules and behaviors:
- Timezone values must be IANA names (`Europe/London`, `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).
- Whenever a timezone is applied (steps 2 to 4), the CLI echoes the resolved
UTC instant on stderr:
`ℹ️ Interpreted "2026-12-24 18:00" as Europe/London (via --timezone) → 2026-12-24T18:00:00.000Z`
- A bare date (`"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.
- `postemi config:get` / `postemi config:get timezone` shows what is saved;
settings survive `auth:logout`.
- `postemi 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`.
### Media Upload Response
Upload returns JSON with path and metadata:
```json
{
"path": "https://…/uploads/2026/08/abc123.jpg",
"size": 123456,
"type": "image/jpeg"
}
```
Extract path for use in posts:
```bash
RESULT=$(postemi upload image.jpg)
PATH=$(echo "$RESULT" | jq -r '.path')
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$PATH" -i "integration-id"
```
### JSON Mode vs CLI Flags
**CLI flags** - Quick posts:
```bash
postemi posts:create -c "Content" -m "$(postemi upload img.jpg | jq -r '.path')" -s "2026-12-31T12:00:00Z" -i "$X_ID"
```
**JSON mode** - Complex posts with multiple platforms and settings:
```bash
postemi posts:create --json post.json
```
JSON mode supports:
- Multiple platforms with different content per platform
- Complex provider-specific settings
- Scheduled posts
- Posts with many comments
- Custom delay between comments
---
## Platform-Specific Examples
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`.
### X (Twitter): `x`
```bash
postemi 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).
### Facebook: `facebook`
```bash
IMG=$(postemi upload photo.jpg | jq -r '.path')
postemi 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).
### Instagram: `instagram`
```bash
IMG=$(postemi upload image.jpg | jq -r '.path')
# Feed post
postemi posts:create -c "Caption #hashtag" -s "2026-12-31T12:00:00Z" \
--settings '{"post_type":"post"}' -m "$IMG" -i "$IG_ID"
# Story
STORY=$(postemi upload story.jpg | jq -r '.path')
postemi 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 (`threads`) and Bluesky (`bluesky`)
```bash
postemi posts:create -c "Post text" -s "2026-12-31T12:00:00Z" -i "$THREADS_ID"
```
No provider settings. Omit `--settings` entirely.
## What Postemi does NOT do
State these plainly rather than attempting a workaround.
- **No AI video generation.** The `/generate-video` and `/video/function`
routes were removed from the API entirely (2026-08-06). Do not tell a user
Postemi can generate video, and do not call those routes: they 404.
- **No image or video generation of any kind.** Media comes from files the
user already has, via `postemi upload`.
- **No channel deletion.** `DELETE /integrations/:id` was deliberately removed
from the public API. Disconnecting a channel is a signed-in, admin action in
the web app.
- **Nothing outside the channel table at the top of this file.**
---
## Optional skills: writing and growth
Two more Postemi skills are optional. They are not installed unless the user
chose them:
| Skill | What it adds | Install |
|---|---|---|
| writing (`postemi-writing` + `postemi-templates`) | post ideas, hooks, formatting, each network's writing rules, the composer's templates | `postemi skills add writing` |
| growth (`postemi-growth`) | choosing a niche, cadence per network, a 30-day plan, the weekly review | `postemi skills add growth` |
```bash
postemi skills list # core + optional, and where each is installed (JSON)
postemi skills add writing --json # into every agent found (Claude Code, Codex, Cursor, Grok Build)
postemi skills add growth --agent claude --json
postemi skills remove growth --json
```
When the user asks for help writing posts or planning content and
`postemi skills list` shows the skill as missing, **offer** it: say what it
adds and ask. Run `postemi skills add` only after the user says yes; never
install it silently. Pass `--json` (or run without a terminal) so the CLI asks
no questions; a new agent session loads the skills.
---
## Supporting Resources
**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):
- [PROVIDER_SETTINGS.md](./PROVIDER_SETTINGS.md) - Settings schema for every supported channel
- [SUPPORTED_FILE_TYPES.md](./SUPPORTED_FILE_TYPES.md) - The eight accepted media types
**Ready-to-use `--json` payloads** (all verified against the current CLI):
- [examples/post-with-comments.json](./examples/post-with-comments.json) - Threading example
- [examples/thread-post.json](./examples/thread-post.json) - Multi-part thread
- [examples/multi-platform-post.json](./examples/multi-platform-post.json) - One post, several channels
- [examples/multi-platform-with-settings.json](./examples/multi-platform-with-settings.json) - Campaign example
- [examples/youtube-video.json](./examples/youtube-video.json) - YouTube with tags
- [examples/tiktok-video.json](./examples/tiktok-video.json) - TikTok with privacy
---
## Common Gotchas
1. **Not authenticated** - Ask the user to run `postemi auth:login` and approve it in the browser
2. **Invalid integration ID** - Run `integrations:list` to get current IDs
3. **Settings schema mismatch** - Check `integrations:settings` for required fields
4. **Media MUST be uploaded to Postemi first** - ⚠️ **CRITICAL (Rule 2):** Every value passed to `-m` or to an `image`/media field in JSON mode must be a `.path` returned by `postemi upload`. Raw local filenames (`image.jpg`) and external URLs (`https://...`) will be rejected. TikTok, Instagram, YouTube and most other providers only accept Postemi-verified URLs. No exceptions: even a "quick test post" needs the upload step.
5. **JSON escaping in shell** - Use single quotes for JSON: `--settings '{...}'`
6. **Date format** - ISO 8601 with an explicit offset (`"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`.
7. **Tool not found** - Check available tools in `integrations:settings` output
8. **Character limits** - Each platform has different limits, check `maxLength` in settings
9. **Required settings** - YouTube requires `title` 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.
10. **Media MIME types** - CLI auto-detects from file extension, ensure correct extension
11. **Analytics returns `{"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.
---
## Quick Reference
```bash
# ⚠️ AUTHENTICATE FIRST - required before any other command
postemi auth:status # Check if authenticated
postemi auth:login # OAuth2 device flow login
postemi auth:signup --email <address> # New account by e-mailed code; no free trial
postemi auth:logout # Remove credentials
# Settings (survive logout)
postemi config:set timezone Europe/London # Timezone for dates without an offset
postemi config:get # Show saved settings as JSON
# Discovery (only after auth is confirmed)
postemi integrations:list # Get integration IDs
postemi integrations:list --group <group-id> # Get integration IDs in a group
postemi integrations:groups # List groups (customers)
postemi integrations:settings <id> # Get settings schema
postemi integrations:trigger <id> <method> -d '{}' # Fetch dynamic data
# Posting (date required unless -t now or --json; explicit offset or --timezone, never naive)
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" -i "id" # Simple (UTC)
postemi posts:create -c "Good morning!" -s "2026-12-31 08:00" --timezone Europe/London -i "id" # Local wall-clock
postemi posts:create -c "text" -t now -i "id" # Publish immediately
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" -t draft -i "id" # Draft
postemi posts:create -c "text" -m "$(postemi upload img.jpg | jq -r '.path')" -s "2026-12-31T12:00:00Z" -i "id" # With media (upload first, Rule 2)
postemi posts:create -c "main" -c "comment" -s "2026-12-31T12:00:00Z" -i "id" # With comment
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" --settings '{}' -i "id" # Platform-specific
postemi posts:create --json file.json # Complex
# Management
postemi posts:list # List posts
postemi posts:find-slot <integration-id> # Next free slot: {"date": "..."}
postemi posts:delete <id> # Delete one post (one channel)
postemi posts:delete-group <group> # Delete every channel of a group
postemi posts:status <id> --status draft # Move to draft (stops workflow)
postemi posts:status <id> --status schedule # Queue draft for publishing
postemi upload <file> # Upload media
# Analytics
postemi analytics:platform <id> # Platform analytics (7 days)
postemi analytics:platform <id> -d 30 # Platform analytics (30 days)
postemi analytics:post <id> # Post analytics (7 days)
postemi analytics:post <id> -d 30 # Post analytics (30 days)
# If analytics:post returns {"missing": true}, resolve it:
postemi posts:missing <id> # List provider content
postemi posts:connect <id> --release-id "<rid>" # Connect content to post
# Optional skills (ask the user first)
postemi skills list # Core + optional skills, where installed
postemi skills add writing|growth --json # Install an optional skill
# Help
postemi --help # Show help
postemi posts:create --help # Command help
```