postemi 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROVIDER_SETTINGS.md +183 -7
- package/README.md +51 -5
- package/SKILL.md +83 -30
- package/dist/index.js +389 -29
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
- package/skills/postemi/SKILL.md +1121 -0
- package/skills/postemi-growth/SKILL.md +221 -0
- package/skills/postemi-growth/reference/30-angles.md +74 -0
- package/skills/postemi-growth/reference/review.md +63 -0
- package/skills/postemi-templates/SKILL.md +107 -0
- package/skills/postemi-templates/reference/engagement.md +155 -0
- package/skills/postemi-templates/reference/everyday.md +132 -0
- package/skills/postemi-templates/reference/hook-contrarian.md +227 -0
- package/skills/postemi-templates/reference/hook-growth.md +210 -0
- package/skills/postemi-templates/reference/hook-how-to.md +266 -0
- package/skills/postemi-templates/reference/hook-learning.md +296 -0
- package/skills/postemi-templates/reference/hook-lists.md +195 -0
- package/skills/postemi-templates/reference/hook-stories.md +282 -0
- package/skills/postemi-templates/reference/hook-talk.md +199 -0
- package/skills/postemi-templates/reference/index.md +251 -0
- package/skills/postemi-templates/reference/launch.md +145 -0
- package/skills/postemi-templates/reference/linkedin.md +449 -0
- package/skills/postemi-templates/reference/x.md +139 -0
- package/skills/postemi-writing/SKILL.md +310 -0
- package/skills/postemi-writing/reference/headline-patterns.md +109 -0
- package/skills/postemi-writing/reference/networks.md +113 -0
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: postemi
|
|
3
|
+
description: Postemi schedules and publishes social posts from the command line to 25+ platforms, including Facebook, Instagram, X, LinkedIn, TikTok, YouTube, Threads, Bluesky, Pinterest and Mastodon.
|
|
4
|
+
homepage: https://postemi.com
|
|
5
|
+
metadata: {"openclaw":{"emoji":"🌎","requires":{"bins":[],"env":[]}}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
The npm package and the command are both **`postemi`**.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g postemi
|
|
14
|
+
postemi auth:login # opens the browser to approve the login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
official website: https://postemi.com
|
|
18
|
+
API base: https://api.postemi.com
|
|
19
|
+
MCP server, for chat clients without a terminal: https://mcp.postemi.com/mcp
|
|
20
|
+
(the user signs in to Postemi in the browser; same account, same channels)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
| Property | Value |
|
|
25
|
+
|----------|-------|
|
|
26
|
+
| **name** | postemi |
|
|
27
|
+
| **description** | CLI for scheduling and publishing posts through the Postemi public API |
|
|
28
|
+
| **allowed-tools** | Bash(postemi:*) |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Which channels you can publish to
|
|
33
|
+
|
|
34
|
+
**Only these. Do not offer a user anything else, and do not try to post to it.**
|
|
35
|
+
Postemi's server carries inherited code for many more providers; that code is not
|
|
36
|
+
enabled and not supported. Postemi adds channels over time, so
|
|
37
|
+
`integrations:list` (below) can show one that is not in this table yet.
|
|
38
|
+
|
|
39
|
+
| Channel | `identifier` | Status |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| X (Twitter) | `x` | Live |
|
|
42
|
+
| Facebook (Pages) | `facebook` | Live |
|
|
43
|
+
| Instagram (business account through a Facebook Page) | `instagram` | Live |
|
|
44
|
+
| Instagram (professional account, no Facebook Page) | `instagram-standalone` | Live |
|
|
45
|
+
| LinkedIn (member feed) | `linkedin` | Live |
|
|
46
|
+
| TikTok | `tiktok` | Live |
|
|
47
|
+
| YouTube | `youtube` | Live |
|
|
48
|
+
| Threads | `threads` | Live |
|
|
49
|
+
| Bluesky | `bluesky` | Live |
|
|
50
|
+
| Telegram | `telegram` | Live |
|
|
51
|
+
| Discord | `discord` | Live |
|
|
52
|
+
| Slack | `slack` | Live |
|
|
53
|
+
| Pinterest | `pinterest` | Coming soon, not schedulable yet |
|
|
54
|
+
| Mastodon (mastodon.social) | `mastodon` | BETA |
|
|
55
|
+
| Mastodon (any other server) | `mastodon-custom` | BETA |
|
|
56
|
+
| Pixelfed (any server) | `pixelfed` | BETA |
|
|
57
|
+
| WordPress (your own site) | `wordpress` | BETA |
|
|
58
|
+
| Tumblr | `tumblr` | BETA |
|
|
59
|
+
| Dev.to | `devto` | BETA |
|
|
60
|
+
| Ghost (your own site) | `ghost` | BETA |
|
|
61
|
+
| Google Chat (a space, by webhook) | `google-chat` | BETA |
|
|
62
|
+
| Microsoft Teams (a channel, by webhook) | `microsoft-teams` | BETA |
|
|
63
|
+
| Google Business Profile | `gmb` | Coming soon, not schedulable yet |
|
|
64
|
+
| LinkedIn Pages (company pages) | `linkedin-page` | Coming soon, not schedulable yet |
|
|
65
|
+
|
|
66
|
+
**Pinterest is coming soon.** Postemi customers cannot connect it until Pinterest
|
|
67
|
+
approves Postemi's app, so it never appears in `integrations:list`. Tell the user
|
|
68
|
+
it is not available yet. (When it opens, a pin needs a `board` id in
|
|
69
|
+
`--settings`; see PROVIDER_SETTINGS.md.)
|
|
70
|
+
|
|
71
|
+
**The other BETA channels** connect and publish, but Postemi has not tried them
|
|
72
|
+
with many real accounts yet. Say so when the user picks one. Most need
|
|
73
|
+
settings (a title for WordPress, Dev.to and Ghost; a picture for Pixelfed);
|
|
74
|
+
see PROVIDER_SETTINGS.md.
|
|
75
|
+
|
|
76
|
+
**Google Business Profile and LinkedIn Pages are coming soon.** Those platforms
|
|
77
|
+
have not approved Postemi yet, so the user cannot connect them. Do not schedule
|
|
78
|
+
for them; personal LinkedIn profiles (`linkedin`) work today.
|
|
79
|
+
|
|
80
|
+
**`integrations:list` is the truth.** The table says what Postemi supports; that
|
|
81
|
+
command says what THIS user has actually connected. Check it before you
|
|
82
|
+
schedule anything, and never post to an id that is not in its output.
|
|
83
|
+
|
|
84
|
+
**Never assume another provider exists.** If a user asks for Reddit, Medium,
|
|
85
|
+
Farcaster, Hashnode, Lemmy, Nostr, VK, a newsletter service or anything else
|
|
86
|
+
that is not in the table, the answer is that Postemi does not offer it today.
|
|
87
|
+
Do not build a `--settings` payload for it.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## ⚠️ Two Hard Rules (Read First)
|
|
92
|
+
|
|
93
|
+
**Rule 1: Authenticate before anything.** All commands fail without valid credentials.
|
|
94
|
+
|
|
95
|
+
**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:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
RESULT=$(postemi upload <file>)
|
|
99
|
+
URL=$(echo "$RESULT" | jq -r '.path')
|
|
100
|
+
postemi posts:create ... -m "$URL" ...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Output contract
|
|
108
|
+
|
|
109
|
+
Every command prints its **JSON result, and nothing else, to stdout**;
|
|
110
|
+
status lines, warnings and errors go to stderr, and every failure exits 1. So
|
|
111
|
+
`postemi upload x.jpg | jq -r '.path'` is always safe, with no stripping needed.
|
|
112
|
+
Unknown flags are an error, not silently ignored. If a flag is rejected, it
|
|
113
|
+
does not exist; do not retry with variations.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## ⚠️ Authentication Required
|
|
118
|
+
|
|
119
|
+
**You MUST authenticate before running any Postemi CLI command.** All commands will fail without valid credentials.
|
|
120
|
+
|
|
121
|
+
Before doing anything else, check auth status:
|
|
122
|
+
```bash
|
|
123
|
+
postemi auth:status
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
If not authenticated:
|
|
127
|
+
1. **Sign in:** `postemi auth:login` opens a browser, and the human approves it.
|
|
128
|
+
You never see or handle a key.
|
|
129
|
+
2. **No account yet:** `postemi auth:signup` creates one, see below. Only when
|
|
130
|
+
the user asks you to create a Postemi account for them. They can also sign
|
|
131
|
+
up at https://postemi.com/app/signup, which starts a 7-day free trial, and
|
|
132
|
+
then run `postemi auth:login`.
|
|
133
|
+
|
|
134
|
+
### Creating an account for the user (no free trial)
|
|
135
|
+
|
|
136
|
+
When the user asks you to make them a Postemi account and they have none:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
postemi auth:signup --email anna@example.com
|
|
140
|
+
# → {codeSent:true, terms, privacy, timezone, language, next} (no terminal: the code is on its way; `next` is the exact second command, every option kept)
|
|
141
|
+
postemi auth:signup --email anna@example.com --code 123456 --accept-terms [--workspace-name "Anna's Cafe"] [--timezone Europe/London]
|
|
142
|
+
# → {created:true, workspaceId, workspaceName, trial:false, credentials, next}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
1. Run it with `--email` only. Postemi e-mails the user a six-digit code
|
|
146
|
+
(valid 10 minutes). Ask the user to read the code to you.
|
|
147
|
+
2. Show the user the terms (https://postemi.com/terms) and the privacy notice
|
|
148
|
+
(https://postemi.com/privacy) and ask whether they agree. Pass
|
|
149
|
+
`--accept-terms` ONLY after they said yes; never decide it for them.
|
|
150
|
+
3. Run it again with `--code` and `--accept-terms`. The CLI keeps the
|
|
151
|
+
sign-in the same way `auth:login` does and never prints it.
|
|
152
|
+
4. **There is no free trial on an account made this way**, and until the
|
|
153
|
+
user pays, only the `billing:*` commands work. Next: `billing:plans`,
|
|
154
|
+
confirm a plan with the user, `billing:subscribe`, give them the link.
|
|
155
|
+
Channels are connected by the user in the web app after paying.
|
|
156
|
+
5. **You keep working for the user only on a plan with API access**
|
|
157
|
+
(`limits.apiAndMcpAccess: true` in `billing:plans`). Basic has none: if
|
|
158
|
+
the user picks it, tell them BEFORE they pay that you will not be able to
|
|
159
|
+
schedule for them afterwards. `billing:subscribe` answers with
|
|
160
|
+
`apiAndMcpAccess: false` and prints the warning on stderr for such a plan.
|
|
161
|
+
|
|
162
|
+
Exit 1 with "already has a Postemi account" (HTTP 409) means the address is
|
|
163
|
+
taken: the user signs in with `postemi auth:login` instead. A wrong or expired
|
|
164
|
+
code: run step 1 again for a new one.
|
|
165
|
+
|
|
166
|
+
`postemi auth:status` reports a valid key of a workspace that has not paid yet
|
|
167
|
+
as `authenticated: true, apiAccess: false, reason: "no-plan"` (exit 0). It is
|
|
168
|
+
not an invalid key; do not run `auth:login` for it. Only the `billing:*`
|
|
169
|
+
commands work until the workspace is on a plan with API access.
|
|
170
|
+
|
|
171
|
+
**Do NOT proceed with any other commands until authentication is confirmed.**
|
|
172
|
+
|
|
173
|
+
### What a key can and cannot do
|
|
174
|
+
|
|
175
|
+
Keys are **per person, named, and scoped**. They are stored hashed and shown
|
|
176
|
+
exactly once, at creation, in the web app under **Settings → Developers**. A
|
|
177
|
+
lost key cannot be recovered. It can only be rotated.
|
|
178
|
+
|
|
179
|
+
Two limits apply to every request, and both explain 403s that look like bugs:
|
|
180
|
+
|
|
181
|
+
1. **Scopes.** A key carries a subset of `posts:read`, `posts:draft`,
|
|
182
|
+
`posts:publish`, `channels:read`, `channels:write`, `media:write`,
|
|
183
|
+
`analytics:read`. A route refuses a key that lacks its scope.
|
|
184
|
+
2. **The owner's current role, re-checked on every request.** A key can never
|
|
185
|
+
do more than the person who created it can do *right now*. If that person is
|
|
186
|
+
demoted, their live keys narrow on the next request, with nobody revoking
|
|
187
|
+
anything. So a key that worked yesterday can legitimately start returning
|
|
188
|
+
403 today with no change to the key.
|
|
189
|
+
|
|
190
|
+
A key can also be **granted only some workspaces and only some channels**. A
|
|
191
|
+
request naming a channel outside the grant is refused even when the scope is
|
|
192
|
+
right.
|
|
193
|
+
|
|
194
|
+
**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
|
|
195
|
+
retry, and do not try a different route to get around it. Ask the user to mint
|
|
196
|
+
a key with the scope, or to have someone with the right role do it.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Core Workflow
|
|
201
|
+
|
|
202
|
+
The fundamental pattern for using Postemi CLI:
|
|
203
|
+
|
|
204
|
+
1. **Authenticate** - Verify or set up authentication (see above)
|
|
205
|
+
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.
|
|
206
|
+
3. **Discover** - List integrations and get their settings
|
|
207
|
+
4. **Fetch** - Use integration tools to retrieve dynamic data (Instagram `audioSearch`; Discord and Slack `channels`)
|
|
208
|
+
5. **Prepare** - Upload media files if needed
|
|
209
|
+
6. **Post** - Create posts with content, media, and platform-specific settings
|
|
210
|
+
7. **Analyze** - Track performance with platform and post-level analytics
|
|
211
|
+
8. **Resolve** - If analytics returns `{"missing": true}`, run `posts:missing` to list provider content, then `posts:connect` to link it
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# 1. Authenticate
|
|
215
|
+
postemi auth:status
|
|
216
|
+
# If not authenticated: postemi auth:login (no client id/secret, it is a device flow)
|
|
217
|
+
|
|
218
|
+
# 2. Discover
|
|
219
|
+
postemi integrations:list
|
|
220
|
+
postemi integrations:settings <integration-id>
|
|
221
|
+
|
|
222
|
+
# 3. Fetch (if needed)
|
|
223
|
+
postemi integrations:trigger <integration-id> <method> -d '{"key":"value"}'
|
|
224
|
+
|
|
225
|
+
# 4. Prepare
|
|
226
|
+
postemi upload image.jpg
|
|
227
|
+
# or, when the file is on the person's phone / not on this machine:
|
|
228
|
+
postemi upload:link # show the url to the person, they drop the files
|
|
229
|
+
postemi upload:files <id> # what arrived; use each .path in -m
|
|
230
|
+
|
|
231
|
+
# 5. Post
|
|
232
|
+
postemi posts:create -c "Content" -m "image.jpg" -i "<integration-id>"
|
|
233
|
+
|
|
234
|
+
# 6. Analyze
|
|
235
|
+
postemi analytics:platform <integration-id> -d 30
|
|
236
|
+
postemi analytics:post <post-id> -d 7
|
|
237
|
+
|
|
238
|
+
# 7. Resolve (if analytics returns {"missing": true})
|
|
239
|
+
postemi posts:missing <post-id>
|
|
240
|
+
postemi posts:connect <post-id> --release-id "<content-id>"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Essential Commands
|
|
246
|
+
|
|
247
|
+
### Workspaces
|
|
248
|
+
|
|
249
|
+
A credential may reach several workspaces (the user ticked "All my
|
|
250
|
+
workspaces" at login, or made a multi-workspace key). The API refuses every
|
|
251
|
+
request from such a key that does not name one, with 403 and the message
|
|
252
|
+
"This API key spans several workspaces", so check first:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
postemi workspaces:list # [{id, name, role, current}]
|
|
256
|
+
postemi workspaces:use <id> # persists; every later command acts there
|
|
257
|
+
postemi workspaces:current
|
|
258
|
+
postemi integrations:list --workspace <id> # one command elsewhere, without switching
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Say the workspace name back to the user when they have more than one, and
|
|
262
|
+
never pick one for them. After a switch, run `integrations:list` again:
|
|
263
|
+
channel ids from the other workspace are invalid here.
|
|
264
|
+
|
|
265
|
+
### Authentication
|
|
266
|
+
|
|
267
|
+
**Sign in: device login**
|
|
268
|
+
```bash
|
|
269
|
+
# Opens a browser. No client ID or secret, the human approves the code shown.
|
|
270
|
+
postemi auth:login
|
|
271
|
+
|
|
272
|
+
# Check auth status (verifies credentials are still valid)
|
|
273
|
+
postemi auth:status
|
|
274
|
+
|
|
275
|
+
# Logout (remove stored credentials)
|
|
276
|
+
postemi auth:logout
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
How it works, so you can explain it if the user asks: the CLI calls
|
|
280
|
+
`POST /device/code`, prints a short user code and opens the approval page in
|
|
281
|
+
the browser, then polls `POST /device/token` until the human (signed in to
|
|
282
|
+
Postemi) approves it and chooses which workspace and which channels the
|
|
283
|
+
resulting key may reach. This runs inside Postemi's own backend; there is no
|
|
284
|
+
separate auth service.
|
|
285
|
+
|
|
286
|
+
The CLI keeps its own sign-in after that; do not read, print or copy it.
|
|
287
|
+
|
|
288
|
+
Off a terminal (when you, an agent, run it) the CLI does not ask for a
|
|
289
|
+
timezone: pass `--timezone` on date-taking commands, or run
|
|
290
|
+
`postemi config:set timezone Europe/London` once. See "Date Handling".
|
|
291
|
+
|
|
292
|
+
The CLI talks to `https://api.postemi.com`. The MCP server, for chat clients,
|
|
293
|
+
is `https://mcp.postemi.com/mcp`. Other hostnames you may have read about do
|
|
294
|
+
not exist.
|
|
295
|
+
|
|
296
|
+
### Upload link: when the file is not on this machine
|
|
297
|
+
|
|
298
|
+
`postemi upload` needs the file on disk. When the person has it on their phone,
|
|
299
|
+
or you cannot see their filesystem, do NOT ask them to host it somewhere
|
|
300
|
+
public. Mint a link:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
postemi upload:link
|
|
304
|
+
# → {"id":"kR3mQ7xP2nLa","url":"https://…","expiresAt":"…"}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Show the `url` to the person verbatim and say: open it, drop the image or
|
|
308
|
+
video, come back and tell me it is done. It works signed out and on a phone.
|
|
309
|
+
When they say done:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
postemi upload:files kR3mQ7xP2nLa
|
|
313
|
+
# → {"status":"ready","count":1,"files":[{"id":"…","name":"clip.mp4","path":"https://…","type":"video"}]}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Pass each `path` to `posts:create -m`. `status: "empty"` is not an error: they
|
|
317
|
+
have not finished, or spoke before the last file landed. Ask them to check the
|
|
318
|
+
page says "uploaded" and run it again. The link lives two hours; mint a new one
|
|
319
|
+
if it expires.
|
|
320
|
+
|
|
321
|
+
### Subscription (only when the user asks)
|
|
322
|
+
|
|
323
|
+
Four commands, the only place a plan is named. Call them only when the user
|
|
324
|
+
asks about plans, asks to subscribe, or asks to manage their subscription;
|
|
325
|
+
never suggest a plan or an upgrade otherwise.
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
postemi billing:plans # → {current:{tier,plan}, plans:[{plan,slug,monthly,yearly,limits}], trial, trialDays}
|
|
329
|
+
postemi billing:subscribe --tier pro --period yearly # → {status:"requires_payment", checkoutUrl, checkoutId, plan, amount, currency, trial, expiresAt, apiAndMcpAccess}
|
|
330
|
+
postemi billing:status # → {state, plan, period, currentPeriodEnd, payerIsCaller, pendingCheckout, apiAndMcpAccess}
|
|
331
|
+
postemi billing:manage # → {portalUrl}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The flow:
|
|
335
|
+
|
|
336
|
+
1. Confirm the plan (Basic, Pro, Creator or Team; `--tier basic`, `pro`,
|
|
337
|
+
`creator` or `team`, or the `slug` that `billing:plans` prints) and the
|
|
338
|
+
period (monthly or yearly) with the user. Basic has no API access: if the
|
|
339
|
+
user picks it, tell them BEFORE they pay that you will not be able to
|
|
340
|
+
schedule for them afterwards.
|
|
341
|
+
2. Run `billing:subscribe` once and give the user `checkoutUrl`. **The user
|
|
342
|
+
opens it and pays with their OWN Link wallet or card.** Do NOT pay with a
|
|
343
|
+
one-time or agent-issued card, a virtual card number or any credential of
|
|
344
|
+
your own: a Postemi subscription renews monthly or yearly, and a one-time
|
|
345
|
+
credential fails at the first renewal, which ends the plan. `--open` opens
|
|
346
|
+
the link in the browser of this machine; use it only when this machine is
|
|
347
|
+
the user's own.
|
|
348
|
+
3. After the user says they have paid, run `billing:status` and check that
|
|
349
|
+
`state` is `trialing` or `active` before continuing setup. It changes
|
|
350
|
+
within seconds of the payment. If `billing:status` shows a
|
|
351
|
+
`pendingCheckout`, hand that link back instead of running
|
|
352
|
+
`billing:subscribe` again.
|
|
353
|
+
4. A live subscription is changed, cancelled or invoiced in the Stripe
|
|
354
|
+
portal: `billing:manage` prints the link. Do not describe plans there.
|
|
355
|
+
|
|
356
|
+
These commands work before the workspace has a plan with API access (they
|
|
357
|
+
are how it gets one). `billing:subscribe` and `billing:manage` need a
|
|
358
|
+
full-workspace key or an OAuth token held by the person who pays (or a
|
|
359
|
+
workspace owner when nobody pays yet); a scoped key is refused with a
|
|
360
|
+
message that says so.
|
|
361
|
+
|
|
362
|
+
### Rate limits
|
|
363
|
+
|
|
364
|
+
Tiered, **per key, per route, per hour**. Each route keeps its own bucket, so a
|
|
365
|
+
polling loop on `posts:list` cannot starve `posts:create`.
|
|
366
|
+
|
|
367
|
+
| Routes | Limit |
|
|
368
|
+
|---|---|
|
|
369
|
+
| Reads: `posts:list`, `integrations:list`, `integrations:groups`, `integrations:settings`, `posts:missing`, `posts:find-slot`, notifications | 600/h (the `API_LIMIT` default; production sets 600) |
|
|
370
|
+
| Publish and delete: `posts:create`, `posts:delete`, `posts:delete-group`, `posts:status`, `integrations:trigger` | 60/h |
|
|
371
|
+
| Analytics and channel refresh: `analytics:platform`, `analytics:post`, `/social/:integration` | 30/h |
|
|
372
|
+
| Uploads: `upload`, `upload-from-url` | 30/h |
|
|
373
|
+
| Upload links: `upload:link` 60/h, `upload:files` 600/h | |
|
|
374
|
+
|
|
375
|
+
The bucket is keyed on the hash of the presented key, so rotating a key starts
|
|
376
|
+
a fresh allowance and the old secret's allowance dies with it. Exceeding a
|
|
377
|
+
limit returns `429`. **Back off; do not retry in a tight loop**: a retry
|
|
378
|
+
storm just burns the next hour's allowance too.
|
|
379
|
+
|
|
380
|
+
### Integration Discovery
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
# List all connected integrations
|
|
384
|
+
postemi integrations:list
|
|
385
|
+
|
|
386
|
+
# List integrations belonging to a specific group (customer)
|
|
387
|
+
postemi integrations:list --group <group-id>
|
|
388
|
+
|
|
389
|
+
# List all groups (customers) as {id, name}
|
|
390
|
+
postemi integrations:groups
|
|
391
|
+
|
|
392
|
+
# Get settings schema for specific integration
|
|
393
|
+
postemi integrations:settings <integration-id>
|
|
394
|
+
|
|
395
|
+
# Trigger integration tool to fetch dynamic data
|
|
396
|
+
postemi integrations:trigger <integration-id> <method-name>
|
|
397
|
+
postemi integrations:trigger <integration-id> <method-name> -d '{"param":"value"}'
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Creating Posts
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
# Simple post (date is REQUIRED)
|
|
404
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "integration-id"
|
|
405
|
+
|
|
406
|
+
# Draft post
|
|
407
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -t draft -i "integration-id"
|
|
408
|
+
|
|
409
|
+
# Post with media (upload each file FIRST, see Rule 2)
|
|
410
|
+
IMG1=$(postemi upload img1.jpg | jq -r '.path')
|
|
411
|
+
IMG2=$(postemi upload img2.jpg | jq -r '.path')
|
|
412
|
+
postemi posts:create -c "Content" -m "$IMG1,$IMG2" -s "2026-12-31T12:00:00Z" -i "integration-id"
|
|
413
|
+
|
|
414
|
+
# Post with comments (each with own media, every file uploaded first)
|
|
415
|
+
MAIN=$(postemi upload main.jpg | jq -r '.path')
|
|
416
|
+
C1=$(postemi upload comment1.jpg | jq -r '.path')
|
|
417
|
+
C2A=$(postemi upload comment2.jpg | jq -r '.path')
|
|
418
|
+
C2B=$(postemi upload comment3.jpg | jq -r '.path')
|
|
419
|
+
postemi posts:create \
|
|
420
|
+
-c "Main post" -m "$MAIN" \
|
|
421
|
+
-c "First comment" -m "$C1" \
|
|
422
|
+
-c "Second comment" -m "$C2A,$C2B" \
|
|
423
|
+
-s "2026-12-31T12:00:00Z" \
|
|
424
|
+
-i "integration-id"
|
|
425
|
+
|
|
426
|
+
# Multi-platform post
|
|
427
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -i "$X_ID,$FB_ID,$THREADS_ID"
|
|
428
|
+
|
|
429
|
+
# Platform-specific settings
|
|
430
|
+
postemi posts:create \
|
|
431
|
+
-c "Content" \
|
|
432
|
+
-s "2026-12-31T12:00:00Z" \
|
|
433
|
+
--settings '{"who_can_reply_post":"everyone"}' \
|
|
434
|
+
-i "$X_ID"
|
|
435
|
+
|
|
436
|
+
# Complex post from JSON file
|
|
437
|
+
postemi posts:create --json post.json
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Managing Posts
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
# List posts (defaults to last 30 days to next 30 days)
|
|
444
|
+
postemi posts:list
|
|
445
|
+
|
|
446
|
+
# List posts in date range
|
|
447
|
+
postemi posts:list --startDate "2026-01-01T00:00:00Z" --endDate "2026-12-31T23:59:59Z"
|
|
448
|
+
|
|
449
|
+
# Delete one post (one channel, with its thread or comments); other channels of its group stay
|
|
450
|
+
postemi posts:delete <post-id>
|
|
451
|
+
|
|
452
|
+
# Delete a whole group: the post on every channel it went to (group from posts:list)
|
|
453
|
+
postemi posts:delete-group <group>
|
|
454
|
+
|
|
455
|
+
# Change post status (draft ↔ schedule)
|
|
456
|
+
postemi posts:status <post-id> --status draft # Move back to draft, terminates any running publish workflow
|
|
457
|
+
postemi posts:status <post-id> --status schedule # Promote a draft into the publishing queue (uses the post's stored date)
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Analytics
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
# Get platform analytics (default: last 7 days)
|
|
464
|
+
postemi analytics:platform <integration-id>
|
|
465
|
+
|
|
466
|
+
# Get platform analytics for last 30 days
|
|
467
|
+
postemi analytics:platform <integration-id> -d 30
|
|
468
|
+
|
|
469
|
+
# Get post analytics (default: last 7 days)
|
|
470
|
+
postemi analytics:post <post-id>
|
|
471
|
+
|
|
472
|
+
# Get post analytics for last 30 days
|
|
473
|
+
postemi analytics:post <post-id> -d 30
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Returns an array of metrics (e.g. Followers, Impressions, Likes, Comments) with daily data points and percentage change over the period.
|
|
477
|
+
|
|
478
|
+
**⚠️ IMPORTANT: Missing Release ID Handling**
|
|
479
|
+
|
|
480
|
+
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:
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
# 1. analytics:post returns {"missing": true}
|
|
484
|
+
postemi analytics:post <post-id>
|
|
485
|
+
|
|
486
|
+
# 2. Get available content from the provider
|
|
487
|
+
postemi posts:missing <post-id>
|
|
488
|
+
# Returns: [{"id": "7321456789012345678", "url": "https://...cover.jpg"}, ...]
|
|
489
|
+
|
|
490
|
+
# 3. Connect the correct content to the post
|
|
491
|
+
postemi posts:connect <post-id> --release-id "7321456789012345678"
|
|
492
|
+
|
|
493
|
+
# 4. Now analytics will work
|
|
494
|
+
postemi analytics:post <post-id>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Connecting Missing Posts
|
|
498
|
+
|
|
499
|
+
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.
|
|
500
|
+
|
|
501
|
+
```bash
|
|
502
|
+
# List recent content from the provider for a post with missing release ID
|
|
503
|
+
postemi posts:missing <post-id>
|
|
504
|
+
|
|
505
|
+
# Connect a post to its published content
|
|
506
|
+
postemi posts:connect <post-id> --release-id "<content-id>"
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Returns an empty array if the provider doesn't support this feature or if the post doesn't have a missing release ID.
|
|
510
|
+
|
|
511
|
+
### Media Upload
|
|
512
|
+
|
|
513
|
+
**⚠️ IMPORTANT:** Always upload files to Postemi before using them in posts. Many platforms (TikTok, Instagram, YouTube) **require verified URLs** and will reject external links.
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
# Upload file and get URL
|
|
517
|
+
postemi upload image.jpg
|
|
518
|
+
|
|
519
|
+
# EXACTLY EIGHT TYPES ARE ACCEPTED. Anything else returns 400 "Unsupported
|
|
520
|
+
# file type.". The server sniffs the magic bytes, so renaming the file does
|
|
521
|
+
# not help.
|
|
522
|
+
#
|
|
523
|
+
# image/jpeg image/png image/gif image/webp
|
|
524
|
+
# image/avif image/bmp image/tiff (max 10 MB)
|
|
525
|
+
# video/mp4 (max 1 GB)
|
|
526
|
+
#
|
|
527
|
+
# NOT accepted: MOV, MKV, WEBM, AVI, SVG, PDF, DOC/DOCX, and every audio
|
|
528
|
+
# format. Convert first: ffmpeg -i clip.mov -c:v libx264 -c:a aac clip.mp4
|
|
529
|
+
# See SUPPORTED_FILE_TYPES.md.
|
|
530
|
+
|
|
531
|
+
# Workflow: Upload → Extract URL → Use in post
|
|
532
|
+
VIDEO=$(postemi upload video.mp4)
|
|
533
|
+
VIDEO_PATH=$(echo "$VIDEO" | jq -r '.path')
|
|
534
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$VIDEO_PATH" -i "tiktok-id"
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## Common Patterns
|
|
540
|
+
|
|
541
|
+
### Pattern 1: Discover & Use Integration Tools
|
|
542
|
+
|
|
543
|
+
**Most supported channels have no tools at all.** Of the channels in the table
|
|
544
|
+
at the top of this file, six expose a tool: Instagram (`audioSearch`),
|
|
545
|
+
Discord (`channels`), Slack (`channels`), Pinterest (`boards`), WordPress
|
|
546
|
+
(`postTypes`, `categoriesList`, `tagsList`) and Dev.to (`tags`,
|
|
547
|
+
`organizations`). The others expose none: `integrations:settings`
|
|
548
|
+
returns an empty `tools` array for them, and `integrations:trigger` on them
|
|
549
|
+
returns `404 Tool not found`.
|
|
550
|
+
|
|
551
|
+
**Never guess a method name.** Read the `tools` array from
|
|
552
|
+
`integrations:settings` first; the `methodName` there is the only string
|
|
553
|
+
`integrations:trigger` will accept.
|
|
554
|
+
|
|
555
|
+
```bash
|
|
556
|
+
IG_ID=$(postemi integrations:list | jq -r '.[] | select(.identifier=="instagram") | .id')
|
|
557
|
+
|
|
558
|
+
# What can this channel actually do?
|
|
559
|
+
postemi integrations:settings "$IG_ID" | jq '.output.tools'
|
|
560
|
+
# [ { "methodName": "audioSearch", "description": "Search audio …", "dataSchema": [ … ] } ]
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**Instagram, find audio for a Reel:**
|
|
564
|
+
```bash
|
|
565
|
+
# Empty query returns trending audio. type is "music" (default) or "original_sound".
|
|
566
|
+
AUDIO=$(postemi integrations:trigger "$IG_ID" audioSearch -d '{"q":"lofi","type":"music"}')
|
|
567
|
+
AUDIO_ID=$(echo "$AUDIO" | jq -r '.output[0].id')
|
|
568
|
+
|
|
569
|
+
VIDEO=$(postemi upload reel.mp4 | jq -r '.path')
|
|
570
|
+
postemi posts:create \
|
|
571
|
+
-c "Reel caption" \
|
|
572
|
+
-s "2026-12-31T12:00:00Z" \
|
|
573
|
+
--settings "{\"post_type\":\"post\",\"audio\":{\"id\":\"$AUDIO_ID\"}}" \
|
|
574
|
+
-m "$VIDEO" \
|
|
575
|
+
-i "$IG_ID"
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### Pattern 2: Upload Media Before Posting
|
|
579
|
+
|
|
580
|
+
```bash
|
|
581
|
+
# Upload multiple files
|
|
582
|
+
VIDEO_RESULT=$(postemi upload video.mp4)
|
|
583
|
+
VIDEO_PATH=$(echo "$VIDEO_RESULT" | jq -r '.path')
|
|
584
|
+
|
|
585
|
+
THUMB_RESULT=$(postemi upload thumbnail.jpg)
|
|
586
|
+
THUMB_PATH=$(echo "$THUMB_RESULT" | jq -r '.path')
|
|
587
|
+
|
|
588
|
+
# Use in post
|
|
589
|
+
postemi posts:create \
|
|
590
|
+
-c "Check out my video!" \
|
|
591
|
+
-s "2026-12-31T12:00:00Z" \
|
|
592
|
+
-m "$VIDEO_PATH" \
|
|
593
|
+
-i "tiktok-id"
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Pattern 3: Twitter Thread
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
# Upload every image first (Rule 2)
|
|
600
|
+
INTRO=$(postemi upload intro.jpg | jq -r '.path')
|
|
601
|
+
P1=$(postemi upload point1.jpg | jq -r '.path')
|
|
602
|
+
P2=$(postemi upload point2.jpg | jq -r '.path')
|
|
603
|
+
OUTRO=$(postemi upload outro.jpg | jq -r '.path')
|
|
604
|
+
|
|
605
|
+
postemi posts:create \
|
|
606
|
+
-c "🧵 Thread starter (1/4)" -m "$INTRO" \
|
|
607
|
+
-c "Point one (2/4)" -m "$P1" \
|
|
608
|
+
-c "Point two (3/4)" -m "$P2" \
|
|
609
|
+
-c "Conclusion (4/4)" -m "$OUTRO" \
|
|
610
|
+
-s "2026-12-31T12:00:00Z" \
|
|
611
|
+
-d 5 \
|
|
612
|
+
-i "$X_ID"
|
|
613
|
+
# -d is the delay between comments in MINUTES (here: 5 minutes)
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Pattern 4: Multi-Platform Campaign (different content per channel)
|
|
617
|
+
|
|
618
|
+
JSON mode takes the API's real request body: `type`, `date`, `shortLink`,
|
|
619
|
+
`tags`, and a `posts` array where each element names an `integration`, its
|
|
620
|
+
`value` thread and its `settings`. There is no `"integrations"` key and no
|
|
621
|
+
`"provider"` key; a full working file is
|
|
622
|
+
[examples/multi-platform-post.json](./examples/multi-platform-post.json).
|
|
623
|
+
|
|
624
|
+
```bash
|
|
625
|
+
IMG=$(postemi upload sale.jpg | jq -r '.path')
|
|
626
|
+
|
|
627
|
+
cat > campaign.json << EOF
|
|
628
|
+
{
|
|
629
|
+
"type": "schedule",
|
|
630
|
+
"date": "2026-12-01T09:00:00Z",
|
|
631
|
+
"shortLink": false,
|
|
632
|
+
"tags": [],
|
|
633
|
+
"posts": [
|
|
634
|
+
{
|
|
635
|
+
"integration": { "id": "<x-id>" },
|
|
636
|
+
"value": [
|
|
637
|
+
{ "content": "Holiday sale! 🎄 20% off every plan. #sale", "image": [ { "id": "a", "path": "$IMG" } ] }
|
|
638
|
+
],
|
|
639
|
+
"settings": { "who_can_reply_post": "everyone" }
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
"integration": { "id": "<facebook-id>" },
|
|
643
|
+
"value": [
|
|
644
|
+
{ "content": "Our holiday sale is live, 20% off every plan until the end of December. Details on the website!", "image": [ { "id": "b", "path": "$IMG" } ] }
|
|
645
|
+
],
|
|
646
|
+
"settings": { "post_type": "post" }
|
|
647
|
+
}
|
|
648
|
+
]
|
|
649
|
+
}
|
|
650
|
+
EOF
|
|
651
|
+
|
|
652
|
+
postemi posts:create --json campaign.json
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Pattern 5: Validate Settings Before Posting
|
|
656
|
+
|
|
657
|
+
```bash
|
|
658
|
+
#!/bin/bash
|
|
659
|
+
|
|
660
|
+
INTEGRATION_ID="$X_ID"
|
|
661
|
+
CONTENT="Your post content here"
|
|
662
|
+
|
|
663
|
+
# Get integration settings and extract max length
|
|
664
|
+
SETTINGS_JSON=$(postemi integrations:settings "$INTEGRATION_ID")
|
|
665
|
+
MAX_LENGTH=$(echo "$SETTINGS_JSON" | jq '.output.maxLength')
|
|
666
|
+
|
|
667
|
+
# Check character limit and truncate if needed
|
|
668
|
+
if [ ${#CONTENT} -gt "$MAX_LENGTH" ]; then
|
|
669
|
+
echo "Content exceeds $MAX_LENGTH chars, truncating..."
|
|
670
|
+
CONTENT="${CONTENT:0:$((MAX_LENGTH - 3))}..."
|
|
671
|
+
fi
|
|
672
|
+
|
|
673
|
+
# Create post with settings
|
|
674
|
+
postemi posts:create \
|
|
675
|
+
-c "$CONTENT" \
|
|
676
|
+
-s "2026-12-31T12:00:00Z" \
|
|
677
|
+
--settings '{"key": "value"}' \
|
|
678
|
+
-i "$INTEGRATION_ID"
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### Pattern 6: Batch Scheduling
|
|
682
|
+
|
|
683
|
+
```bash
|
|
684
|
+
#!/bin/bash
|
|
685
|
+
|
|
686
|
+
# Schedule posts for the week
|
|
687
|
+
DATES=(
|
|
688
|
+
"2026-02-14T09:00:00Z"
|
|
689
|
+
"2026-02-15T09:00:00Z"
|
|
690
|
+
"2026-02-16T09:00:00Z"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
CONTENT=(
|
|
694
|
+
"Monday motivation 💪"
|
|
695
|
+
"Tuesday tips 💡"
|
|
696
|
+
"Wednesday wisdom 🧠"
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
for i in "${!DATES[@]}"; do
|
|
700
|
+
# Rule 2: upload each file before passing to -m
|
|
701
|
+
IMG=$(postemi upload "post-${i}.jpg" | jq -r '.path')
|
|
702
|
+
postemi posts:create \
|
|
703
|
+
-c "${CONTENT[$i]}" \
|
|
704
|
+
-s "${DATES[$i]}" \
|
|
705
|
+
-i "$X_ID" \
|
|
706
|
+
-m "$IMG"
|
|
707
|
+
echo "Scheduled: ${CONTENT[$i]} for ${DATES[$i]}"
|
|
708
|
+
done
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### Pattern 7: Error Handling & Retry
|
|
712
|
+
|
|
713
|
+
`posts:create` already retries for you: every run sends an `Idempotency-Key`
|
|
714
|
+
(a new UUID per run) and, after a network failure or a 5xx, retries ONCE with
|
|
715
|
+
the same key, so the server hands back the post it already made instead of
|
|
716
|
+
making a second one. Running the command again is a NEW key and a NEW post.
|
|
717
|
+
So never wrap `posts:create` in a retry loop; after a failure, look first:
|
|
718
|
+
|
|
719
|
+
```bash
|
|
720
|
+
#!/bin/bash
|
|
721
|
+
|
|
722
|
+
CONTENT="Your post content"
|
|
723
|
+
INTEGRATION_ID="$X_ID"
|
|
724
|
+
DATE="2026-12-31T12:00:00Z"
|
|
725
|
+
|
|
726
|
+
if postemi posts:create -c "$CONTENT" -s "$DATE" -i "$INTEGRATION_ID"; then
|
|
727
|
+
echo "Post created successfully"
|
|
728
|
+
else
|
|
729
|
+
# Did it get through anyway? Check before running it again.
|
|
730
|
+
postemi posts:list --startDate "$DATE" --endDate "$DATE"
|
|
731
|
+
fi
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
A 403 that names a missing permission is not retryable: the key is valid
|
|
735
|
+
and lacks the scope. A 401 saying the workspace has no plan with API access
|
|
736
|
+
is not retryable either: see `billing:plans`.
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
## Technical Concepts
|
|
741
|
+
|
|
742
|
+
### Integration Tools Workflow
|
|
743
|
+
|
|
744
|
+
Some integrations expose a tool for data that cannot be hard-coded. Among supported channels Instagram, Discord and Slack do. The workflow:
|
|
745
|
+
|
|
746
|
+
1. **Check available tools** - `integrations:settings` returns a `tools` array
|
|
747
|
+
2. **Review tool schema** - Each tool has `methodName`, `description`, and `dataSchema`
|
|
748
|
+
3. **Trigger tool** - Call `integrations:trigger` with required parameters
|
|
749
|
+
4. **Use output** - Tool returns data to use in post settings
|
|
750
|
+
|
|
751
|
+
**Tools on supported channels, exhaustively:**
|
|
752
|
+
|
|
753
|
+
| Channel | Tools |
|
|
754
|
+
|---|---|
|
|
755
|
+
| Instagram (`instagram`) | `audioSearch` |
|
|
756
|
+
| Discord (`discord`) | `channels` (the server's channels; the `id` goes into `settings.channel`) |
|
|
757
|
+
| Slack (`slack`) | `channels` (the workspace's channels; the `id` goes into `settings.channel`) |
|
|
758
|
+
| X, Facebook, Threads, Bluesky, YouTube, LinkedIn, TikTok, Telegram | *(none)* |
|
|
759
|
+
|
|
760
|
+
That is the whole list. There is no `getPlaylists`, no `getCompanies`, no
|
|
761
|
+
`getBoards`, no `getFlairs`. Earlier versions of this file invented all four,
|
|
762
|
+
and `integrations:trigger` answers `404 Tool not found` for every one of them.
|
|
763
|
+
|
|
764
|
+
### Provider Settings Structure
|
|
765
|
+
|
|
766
|
+
Platform-specific settings use a discriminator pattern with `__type` field:
|
|
767
|
+
|
|
768
|
+
```json
|
|
769
|
+
{
|
|
770
|
+
"posts": [
|
|
771
|
+
{
|
|
772
|
+
"provider": "youtube",
|
|
773
|
+
"post": [{ "content": "Video description", "image": [...] }],
|
|
774
|
+
"settings": {
|
|
775
|
+
"__type": "youtube",
|
|
776
|
+
"title": "Video title",
|
|
777
|
+
"type": "public",
|
|
778
|
+
"selfDeclaredMadeForKids": "no",
|
|
779
|
+
"tags": [{ "value": "tech", "label": "Tech" }]
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
Pass settings directly:
|
|
787
|
+
```bash
|
|
788
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" --settings '{"title":"…","type":"public"}' -i "youtube-id"
|
|
789
|
+
# Backend automatically adds "__type" based on integration ID
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
Channels with **no settings of their own** (Threads and Bluesky) take an
|
|
793
|
+
empty object. Do not invent fields for them.
|
|
794
|
+
|
|
795
|
+
**You never have to write `__type` yourself.** The server stamps it from the
|
|
796
|
+
integration's provider before validating, overwriting whatever you sent. An
|
|
797
|
+
example file carrying `"__type": "EmptySettings"` still works for that reason:
|
|
798
|
+
it is being replaced, not accepted.
|
|
799
|
+
|
|
800
|
+
### Two behaviours that surprise people
|
|
801
|
+
|
|
802
|
+
**1. A key without `posts:publish` silently produces a draft.** `posts:create`
|
|
803
|
+
requires only `posts:draft`. If the key (or its owner's current role, a
|
|
804
|
+
Contributor/DRAFTER) lacks `posts:publish`, the server coerces `type` to
|
|
805
|
+
`draft` and creates a draft instead of refusing. It is not silent in the
|
|
806
|
+
response: **read back the `type` you got, do not assume the one you sent.**
|
|
807
|
+
If the user wanted it published, tell them the key cannot, rather than
|
|
808
|
+
reporting success.
|
|
809
|
+
|
|
810
|
+
**2. One X channel per post, always.** A single post may not target two X
|
|
811
|
+
channels, whatever the content. X's Developer Policy forbids substantially
|
|
812
|
+
similar content from multiple accounts through one developer app, and the
|
|
813
|
+
account at risk is Postemi's, shared by every customer. Split it into two posts.
|
|
814
|
+
X alongside Facebook, Instagram, Threads, Bluesky, YouTube and the rest is
|
|
815
|
+
unaffected. That combination is the entire point.
|
|
816
|
+
|
|
817
|
+
### Comments and Threading
|
|
818
|
+
|
|
819
|
+
Posts can have comments (threads on Twitter/X, replies elsewhere). Each comment can have its own media:
|
|
820
|
+
|
|
821
|
+
```bash
|
|
822
|
+
# Upload every file first (Rule 2)
|
|
823
|
+
I1=$(postemi upload image1.jpg | jq -r '.path')
|
|
824
|
+
I2=$(postemi upload image2.jpg | jq -r '.path')
|
|
825
|
+
CI=$(postemi upload comment-img.jpg | jq -r '.path')
|
|
826
|
+
A1=$(postemi upload another.jpg | jq -r '.path')
|
|
827
|
+
A2=$(postemi upload more.jpg | jq -r '.path')
|
|
828
|
+
|
|
829
|
+
postemi posts:create \
|
|
830
|
+
-c "Main post" -m "$I1,$I2" \
|
|
831
|
+
-c "Comment 1" -m "$CI" \
|
|
832
|
+
-c "Comment 2" -m "$A1,$A2" \
|
|
833
|
+
-s "2026-12-31T12:00:00Z" \
|
|
834
|
+
-d 5 \ # Delay between comments in minutes
|
|
835
|
+
-i "integration-id"
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
Internally creates (note: every URL is a Postemi-uploaded `.path`, not a raw filename):
|
|
839
|
+
```json
|
|
840
|
+
{
|
|
841
|
+
"posts": [{
|
|
842
|
+
"value": [
|
|
843
|
+
{ "content": "Main post", "image": ["<uploaded image1>", "<uploaded image2>"] },
|
|
844
|
+
{ "content": "Comment 1", "image": ["<uploaded comment-img>"], "delay": 5 },
|
|
845
|
+
{ "content": "Comment 2", "image": ["<uploaded another>", "<uploaded more>"], "delay": 5 }
|
|
846
|
+
]
|
|
847
|
+
}]
|
|
848
|
+
}
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### Date Handling
|
|
852
|
+
|
|
853
|
+
**The contract: ALWAYS pass an explicit offset, or pass `--timezone` with an
|
|
854
|
+
IANA name.** The CLI never guesses a timezone. The server reads a naive
|
|
855
|
+
datetime as UTC, which silently shifts a local "12:00" by the user's UTC
|
|
856
|
+
offset. The CLI refuses ambiguity instead.
|
|
857
|
+
|
|
858
|
+
The two safe forms:
|
|
859
|
+
|
|
860
|
+
```bash
|
|
861
|
+
-s "2026-12-31T12:00:00Z" # explicit UTC
|
|
862
|
+
-s "2026-12-31 12:00" --timezone Europe/London # wall-clock + IANA zone
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
How a date is resolved, in priority order:
|
|
866
|
+
|
|
867
|
+
1. **Explicit offset in the string** (`Z`, `+01:00`), used as written.
|
|
868
|
+
2. **`--timezone <IANA name>`** on `posts:create` / `posts:list`.
|
|
869
|
+
3. **The saved config**: `postemi config:set timezone Europe/London`
|
|
870
|
+
(also offered interactively during `postemi auth:login` on a TTY).
|
|
871
|
+
4. **None of the above and the date is naive → hard error, exit 1.** The
|
|
872
|
+
error names the fixes. Do not retry the same naive date; add a
|
|
873
|
+
timezone.
|
|
874
|
+
|
|
875
|
+
Rules and behaviors:
|
|
876
|
+
|
|
877
|
+
- Timezone values must be IANA names (`Europe/London`, `UTC`). **Numeric
|
|
878
|
+
offsets (`+02:00`, `UTC+2`, `Etc/GMT+2`) are rejected as timezone values**.
|
|
879
|
+
Never hand-compute DST; put an offset inside the date string only when
|
|
880
|
+
you are certain of it, otherwise use the IANA name and let the CLI convert
|
|
881
|
+
(DST-aware).
|
|
882
|
+
- Whenever a timezone is applied (steps 2 to 4), the CLI echoes the resolved
|
|
883
|
+
UTC instant on stderr:
|
|
884
|
+
`ℹ️ Interpreted "2026-12-24 18:00" as Europe/London (via --timezone) → 2026-12-24T18:00:00.000Z`
|
|
885
|
+
- A bare date (`"2026-12-31"`) means midnight in the resolved timezone.
|
|
886
|
+
- `posts:list --startDate/--endDate` accept the same shapes and the same
|
|
887
|
+
`--timezone` flag; the defaults (30 days back / 30 days forward) never
|
|
888
|
+
need a timezone.
|
|
889
|
+
- `postemi config:get` / `postemi config:get timezone` shows what is saved;
|
|
890
|
+
settings survive `auth:logout`.
|
|
891
|
+
- `postemi posts:find-slot <integration-id>` returns `{"date": "..."}`, the
|
|
892
|
+
next free slot on that channel's schedule; pass it straight back to
|
|
893
|
+
`posts:create -s`.
|
|
894
|
+
- `-t now` publishes immediately and needs no `--date`.
|
|
895
|
+
|
|
896
|
+
### Media Upload Response
|
|
897
|
+
|
|
898
|
+
Upload returns JSON with path and metadata:
|
|
899
|
+
```json
|
|
900
|
+
{
|
|
901
|
+
"path": "https://…/uploads/2026/08/abc123.jpg",
|
|
902
|
+
"size": 123456,
|
|
903
|
+
"type": "image/jpeg"
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
Extract path for use in posts:
|
|
908
|
+
```bash
|
|
909
|
+
RESULT=$(postemi upload image.jpg)
|
|
910
|
+
PATH=$(echo "$RESULT" | jq -r '.path')
|
|
911
|
+
postemi posts:create -c "Content" -s "2026-12-31T12:00:00Z" -m "$PATH" -i "integration-id"
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### JSON Mode vs CLI Flags
|
|
915
|
+
|
|
916
|
+
**CLI flags** - Quick posts:
|
|
917
|
+
```bash
|
|
918
|
+
postemi posts:create -c "Content" -m "$(postemi upload img.jpg | jq -r '.path')" -s "2026-12-31T12:00:00Z" -i "$X_ID"
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
**JSON mode** - Complex posts with multiple platforms and settings:
|
|
922
|
+
```bash
|
|
923
|
+
postemi posts:create --json post.json
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
JSON mode supports:
|
|
927
|
+
- Multiple platforms with different content per platform
|
|
928
|
+
- Complex provider-specific settings
|
|
929
|
+
- Scheduled posts
|
|
930
|
+
- Posts with many comments
|
|
931
|
+
- Custom delay between comments
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
935
|
+
## Platform-Specific Examples
|
|
936
|
+
|
|
937
|
+
Every field below is taken from the server's validation DTOs. A field that is
|
|
938
|
+
not listed does not exist, and sending it is a `400`.
|
|
939
|
+
|
|
940
|
+
### X (Twitter): `x`
|
|
941
|
+
```bash
|
|
942
|
+
postemi posts:create \
|
|
943
|
+
-c "Tweet content" \
|
|
944
|
+
-s "2026-12-31T12:00:00Z" \
|
|
945
|
+
--settings '{"who_can_reply_post":"everyone"}' \
|
|
946
|
+
-i "$X_ID"
|
|
947
|
+
```
|
|
948
|
+
`who_can_reply_post` is **required**: `everyone` | `following` |
|
|
949
|
+
`mentionedUsers` | `subscribers` | `verified`. Optional: `community` (must
|
|
950
|
+
match `https://x.com/i/communities/<digits>`), `made_with_ai` (bool),
|
|
951
|
+
`paid_partnership` (bool).
|
|
952
|
+
|
|
953
|
+
### Facebook: `facebook`
|
|
954
|
+
```bash
|
|
955
|
+
IMG=$(postemi upload photo.jpg | jq -r '.path')
|
|
956
|
+
postemi posts:create \
|
|
957
|
+
-c "Post text" \
|
|
958
|
+
-s "2026-12-31T12:00:00Z" \
|
|
959
|
+
--settings '{"post_type":"post"}' \
|
|
960
|
+
-m "$IMG" \
|
|
961
|
+
-i "$FB_ID"
|
|
962
|
+
```
|
|
963
|
+
All optional: `post_type` (`post` | `story`), `url`, `text_format_preset_id`
|
|
964
|
+
(a background for a **text-only** post; Pages only, ~130 characters max).
|
|
965
|
+
|
|
966
|
+
### Instagram: `instagram`
|
|
967
|
+
```bash
|
|
968
|
+
IMG=$(postemi upload image.jpg | jq -r '.path')
|
|
969
|
+
|
|
970
|
+
# Feed post
|
|
971
|
+
postemi posts:create -c "Caption #hashtag" -s "2026-12-31T12:00:00Z" \
|
|
972
|
+
--settings '{"post_type":"post"}' -m "$IMG" -i "$IG_ID"
|
|
973
|
+
|
|
974
|
+
# Story
|
|
975
|
+
STORY=$(postemi upload story.jpg | jq -r '.path')
|
|
976
|
+
postemi posts:create -c "" -s "2026-12-31T12:00:00Z" \
|
|
977
|
+
--settings '{"post_type":"story"}' -m "$STORY" -i "$IG_ID"
|
|
978
|
+
```
|
|
979
|
+
`post_type` is **required**: `post` | `story`. Optional: `collaborators` (array
|
|
980
|
+
of `{label}`), `audio` (`{id, title?, artist?, image?, audio_volume?,
|
|
981
|
+
video_volume?}`; get the `id` from the `audioSearch` tool), `is_trial_reel`,
|
|
982
|
+
`graduation_strategy` (`MANUAL` | `SS_PERFORMANCE`).
|
|
983
|
+
|
|
984
|
+
### Threads (`threads`) and Bluesky (`bluesky`)
|
|
985
|
+
```bash
|
|
986
|
+
postemi posts:create -c "Post text" -s "2026-12-31T12:00:00Z" -i "$THREADS_ID"
|
|
987
|
+
```
|
|
988
|
+
No provider settings. Omit `--settings` entirely.
|
|
989
|
+
|
|
990
|
+
## What Postemi does NOT do
|
|
991
|
+
|
|
992
|
+
State these plainly rather than attempting a workaround.
|
|
993
|
+
|
|
994
|
+
- **No AI video generation.** The `/generate-video` and `/video/function`
|
|
995
|
+
routes were removed from the API entirely (2026-08-06). Do not tell a user
|
|
996
|
+
Postemi can generate video, and do not call those routes: they 404.
|
|
997
|
+
- **No image or video generation of any kind.** Media comes from files the
|
|
998
|
+
user already has, via `postemi upload`.
|
|
999
|
+
- **No channel deletion.** `DELETE /integrations/:id` was deliberately removed
|
|
1000
|
+
from the public API. Disconnecting a channel is a signed-in, admin action in
|
|
1001
|
+
the web app.
|
|
1002
|
+
- **Nothing outside the channel table at the top of this file.**
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
1006
|
+
## Optional skills: writing and growth
|
|
1007
|
+
|
|
1008
|
+
Two more Postemi skills are optional. They are not installed unless the user
|
|
1009
|
+
chose them:
|
|
1010
|
+
|
|
1011
|
+
| Skill | What it adds | Install |
|
|
1012
|
+
|---|---|---|
|
|
1013
|
+
| writing (`postemi-writing` + `postemi-templates`) | post ideas, hooks, formatting, each network's writing rules, the composer's templates | `postemi skills add writing` |
|
|
1014
|
+
| growth (`postemi-growth`) | choosing a niche, cadence per network, a 30-day plan, the weekly review | `postemi skills add growth` |
|
|
1015
|
+
|
|
1016
|
+
```bash
|
|
1017
|
+
postemi skills list # core + optional, and where each is installed (JSON)
|
|
1018
|
+
postemi skills add writing --json # into every agent found (Claude Code, Codex, Cursor, Grok Build)
|
|
1019
|
+
postemi skills add growth --agent claude --json
|
|
1020
|
+
postemi skills remove growth --json
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
When the user asks for help writing posts or planning content and
|
|
1024
|
+
`postemi skills list` shows the skill as missing, **offer** it: say what it
|
|
1025
|
+
adds and ask. Run `postemi skills add` only after the user says yes; never
|
|
1026
|
+
install it silently. Pass `--json` (or run without a terminal) so the CLI asks
|
|
1027
|
+
no questions; a new agent session loads the skills.
|
|
1028
|
+
|
|
1029
|
+
---
|
|
1030
|
+
|
|
1031
|
+
## Supporting Resources
|
|
1032
|
+
|
|
1033
|
+
**This file is the command reference.** Everything the CLI accepts is
|
|
1034
|
+
documented above; there is no second syntax guide to consult.
|
|
1035
|
+
|
|
1036
|
+
**Deep-dive documentation** (shipped in the npm package):
|
|
1037
|
+
- [PROVIDER_SETTINGS.md](./PROVIDER_SETTINGS.md) - Settings schema for every supported channel
|
|
1038
|
+
- [SUPPORTED_FILE_TYPES.md](./SUPPORTED_FILE_TYPES.md) - The eight accepted media types
|
|
1039
|
+
|
|
1040
|
+
**Ready-to-use `--json` payloads** (all verified against the current CLI):
|
|
1041
|
+
- [examples/post-with-comments.json](./examples/post-with-comments.json) - Threading example
|
|
1042
|
+
- [examples/thread-post.json](./examples/thread-post.json) - Multi-part thread
|
|
1043
|
+
- [examples/multi-platform-post.json](./examples/multi-platform-post.json) - One post, several channels
|
|
1044
|
+
- [examples/multi-platform-with-settings.json](./examples/multi-platform-with-settings.json) - Campaign example
|
|
1045
|
+
- [examples/youtube-video.json](./examples/youtube-video.json) - YouTube with tags
|
|
1046
|
+
- [examples/tiktok-video.json](./examples/tiktok-video.json) - TikTok with privacy
|
|
1047
|
+
|
|
1048
|
+
---
|
|
1049
|
+
|
|
1050
|
+
## Common Gotchas
|
|
1051
|
+
|
|
1052
|
+
1. **Not authenticated** - Ask the user to run `postemi auth:login` and approve it in the browser
|
|
1053
|
+
2. **Invalid integration ID** - Run `integrations:list` to get current IDs
|
|
1054
|
+
3. **Settings schema mismatch** - Check `integrations:settings` for required fields
|
|
1055
|
+
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.
|
|
1056
|
+
5. **JSON escaping in shell** - Use single quotes for JSON: `--settings '{...}'`
|
|
1057
|
+
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`.
|
|
1058
|
+
7. **Tool not found** - Check available tools in `integrations:settings` output
|
|
1059
|
+
8. **Character limits** - Each platform has different limits, check `maxLength` in settings
|
|
1060
|
+
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.
|
|
1061
|
+
10. **Media MIME types** - CLI auto-detects from file extension, ensure correct extension
|
|
1062
|
+
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.
|
|
1063
|
+
|
|
1064
|
+
---
|
|
1065
|
+
|
|
1066
|
+
## Quick Reference
|
|
1067
|
+
|
|
1068
|
+
```bash
|
|
1069
|
+
# ⚠️ AUTHENTICATE FIRST - required before any other command
|
|
1070
|
+
postemi auth:status # Check if authenticated
|
|
1071
|
+
postemi auth:login # OAuth2 device flow login
|
|
1072
|
+
postemi auth:signup --email <address> # New account by e-mailed code; no free trial
|
|
1073
|
+
postemi auth:logout # Remove credentials
|
|
1074
|
+
|
|
1075
|
+
# Settings (survive logout)
|
|
1076
|
+
postemi config:set timezone Europe/London # Timezone for dates without an offset
|
|
1077
|
+
postemi config:get # Show saved settings as JSON
|
|
1078
|
+
|
|
1079
|
+
# Discovery (only after auth is confirmed)
|
|
1080
|
+
postemi integrations:list # Get integration IDs
|
|
1081
|
+
postemi integrations:list --group <group-id> # Get integration IDs in a group
|
|
1082
|
+
postemi integrations:groups # List groups (customers)
|
|
1083
|
+
postemi integrations:settings <id> # Get settings schema
|
|
1084
|
+
postemi integrations:trigger <id> <method> -d '{}' # Fetch dynamic data
|
|
1085
|
+
|
|
1086
|
+
# Posting (date required unless -t now or --json; explicit offset or --timezone, never naive)
|
|
1087
|
+
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" -i "id" # Simple (UTC)
|
|
1088
|
+
postemi posts:create -c "Good morning!" -s "2026-12-31 08:00" --timezone Europe/London -i "id" # Local wall-clock
|
|
1089
|
+
postemi posts:create -c "text" -t now -i "id" # Publish immediately
|
|
1090
|
+
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" -t draft -i "id" # Draft
|
|
1091
|
+
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)
|
|
1092
|
+
postemi posts:create -c "main" -c "comment" -s "2026-12-31T12:00:00Z" -i "id" # With comment
|
|
1093
|
+
postemi posts:create -c "text" -s "2026-12-31T12:00:00Z" --settings '{}' -i "id" # Platform-specific
|
|
1094
|
+
postemi posts:create --json file.json # Complex
|
|
1095
|
+
|
|
1096
|
+
# Management
|
|
1097
|
+
postemi posts:list # List posts
|
|
1098
|
+
postemi posts:find-slot <integration-id> # Next free slot: {"date": "..."}
|
|
1099
|
+
postemi posts:delete <id> # Delete one post (one channel)
|
|
1100
|
+
postemi posts:delete-group <group> # Delete every channel of a group
|
|
1101
|
+
postemi posts:status <id> --status draft # Move to draft (stops workflow)
|
|
1102
|
+
postemi posts:status <id> --status schedule # Queue draft for publishing
|
|
1103
|
+
postemi upload <file> # Upload media
|
|
1104
|
+
|
|
1105
|
+
# Analytics
|
|
1106
|
+
postemi analytics:platform <id> # Platform analytics (7 days)
|
|
1107
|
+
postemi analytics:platform <id> -d 30 # Platform analytics (30 days)
|
|
1108
|
+
postemi analytics:post <id> # Post analytics (7 days)
|
|
1109
|
+
postemi analytics:post <id> -d 30 # Post analytics (30 days)
|
|
1110
|
+
# If analytics:post returns {"missing": true}, resolve it:
|
|
1111
|
+
postemi posts:missing <id> # List provider content
|
|
1112
|
+
postemi posts:connect <id> --release-id "<rid>" # Connect content to post
|
|
1113
|
+
|
|
1114
|
+
# Optional skills (ask the user first)
|
|
1115
|
+
postemi skills list # Core + optional skills, where installed
|
|
1116
|
+
postemi skills add writing|growth --json # Install an optional skill
|
|
1117
|
+
|
|
1118
|
+
# Help
|
|
1119
|
+
postemi --help # Show help
|
|
1120
|
+
postemi posts:create --help # Command help
|
|
1121
|
+
```
|