@omnisocials/mcp-server 1.19.0 → 1.20.1

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.
@@ -24,6 +24,30 @@ const mediaIdEntry = z.union([
24
24
  alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
25
25
  }),
26
26
  ]).describe("Library media ID — a plain string, or { id, alt } to attach an accessibility description (alt text).");
27
+ // Pinterest carousels cap at 5 images (Pinterest v5 `multiple_image_urls`);
28
+ // videos are exempt — they publish as single video pins. Mirrors the
29
+ // server-side create-time check in shared/post-validation with the identical
30
+ // message; failing here just saves the agent an API round-trip. Only the
31
+ // per-platform object form is checked — with a flat array this field alone
32
+ // can't tell whether Pinterest is selected, so the server covers that case.
33
+ const VIDEO_URL_RE = /\.(mp4|mov|webm|m4v|avi|mkv)(\?|$)/i;
34
+ const pinterestImageCap = (value, ctx) => {
35
+ if (!value || typeof value !== "object" || Array.isArray(value))
36
+ return;
37
+ const entries = value.pinterest;
38
+ if (!Array.isArray(entries))
39
+ return;
40
+ const imageCount = entries.filter((entry) => {
41
+ const url = typeof entry === "string" ? entry : entry?.url;
42
+ return !(typeof url === "string" && VIDEO_URL_RE.test(url));
43
+ }).length;
44
+ if (imageCount > 5) {
45
+ ctx.addIssue({
46
+ code: z.ZodIssueCode.custom,
47
+ message: "Pinterest carousel pins support a maximum of 5 images. Please remove some images and try again.",
48
+ });
49
+ }
50
+ };
27
51
  // Reusable per-platform option objects for the "first comment" feature — text
28
52
  // auto-posted as a comment on the post right after it publishes (hashtags out
29
53
  // of the caption, "link in first comment", etc.). Only platforms with a
@@ -59,6 +83,76 @@ const LINKEDIN_PAGE_OPTIONS = z
59
83
  })
60
84
  .optional()
61
85
  .describe("LinkedIn Company Page options");
86
+ // Per-platform option schemas shared verbatim by create_post,
87
+ // create_and_publish_post and update_post (they were byte-identical in all
88
+ // three). Platform blocks that genuinely differ per tool (instagram, youtube,
89
+ // google_business, and the thread_parts wrappers with their per-tool
90
+ // nullability/descriptions) stay inline in each tool.
91
+ const TIKTOK_OPTIONS = z.object({
92
+ privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
93
+ disable_comment: z.boolean().optional(),
94
+ disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
95
+ disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
96
+ video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
97
+ is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
98
+ brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
99
+ brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
100
+ auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
101
+ }).optional().describe("TikTok options");
102
+ const PINTEREST_OPTIONS = z.object({
103
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
104
+ title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
105
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
106
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
107
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
108
+ }).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.");
109
+ // One Instagram photo tag. The wrapping z.array(...).describe(...) stays
110
+ // per-tool because update_post documents replace/clear semantics.
111
+ const USER_TAG_ENTRY = z.object({
112
+ username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
113
+ x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
114
+ y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
115
+ image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
116
+ });
117
+ // Inner thread-part schemas for the chained-thread platforms. The wrapping
118
+ // z.array(...).min(2).max(25)[.nullable()].optional().describe(...) stays
119
+ // per-tool (update_post allows null to clear the thread).
120
+ const X_THREAD_PART = z.object({
121
+ text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
122
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
123
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
124
+ });
125
+ const BLUESKY_THREAD_PART = z.object({
126
+ text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
127
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
128
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
129
+ });
130
+ const MASTODON_THREAD_PART = z.object({
131
+ text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
132
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
133
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
134
+ });
135
+ const GOOGLE_BUSINESS_OPTIONS = z.object({
136
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
137
+ cta: z.object({
138
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
139
+ url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
140
+ }).optional().describe("Optional call-to-action button."),
141
+ event: z.object({
142
+ title: z.string().max(58).describe("Event title (max 58 chars)."),
143
+ schedule: z.object({
144
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
145
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
146
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
147
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
148
+ }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
149
+ }).optional().describe("Required when topic_type is EVENT."),
150
+ offer: z.object({
151
+ couponCode: z.string().max(58).optional(),
152
+ redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
153
+ termsConditions: z.string().max(4000).optional(),
154
+ }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
155
+ }).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400.");
62
156
  // Render full content for get_post — preserves per-platform overrides so Claude
63
157
  // can see custom captions (e.g. a shorter X version) instead of only `default`.
64
158
  function renderContent(content) {
@@ -128,7 +222,7 @@ export function registerPostTools(server, getClient) {
128
222
  content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
129
223
  };
130
224
  }
131
- const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
225
+ const posts = Array.isArray(result.data) ? result.data : [];
132
226
  if (!posts.length) {
133
227
  return {
134
228
  content: [{ type: "text", text: `No posts found${params.status ? ` with status "${params.status}"` : ""}.` }],
@@ -140,12 +234,13 @@ export function registerPostTools(server, getClient) {
140
234
  md += `|---|---------|----------|--------|------|----|\n`;
141
235
  for (let i = 0; i < posts.length; i++) {
142
236
  const p = posts[i];
143
- const isObj = typeof p.content === "object" && p.content !== null;
144
- const rawContent = isObj
145
- ? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
146
- : (p.content || "");
147
- const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
148
- const threadParts = Array.isArray(p.x?.thread_parts) ? p.x.thread_parts : [];
237
+ const contentObj = typeof p.content === "object" && p.content !== null ? p.content : null;
238
+ const rawContent = contentObj
239
+ ? (contentObj.default || Object.values(contentObj).find((v) => typeof v === "string" && v) || "")
240
+ : (typeof p.content === "string" ? p.content : "");
241
+ const hasOverrides = !!contentObj && Object.keys(contentObj).filter((k) => k !== "default" && typeof contentObj[k] === "string" && contentObj[k]).length > 0;
242
+ const xParts = p.x?.thread_parts;
243
+ const threadParts = Array.isArray(xParts) ? xParts : [];
149
244
  const isThread = threadParts.length >= 2;
150
245
  let content;
151
246
  if (isThread && !rawContent) {
@@ -192,7 +287,7 @@ export function registerPostTools(server, getClient) {
192
287
  md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
193
288
  if (p.published_at)
194
289
  md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
195
- md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
290
+ md += `| **Created** | ${formatDateTime(p.created_at ?? null)} |\n`;
196
291
  if (appUrl)
197
292
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
198
293
  // Retry linkage: a "published" post with `retries` set and empty
@@ -236,7 +331,7 @@ export function registerPostTools(server, getClient) {
236
331
  md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
237
332
  }
238
333
  items.forEach((m, i) => {
239
- const alt = typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
334
+ const alt = typeof m !== "string" && typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
240
335
  md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
241
336
  });
242
337
  md += `\n`;
@@ -244,9 +339,8 @@ export function registerPostTools(server, getClient) {
244
339
  md = md.trimEnd();
245
340
  }
246
341
  // X thread: when present, the canonical tweet text lives here, not in `content`.
247
- const threadParts = Array.isArray(p.x?.thread_parts)
248
- ? p.x.thread_parts
249
- : [];
342
+ const rawXParts = p.x?.thread_parts;
343
+ const threadParts = Array.isArray(rawXParts) ? rawXParts : [];
250
344
  if (threadParts.length >= 2) {
251
345
  md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
252
346
  for (let i = 0; i < threadParts.length; i++) {
@@ -263,9 +357,8 @@ export function registerPostTools(server, getClient) {
263
357
  }
264
358
  }
265
359
  // Bluesky thread: when present, the canonical post text lives here, not in `content`.
266
- const bskyThreadParts = Array.isArray(p.bluesky?.thread_parts)
267
- ? p.bluesky.thread_parts
268
- : [];
360
+ const rawBskyParts = p.bluesky?.thread_parts;
361
+ const bskyThreadParts = Array.isArray(rawBskyParts) ? rawBskyParts : [];
269
362
  if (bskyThreadParts.length >= 2) {
270
363
  md += `\n\n### Bluesky Thread (${bskyThreadParts.length} parts)\n\n`;
271
364
  for (let i = 0; i < bskyThreadParts.length; i++) {
@@ -282,9 +375,8 @@ export function registerPostTools(server, getClient) {
282
375
  }
283
376
  }
284
377
  // Mastodon thread: when present, the canonical status text lives here, not in `content`.
285
- const mastoThreadParts = Array.isArray(p.mastodon?.thread_parts)
286
- ? p.mastodon.thread_parts
287
- : [];
378
+ const rawMastoParts = p.mastodon?.thread_parts;
379
+ const mastoThreadParts = Array.isArray(rawMastoParts) ? rawMastoParts : [];
288
380
  if (mastoThreadParts.length >= 2) {
289
381
  md += `\n\n### Mastodon Thread (${mastoThreadParts.length} parts)\n\n`;
290
382
  for (let i = 0; i < mastoThreadParts.length; i++) {
@@ -413,6 +505,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
413
505
  - YouTube: Title, privacy status, tags?
414
506
  - TikTok: Privacy level?
415
507
  - **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); 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.
508
+ - **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.
416
509
 
417
510
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
418
511
 
@@ -427,7 +520,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
427
520
  media_urls: z.union([
428
521
  z.array(mediaUrlEntry),
429
522
  z.record(z.string(), z.array(mediaUrlEntry)),
430
- ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
523
+ ]).superRefine(pinterestImageCap).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
431
524
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
432
525
  link_url: z.string().optional().describe("URL to share as a rich preview card on platforms that support link-share posts (LinkedIn and Facebook). The URL renders as a tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares, and ignored on posts that already have media attached (media wins)."),
433
526
  link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
@@ -435,22 +528,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
435
528
  link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
436
529
  location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue (with a street address) to tag the post's location. Applied to single-image and carousel Instagram feed posts. To get a valid ID, call the `search_locations` tool with the place name and let the user pick. Ignored by other platforms."),
437
530
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
438
- user_tags: z.array(z.object({
439
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
440
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
441
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
442
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
443
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
531
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
444
532
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
445
533
  hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
446
534
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
447
- pinterest: z.object({
448
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
449
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
450
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
451
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
452
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
453
- }).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. 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."),
535
+ pinterest: PINTEREST_OPTIONS,
454
536
  youtube: z.object({
455
537
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
456
538
  tags: z.array(z.string()).optional(),
@@ -472,66 +554,25 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
472
554
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
473
555
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
474
556
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
557
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
475
558
  }).optional().describe("Instagram options"),
476
559
  facebook: FACEBOOK_OPTIONS,
477
560
  linkedin: LINKEDIN_OPTIONS,
478
561
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
479
- tiktok: z.object({
480
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
481
- disable_comment: z.boolean().optional(),
482
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
483
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
484
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
485
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
486
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
487
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
488
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
489
- }).optional().describe("TikTok options"),
562
+ tiktok: TIKTOK_OPTIONS,
490
563
  x: z.object({
491
564
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
492
565
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
493
566
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
494
- thread_parts: z.array(z.object({
495
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
496
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
497
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
498
- })).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
567
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
499
568
  }).optional().describe("X (Twitter) options"),
500
569
  bluesky: z.object({
501
- thread_parts: z.array(z.object({
502
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
503
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
504
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
505
- })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
570
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
506
571
  }).optional().describe("Bluesky options"),
507
572
  mastodon: z.object({
508
- thread_parts: z.array(z.object({
509
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
510
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
511
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
512
- })).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
573
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
513
574
  }).optional().describe("Mastodon options"),
514
- google_business: z.object({
515
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
516
- cta: z.object({
517
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
518
- url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
519
- }).optional().describe("Optional call-to-action button."),
520
- event: z.object({
521
- title: z.string().max(58).describe("Event title (max 58 chars)."),
522
- schedule: z.object({
523
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
524
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
525
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
526
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
527
- }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
528
- }).optional().describe("Required when topic_type is EVENT."),
529
- offer: z.object({
530
- couponCode: z.string().max(58).optional(),
531
- redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
532
- termsConditions: z.string().max(4000).optional(),
533
- }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
534
- }).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400."),
575
+ google_business: GOOGLE_BUSINESS_OPTIONS,
535
576
  }, async (params) => {
536
577
  const result = await getClient().createPost({ ...params, source: "mcp" });
537
578
  if (result.error) {
@@ -540,6 +581,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
540
581
  };
541
582
  }
542
583
  const p = result.data;
584
+ if (!p) {
585
+ // Impossible path (no error + no data) — never surface it as a tool
586
+ // error: agents retry on errors and that produces orphan duplicates.
587
+ return {
588
+ content: [{ type: "text", text: "Draft created, but the API returned no details. Verify with list_posts." }],
589
+ };
590
+ }
543
591
  // The post is already created by the time this runs. If response
544
592
  // formatting throws, we must NOT surface it as a tool error — agents
545
593
  // retry on errors and that produces orphan duplicate posts.
@@ -559,6 +607,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
559
607
  if (appUrl)
560
608
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
561
609
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
610
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
611
+ for (const w of result.warnings) {
612
+ md += `⚠️ ${w.message}\n`;
613
+ }
614
+ md += `\n`;
615
+ }
562
616
  md += `_Share the link above with the user so they can review or edit the draft. Publish later with \`publish_post\` once they confirm._`;
563
617
  return { content: [{ type: "text", text: md }] };
564
618
  }
@@ -597,6 +651,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
597
651
  - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** (no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
598
652
  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.
599
653
  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). 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.
654
+ 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.
600
655
 
601
656
  Do NOT call without required media — it will fail.`, {
602
657
  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."),
@@ -608,33 +663,27 @@ Do NOT call without required media — it will fail.`, {
608
663
  media_urls: z.union([
609
664
  z.array(mediaUrlEntry),
610
665
  z.record(z.string(), z.array(mediaUrlEntry)),
611
- ]).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
666
+ ]).superRefine(pinterestImageCap).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
612
667
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
613
668
  location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue to tag the post's location. Applied to single-image and carousel Instagram feed posts. Use the `search_locations` tool to find a valid ID. Ignored by other platforms."),
614
669
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms."),
615
- user_tags: z.array(z.object({
616
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
617
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
618
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
619
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
620
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
670
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
621
671
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
622
672
  hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
623
673
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
624
- pinterest: z.object({
625
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
626
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
627
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
628
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
629
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
630
- }).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. 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."),
674
+ pinterest: PINTEREST_OPTIONS,
631
675
  youtube: z.object({
632
676
  title: z.string().optional().describe("Short title shown on YouTube."),
633
677
  tags: z.array(z.string()).optional(),
634
678
  privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
679
+ category_id: z.string().optional(),
680
+ made_for_kids: z.boolean().optional(),
681
+ notify_subscribers: z.boolean().optional(),
682
+ contains_synthetic_media: z.boolean().optional(),
635
683
  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."),
636
684
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
637
685
  instagram: z.object({
686
+ share_to_feed: z.boolean().optional(),
638
687
  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."),
639
688
  thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
640
689
  cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
@@ -644,66 +693,25 @@ Do NOT call without required media — it will fail.`, {
644
693
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
645
694
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
646
695
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
696
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
647
697
  }).optional().describe("Instagram options"),
648
698
  facebook: FACEBOOK_OPTIONS,
649
699
  linkedin: LINKEDIN_OPTIONS,
650
700
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
651
- tiktok: z.object({
652
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
653
- disable_comment: z.boolean().optional(),
654
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
655
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
656
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
657
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
658
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
659
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
660
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
661
- }).optional().describe("TikTok options"),
701
+ tiktok: TIKTOK_OPTIONS,
662
702
  x: z.object({
663
703
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
664
704
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
665
705
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
666
- thread_parts: z.array(z.object({
667
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
668
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
669
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
670
- })).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
706
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
671
707
  }).optional().describe("X (Twitter) options"),
672
708
  bluesky: z.object({
673
- thread_parts: z.array(z.object({
674
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
675
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
676
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
677
- })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
709
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
678
710
  }).optional().describe("Bluesky options"),
679
711
  mastodon: z.object({
680
- thread_parts: z.array(z.object({
681
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
682
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
683
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
684
- })).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
712
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
685
713
  }).optional().describe("Mastodon options"),
686
- google_business: z.object({
687
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
688
- cta: z.object({
689
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
690
- url: z.string().optional(),
691
- }).optional(),
692
- event: z.object({
693
- title: z.string().max(58),
694
- schedule: z.object({
695
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
696
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
697
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
698
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
699
- }).optional(),
700
- }).optional(),
701
- offer: z.object({
702
- couponCode: z.string().max(58).optional(),
703
- redeemOnlineUrl: z.string().optional(),
704
- termsConditions: z.string().max(4000).optional(),
705
- }).optional(),
706
- }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
714
+ google_business: GOOGLE_BUSINESS_OPTIONS,
707
715
  }, async (params) => {
708
716
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
709
717
  if (result.error) {
@@ -712,6 +720,13 @@ Do NOT call without required media — it will fail.`, {
712
720
  };
713
721
  }
714
722
  const p = result.data;
723
+ if (!p) {
724
+ // Impossible path (no error + no data) — the post is already queued;
725
+ // never let it look like an API error (agents would retry = repost).
726
+ return {
727
+ content: [{ type: "text", text: "Post created and queued for publishing, but the API returned no details. Verify with list_posts." }],
728
+ };
729
+ }
715
730
  const appUrl = postAppUrl(p);
716
731
  try {
717
732
  const channels = selectedChannels(p);
@@ -725,6 +740,12 @@ Do NOT call without required media — it will fail.`, {
725
740
  if (appUrl)
726
741
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
727
742
  md += `\n**Content:** ${truncate(p.content, 100)}`;
743
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
744
+ md += `\n`;
745
+ for (const w of result.warnings) {
746
+ md += `\n⚠️ ${w.message}`;
747
+ }
748
+ }
728
749
  return { content: [{ type: "text", text: md }] };
729
750
  }
730
751
  catch {
@@ -737,7 +758,7 @@ Do NOT call without required media — it will fail.`, {
737
758
  });
738
759
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
739
760
 
740
- **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
761
+ **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
741
762
 
742
763
  **X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread.`, {
743
764
  id: z.string().describe("The post ID to update"),
@@ -751,15 +772,10 @@ Do NOT call without required media — it will fail.`, {
751
772
  media_urls: z.union([
752
773
  z.array(mediaUrlEntry),
753
774
  z.record(z.string(), z.array(mediaUrlEntry)),
754
- ]).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
775
+ ]).superRefine(pinterestImageCap).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
755
776
  location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
756
777
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
757
- user_tags: z.array(z.object({
758
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
759
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
760
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
761
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
762
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
778
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
763
779
  youtube: z.object({
764
780
  title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
765
781
  tags: z.array(z.string()).optional(),
@@ -770,13 +786,7 @@ Do NOT call without required media — it will fail.`, {
770
786
  contains_synthetic_media: z.boolean().optional(),
771
787
  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."),
772
788
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
773
- pinterest: z.object({
774
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
775
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
776
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
777
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
778
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
779
- }).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. 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."),
789
+ pinterest: PINTEREST_OPTIONS,
780
790
  instagram: z.object({
781
791
  share_to_feed: z.boolean().optional(),
782
792
  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."),
@@ -788,66 +798,25 @@ Do NOT call without required media — it will fail.`, {
788
798
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Pass an empty string to clear a previously set first comment."),
789
799
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account. Ineligible accounts fail at publish time with a clear per-platform error. Reels only. Pass false to turn a Trial Reel back into a regular reel."),
790
800
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
801
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
791
802
  }).optional().describe("Instagram options. MERGED into the post's stored options: omitted keys keep their current value (updating one option cannot clear a Trial Reel flag or reel music configured elsewhere)."),
792
803
  facebook: FACEBOOK_OPTIONS,
793
804
  linkedin: LINKEDIN_OPTIONS,
794
805
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
795
- tiktok: z.object({
796
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
797
- disable_comment: z.boolean().optional(),
798
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
799
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
800
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
801
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
802
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
803
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
804
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
805
- }).optional().describe("TikTok options"),
806
+ tiktok: TIKTOK_OPTIONS,
806
807
  x: z.object({
807
808
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
808
809
  paid_partnership: z.boolean().optional(),
809
810
  made_with_ai: z.boolean().optional(),
810
- thread_parts: z.array(z.object({
811
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
812
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
813
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
814
- })).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
811
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
815
812
  }).optional().describe("X (Twitter) options"),
816
813
  bluesky: z.object({
817
- thread_parts: z.array(z.object({
818
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
819
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
820
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
821
- })).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
814
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
822
815
  }).optional().describe("Bluesky options"),
823
816
  mastodon: z.object({
824
- thread_parts: z.array(z.object({
825
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
826
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
827
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
828
- })).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
817
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
829
818
  }).optional().describe("Mastodon options"),
830
- google_business: z.object({
831
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
832
- cta: z.object({
833
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
834
- url: z.string().optional(),
835
- }).optional(),
836
- event: z.object({
837
- title: z.string().max(58),
838
- schedule: z.object({
839
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
840
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
841
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
842
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
843
- }).optional(),
844
- }).optional(),
845
- offer: z.object({
846
- couponCode: z.string().max(58).optional(),
847
- redeemOnlineUrl: z.string().optional(),
848
- termsConditions: z.string().max(4000).optional(),
849
- }).optional(),
850
- }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
819
+ google_business: GOOGLE_BUSINESS_OPTIONS,
851
820
  }, async ({ id, ...data }) => {
852
821
  const result = await getClient().updatePost(id, data);
853
822
  if (result.error) {
@@ -856,6 +825,11 @@ Do NOT call without required media — it will fail.`, {
856
825
  };
857
826
  }
858
827
  const p = result.data;
828
+ if (!p) {
829
+ return {
830
+ content: [{ type: "text", text: "Post updated, but the API returned no details. Verify with get_post." }],
831
+ };
832
+ }
859
833
  const schedAt = p.schedule_at ?? p.scheduled_at;
860
834
  const appUrl = postAppUrl(p);
861
835
  let md = `## Post Updated\n\n`;
@@ -891,6 +865,11 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
891
865
  };
892
866
  }
893
867
  const p = result.data;
868
+ if (!p) {
869
+ return {
870
+ content: [{ type: "text", text: "Post queued for publishing. Check its status with get_post." }],
871
+ };
872
+ }
894
873
  let md = `## Post Queued for Publishing\n\n`;
895
874
  md += `| Field | Value |\n`;
896
875
  md += `|-------|-------|\n`;
@@ -912,6 +891,11 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
912
891
  };
913
892
  }
914
893
  const p = result.data;
894
+ if (!p) {
895
+ return {
896
+ content: [{ type: "text", text: "Retry queued. Check the outcome with get_post in a minute or two." }],
897
+ };
898
+ }
915
899
  let md = `## Retry Queued\n\n`;
916
900
  md += `| Field | Value |\n`;
917
901
  md += `|-------|-------|\n`;
@@ -940,7 +924,10 @@ Notes:
940
924
  const result = await getClient().searchLocations(query);
941
925
  const places = result?.data || [];
942
926
  if (!places.length) {
943
- const why = result?.error ||
927
+ // The degraded path returns `error` as a plain string; the object form
928
+ // only appears on transport failures — render its message.
929
+ const err = result?.error;
930
+ const why = (typeof err === "string" ? err : err?.message) ||
944
931
  `No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
945
932
  return { content: [{ type: "text", text: why }] };
946
933
  }
@@ -968,7 +955,10 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
968
955
  const result = await getClient().searchInstagramAudio(query, type);
969
956
  const tracks = result?.data || [];
970
957
  if (!tracks.length) {
971
- const why = result?.error ||
958
+ // Same nonstandard envelope as search_locations — string `error` on
959
+ // the degraded path, { code, message } only on transport failures.
960
+ const err = result?.error;
961
+ const why = (typeof err === "string" ? err : err?.message) ||
972
962
  (query
973
963
  ? `No tracks found for "${query}". Try another title or artist — only music licensed for third-party publishing appears here.`
974
964
  : "No trending audio available right now.");
@@ -987,11 +977,11 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
987
977
  });
988
978
  return { content: [{ type: "text", text: md }] };
989
979
  });
990
- server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
980
+ server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live for most platforms, so expect a few seconds of latency; X results may come from a snapshot up to 24h old (X bills per returned post) — the snapshot refreshes right after the user publishes to X through OmniSocials. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
991
981
  limit: z
992
982
  .string()
993
983
  .optional()
994
- .describe("Max posts per connected platform (1-50, default 25)."),
984
+ .describe("Max posts per connected platform (1-50, default 25; X defaults to 10 unless set explicitly — its API bills per returned post)."),
995
985
  platforms: z
996
986
  .string()
997
987
  .optional()