@omnisocials/mcp-server 1.20.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.
@@ -83,6 +83,76 @@ const LINKEDIN_PAGE_OPTIONS = z
83
83
  })
84
84
  .optional()
85
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.");
86
156
  // Render full content for get_post — preserves per-platform overrides so Claude
87
157
  // can see custom captions (e.g. a shorter X version) instead of only `default`.
88
158
  function renderContent(content) {
@@ -152,7 +222,7 @@ export function registerPostTools(server, getClient) {
152
222
  content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
153
223
  };
154
224
  }
155
- const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
225
+ const posts = Array.isArray(result.data) ? result.data : [];
156
226
  if (!posts.length) {
157
227
  return {
158
228
  content: [{ type: "text", text: `No posts found${params.status ? ` with status "${params.status}"` : ""}.` }],
@@ -164,12 +234,13 @@ export function registerPostTools(server, getClient) {
164
234
  md += `|---|---------|----------|--------|------|----|\n`;
165
235
  for (let i = 0; i < posts.length; i++) {
166
236
  const p = posts[i];
167
- const isObj = typeof p.content === "object" && p.content !== null;
168
- const rawContent = isObj
169
- ? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
170
- : (p.content || "");
171
- const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
172
- 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 : [];
173
244
  const isThread = threadParts.length >= 2;
174
245
  let content;
175
246
  if (isThread && !rawContent) {
@@ -216,7 +287,7 @@ export function registerPostTools(server, getClient) {
216
287
  md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
217
288
  if (p.published_at)
218
289
  md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
219
- md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
290
+ md += `| **Created** | ${formatDateTime(p.created_at ?? null)} |\n`;
220
291
  if (appUrl)
221
292
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
222
293
  // Retry linkage: a "published" post with `retries` set and empty
@@ -260,7 +331,7 @@ export function registerPostTools(server, getClient) {
260
331
  md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
261
332
  }
262
333
  items.forEach((m, i) => {
263
- 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}"` : "";
264
335
  md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
265
336
  });
266
337
  md += `\n`;
@@ -268,9 +339,8 @@ export function registerPostTools(server, getClient) {
268
339
  md = md.trimEnd();
269
340
  }
270
341
  // X thread: when present, the canonical tweet text lives here, not in `content`.
271
- const threadParts = Array.isArray(p.x?.thread_parts)
272
- ? p.x.thread_parts
273
- : [];
342
+ const rawXParts = p.x?.thread_parts;
343
+ const threadParts = Array.isArray(rawXParts) ? rawXParts : [];
274
344
  if (threadParts.length >= 2) {
275
345
  md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
276
346
  for (let i = 0; i < threadParts.length; i++) {
@@ -287,9 +357,8 @@ export function registerPostTools(server, getClient) {
287
357
  }
288
358
  }
289
359
  // Bluesky thread: when present, the canonical post text lives here, not in `content`.
290
- const bskyThreadParts = Array.isArray(p.bluesky?.thread_parts)
291
- ? p.bluesky.thread_parts
292
- : [];
360
+ const rawBskyParts = p.bluesky?.thread_parts;
361
+ const bskyThreadParts = Array.isArray(rawBskyParts) ? rawBskyParts : [];
293
362
  if (bskyThreadParts.length >= 2) {
294
363
  md += `\n\n### Bluesky Thread (${bskyThreadParts.length} parts)\n\n`;
295
364
  for (let i = 0; i < bskyThreadParts.length; i++) {
@@ -306,9 +375,8 @@ export function registerPostTools(server, getClient) {
306
375
  }
307
376
  }
308
377
  // Mastodon thread: when present, the canonical status text lives here, not in `content`.
309
- const mastoThreadParts = Array.isArray(p.mastodon?.thread_parts)
310
- ? p.mastodon.thread_parts
311
- : [];
378
+ const rawMastoParts = p.mastodon?.thread_parts;
379
+ const mastoThreadParts = Array.isArray(rawMastoParts) ? rawMastoParts : [];
312
380
  if (mastoThreadParts.length >= 2) {
313
381
  md += `\n\n### Mastodon Thread (${mastoThreadParts.length} parts)\n\n`;
314
382
  for (let i = 0; i < mastoThreadParts.length; i++) {
@@ -437,6 +505,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
437
505
  - YouTube: Title, privacy status, tags?
438
506
  - TikTok: Privacy level?
439
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.
440
509
 
441
510
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
442
511
 
@@ -459,22 +528,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
459
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)."),
460
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."),
461
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."),
462
- user_tags: z.array(z.object({
463
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
464
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
465
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
466
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
467
- })).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."),
468
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."),
469
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."),
470
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."),
471
- pinterest: z.object({
472
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
473
- 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."),
474
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
475
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
476
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
477
- }).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."),
535
+ pinterest: PINTEREST_OPTIONS,
478
536
  youtube: z.object({
479
537
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
480
538
  tags: z.array(z.string()).optional(),
@@ -496,66 +554,25 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
496
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."),
497
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."),
498
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."),
499
558
  }).optional().describe("Instagram options"),
500
559
  facebook: FACEBOOK_OPTIONS,
501
560
  linkedin: LINKEDIN_OPTIONS,
502
561
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
503
- tiktok: z.object({
504
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
505
- disable_comment: z.boolean().optional(),
506
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
507
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
508
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
509
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
510
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
511
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
512
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
513
- }).optional().describe("TikTok options"),
562
+ tiktok: TIKTOK_OPTIONS,
514
563
  x: z.object({
515
564
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
516
565
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
517
566
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
518
- thread_parts: z.array(z.object({
519
- 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."),
520
- 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."),
521
- 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)."),
522
- })).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."),
523
568
  }).optional().describe("X (Twitter) options"),
524
569
  bluesky: z.object({
525
- thread_parts: z.array(z.object({
526
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
527
- 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)."),
528
- 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)."),
529
- })).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."),
530
571
  }).optional().describe("Bluesky options"),
531
572
  mastodon: z.object({
532
- thread_parts: z.array(z.object({
533
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
534
- 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)."),
535
- 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)."),
536
- })).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."),
537
574
  }).optional().describe("Mastodon options"),
538
- google_business: z.object({
539
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
540
- cta: z.object({
541
- 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."),
542
- 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."),
543
- }).optional().describe("Optional call-to-action button."),
544
- event: z.object({
545
- title: z.string().max(58).describe("Event title (max 58 chars)."),
546
- schedule: z.object({
547
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
548
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
549
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
550
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
551
- }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
552
- }).optional().describe("Required when topic_type is EVENT."),
553
- offer: z.object({
554
- couponCode: z.string().max(58).optional(),
555
- redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
556
- termsConditions: z.string().max(4000).optional(),
557
- }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
558
- }).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,
559
576
  }, async (params) => {
560
577
  const result = await getClient().createPost({ ...params, source: "mcp" });
561
578
  if (result.error) {
@@ -564,6 +581,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
564
581
  };
565
582
  }
566
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
+ }
567
591
  // The post is already created by the time this runs. If response
568
592
  // formatting throws, we must NOT surface it as a tool error — agents
569
593
  // retry on errors and that produces orphan duplicate posts.
@@ -583,6 +607,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
583
607
  if (appUrl)
584
608
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
585
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
+ }
586
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._`;
587
617
  return { content: [{ type: "text", text: md }] };
588
618
  }
@@ -621,6 +651,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
621
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.
622
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.
623
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.
624
655
 
625
656
  Do NOT call without required media — it will fail.`, {
626
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."),
@@ -636,29 +667,23 @@ Do NOT call without required media — it will fail.`, {
636
667
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
637
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."),
638
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."),
639
- user_tags: z.array(z.object({
640
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
641
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
642
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
643
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
644
- })).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."),
645
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."),
646
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)."),
647
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."),
648
- pinterest: z.object({
649
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
650
- 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."),
651
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
652
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
653
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
654
- }).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."),
674
+ pinterest: PINTEREST_OPTIONS,
655
675
  youtube: z.object({
656
676
  title: z.string().optional().describe("Short title shown on YouTube."),
657
677
  tags: z.array(z.string()).optional(),
658
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(),
659
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."),
660
684
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
661
685
  instagram: z.object({
686
+ share_to_feed: z.boolean().optional(),
662
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."),
663
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'."),
664
689
  cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
@@ -668,66 +693,25 @@ Do NOT call without required media — it will fail.`, {
668
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."),
669
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."),
670
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."),
671
697
  }).optional().describe("Instagram options"),
672
698
  facebook: FACEBOOK_OPTIONS,
673
699
  linkedin: LINKEDIN_OPTIONS,
674
700
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
675
- tiktok: z.object({
676
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
677
- disable_comment: z.boolean().optional(),
678
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
679
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
680
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
681
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
682
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
683
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
684
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
685
- }).optional().describe("TikTok options"),
701
+ tiktok: TIKTOK_OPTIONS,
686
702
  x: z.object({
687
703
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
688
704
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
689
705
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
690
- thread_parts: z.array(z.object({
691
- 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."),
692
- 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."),
693
- 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)."),
694
- })).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."),
695
707
  }).optional().describe("X (Twitter) options"),
696
708
  bluesky: z.object({
697
- thread_parts: z.array(z.object({
698
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
699
- 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)."),
700
- 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)."),
701
- })).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."),
702
710
  }).optional().describe("Bluesky options"),
703
711
  mastodon: z.object({
704
- thread_parts: z.array(z.object({
705
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
706
- 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)."),
707
- 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)."),
708
- })).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."),
709
713
  }).optional().describe("Mastodon options"),
710
- google_business: z.object({
711
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
712
- cta: z.object({
713
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
714
- url: z.string().optional(),
715
- }).optional(),
716
- event: z.object({
717
- title: z.string().max(58),
718
- schedule: z.object({
719
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
720
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
721
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
722
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
723
- }).optional(),
724
- }).optional(),
725
- offer: z.object({
726
- couponCode: z.string().max(58).optional(),
727
- redeemOnlineUrl: z.string().optional(),
728
- termsConditions: z.string().max(4000).optional(),
729
- }).optional(),
730
- }).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,
731
715
  }, async (params) => {
732
716
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
733
717
  if (result.error) {
@@ -736,6 +720,13 @@ Do NOT call without required media — it will fail.`, {
736
720
  };
737
721
  }
738
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
+ }
739
730
  const appUrl = postAppUrl(p);
740
731
  try {
741
732
  const channels = selectedChannels(p);
@@ -749,6 +740,12 @@ Do NOT call without required media — it will fail.`, {
749
740
  if (appUrl)
750
741
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
751
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
+ }
752
749
  return { content: [{ type: "text", text: md }] };
753
750
  }
754
751
  catch {
@@ -778,12 +775,7 @@ Do NOT call without required media — it will fail.`, {
778
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."),
779
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."),
780
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."),
781
- user_tags: z.array(z.object({
782
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
783
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
784
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
785
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
786
- })).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."),
787
779
  youtube: z.object({
788
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)."),
789
781
  tags: z.array(z.string()).optional(),
@@ -794,13 +786,7 @@ Do NOT call without required media — it will fail.`, {
794
786
  contains_synthetic_media: z.boolean().optional(),
795
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."),
796
788
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
797
- pinterest: z.object({
798
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
799
- 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."),
800
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
801
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
802
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
803
- }).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."),
789
+ pinterest: PINTEREST_OPTIONS,
804
790
  instagram: z.object({
805
791
  share_to_feed: z.boolean().optional(),
806
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."),
@@ -812,66 +798,25 @@ Do NOT call without required media — it will fail.`, {
812
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."),
813
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."),
814
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."),
815
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)."),
816
803
  facebook: FACEBOOK_OPTIONS,
817
804
  linkedin: LINKEDIN_OPTIONS,
818
805
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
819
- tiktok: z.object({
820
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
821
- disable_comment: z.boolean().optional(),
822
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
823
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
824
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
825
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
826
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
827
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
828
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
829
- }).optional().describe("TikTok options"),
806
+ tiktok: TIKTOK_OPTIONS,
830
807
  x: z.object({
831
808
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
832
809
  paid_partnership: z.boolean().optional(),
833
810
  made_with_ai: z.boolean().optional(),
834
- thread_parts: z.array(z.object({
835
- 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."),
836
- 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."),
837
- 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)."),
838
- })).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."),
839
812
  }).optional().describe("X (Twitter) options"),
840
813
  bluesky: z.object({
841
- thread_parts: z.array(z.object({
842
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
843
- 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)."),
844
- 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)."),
845
- })).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."),
846
815
  }).optional().describe("Bluesky options"),
847
816
  mastodon: z.object({
848
- thread_parts: z.array(z.object({
849
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
850
- 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)."),
851
- 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)."),
852
- })).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."),
853
818
  }).optional().describe("Mastodon options"),
854
- google_business: z.object({
855
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
856
- cta: z.object({
857
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
858
- url: z.string().optional(),
859
- }).optional(),
860
- event: z.object({
861
- title: z.string().max(58),
862
- schedule: z.object({
863
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
864
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
865
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
866
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
867
- }).optional(),
868
- }).optional(),
869
- offer: z.object({
870
- couponCode: z.string().max(58).optional(),
871
- redeemOnlineUrl: z.string().optional(),
872
- termsConditions: z.string().max(4000).optional(),
873
- }).optional(),
874
- }).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,
875
820
  }, async ({ id, ...data }) => {
876
821
  const result = await getClient().updatePost(id, data);
877
822
  if (result.error) {
@@ -880,6 +825,11 @@ Do NOT call without required media — it will fail.`, {
880
825
  };
881
826
  }
882
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
+ }
883
833
  const schedAt = p.schedule_at ?? p.scheduled_at;
884
834
  const appUrl = postAppUrl(p);
885
835
  let md = `## Post Updated\n\n`;
@@ -915,6 +865,11 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
915
865
  };
916
866
  }
917
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
+ }
918
873
  let md = `## Post Queued for Publishing\n\n`;
919
874
  md += `| Field | Value |\n`;
920
875
  md += `|-------|-------|\n`;
@@ -936,6 +891,11 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
936
891
  };
937
892
  }
938
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
+ }
939
899
  let md = `## Retry Queued\n\n`;
940
900
  md += `| Field | Value |\n`;
941
901
  md += `|-------|-------|\n`;
@@ -964,7 +924,10 @@ Notes:
964
924
  const result = await getClient().searchLocations(query);
965
925
  const places = result?.data || [];
966
926
  if (!places.length) {
967
- 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) ||
968
931
  `No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
969
932
  return { content: [{ type: "text", text: why }] };
970
933
  }
@@ -992,7 +955,10 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
992
955
  const result = await getClient().searchInstagramAudio(query, type);
993
956
  const tracks = result?.data || [];
994
957
  if (!tracks.length) {
995
- 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) ||
996
962
  (query
997
963
  ? `No tracks found for "${query}". Try another title or artist — only music licensed for third-party publishing appears here.`
998
964
  : "No trending audio available right now.");
@@ -1011,11 +977,11 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1011
977
  });
1012
978
  return { content: [{ type: "text", text: md }] };
1013
979
  });
1014
- 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.", {
1015
981
  limit: z
1016
982
  .string()
1017
983
  .optional()
1018
- .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)."),
1019
985
  platforms: z
1020
986
  .string()
1021
987
  .optional()