@omnisocials/mcp-server 1.35.0 → 1.39.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/README.md CHANGED
@@ -9,7 +9,7 @@ MCP (Model Context Protocol) server for the [OmniSocials](https://omnisocials.co
9
9
 
10
10
  ## Remote MCP URL (no install)
11
11
 
12
- For clients that support remote MCP over HTTP (claude.ai, ChatGPT, Notion, OpenClaw):
12
+ For clients that support remote MCP over HTTP (claude.ai, ChatGPT, Notion, Muse, OpenClaw):
13
13
 
14
14
  ```
15
15
  https://mcp.omnisocials.com?API_KEY=omsk_live_your_key_here
@@ -115,7 +115,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
115
115
 
116
116
  ## Available Tools
117
117
 
118
- ### Posts (13 tools)
118
+ ### Posts (15 tools)
119
119
 
120
120
  | Tool | Description |
121
121
  |------|-------------|
@@ -130,7 +130,9 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
130
130
  | `list_approval_workflows` | List the workspace's approval workflows (steps + approvers); pass an id as `approval_workflow_id` on `create_post` to hold the post for review |
131
131
  | `approve_post` | Approve the current step of a post's approval workflow (the connected user must be a listed approver for that step) |
132
132
  | `reject_post` | Reject a post's approval workflow, stopping it immediately |
133
+ | `get_post_approval` | Read the approval review of a post: steps, approvers and their decisions, the rejection reason and the comment thread |
133
134
  | `search_locations` | Find an Instagram or Threads location to tag (Instagram place IDs for `location_id`, Threads location ids for `threads.location_id`; Threads also searches by latitude/longitude) |
135
+ | `list_pinterest_products` | List the product Pins of the connected Pinterest account (catalog or own Pins), or check one Pin, to tag products with `pinterest.product_tags` |
134
136
  | `search_instagram_audio` | Search Instagram's licensed music catalog for a track to attach to a Reel |
135
137
 
136
138
  ### Media & folders (6 tools)
@@ -178,12 +180,12 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
178
180
  | `list_workspaces` | List all workspaces available in this session |
179
181
  | `switch_workspace` | Switch the active workspace **by name** (id or list number also accepted) |
180
182
 
181
- ### Webhooks (5 tools)
183
+ ### Webhooks (6 tools)
182
184
 
183
185
  | Tool | Description |
184
186
  |------|-------------|
185
187
  | `list_webhooks` | List all webhooks |
186
- | `create_webhook` | Create a webhook for event notifications |
188
+ | `create_webhook` | Create a webhook for event notifications (`post.scheduled`, `post.published`, `post.failed`, `post.approved`, `post.rejected`) |
187
189
  | `get_webhook` | Get webhook details |
188
190
  | `update_webhook` | Update a webhook |
189
191
  | `delete_webhook` | Delete a webhook |
@@ -194,10 +196,10 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
194
196
  | Tool | Description |
195
197
  |------|-------------|
196
198
  | `list_inbox_conversations` | List conversations: Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs (opt-in), and Threads replies on your posts plus mentions; filter by platform, type, unread, or `unanswered` |
197
- | `get_next_unanswered` | The next item that still needs an answer (longest-waiting first) with the whole thread and the post it is on; filter by platform or type, `include_read`, `exclude` |
199
+ | `get_next_unanswered` | The next item that still needs an answer (DMs that can still be answered first, then expired Instagram/Facebook DMs, then comments and mentions oldest first) with the whole thread, the post it is on and its `reply_window`; filter by platform or type, `include_read`, `exclude` |
198
200
  | `get_inbox_conversation` | Get a conversation's full message history with the post it belongs to; every message shows its `#id` |
199
201
  | `mark_inbox_read` | Mark all incoming messages in a conversation as read (also the durable skip for `get_next_unanswered`) |
200
- | `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only); `include_next` returns the next unanswered item in the same call |
202
+ | `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only); `message_id` picks the comment being answered; `include_next` returns the next unanswered item in the same call |
201
203
  | `hide_inbox_comment` | Hide or unhide a comment on one of your posts (Facebook, Instagram, TikTok, YouTube, Threads) |
202
204
  | `delete_inbox_comment` | Permanently delete a comment on one of your posts, with the replies under it (Facebook, Instagram, TikTok) |
203
205
  | `hide_inbox_reply` | Older name for `hide_inbox_comment`, kept for compatibility |
@@ -227,6 +229,49 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
227
229
 
228
230
  ## Changelog
229
231
 
232
+ ### 1.39.0 (2026-10-05)
233
+
234
+ - **Added: tag products on a Pinterest Pin.** `pinterest.product_tags` on `create_post`, `create_and_publish_post` and `update_post` takes up to 24 product Pins of the connected Pinterest account (Pin IDs or Pin links), so people can shop the items in the image. Pinterest accepts a product only when its Pin is public, belongs to the same account and links to a website that account claimed; products of other merchants cannot be tagged. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the other products are still tagged, and `get_post` shows the outcome (`product_tags_result`, with Pinterest's reason for each product that was not tagged).
235
+ - **Added: `list_pinterest_products`.** New tool that lists the product Pins of the connected Pinterest account, with the `pin_id` to use in `pinterest.product_tags`. `source` `catalog` reads the Pinterest catalog (price and stock included) and needs catalog access, which the user gives one time in the OmniSocials composer (Pinterest options, Add products, Connect catalog). `source` `pins` reads the account's own Pins and works on every connection. `check_pin` checks one Pin ID or Pin link instead of listing.
236
+
237
+ ### 1.38.0 (2026-10-02)
238
+
239
+ - **Added: read the approval review of a post.** New tool `get_post_approval` returns the review of a post that went through an approval workflow: every step with its approvers and their decisions, the rejection (who, when and the reason) and the comment thread, oldest first. Until now the tools only gave the status, so an assistant saw `rejected` and not who rejected the post or why. Use it on a rejected post to learn what to change before sending it again, or on a post that waits for approval to see who it waits for and what the reviewers wrote. Read-only. A post without an approval workflow returns status `none`. Backed by `GET /posts/{id}/approval` (scope `posts:read`). Same on the companion server (`mcp.omnisocials.com`).
240
+ - **Added: webhook events `post.approved` and `post.rejected`.** `create_webhook` and `update_webhook` take them next to `post.scheduled`, `post.published` and `post.failed`. `post.approved` fires when the last step of a post's approval workflow is approved (the post moves to `scheduled`, or `posting` when its publish time already passed). `post.rejected` fires when an approver rejects the post (it will not publish). Both carry one more object, `data.approval`: `{ status, decided_by, reason }` (`reason` is `null` on an approval and when the approver gave none), and their `targets` array is empty. Same on the companion server.
241
+ - **Changed: more approval emails.** A rejection, a reviewer comment, a reviewer edit and the final approval now email the person who requested the approval, and the requester's reply in the review thread emails the reviewers. A post that still waits for approval gets automatic emails near its publish time: the reviewers 24 hours before, and the reviewers plus the requester when the time passes (the post did not publish; a late approval publishes it at once). No tool input or output changed for this.
242
+ - **Changed: X link-post credits count only `http://` and `https://` links.** A post that names a website without a scheme (`brand.com`, `www.brand.com`) no longer uses credits and no longer returns the `x_url_post_credits` warning, because X does not bill it as a link post. A post with a full link still uses 20 credits. The descriptions of `create_post` and `create_and_publish_post` say which links count. Same on the companion server.
243
+
244
+ ### 1.37.1 (2026-09-30)
245
+
246
+ - **Changed: Facebook Reels longer than 90 seconds are accepted.** The API no longer refuses a Facebook Reel over 90 seconds at submit: a Reel now follows the 240 minute ceiling of a Facebook feed video. Meta removed the Reel length limit in June 2025 (every Facebook video is a reel now), while its Reels Publishing API guide still lists 3 to 90 seconds. A 3 minute Reel published through the API on 2026-09-30, and Reels of 91 to 117 seconds published before the cap existed. The `create_post` and `create_and_publish_post` descriptions no longer tell the assistant to keep a Facebook Reel under 90 seconds. If Facebook refuses a length at publish time, the Facebook target fails with "Facebook did not accept the length of this video..." and the video can go out as a regular Facebook post (`type: "post"`). Same on the companion server.
247
+ - **Changed: `video_cover` on YouTube Shorts is described as channel-dependent.** YouTube shows the cover on Shorts only on channels where it has enabled custom Shorts thumbnails (rolling out since July 2026, Partner Program channels first); on other channels it stores the cover as the default thumbnail but shows a frame from the video (checked on 15 published Shorts from 5 channels: 3 channels show it, 1 does not). The `video_cover` and `youtube` descriptions now say so, so the assistant says it depends on the channel instead of promising it. The image is now sent at up to 1080 x 1920, YouTube's Shorts thumbnail size. Same on the companion server.
248
+ - **Companion server: `video_cover` added.** The hosted server now accepts `video_cover` on `create_post`, `create_and_publish_post` and `update_post` (`null` removes it), takes `facebook.thumbnail_type` / `thumb_offset` / `cover_url`, and lists the cover under Platform Options in `get_post`. This package has had them since 1.35.0.
249
+
250
+ ### 1.37.0 (2026-09-28)
251
+
252
+ - **Added: the original PDF is kept, and LinkedIn receives it.** `upload_media` (url, base64 and the `upload_url` snippet) still splits a PDF into one image slide per page (max 20) and now also keeps the original file. When a LinkedIn post's `media_ids` are exactly that PDF's slides, in order and unchanged, LinkedIn receives the original file as the document: text stays sharp, in-document links work, viewers download the real file, and every page is included even past the 20-slide cap. The upload result names the kept file (`pdf.id`, `pdf.name`, `pdf.url`, `pdf.size_bytes`), the tool output says so, and every slide returned by `list_media` / `get_media` carries `pdf: { id, name, url, page, total_pages, rendered_pages }`. Same on the companion server (`mcp.omnisocials.com`).
253
+ - **Added: `pdf_mode` on `upload_media`.** `"slides"` (default) keeps one image media item per page, one Media ID each. `"document"` keeps the PDF as ONE media item (`type: "document"`, its page images in `pdf.pages`); pass its single Media ID in `media_ids` and the post gets every page, in order. The tool output explains which shape came back, and `list_media` shows document items as "Document (PDF, N pages)". Same on the companion server (`mcp.omnisocials.com`), where the `upload_url` snippet takes it as a form field (`-F pdf_mode=document`).
254
+ - **Added: `document_source` on the `linkedin` / `linkedin_page` options** of `create_post`, `create_and_publish_post` and `update_post`. `"original_pdf"` (default) sends the kept original when the images are its unchanged pages; `"slides"` rebuilds the document from the slide images instead (a flattened copy). On `update_post`: `"slides"` sets it, `"original_pdf"`/`null` reverts, omitted keeps the current value. `get_post` prints "document source: rebuilt from the slide images" when a channel opted out. Ignored when the images are not a PDF's pages, when `carousel_as_images` is true, and for 0-1 images, videos and polls. Same on the companion server.
255
+
256
+ ### 1.36.0 (2026-09-24)
257
+
258
+ - **Added: `reply_to_inbox` takes `message_id`.** Every comment on a post shares one inbox conversation, and the reply endpoint always answered the newest incoming comment on that post, so a reply drafted for one person could land under a different person's comment. Pass `message_id` (the "Message to answer" `#id` from `get_next_unanswered`, or a `#id` from `get_inbox_conversation`) and the reply goes under exactly that comment. Without it the old behaviour is kept for compatibility (newest comment). Ignored for DMs. A `404 not_found` means the id is not an incoming message of that conversation. The tool description and the `get_next_unanswered` output tell the assistant to always pass it for comments. Same on the companion server (`mcp.omnisocials.com`).
259
+ - **Changed: `get_next_unanswered` serves DMs first and no longer drops expired ones.** A customer's Facebook DM ("How do I buy and where please") was never served: DMs older than 24 hours were filtered out and the queue was oldest-first across everything, so a fresh DM sat behind a three-week comment backlog until Meta's reply window closed. The order is now: Instagram/Facebook DMs inside Meta's 24-hour window (the one closing soonest first) and X DMs, then Instagram/Facebook DMs whose window has closed, then comments and mentions oldest first (`order: "newest"` reverses within each group). Each item carries `reply_window: { open, closes_at }` (only Instagram/Facebook DMs have a window; everything else is `open: true, closes_at: null`). When `open` is false the tool prints a warning that OmniSocials cannot reply and that the user must answer from the Instagram or Facebook app (mirrored into the inbox) or skip with `mark_inbox_read`. `list_inbox_conversations` with `unanswered: true` now includes those expired DM conversations too. Same on the companion server.
260
+ - **Changed: `reply_to_inbox` checks the 24-hour window before sending.** A `422 outside_messaging_window` on an Instagram/Facebook DM is now raised before the platform call (it was only mapped from Meta's error afterwards). Nothing is sent, the message names when the window closed, and the tool description tells the assistant not to retry. Same on the companion server.
261
+ - **Changed: `hide_inbox_comment` explains a 502 `hide_not_applied`.** After an Instagram hide or unhide the API reads the comment back and reports Instagram's own `hidden` state. When Instagram accepted the call but the comment is still in the old state, the API answers 502 `hide_not_applied` and the inbox row is left unchanged (a hide had been reported as done while the comment stayed public). This happens with comments Instagram shows under "Comments from Facebook" on a reel that is also shared to Facebook: they live on Facebook and Instagram's hide does not reach them. The tool description tells the assistant to have the user hide it in the Instagram or Facebook app and not retry. Same on the companion server.
262
+ - **Changed: Facebook `reach` is absent, never 0, in `get_post_analytics` and `get_bulk_post_analytics`.** Meta retired Facebook post reach on 2026-06-15 and the replacement metric it documents (`post_total_media_view_unique`) returns 0 for every Page post while views and clicks keep counting, so the API had stored and returned a false 0 for every Facebook post. The key is now left out, and a row collected before 2026-09-27 that still carries `reach: 0` is dropped by the API. The tool description tells the assistant to treat a missing `reach` as "not reported" and to sum reach only over the platforms that carry it (Instagram, LinkedIn, TikTok with the Business API). Same on the companion server (`mcp.omnisocials.com`).
263
+
264
+ ### 1.35.1 (2026-09-22)
265
+
266
+ - **Changed: `reply_to_inbox` explains a 422 `reply_not_allowed`.** The API answers 422 instead of 502 when the platform refuses replies to an item for good (Facebook `#1705`: comment on a share or boosted copy of the post, or the commenter restricts replies; YouTube: the thread's `canReply` is false). The item is dropped from `get_next_unanswered` automatically; relay it, do not retry.
267
+ - **Changed: `hide_inbox_comment` explains a 422 `cannot_hide`.** Facebook does not let the Page hide some comments (the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post); the API now reads the comment back and answers 422 when Facebook's `can_hide` is false, instead of a 502 on every attempt. Retrying never changes it; use `mark_inbox_read` if the item should stop coming up.
268
+
269
+ ### 1.35.0 (2026-09-17)
270
+
271
+ - **Added: video covers.** `create_post`, `create_and_publish_post` and `update_post` accept `video_cover` for a post whose media is one video: `type: "frame"` with `thumb_offset` (milliseconds into the video) or `type: "custom"` with `cover_url` (JPEG/PNG), plus per-platform `overrides` keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). On `update_post`, `video_cover: null` removes the stored cover. `get_post` shows the cover in use.
272
+ - **Changed: analytics say "not reported" instead of 0.** Metrics a platform never reports at post level (reach on X, saves on Facebook, link clicks on Instagram feed posts, and so on) render as N/A with the reason, and `reach` is null when the platform did not report it, so a zero is never invented.
273
+ - **Changed: `reply_to_inbox` explains a 409 `duplicate_reply`.** The API now refuses the same reply (same text and attachment) sent twice to one comment, or to one DM conversation, within 10 minutes; the tool description tells the assistant that nothing was sent again and to continue with `get_next_unanswered` rather than resend. Guards against a re-invoked tool call after a slow response (a customer's Instagram comment got the identical reply twice, 21 seconds apart).
274
+
230
275
  ### 1.34.1 (2026-09-14)
231
276
 
232
277
  - **Changed: Threads threads and the Threads inbox are no longer "rolling out".** Meta approved the reply, read-replies and mentions permissions, so `threads.thread_parts` and the Threads inbox work for every Threads connection; accounts connected before 2026-09-14 need a one-time reconnect under Settings -> Organisation -> Workspaces (`400`/`401` errors name the reconnect). Threads location tagging stays rolling out (not approved yet). Same on the companion server.
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
1
+ import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -270,11 +270,19 @@ export declare class OmniSocialsClient {
270
270
  retryPost(id: string): Promise<ApiResponse<RetryPostResult>>;
271
271
  approvePost(id: string): Promise<ApiResponse<ApprovePostResult>>;
272
272
  rejectPost(id: string, comment?: string): Promise<ApiResponse<RejectPostResult>>;
273
+ getPostApproval(id: string): Promise<ApiResponse<PostApproval>>;
273
274
  searchLocations(query?: string, platform?: "instagram" | "threads", coords?: {
274
275
  latitude?: number;
275
276
  longitude?: number;
276
277
  }): Promise<LocationSearchResult>;
277
278
  validateLocation(id: string): Promise<ApiResponse<unknown>>;
279
+ listPinterestProducts(params?: {
280
+ source?: "catalog" | "pins";
281
+ product_group_id?: string;
282
+ bookmark?: string;
283
+ page_size?: string;
284
+ }): Promise<PinterestProductsResult>;
285
+ validatePinterestProduct(id: string): Promise<PinterestProductCheck>;
278
286
  searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<AudioSearchResult>;
279
287
  listMedia(params?: {
280
288
  limit?: string;
@@ -287,6 +295,7 @@ export declare class OmniSocialsClient {
287
295
  filename?: string;
288
296
  name?: string;
289
297
  folder?: string;
298
+ pdf_mode?: "slides" | "document";
290
299
  }): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
291
300
  compatibility?: MediaCompatibility;
292
301
  message?: string;
@@ -297,6 +306,7 @@ export declare class OmniSocialsClient {
297
306
  filename?: string;
298
307
  name?: string;
299
308
  folder?: string;
309
+ pdf_mode?: "slides" | "document";
300
310
  }): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
301
311
  compatibility?: MediaCompatibility;
302
312
  message?: string;
@@ -394,6 +404,7 @@ export declare class OmniSocialsClient {
394
404
  markInboxRead(conversationId: string): Promise<ApiResponse<unknown>>;
395
405
  replyToInboxConversation(conversationId: string, data: {
396
406
  text: string;
407
+ message_id?: string;
397
408
  attachment_url?: string;
398
409
  attachment_type?: string;
399
410
  include_next?: boolean;
package/build/client.js CHANGED
@@ -285,6 +285,12 @@ export class OmniSocialsClient {
285
285
  ...(comment ? { comment } : {}),
286
286
  });
287
287
  }
288
+ // Reads the review of the post's approval workflow: every step with its
289
+ // approvers and their decisions, the rejection with its reason, and the
290
+ // comment thread. A post without a workflow answers 200 with status "none".
291
+ async getPostApproval(id) {
292
+ return this.request("GET", `/posts/${id}/approval`);
293
+ }
288
294
  // Locations (Instagram or Threads place tagging). NOT the standard
289
295
  // envelope; see LocationSearchResult: Instagram's degraded-path `error` is
290
296
  // a plain string next to `data`; Threads answers with `locations` and a
@@ -303,6 +309,24 @@ export class OmniSocialsClient {
303
309
  async validateLocation(id) {
304
310
  return this.request("GET", "/locations/validate", undefined, { id });
305
311
  }
312
+ // Pinterest product Pins (for `pinterest.product_tags`). NOT the standard
313
+ // envelope; see PinterestProductsResult: `products` on success, a
314
+ // { code, message } `error` object when the list could not be read.
315
+ async listPinterestProducts(params) {
316
+ const query = {};
317
+ if (params?.source)
318
+ query.source = params.source;
319
+ if (params?.product_group_id)
320
+ query.product_group_id = params.product_group_id;
321
+ if (params?.bookmark)
322
+ query.bookmark = params.bookmark;
323
+ if (params?.page_size)
324
+ query.page_size = params.page_size;
325
+ return this.request("GET", "/pinterest/products", undefined, query);
326
+ }
327
+ async validatePinterestProduct(id) {
328
+ return this.request("GET", "/pinterest/products/validate", undefined, { id });
329
+ }
306
330
  // Audio (Instagram Reel music). Same nonstandard string-`error` envelope.
307
331
  async searchInstagramAudio(query, type) {
308
332
  const params = {};
package/build/index.js CHANGED
@@ -29,7 +29,7 @@ const sessionState = { activeIndex: 0 };
29
29
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
30
30
  const server = new McpServer({
31
31
  name: "OmniSocials",
32
- version: "1.35.0",
32
+ version: "1.39.0",
33
33
  });
34
34
  // Register all tools - pass getter function so tools always use the active workspace's client
35
35
  registerPostTools(server, getActiveClient);
@@ -20,9 +20,11 @@ export declare function getImpressions(platform: string, metrics: Metrics): numb
20
20
  export declare function getReach(platform: string, metrics: Metrics): number | null;
21
21
  /** Post-level metrics a platform can never report: platform -> metric key -> reason. */
22
22
  export declare const UNSUPPORTED_POST_METRICS: Record<string, Record<string, string>>;
23
- /** Reason `metricKey` is unavailable on `platform`, or null when it is available. */
24
- export declare function unsupportedMetricReason(platform: string, metricKey: string): string | null;
25
- /** True when the platform can never report `metricKey`; a finite stored value overrides the map. */
23
+ /** Metrics unavailable for some post types only: platform -> content_type -> metric key -> reason. Empty since Facebook reach moved to the platform-wide map. */
24
+ export declare const UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE: Record<string, Record<string, Record<string, string>>>;
25
+ /** Reason `metricKey` is unavailable on `platform` (refined by `contentType`), or null when it is available. */
26
+ export declare function unsupportedMetricReason(platform: string, metricKey: string, contentType?: string): string | null;
27
+ /** True when the platform can never report `metricKey` for this post; a finite stored value overrides the map. */
26
28
  export declare function isMetricUnsupported(platform: string, metricKey: string, metrics?: Metrics): boolean;
27
29
  /**
28
30
  * Engagement rate as a percentage (0-100), suppressed below the impression
package/build/metrics.js CHANGED
@@ -129,6 +129,7 @@ export const UNSUPPORTED_POST_METRICS = {
129
129
  reach: "X does not report reach, only impressions.",
130
130
  },
131
131
  facebook: {
132
+ reach: "Facebook stopped reporting post reach in June 2026: Meta retired the metric and its replacement returns no data.",
132
133
  saves: "Facebook does not report saves.",
133
134
  },
134
135
  instagram: {
@@ -190,13 +191,19 @@ export const UNSUPPORTED_POST_METRICS = {
190
191
  engagement_rate: "Google discontinued per-post metrics for Business Profiles in 2023.",
191
192
  },
192
193
  };
193
- /** Reason `metricKey` is unavailable on `platform`, or null when it is available. */
194
- export function unsupportedMetricReason(platform, metricKey) {
194
+ /** Metrics unavailable for some post types only: platform -> content_type -> metric key -> reason. Empty since Facebook reach moved to the platform-wide map. */
195
+ export const UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE = {};
196
+ /** Reason `metricKey` is unavailable on `platform` (refined by `contentType`), or null when it is available. */
197
+ export function unsupportedMetricReason(platform, metricKey, contentType) {
198
+ const typed = contentType ? UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE[platform]?.[contentType]?.[metricKey] : undefined;
199
+ if (typed)
200
+ return typed;
195
201
  return UNSUPPORTED_POST_METRICS[platform]?.[metricKey] ?? null;
196
202
  }
197
- /** True when the platform can never report `metricKey`; a finite stored value overrides the map. */
203
+ /** True when the platform can never report `metricKey` for this post; a finite stored value overrides the map. */
198
204
  export function isMetricUnsupported(platform, metricKey, metrics) {
199
- if (!unsupportedMetricReason(platform, metricKey))
205
+ const contentType = typeof metrics?.content_type === "string" ? metrics.content_type : undefined;
206
+ if (!unsupportedMetricReason(platform, metricKey, contentType))
200
207
  return false;
201
208
  if (metrics && finiteOrNull(metrics[metricKey]) !== null)
202
209
  return false;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
3
3
  import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
4
4
  export function registerAnalyticsTools(server, getClient) {
5
- server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky and Mastodon; link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
5
+ server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
6
6
  post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
7
7
  }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -80,11 +80,15 @@ function renderNextUnanswered(result) {
80
80
  const c = item.conversation;
81
81
  const who = c.participant?.name || c.participant?.username || "Unknown";
82
82
  let md = `## Next: ${PLATFORM_EMOJI[c.platform] || ""} ${c.platform} ${c.type} from ${who}\n\n`;
83
- md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\`\n\n`;
83
+ md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\` (pass it as message_id when replying)\n\n`;
84
+ if (item.reply_window?.open === false) {
85
+ const app = c.platform === "instagram" ? "Instagram" : "Facebook";
86
+ md += `⚠️ **Meta's 24-hour reply window for this DM closed ${formatDateTime(item.reply_window.closes_at)}.** OmniSocials cannot send a reply here (reply_to_inbox answers 422 outside_messaging_window). Tell the user to answer it from the ${app} app; that reply is mirrored into the inbox and clears this item. Or mark_inbox_read to skip it.\n\n`;
87
+ }
84
88
  md += renderInboxPost(item.message.post || c.post);
85
89
  md += renderInboxMessages(item.messages, item.message.id);
86
90
  md += `_${result.remaining || 0} more waiting._\n\n`;
87
- md += `_Reply: reply_to_inbox with the conversation ID (add include_next: true to get the next one in the same call). Skip for good: mark_inbox_read. Comments: hide_inbox_comment / delete_inbox_comment with the message id._`;
91
+ md += `_Reply: reply_to_inbox with the conversation ID and message_id: "${item.message.id}" (add include_next: true to get the next one in the same call). Skip for good: mark_inbox_read. Comments: hide_inbox_comment / delete_inbox_comment with the message id._`;
88
92
  return md;
89
93
  }
90
94
  export function registerInboxTools(server, getClient) {
@@ -104,7 +108,7 @@ export function registerInboxTools(server, getClient) {
104
108
  unanswered: z
105
109
  .boolean()
106
110
  .optional()
107
- .describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs within the 24-hour reply window), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
111
+ .describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs past the 24-hour reply window included; they cannot be answered through the API but the customer is still waiting), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
108
112
  limit: z
109
113
  .number()
110
114
  .optional()
@@ -201,8 +205,12 @@ export function registerInboxTools(server, getClient) {
201
205
  ],
202
206
  };
203
207
  });
204
- server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, X, or Threads. You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost); a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set); other platforms are text-only. Set include_next: true to get the next conversation that needs an answer back in the same call (same as get_next_unanswered), so the inbox can be worked through as: get_next_unanswered → draft → reply_to_inbox(include_next) → draft → repeat.", {
208
+ server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, X, or Threads. You can only reply to conversations that already exist. For a comment or mention, pass message_id (the #id of the comment being answered): comments on one post share a conversation and without it the reply goes under the newest comment on the post. Meta direct-message replies must be within the platform's 24-hour messaging window: a 422 outside_messaging_window means the window has closed, nothing was sent, and the user must answer that DM from the Instagram or Facebook app (mirrored into the inbox) or skip it with mark_inbox_read; do not retry. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost); a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set); other platforms are text-only. Set include_next: true to get the next conversation that needs an answer back in the same call (same as get_next_unanswered), so the inbox can be worked through as: get_next_unanswered → draft → reply_to_inbox(include_next) → draft → repeat. A 409 duplicate_reply means this exact reply already went out to this conversation in the last 10 minutes (an earlier call that looked slow or failed did succeed) or is being sent right now: nothing was sent again, do NOT retry it — continue with get_next_unanswered. A 422 reply_not_allowed means the platform refuses replies to this item for good (Facebook #1705: the comment sits on a share or boosted copy of the post, or the commenter restricts replies; YouTube: the thread's canReply is false because comments are off on the video or the comment was removed or held for review): the item is dropped from get_next_unanswered automatically, tell the user, and do NOT retry.", {
205
209
  conversation_id: z.string().describe("The conversation ID to reply to"),
210
+ message_id: z
211
+ .string()
212
+ .optional()
213
+ .describe("For comment/mention threads: the inbox id of the comment you are answering (the 'Message to answer' #id from get_next_unanswered, or a #id from get_inbox_conversation). Every comment on a post shares one conversation, so without it the reply is posted under the newest comment on the post, which can be a different person than the one you drafted for. Always pass it for comments. Ignored for DMs."),
206
214
  text: z
207
215
  .string()
208
216
  .optional()
@@ -219,9 +227,10 @@ export function registerInboxTools(server, getClient) {
219
227
  .boolean()
220
228
  .optional()
221
229
  .describe("After sending, also return the next conversation that needs an answer (full thread + post context), or 'nothing waiting'"),
222
- }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, text, attachment_url, attachment_type, include_next }) => {
230
+ }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, message_id, text, attachment_url, attachment_type, include_next }) => {
223
231
  const result = await getClient().replyToInboxConversation(conversation_id, {
224
232
  text: text ?? "",
233
+ ...(message_id ? { message_id } : {}),
225
234
  attachment_url,
226
235
  attachment_type,
227
236
  ...(include_next ? { include_next: true } : {}),
@@ -240,7 +249,7 @@ export function registerInboxTools(server, getClient) {
240
249
  }
241
250
  return { content: [{ type: "text", text: md }] };
242
251
  });
243
- server.tool("get_next_unanswered", "Get the next inbox item that needs an answer, with the whole conversation and the post it is on, in one call. Serves the longest-waiting item first: the customer's latest DM with no reply after it (Instagram/Facebook DMs within the 24-hour reply window only, since Meta refuses later replies), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answered (they are mirrored into the inbox), so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped (no reply path). Only unread items are served by default: to skip one for good, mark_inbox_read it; pass include_read to also see read-but-unanswered items. Looks back 30 days. Loop: get_next_unanswered → draft a reply → reply_to_inbox with include_next: true → repeat until nothing is waiting.", {
252
+ server.tool("get_next_unanswered", "Get the next inbox item that needs an answer, with the whole conversation and the post it is on, in one call. An item needs an answer when it is the customer's latest DM with no reply after it, or a comment/mention nobody replied to and that is not hidden. Order: DMs that can still be answered first (Instagram/Facebook DMs inside Meta's 24-hour window, the one closing soonest first, plus X DMs), then Instagram/Facebook DMs whose window has closed (reply_window.open = false: OmniSocials cannot reply, tell the user to answer from the native app or skip with mark_inbox_read), then comments and mentions, oldest first. Replies typed in the native apps count as answered (they are mirrored into the inbox), so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped (no reply path). Only unread items are served by default: to skip one for good, mark_inbox_read it; pass include_read to also see read-but-unanswered items. Looks back 30 days. Loop: get_next_unanswered → draft a reply → reply_to_inbox with the conversation id, message_id = the served message's #id, and include_next: true → repeat until nothing is waiting.", {
244
253
  platform: z
245
254
  .enum(INBOX_PLATFORMS)
246
255
  .optional()
@@ -252,7 +261,7 @@ export function registerInboxTools(server, getClient) {
252
261
  order: z
253
262
  .enum(["oldest", "newest"])
254
263
  .optional()
255
- .describe("oldest (default): the item that has waited longest first; newest: the most recent first"),
264
+ .describe("oldest (default): the item that has waited longest first; newest: the most recent first. Applies within each group (answerable DMs, then expired Instagram/Facebook DMs, then comments); the groups keep their order"),
256
265
  include_read: z
257
266
  .boolean()
258
267
  .optional()
@@ -307,7 +316,7 @@ export function registerInboxTools(server, getClient) {
307
316
  ],
308
317
  };
309
318
  };
310
- server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
319
+ server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying. A 422 cannot_hide means Facebook does not let the Page hide this particular comment (the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post): retrying never changes it — tell the user, and use mark_inbox_read on the conversation if it should stop coming up. A 502 hide_not_applied means Instagram accepted the call but still reports the comment as visible (this happens with comments shown under 'Comments from Facebook' on a reel shared to Facebook; they live on Facebook and Instagram's hide does not reach them): nothing changed in the inbox, tell the user to hide it in the Instagram or Facebook app, do not retry.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
311
320
  // Older name from 1.28.0 (when hiding was Threads-only). Kept so existing
312
321
  // setups keep working; same call, same platforms as hide_inbox_comment.
313
322
  server.tool("hide_inbox_reply", "Older name for hide_inbox_comment, kept so existing setups keep working; prefer hide_inbox_comment. Same behaviour: hide (or unhide with hide: false) a comment someone left on one of the workspace's posts, as the post owner, on Facebook, Instagram, TikTok, YouTube or Threads. Takes the message id (the #id in the thread), not the conversation id. A 403 reconnect_required means the account must be reconnected in the dashboard to grant the comment-moderation permission.", hideCommentSchema, { title: "Hide Inbox Comment (older name)", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, formatBytes } from "../client.js";
3
3
  export function registerMediaTools(server, getClient) {
4
- server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
4
+ server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video/document — a document is a PDF kept as one item; its ID in media_ids expands into every page), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
5
5
  limit: z.string().optional().describe("Max results to return"),
6
6
  offset: z.string().optional().describe("Offset for pagination"),
7
7
  search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
@@ -30,7 +30,16 @@ export function registerMediaTools(server, getClient) {
30
30
  // `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
31
31
  const rawType = typeof m.type === "string" ? m.type : "";
32
32
  const isVideo = rawType === "video" || rawType.startsWith("video/");
33
- const type = isVideo ? "Video" : "Image";
33
+ const isDocument = rawType === "document";
34
+ // A PDF document item names its page count (pass its ONE id and the
35
+ // post gets every page); a slide rendered from a PDF names its file
36
+ // and page, so the assistant can pass the whole deck in order.
37
+ const pdfTag = m.pdf
38
+ ? isDocument
39
+ ? ` (PDF, ${Array.isArray(m.pdf.pages) ? m.pdf.pages.length : m.pdf.rendered_pages} pages)`
40
+ : ` (PDF ${m.pdf.name ? `"${m.pdf.name}" ` : ""}page ${m.pdf.page}/${m.pdf.total_pages})`
41
+ : "";
42
+ const type = (isVideo ? "Video" : isDocument ? "Document" : "Image") + pdfTag;
34
43
  const folder = m.folder_id ? `\`${m.folder_id}\`` : "—";
35
44
  md += `| ${i + 1} | ${type} | ${m.name || m.filename || "—"} | ${folder} | ${(m.size || "—")} | \`${m.id}\` |\n`;
36
45
  }
@@ -57,7 +66,7 @@ export function registerMediaTools(server, getClient) {
57
66
  });
58
67
  server.tool("upload_media", `Upload media to the library. Accepts EITHER a public URL OR base64-encoded image data (e.g. when the user pastes an image directly in chat). Supported: JPEG, PNG, GIF, WebP, MP4, MOV, AVI, and PDF.
59
68
 
60
- PDF = carousel: upload a PDF (as a 'url' or as base64_data with mime_type "application/pdf") and it is split into one image slide per page (max 20 pages). The response lists a Media ID for every slide — pass ALL of them, in order, as media_ids to create_post to post the deck as a carousel. On LinkedIn the slides post as a native swipeable DOCUMENT; on Instagram, TikTok, Threads and Pinterest as an image carousel. This is the way to let a user post an existing slide deck (Canva/PowerPoint/Figma exported to PDF) as a carousel.
69
+ PDF = carousel: upload a PDF (as a 'url' or as base64_data with mime_type "application/pdf") and it is split into one image slide per page (max 20 pages). The response lists a Media ID for every slide — pass ALL of them, in order, as media_ids to create_post to post the deck as a carousel. Prefer pdf_mode "document" when the user wants ONE library item for the deck: the response then has a single Media ID whose media_ids entry expands into every page at post time. On LinkedIn the slides post as a native swipeable DOCUMENT made from the ORIGINAL PDF file (the file is kept: text stays sharp, in-document links work, viewers download the real file, and every page is included even past the 20-slide cap) as long as the slides are posted unchanged and in order; on Instagram, TikTok, Threads and Pinterest as an image carousel. Set linkedin.document_source to 'slides' on the post to send a document rebuilt from the slide images instead. This is the way to let a user post an existing slide deck (Canva/PowerPoint/Figma exported to PDF) as a carousel.
61
70
 
62
71
  Size limits: base64 and direct uploads are capped at 100 MB (and base64 inflates the request body, so the practical ceiling is ~75 MB). For anything larger — up to 1 GB — use ONE of: (a) pass a public 'url' and the server fetches it (handles up to 1 GB), or (b) have the user upload the file in the OmniSocials Library UI at https://app.omnisocials.com/library . IMPORTANT: if the user has a large LOCAL file (over ~100 MB) with no public URL, do NOT tell them to compress or shrink it — instead point them to the Library UI link above, which uploads files up to 1 GB directly.
63
72
 
@@ -72,6 +81,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
72
81
  filename: z.string().optional().describe("Optional filename for the uploaded media"),
73
82
  name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
74
83
  folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
84
+ pdf_mode: z.enum(["slides", "document"]).optional().describe('PDF uploads only. "slides" (default): one image media item per page, one Media ID each (pass ALL of media_ids to create_post). "document": ONE media item for the whole PDF (type "document"); pass its single Media ID in media_ids and the post gets every page in order. Both keep the original file, which LinkedIn receives. Prefer "document" when the user wants one library item for the deck.'),
75
85
  }, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
76
86
  let result;
77
87
  if (params.base64_data && params.mime_type) {
@@ -81,10 +91,17 @@ When the user provides an image in the conversation (not a URL), use base64_data
81
91
  filename: params.filename,
82
92
  name: params.name || params.filename,
83
93
  folder: params.folder,
94
+ ...(params.pdf_mode ? { pdf_mode: params.pdf_mode } : {}),
84
95
  });
85
96
  }
86
97
  else if (params.url) {
87
- result = await getClient().uploadMedia({ url: params.url, filename: params.filename, name: params.name || params.filename, folder: params.folder });
98
+ result = await getClient().uploadMedia({
99
+ url: params.url,
100
+ filename: params.filename,
101
+ name: params.name || params.filename,
102
+ folder: params.folder,
103
+ ...(params.pdf_mode ? { pdf_mode: params.pdf_mode } : {}),
104
+ });
88
105
  }
89
106
  else {
90
107
  return {
@@ -97,6 +114,23 @@ When the user provides an image in the conversation (not a URL), use base64_data
97
114
  };
98
115
  }
99
116
  const compatibility = result.compatibility;
117
+ // PDF kept as ONE document item (pdf_mode "document").
118
+ if (result.data?.type === "document" && result.pdf) {
119
+ const d = result.data;
120
+ const pdfInfo = result.pdf;
121
+ const pageCount = Array.isArray(d.pdf?.pages) ? d.pdf.pages.length : pdfInfo.rendered_pages;
122
+ let md = `## PDF Uploaded as One Document\n\n`;
123
+ md += `**${d.name || pdfInfo.name || "document.pdf"}**, ${pageCount} ${pageCount === 1 ? "page" : "pages"}`;
124
+ if (pdfInfo.truncated)
125
+ md += ` (${pdfInfo.total_pages} in the file; the first ${pdfInfo.rendered_pages} became page images)`;
126
+ md += `.\n\n**Media ID:** \`${d.id}\`\n\n`;
127
+ md += `Pass this ONE id in \`create_post\` as \`media_ids: ["${d.id}"]\`; the post gets every page, in order. `;
128
+ md += `On LinkedIn it posts as a swipeable **document** made from the **original PDF file** (sharp text, working links, the real download); on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first page.`;
129
+ if (compatibility && compatibility.compatible === false && compatibility.summary) {
130
+ md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before posting.`;
131
+ }
132
+ return { content: [{ type: "text", text: md }] };
133
+ }
100
134
  // PDF upload: the server split it into image slides. Surface every slide
101
135
  // id and tell the model to pass them ALL to create_post as one carousel.
102
136
  const mediaIds = result.media_ids;
@@ -115,7 +149,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
115
149
  });
116
150
  md += `\n**Pass ALL of these to \`create_post\` as \`media_ids\`, in this order**, to post the deck as a carousel:\n\n`;
117
151
  md += `\`\`\`\nmedia_ids: [${mediaIds.map((id) => `"${id}"`).join(", ")}]\n\`\`\`\n\n`;
118
- md += `On LinkedIn this posts as a swipeable **document** carousel; on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first slide.`;
152
+ md += `On LinkedIn this posts as a swipeable **document** carousel made from the **original PDF file** (kept in storage: sharp text, working links, the real download${pdf?.truncated ? `, all ${pdf.total_pages} pages` : ""}) as long as the slides are posted unchanged and in order; on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first slide. To send a document rebuilt from the slide images instead, set linkedin.document_source to "slides" on the post.`;
119
153
  if (compatibility && compatibility.compatible === false && compatibility.summary) {
120
154
  md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before posting.`;
121
155
  }
@@ -88,7 +88,7 @@ const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
88
88
  .describe("Per-platform overrides keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). An override wins over the base cover for that platform."),
89
89
  })
90
90
  .optional()
91
- .describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. TikTok only takes a frame: a 'custom' cover is skipped there, so add a tiktok override with type 'frame' when the user wants a specific TikTok frame. The per-platform reel fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. get_post reads it back as `video_cover`.");
91
+ .describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. YouTube Shorts: the cover shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (rolling out since July 2026, Partner Program channels first); on other channels YouTube stores it as the default thumbnail but shows a frame from the video on Shorts. Say that it depends on the channel; do not promise it. TikTok only takes a frame: a 'custom' cover is skipped there, so add a tiktok override with type 'frame' when the user wants a specific TikTok frame. The per-platform reel fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. get_post reads it back as `video_cover`.");
92
92
  const LINKEDIN_OPTIONS = z
93
93
  .object({
94
94
  first_comment: z
@@ -101,6 +101,11 @@ const LINKEDIN_OPTIONS = z
101
101
  .nullable()
102
102
  .optional()
103
103
  .describe("Multi-image style for the profile post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
104
+ document_source: z
105
+ .enum(["original_pdf", "slides"])
106
+ .nullable()
107
+ .optional()
108
+ .describe("Where the profile post's document carousel comes from when its images are exactly the rendered pages of one uploaded PDF (unchanged, in order). 'original_pdf' (default): LinkedIn receives the kept original PDF file: sharp text, working in-document links, the real download, every page even past the 20-slide cap. 'slides': rebuild the document from the slide images (a flattened copy). Ignored when the images are not a PDF's pages, when carousel_as_images is true, for 0-1 images, videos and polls. On update_post: 'slides' sets it, 'original_pdf'/null reverts to the default, omitted keeps the current value."),
104
109
  })
105
110
  .optional()
106
111
  .describe("LinkedIn Profile options");
@@ -116,6 +121,11 @@ const LINKEDIN_PAGE_OPTIONS = z
116
121
  .nullable()
117
122
  .optional()
118
123
  .describe("Multi-image style for the company-page post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
124
+ document_source: z
125
+ .enum(["original_pdf", "slides"])
126
+ .nullable()
127
+ .optional()
128
+ .describe("Same as linkedin.document_source, for the company-page post: 'original_pdf' (default) sends the kept original PDF when the images are its unchanged pages; 'slides' rebuilds the document from the slide images. On update_post: 'slides' sets it, 'original_pdf'/null reverts, omitted keeps the current value."),
119
129
  })
120
130
  .optional()
121
131
  .describe("LinkedIn Company Page options");
@@ -174,6 +184,7 @@ const PINTEREST_OPTIONS = z.object({
174
184
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
175
185
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
176
186
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
187
+ product_tags: z.array(z.string()).max(24).optional().describe("Products to tag on the Pin, so people can shop the items in the image. Up to 24 product Pins of the connected Pinterest account, each as a Pin ID string or a Pin link (https://www.pinterest.com/pin/<id>/). Get the IDs with list_pinterest_products. Only the account's own product Pins can be tagged (public, with a link on a website the account claimed on Pinterest); products of other merchants cannot. The tags are added right after the Pin is published; a product Pinterest refuses never fails the post, and get_post shows the outcome (product_tags_result). On update_post the pinterest object replaces the stored one, so leave product_tags out to remove the tags."),
177
188
  }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate.");
178
189
  // One Instagram photo tag. The wrapping z.array(...).describe(...) stays
179
190
  // per-tool because update_post documents replace/clear semantics.
@@ -627,6 +638,18 @@ export function registerPostTools(server, getClient) {
627
638
  optionRows.push(["Pinterest title", pin.title]);
628
639
  if (typeof pin.link === "string" && pin.link)
629
640
  optionRows.push(["Pinterest link", pin.link]);
641
+ if (Array.isArray(pin.product_tags) && pin.product_tags.length) {
642
+ optionRows.push(["Pinterest product tags", pin.product_tags.join(", ")]);
643
+ }
644
+ // Outcome of the tag step, written after the Pin is live.
645
+ const tagResult = pin.product_tags_result;
646
+ if (tagResult && typeof tagResult === "object") {
647
+ const skipped = (tagResult.skipped || []).map((item) => `${item.pin_id} ${item.reason}`).join(", ");
648
+ optionRows.push([
649
+ "Pinterest products tagged",
650
+ `${(tagResult.tagged || []).length} of ${tagResult.requested ?? 0}${skipped ? ` (not tagged: ${skipped})` : ""}${tagResult.error ? ` (Pinterest: ${tagResult.error})` : ""}`,
651
+ ]);
652
+ }
630
653
  // Cross-platform video cover (base + per-platform overrides).
631
654
  const describeCover = (c) => {
632
655
  if (!c || typeof c !== "object")
@@ -662,15 +685,20 @@ export function registerPostTools(server, getClient) {
662
685
  for (const [k, v] of optionRows)
663
686
  md += `| ${k} | ${v.replace(/\|/g, "\\|")} |\n`;
664
687
  }
665
- // LinkedIn multi-image style: nested under each channel's options
666
- // object by the API. Only rendered when the post opts out of the
667
- // default PDF document carousel.
688
+ // LinkedIn multi-image style + document source: nested under each
689
+ // channel's options object by the API. Only rendered when the post
690
+ // opts out of a default (document carousel; kept original PDF).
668
691
  for (const channel of ["linkedin", "linkedin_page"]) {
669
692
  const opts = p[channel];
670
- if (opts && typeof opts === "object" && opts.carousel_as_images === true) {
671
- const label = channel === "linkedin_page" ? "LinkedIn Page" : "LinkedIn Profile";
693
+ if (!opts || typeof opts !== "object")
694
+ continue;
695
+ const label = channel === "linkedin_page" ? "LinkedIn Page" : "LinkedIn Profile";
696
+ if (opts.carousel_as_images === true) {
672
697
  md += `\n\n**${label} multi-image style:** plain image gallery (default is the swipeable PDF document carousel)\n`;
673
698
  }
699
+ if (opts.document_source === "slides") {
700
+ md += `\n\n**${label} document source:** rebuilt from the slide images (default sends the kept original PDF when the images are its unchanged pages)\n`;
701
+ }
674
702
  }
675
703
  // Trial Reel state: nested under the instagram options object by the
676
704
  // API. Only rendered when enabled, so batch-scheduled Trial Reels can be
@@ -729,7 +757,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
729
757
 
730
758
  **Per-platform video caps (duration + size, checked via ffprobe at submit; exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s; yours is 2min 35s. Trim the video or deselect X."):**
731
759
  - X — **140s (2min 20s)** · **512MB** on free/Basic accounts; when the connected X account has **Premium or Premium+** the caps rise to **125min / 16GB** automatically (tier is read from the connection; reconnect X after upgrading). X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images
732
- - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
760
+ - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook 240min (Post + Reel; the old 90s Reel cap is gone)
733
761
  Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
734
762
 
735
763
  When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
@@ -743,7 +771,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
743
771
  - TikTok: Privacy level?
744
772
  - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
745
773
  - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
746
- - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
774
+ - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). Only a link written with http:// or https:// counts; a bare domain (brand.com) or a www. link is free. The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
747
775
 
748
776
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
749
777
 
@@ -754,7 +782,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
754
782
  media_ids: z.union([
755
783
  z.array(mediaIdEntry),
756
784
  z.record(z.string(), z.array(mediaIdEntry)),
757
- ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
785
+ ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). A media item of type 'document' (a PDF kept as one item) expands into its page images, in order, so one id attaches the whole deck."),
758
786
  media_urls: z.union([
759
787
  z.array(mediaUrlEntry),
760
788
  z.record(z.string(), z.array(mediaUrlEntry)),
@@ -781,7 +809,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
781
809
  notify_subscribers: z.boolean().optional(),
782
810
  contains_synthetic_media: z.boolean().optional(),
783
811
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
784
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
812
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
785
813
  instagram: z.object({
786
814
  share_to_feed: z.boolean().optional(),
787
815
  thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
@@ -901,10 +929,10 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
901
929
  - Pinterest: ALWAYS need an image + board_id.
902
930
  - Ask the user which media to use. Use list_media to show options.
903
931
  - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
904
- - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
932
+ - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook 240min (Post + Reel; the old 90s Reel cap is gone).
905
933
  4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
906
934
  5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
907
- 6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
935
+ 6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part; only a link written with http:// or https:// counts, a bare domain or www. link is free). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
908
936
 
909
937
  Do NOT call without required media — it will fail.`, {
910
938
  content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
@@ -939,7 +967,7 @@ Do NOT call without required media — it will fail.`, {
939
967
  notify_subscribers: z.boolean().optional(),
940
968
  contains_synthetic_media: z.boolean().optional(),
941
969
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
942
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
970
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
943
971
  instagram: z.object({
944
972
  share_to_feed: z.boolean().optional(),
945
973
  thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
@@ -1053,7 +1081,7 @@ Do NOT call without required media — it will fail.`, {
1053
1081
  notify_subscribers: z.boolean().optional(),
1054
1082
  contains_synthetic_media: z.boolean().optional(),
1055
1083
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled. Pass an empty string to clear a previously set first comment."),
1056
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
1084
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
1057
1085
  pinterest: PINTEREST_OPTIONS,
1058
1086
  instagram: z.object({
1059
1087
  share_to_feed: z.boolean().optional(),
@@ -1272,6 +1300,72 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
1272
1300
  content: [{ type: "text", text: md }],
1273
1301
  };
1274
1302
  });
1303
+ server.tool("get_post_approval", `Read the approval review of a post: every workflow step with its approvers and their decisions, the rejection with its reason, and the comment thread.
1304
+
1305
+ Use it when \`get_post\` shows the status \`rejected\` (who rejected it and why, what to change before sending it again) or \`in_approval\` (who the post waits for, what the reviewers wrote). Read-only. A post without an approval workflow returns status \`none\`.`, {
1306
+ id: z.string().describe("The post ID"),
1307
+ }, { title: "Get Post Approval", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
1308
+ const result = await getClient().getPostApproval(id);
1309
+ if (result.error) {
1310
+ return {
1311
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1312
+ };
1313
+ }
1314
+ const a = result.data;
1315
+ if (!a) {
1316
+ return {
1317
+ content: [{ type: "text", text: "The API returned no approval review for this post. Check its status with get_post." }],
1318
+ };
1319
+ }
1320
+ let md = `## Approval Review\n\n`;
1321
+ md += `| Field | Value |\n`;
1322
+ md += `|-------|-------|\n`;
1323
+ md += `| **Post** | \`${a.post_id || id}\` |\n`;
1324
+ md += `| **Status** | ${capitalize(a.status || "none")} |\n`;
1325
+ if (a.workflow?.name)
1326
+ md += `| **Workflow** | ${a.workflow.name} |\n`;
1327
+ if (a.requested_by?.name)
1328
+ md += `| **Requested by** | ${a.requested_by.name} |\n`;
1329
+ if (a.status === "none") {
1330
+ md += `\n_This post has no approval workflow._`;
1331
+ return {
1332
+ content: [{ type: "text", text: md }],
1333
+ };
1334
+ }
1335
+ if (a.rejection) {
1336
+ md += `\n### Rejected\n\n`;
1337
+ md += `${a.rejection.by?.name || "A reviewer"} rejected this post${a.rejection.at ? ` on ${formatDateTime(a.rejection.at)}` : ""}.\n`;
1338
+ md += a.rejection.reason ? `\n**Reason:** ${a.rejection.reason}\n` : `\n_No reason was given._\n`;
1339
+ }
1340
+ if (Array.isArray(a.steps) && a.steps.length) {
1341
+ md += `\n### Steps\n\n`;
1342
+ md += `| # | Step | Needs | Status | Approvers |\n`;
1343
+ md += `|---|------|-------|--------|-----------|\n`;
1344
+ for (const s of a.steps) {
1345
+ const who = (s.approvers || [])
1346
+ .map((p) => `${p.name || p.email || p.id} (${p.status})`)
1347
+ .join(", ");
1348
+ const active = a.current_step === s.order ? " (waiting now)" : "";
1349
+ md += `| ${s.order} | ${s.name}${active} | ${s.require_mode === "any" ? "one approval" : "all approvers"} | ${capitalize(s.status)} | ${who || "none"} |\n`;
1350
+ }
1351
+ }
1352
+ if (Array.isArray(a.comments) && a.comments.length) {
1353
+ md += `\n### Comments (${a.comments.length}, oldest first)\n\n`;
1354
+ for (const c of a.comments) {
1355
+ const where = c.account ? ` on ${capitalize(c.account.replace(/_/g, " "))}` : "";
1356
+ md += `- **${c.author?.name || "Unknown"}**${where}, ${formatDateTime(c.created_at)}: ${String(c.message).replace(/\n+/g, " / ")}\n`;
1357
+ }
1358
+ }
1359
+ else {
1360
+ md += `\n_No comments in the review thread._`;
1361
+ }
1362
+ if (a.status === "rejected") {
1363
+ md += `\n\n_A rejected post does not publish. To send it again, open it in OmniSocials, edit it and send it for approval there, or create a new post with \`approval_workflow_id\`._`;
1364
+ }
1365
+ return {
1366
+ content: [{ type: "text", text: md }],
1367
+ };
1368
+ });
1275
1369
  server.tool("search_locations", `Search for a location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
1276
1370
 
1277
1371
  Pick the platform with \`platform\`: "instagram" (default) or "threads". The two use DIFFERENT ids (a Facebook Place ID is not a Threads location id), so search with the platform you will tag. If the user wants both, search twice.
@@ -1363,6 +1457,91 @@ Notes:
1363
1457
  });
1364
1458
  return { content: [{ type: "text", text: md }] };
1365
1459
  });
1460
+ server.tool("list_pinterest_products", `List the product Pins of the connected Pinterest account, to tag products on a Pin (people can then shop the items in the image). Use this whenever the user wants to tag products on a Pinterest post.
1461
+
1462
+ Flow: call it → show the products to the user → pass the chosen \`pin_id\` values (max 24) as \`pinterest.product_tags\` on create_post / create_and_publish_post / update_post. If the user gives a Pin link or Pin ID of a product, pass it as \`check_pin\` to check it first, or put it straight in \`pinterest.product_tags\`.
1463
+
1464
+ Notes:
1465
+ - Only the account's own product Pins can be tagged: a public product Pin with a link on a website the account claimed on Pinterest. Products of other merchants cannot be tagged through the API.
1466
+ - \`source\` "catalog" reads the Pinterest catalog (with price and stock). It needs catalog access, which the user gives one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog. \`source\` "pins" reads the account's own Pins and returns the product Pins; one call scans up to 250 Pins, so an empty result with a bookmark means: call again with that bookmark. Without \`source\`, the catalog is used when the connection has catalog access, else the Pins.
1467
+ - A product that Pinterest refuses never fails the post; get_post shows which products were tagged.`, {
1468
+ source: z
1469
+ .enum(["catalog", "pins"])
1470
+ .optional()
1471
+ .describe("Where to read product Pins from. Default: catalog when the Pinterest connection has catalog access, else pins."),
1472
+ product_group_id: z
1473
+ .string()
1474
+ .optional()
1475
+ .describe("Catalog source only. A product group ID from an earlier result. Default: the group named All Products."),
1476
+ bookmark: z
1477
+ .string()
1478
+ .optional()
1479
+ .describe("Cursor from an earlier result, to get the next page."),
1480
+ check_pin: z
1481
+ .string()
1482
+ .optional()
1483
+ .describe("A Pin ID or Pin link (https://www.pinterest.com/pin/<id>/) to check instead of listing: answers whether that Pin can be tagged as a product."),
1484
+ }, { title: "List Pinterest Products", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ source, product_group_id, bookmark, check_pin }) => {
1485
+ const messageOf = (err) => typeof err === "string" ? err : err?.message;
1486
+ if (check_pin) {
1487
+ const check = await getClient().validatePinterestProduct(check_pin);
1488
+ const failed = messageOf(check?.error);
1489
+ if (failed)
1490
+ return { content: [{ type: "text", text: failed }] };
1491
+ const name = check?.title ? ` (${check.title})` : "";
1492
+ let text;
1493
+ if (check?.valid) {
1494
+ text = `Pin \`${check.pin_id}\`${name} is a product Pin of this account. Use it in \`pinterest.product_tags\`.`;
1495
+ }
1496
+ else if (check?.unverified) {
1497
+ text = `Could not check Pin \`${check?.pin_id ?? check_pin}\`: ${check?.reason || "no answer from Pinterest"} You can still pass it in \`pinterest.product_tags\`; Pinterest checks it at publish.`;
1498
+ }
1499
+ else {
1500
+ text = `Pin ${check?.pin_id ? `\`${check.pin_id}\`${name}` : `"${check_pin}"`} cannot be tagged: ${check?.reason || "Pinterest did not accept it."}`;
1501
+ }
1502
+ return { content: [{ type: "text", text }] };
1503
+ }
1504
+ const result = await getClient().listPinterestProducts({ source, product_group_id, bookmark });
1505
+ const failed = messageOf(result?.error);
1506
+ if (failed)
1507
+ return { content: [{ type: "text", text: failed }] };
1508
+ const products = result?.products || [];
1509
+ const fromCatalog = result?.source === "catalog";
1510
+ const groups = result?.product_groups || [];
1511
+ let md = `## Pinterest product Pins (${fromCatalog ? "catalog" : "the account's own Pins"})\n\n`;
1512
+ if (!products.length) {
1513
+ if (result?.bookmark) {
1514
+ md += `No product Pins in this part of the Pin list. Call again with bookmark \`${result.bookmark}\` to scan the next part.\n`;
1515
+ }
1516
+ else if (fromCatalog) {
1517
+ md += groups.length
1518
+ ? "This product group has no products.\n"
1519
+ : "The Pinterest account has no catalog. Try source \"pins\", or pass a product Pin link as check_pin.\n";
1520
+ }
1521
+ else {
1522
+ md += "No product Pins found. A product Pin comes from the Pinterest catalog, or links to a product page on a website the account claimed on Pinterest.\n";
1523
+ }
1524
+ }
1525
+ else {
1526
+ md += `Ask the user which products to tag, then pass their IDs (max 24) as \`pinterest.product_tags\`:\n\n`;
1527
+ md += `| # | Product | Price | Stock | pin_id |\n|---|---------|-------|-------|--------|\n`;
1528
+ products.forEach((p, i) => {
1529
+ const price = typeof p.price === "number" ? `${p.price}${p.currency ? ` ${p.currency}` : ""}` : "-";
1530
+ md += `| ${i + 1} | ${p.title || p.link || "-"} | ${price} | ${p.availability || "-"} | \`${p.pin_id}\` |\n`;
1531
+ });
1532
+ if (result?.bookmark)
1533
+ md += `\nMore products: call again with bookmark \`${result.bookmark}\`.\n`;
1534
+ }
1535
+ if (fromCatalog && groups.length > 1) {
1536
+ md += `\nProduct groups (pass one as product_group_id): ${groups
1537
+ .map((g) => `${g.name || "Unnamed"} \`${g.id}\`${g.id === result?.product_group_id ? " (shown)" : ""}`)
1538
+ .join(", ")}\n`;
1539
+ }
1540
+ if (!fromCatalog && result?.catalog_access === false) {
1541
+ md += `\nThe connection has no catalog access, so catalog products are not listed. The user can give it one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog.\n`;
1542
+ }
1543
+ return { content: [{ type: "text", text: md }] };
1544
+ });
1366
1545
  server.tool("search_instagram_audio", `Search Instagram's licensed music catalog for a track to attach to a REEL. Use this whenever the user wants to post a reel WITH music/a song/a sound (e.g. "post this reel with trending audio", "add that song to it").
1367
1546
 
1368
1547
  Flow: call with a song/artist/keyword (or NO query for currently trending audio) → present the options → once the user picks, pass that result's \`audio_id\` as \`instagram.audio_id\` on create_post / create_and_publish_post / update_post. Optionally mix with \`instagram.audio_volume\` / \`instagram.video_volume\` (0-100; set video_volume 0 to fully replace the video's own sound). Instagram Reels only — feed posts, carousels, and Stories can't take music via the API (Meta limitation).
@@ -28,9 +28,9 @@ export function registerWebhookTools(server, getClient) {
28
28
  content: [{ type: "text", text: md }],
29
29
  };
30
30
  });
31
- server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed", {
31
+ server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed, post.approved (the last approval of a post's approval workflow is in), post.rejected (an approver rejected the post)", {
32
32
  url: z.string().describe("The HTTPS URL to send webhook events to"),
33
- events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed"),
33
+ events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed, post.approved, post.rejected"),
34
34
  }, { title: "Create Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
35
35
  const result = await getClient().createWebhook(params);
36
36
  if (result.error) {
@@ -95,7 +95,7 @@ export function registerWebhookTools(server, getClient) {
95
95
  server.tool("update_webhook", "Update a webhook's URL, events, or active status.", {
96
96
  id: z.string().describe("The webhook ID to update"),
97
97
  url: z.string().optional().describe("Updated HTTPS URL"),
98
- events: z.array(z.string()).optional().describe("Updated event list"),
98
+ events: z.array(z.string()).optional().describe("Updated event list: post.scheduled, post.published, post.failed, post.approved, post.rejected"),
99
99
  is_active: z.boolean().optional().describe("Enable or disable the webhook"),
100
100
  }, { title: "Update Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
101
101
  const result = await getClient().updateWebhook(id, data);
package/build/types.d.ts CHANGED
@@ -84,13 +84,23 @@ export interface InboxMessage {
84
84
  }
85
85
  /** What GET /inbox/next serves (and what `reply_to_inbox` returns as `next`
86
86
  * when `include_next` is set). `message` is the unanswered incoming item: its
87
- * `id` is what hide/delete take, its `conversation_id` is what reply takes.
87
+ * `id` is what hide/delete take and the `message_id` to pass to reply for a
88
+ * comment thread, its `conversation_id` is what reply takes as the path.
88
89
  * `messages` is the thread oldest first (the most recent 50, plus the target
89
90
  * and its parent when they fall outside that window). */
90
91
  export interface InboxNextUnanswered {
91
92
  conversation: InboxConversation;
92
93
  message: InboxMessage;
93
94
  messages: InboxMessage[];
95
+ /** Only Instagram and Facebook DMs have a reply window (Meta's 24 hours
96
+ * after the customer's last message): `closes_at` is that deadline and
97
+ * `open` is false once it has passed, in which case reply_to_inbox answers
98
+ * 422 outside_messaging_window. Every other item: open true, closes_at
99
+ * null. */
100
+ reply_window: {
101
+ open: boolean;
102
+ closes_at: string | null;
103
+ };
94
104
  }
95
105
  /** Response of DELETE /inbox/messages/:id. Replies under the comment are
96
106
  * removed with it; their inbox ids are listed in `removed_reply_ids`. */
@@ -274,6 +284,68 @@ export interface RejectPostResult {
274
284
  approval_status: "rejected";
275
285
  message?: string;
276
286
  }
287
+ /** One approver of a step in GET /posts/:id/approval. */
288
+ export interface PostApprovalApprover {
289
+ id: string;
290
+ name: string | null;
291
+ email: string | null;
292
+ status: "pending" | "approved" | "rejected";
293
+ decided_at: string | null;
294
+ /** The reason this approver gave with a rejection. */
295
+ comment: string | null;
296
+ }
297
+ export interface PostApprovalStep {
298
+ order: number;
299
+ name: string;
300
+ /** `all` = every approver of the step must approve. `any` = one approval is enough. */
301
+ require_mode: "all" | "any";
302
+ status: "pending" | "approved" | "rejected";
303
+ approvers: PostApprovalApprover[];
304
+ }
305
+ export interface PostApprovalComment {
306
+ id: string;
307
+ author: {
308
+ id: string;
309
+ name: string | null;
310
+ } | null;
311
+ message: string;
312
+ /** The channel the comment is about (`instagram`, `linkedin_page`, ...). Null = the whole post. */
313
+ account: string | null;
314
+ created_at: string;
315
+ }
316
+ /** `data` of GET /posts/:id/approval (backend/routes/api/v1/posts.js).
317
+ * `status: "none"` = the post has no approval workflow: `workflow`,
318
+ * `requested_by`, `requested_at`, `current_step` and `rejection` are null and
319
+ * `steps` and `comments` are empty. */
320
+ export interface PostApproval {
321
+ post_id: string;
322
+ status: "none" | "pending" | "approved" | "rejected";
323
+ /** `id` is null for a one-off approval that was not made from a saved workflow. */
324
+ workflow: {
325
+ id: string | null;
326
+ name: string;
327
+ } | null;
328
+ requested_by: {
329
+ id: string;
330
+ name: string | null;
331
+ } | null;
332
+ requested_at: string | null;
333
+ /** Order of the step the post waits on. Null when the review is finished. */
334
+ current_step: number | null;
335
+ steps: PostApprovalStep[];
336
+ rejection: {
337
+ by: {
338
+ id: string;
339
+ name: string | null;
340
+ };
341
+ reason: string | null;
342
+ at: string | null;
343
+ step: number | null;
344
+ } | null;
345
+ /** The review thread, oldest first. Includes the entries OmniSocials
346
+ * writes when a reviewer edits the post. */
347
+ comments: PostApprovalComment[];
348
+ }
277
349
  /** One place from GET /locations/search
278
350
  * (@openapi block in backend/routes/api/v1/locations.js). */
279
351
  export interface LocationPlace {
@@ -314,6 +386,57 @@ export interface LocationSearchResult {
314
386
  };
315
387
  needsPermission?: boolean;
316
388
  }
389
+ /** One product Pin from GET /pinterest/products
390
+ * (@openapi block in backend/routes/api/v1/pinterest.js). */
391
+ export interface PinterestProduct {
392
+ /** Use in `pinterest.product_tags`. */
393
+ pin_id: string;
394
+ title?: string | null;
395
+ description?: string | null;
396
+ link?: string | null;
397
+ image_url?: string | null;
398
+ /** Catalog source only. */
399
+ price?: number | null;
400
+ currency?: string | null;
401
+ availability?: string | null;
402
+ item_id?: string | null;
403
+ }
404
+ /** GET /pinterest/products envelope: NOT the standard data/error envelope.
405
+ * Success: `products` + `bookmark` + `source` + `catalog_access` (and, for
406
+ * the catalog source, `product_groups` + `product_group_id`). When the list
407
+ * could not be read, `error` is a { code, message } object with code one of
408
+ * pinterest_not_connected | pinterest_catalog_access_required |
409
+ * platform_error, and `products` is absent. */
410
+ export interface PinterestProductsResult {
411
+ products?: PinterestProduct[];
412
+ bookmark?: string | null;
413
+ source?: "catalog" | "pins";
414
+ catalog_access?: boolean;
415
+ product_groups?: Array<{
416
+ id: string;
417
+ name?: string | null;
418
+ }>;
419
+ product_group_id?: string | null;
420
+ error?: string | {
421
+ code: string;
422
+ message: string;
423
+ };
424
+ }
425
+ /** GET /pinterest/products/validate result. `unverified` = the check could
426
+ * not run; the publish step is then the final check. */
427
+ export interface PinterestProductCheck {
428
+ valid?: boolean;
429
+ pin_id?: string | null;
430
+ title?: string | null;
431
+ link?: string | null;
432
+ image_url?: string | null;
433
+ unverified?: boolean;
434
+ reason?: string;
435
+ error?: string | {
436
+ code: string;
437
+ message: string;
438
+ };
439
+ }
317
440
  /** One track from GET /audio/search
318
441
  * (@openapi block in backend/routes/api/v1/audio.js). */
319
442
  export interface InstagramAudioTrack {
@@ -366,14 +489,29 @@ export interface MediaItem {
366
489
  size: string;
367
490
  status?: string;
368
491
  created_at: string;
492
+ pdf?: {
493
+ id: string;
494
+ name: string;
495
+ url: string;
496
+ page?: number;
497
+ total_pages: number;
498
+ rendered_pages: number;
499
+ truncated?: boolean;
500
+ pages?: Array<{
501
+ url: string;
502
+ width?: number | null;
503
+ height?: number | null;
504
+ }>;
505
+ };
369
506
  }
370
507
  /**
371
508
  * Extra top-level fields returned by /media/upload* when the uploaded file is a
372
- * PDF. A PDF is rasterized into one image slide per page (max 20): `data`
373
- * mirrors the first slide (back-compat), while `slides` + `media_ids` carry the
374
- * whole carousel in page order. Pass ALL of `media_ids` to create_post. On
375
- * LinkedIn the slides post as a native swipeable document; elsewhere as an
376
- * image carousel.
509
+ * PDF. A PDF is rasterized into one image slide per page (max 20) and the
510
+ * original file is kept: `data` mirrors the first slide (back-compat), while
511
+ * `slides` + `media_ids` carry the whole carousel in page order. Pass ALL of
512
+ * `media_ids` to create_post. On LinkedIn the slides post as a native
513
+ * swipeable document made from the original PDF (unless the post's
514
+ * linkedin.document_source is "slides"); elsewhere as an image carousel.
377
515
  */
378
516
  export interface PdfUploadResult {
379
517
  slides: MediaItem[];
@@ -382,6 +520,10 @@ export interface PdfUploadResult {
382
520
  total_pages: number;
383
521
  rendered_pages: number;
384
522
  truncated: boolean;
523
+ id?: string;
524
+ name?: string;
525
+ url?: string;
526
+ size_bytes?: number;
385
527
  };
386
528
  }
387
529
  export interface Account {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.35.0",
3
+ "version": "1.39.0",
4
4
  "mcpName": "io.github.omnisocials/mcp-server",
5
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "overrides": {
55
55
  "zod": "4.4.0",
56
- "fast-uri": "^3.1.7",
56
+ "fast-uri": "^3.1.8",
57
57
  "hono": "^4.13.7",
58
58
  "qs": "^6.16.0"
59
59
  }