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.
@@ -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
+ ```