@omnisocials/mcp-server 1.39.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,36 @@
1
1
  import { z } from "zod";
2
2
  import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
3
+ // Comment-to-DM automations bound to the post (Instagram or Facebook must be
4
+ // a channel; `platforms` picks which). The shape of create_automation without
5
+ // the post: always this post, comment trigger. Needs the opt-in
6
+ // automations:write scope on the API key.
7
+ const COMMENT_DM_AUTOMATION = z.object({
8
+ platforms: z.array(z.enum(["instagram", "facebook"])).min(1).optional().describe("The channels that get the automation: 'instagram', 'facebook' (the Facebook Page; the DM lands in Messenger) or both. One automation per channel, each with the same keywords, DM, public reply and limits. Each one must be in channels. Omit it for one automation: Instagram when the post goes there, else Facebook."),
9
+ trigger: z.object({
10
+ keywords: z.array(z.string()).max(20).optional().describe("Keywords that start the automation, matched case-insensitively (up to 20). Required unless match is 'any'."),
11
+ match: z.enum(["word", "contains", "any"]).optional().describe("word (default): the keyword stands on its own. contains: anywhere in the comment. any: every comment (ask the user first)."),
12
+ }).optional(),
13
+ actions: z.object({
14
+ dm: z.object({ text: z.string().max(1000).describe("The DM the person receives, once (max 1000 characters). Confirm the text with the user: it goes out in their name.") }),
15
+ public_reply: z.object({
16
+ enabled: z.boolean().optional(),
17
+ variations: z.array(z.string().max(300)).max(10).optional().describe("Public replies posted under the comment, one picked at random per comment."),
18
+ }).optional(),
19
+ }),
20
+ limits: z.object({
21
+ max_sends: z.number().int().positive().nullable().optional().describe("Stop after this many DMs."),
22
+ stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601)."),
23
+ }).optional(),
24
+ }).describe("Comment-to-DM automation on this post: when somebody comments one of trigger.keywords on the post once it is live on Instagram or the Facebook Page, the account DMs them actions.dm.text once (on Facebook the DM lands in Messenger), and optionally posts one of actions.public_reply.variations under the comment. Instagram or Facebook must be in channels; platforms picks which ones get it. Show the user the keywords and the exact DM text before creating it. The created automations come back as comment_dm_automation on the post (automations lists the id of each channel); manage them later with update_automation / get_automation_activity. Needs the opt-in automations:write scope on the API key.");
25
+ const commentDmLine = (p) => {
26
+ const a = p.comment_dm_automation;
27
+ if (!a)
28
+ return "";
29
+ const keywords = a.trigger.match === "any" ? "any comment" : a.trigger.keywords.map((k) => `"${k}"`).join(", ");
30
+ const list = a.automations?.length ? a.automations : [{ id: a.id, platform: a.platform }];
31
+ const ids = list.map((x) => `\`${x.id}\` (${x.platform === "facebook" ? "Facebook Page" : "Instagram"})`).join(", ");
32
+ return `| **Comment-to-DM** | ${a.status}, keywords ${keywords}, ${list.length > 1 ? "automations" : "automation"} ${ids} |\n`;
33
+ };
3
34
  // Shared media entry schemas — every media list (flat arrays, per-platform
4
35
  // records, and x/bluesky/mastodon/threads thread parts) accepts either a plain string
5
36
  // or an object carrying `alt`: an accessibility description (alt text, max
@@ -81,6 +112,11 @@ const VIDEO_COVER_ENTRY = z.object({
81
112
  thumb_offset: z.number().int().min(0).optional().describe("Milliseconds into the video (type 'frame'). 3000 = 0:03."),
82
113
  cover_url: z.string().optional().describe("Public image URL, JPEG or PNG (type 'custom')."),
83
114
  });
115
+ // Image fit per channel (shared/post-validation imageFit.js on the backend).
116
+ const MEDIA_FIT_OPTIONS = z
117
+ .record(z.string(), z.enum(["auto", "crop", "keep"]))
118
+ .optional()
119
+ .describe("Image fit per channel, for feed images (type 'post') whose ratio is outside what a channel shows uncropped: { instagram: 'auto' | 'crop' | 'keep', pinterest: ... }. 'auto' (the default when omitted) takes the channel's rule: Instagram crops a tall (9:16) or wide image to 4:5 / 1.91:1 around the subject (Instagram cuts it to that window anyway, and Meta cannot boost a feed post outside it); Pinterest keeps a Pin taller than 2:3 and only reports it. 'crop' / 'keep' force it for that channel. Every decision comes back in the response `warnings` with code `image_fit` (relay it). The crop is a separate file for that channel only; 'default' and the other channels keep the original. On update_post, media_fit alone (no media) re-decides the stored images: 'keep' gives the original back, 'crop' crops an image that was kept.");
84
120
  const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
85
121
  overrides: z
86
122
  .record(z.string(), VIDEO_COVER_ENTRY.nullable())
@@ -152,6 +188,103 @@ const LINKEDIN_POLL_OPTIONS = z
152
188
  .nullable()
153
189
  .optional()
154
190
  .describe("Non-sponsored LinkedIn poll(s) — independent per channel, keyed by `linkedin` (personal profile) / `linkedin_page` (company page). A poll is mutually exclusive with media and a link share on that channel's post — a poll takes priority over both at publish time. Still requires `content.linkedin` (or `content.default`) as that channel's caption; the poll itself only carries the question/options/duration. On update_post, set a channel's key to `null` to clear that channel's poll and revert it to a normal post — send the full desired state for both channels, since the whole object replaces wholesale.");
191
+ // Polls on X, Threads and Mastodon (2026-10-12). One shape, nested under
192
+ // the channel's options as `poll`; the post text is the question. A channel
193
+ // with a poll carries no media (the API refuses the pair). On update_post,
194
+ // `null` removes the poll; omitted leaves it.
195
+ const X_POLL_OPTIONS = z
196
+ .object({
197
+ options: z.array(z.string().min(1).max(25)).min(2).max(4).describe("2 to 4 answer options of up to 25 characters, all different."),
198
+ duration_minutes: z.number().int().min(5).max(10080).optional().describe("How long the poll runs, in minutes (5 to 10080 = 7 days). Default 1440 (one day)."),
199
+ })
200
+ .nullable()
201
+ .optional()
202
+ .describe("Attach a poll to the tweet; the tweet text is the question. No media on X together with a poll. On a thread the poll goes on the first tweet. On update_post, null removes it.");
203
+ const THREADS_POLL_OPTIONS = z
204
+ .object({
205
+ options: z.array(z.string().min(1).max(25)).min(2).max(4).describe("2 to 4 answer options of 1 to 25 characters, all different."),
206
+ })
207
+ .nullable()
208
+ .optional()
209
+ .describe("Attach a poll to the post; the post text is the question. Threads closes the poll 24 hours after it is published (no duration). Polls go on text-only posts: no media on Threads together with a poll. On a thread the poll goes on the first post. On update_post, null removes it.");
210
+ const MASTODON_POLL_OPTIONS = z
211
+ .object({
212
+ options: z.array(z.string().min(1)).min(2).describe("The answer options, all different; count and length within the instance's limits (Mastodon's defaults: 4 options of 50 characters)."),
213
+ expires_in: z.number().int().min(300).optional().describe("How long the poll runs, in seconds, within the instance's range (Mastodon's defaults: 5 minutes to 1 month). Default 86400 (one day)."),
214
+ multiple: z.boolean().optional().describe("Allow several answers per person."),
215
+ hide_totals: z.boolean().optional().describe("Hide the vote counts until the poll ends."),
216
+ })
217
+ .nullable()
218
+ .optional()
219
+ .describe("Attach a poll to the status; the status text is the question. No media on Mastodon together with a poll. On a thread the poll goes on the first status. On update_post, null removes it.");
220
+ // Telegram (2026-10-17): the connected channel or group, posted by the
221
+ // OmniSocials bot. One schema for create and update; on update_post every
222
+ // key merges into the stored options and null removes it. Same shape as the
223
+ // hosted server (backend/routes/api/v1/mcp.js, telegramSchema).
224
+ const TELEGRAM_OPTIONS = z
225
+ .object({
226
+ silent: z.boolean().nullable().optional().describe("Send without a notification sound."),
227
+ link_preview: z.boolean().nullable().optional().describe("false hides the link preview of a text-only post (shown by default)."),
228
+ pin: z.boolean().nullable().optional().describe("Pin the post in the chat after it is sent. Needs the bot's Edit messages right in a channel (Pin messages in a group); without it the post is sent unpinned and get_post shows pin_error."),
229
+ protect_content: z.boolean().nullable().optional().describe("Stop forwarding and saving of the post in Telegram apps."),
230
+ spoiler: z.boolean().nullable().optional().describe("Blur the photos and videos until someone taps them."),
231
+ caption_above_media: z.boolean().nullable().optional().describe("Show the text above the media instead of below."),
232
+ buttons: z
233
+ .array(z.object({
234
+ text: z.string().max(64).describe("Button label (1 to 64 chars)."),
235
+ url: z.string().describe("Link the button opens: https://, http:// or tg://."),
236
+ }))
237
+ .max(3)
238
+ .nullable()
239
+ .optional()
240
+ .describe("Up to 3 link buttons under the post, one per row. Not on an album (2+ media) or a poll."),
241
+ poll: z
242
+ .object({
243
+ options: z.array(z.string().min(1).max(100)).min(2).max(12).describe("2 to 12 answer options of up to 100 chars, all different."),
244
+ open_period: z.number().int().min(5).max(2628000).optional().describe("Seconds the poll stays open (5 s to about 30 days). Omit to keep it open."),
245
+ multiple: z.boolean().optional().describe("Allow several answers per person."),
246
+ })
247
+ .nullable()
248
+ .optional()
249
+ .describe("A native Telegram poll. The post text is the question (max 300 chars). No media with a poll. On update_post, null removes it."),
250
+ first_comment: z.string().max(4096).optional().describe("A reply the bot sends under the post right after it is published (max 4,096 chars). On update_post pass an empty string to clear it."),
251
+ })
252
+ .nullable()
253
+ .optional()
254
+ .describe("Telegram options. Text up to 4,096 chars; with media, text over 1,024 chars is sent as a reply under the media. Up to 10 photos and videos per album (no GIF in an album), files up to 50 MB, no Stories. A Reel is sent as a video message.");
255
+ // Discord (2026-10-22): the connected Discord channel, posted through its
256
+ // webhook. One schema for create and update; on update_post every key merges
257
+ // into the stored options and null removes it. Same shape as the hosted
258
+ // server (backend/routes/api/v1/mcp.js, discordSchema).
259
+ const DISCORD_OPTIONS = z
260
+ .object({
261
+ silent: z.boolean().nullable().optional().describe("Send without a push or desktop notification (Discord's @silent)."),
262
+ link_preview: z.boolean().nullable().optional().describe("false hides the previews of the links in the text (shown by default)."),
263
+ allow_mentions: z.boolean().nullable().optional().describe("true lets @everyone, @here, role and user mentions in the text notify people. Off by default: mentions show as text and ping nobody. Only turn it on when the user asks to ping."),
264
+ spoiler: z.boolean().nullable().optional().describe("Blur every image and video until someone clicks it."),
265
+ thread_name: z.string().max(100).nullable().optional().describe("The post title in a forum or media channel, where Discord requires one (1 to 100 chars). Only for those channels: a text channel takes none, so the post goes out there without it (warning thread_name_ignored). On update_post, null removes it."),
266
+ buttons: z
267
+ .array(z.object({
268
+ text: z.string().max(80).describe("Button label (1 to 80 chars)."),
269
+ url: z.string().describe("Link the button opens: https:// or http://."),
270
+ }))
271
+ .max(5)
272
+ .nullable()
273
+ .optional()
274
+ .describe("Up to 5 link buttons in one row under the message. Not with a poll."),
275
+ poll: z
276
+ .object({
277
+ options: z.array(z.string().min(1).max(55)).min(2).max(10).describe("2 to 10 answer options of up to 55 chars, all different."),
278
+ duration_hours: z.number().int().min(1).max(768).optional().describe("Hours the poll stays open (1 to 768, 32 days). Default 24."),
279
+ multiple: z.boolean().optional().describe("Allow several answers per person."),
280
+ })
281
+ .nullable()
282
+ .optional()
283
+ .describe("A native Discord poll. The post text is the question (max 300 chars). No media and no buttons with a poll. On update_post, null removes it."),
284
+ })
285
+ .nullable()
286
+ .optional()
287
+ .describe("Discord options. The post goes to the connected Discord channel through its webhook and shows the webhook's name and picture. Text up to 2,000 chars. Up to 10 images, GIFs and videos per message (they may mix); a video or GIF over Discord's 20 MB file limit is sent as a link in the text. No Stories; a Reel is sent as one video message.");
155
288
  // Per-platform option schemas shared verbatim by create_post,
156
289
  // create_and_publish_post and update_post (they were byte-identical in all
157
290
  // three). Platform blocks that genuinely differ per tool (instagram, youtube,
@@ -204,8 +337,8 @@ const X_THREAD_PART = z.object({
204
337
  });
205
338
  const BLUESKY_THREAD_PART = z.object({
206
339
  text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
207
- 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)."),
208
- 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)."),
340
+ media_ids: z.array(mediaIdEntry).max(10).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 10 images (5 or more post as a gallery). Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
341
+ media_urls: z.array(mediaUrlEntry).max(10).optional().describe("Per-post media as external URLs. A part is one video OR up to 10 images (5 or more post as a gallery). Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
209
342
  });
210
343
  const MASTODON_THREAD_PART = z.object({
211
344
  text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
@@ -231,7 +364,7 @@ const GOOGLE_BUSINESS_OPTIONS = z.object({
231
364
  endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
232
365
  endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
233
366
  }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
234
- }).optional().describe("Required when topic_type is EVENT."),
367
+ }).optional().describe("Required when topic_type is EVENT or OFFER. For an OFFER, the title is the offer's name and the schedule its validity period (Google rejects an offer without it)."),
235
368
  offer: z.object({
236
369
  couponCode: z.string().max(58).optional(),
237
370
  redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
@@ -285,15 +418,32 @@ function postAppUrl(p) {
285
418
  // client URL, so it's correct on staging/production. Fall back to a
286
419
  // hardcoded production URL for older API responses that don't return it.
287
420
  if (typeof p?.app_url === "string" && p.app_url)
288
- return p.app_url;
421
+ return withRef(p.app_url);
289
422
  const id = p?.id;
290
423
  if (!id)
291
424
  return null;
292
425
  const status = String(p?.status || "").toLowerCase();
293
426
  const isEditable = status === "draft" || status === "scheduled" || status === "" || status === "failed";
294
- return isEditable
427
+ return withRef(isEditable
295
428
  ? `https://app.omnisocials.com/create-post/${id}`
296
- : `https://app.omnisocials.com/posts/${id}`;
429
+ : `https://app.omnisocials.com/posts/${id}`);
430
+ }
431
+ // `?ref=mcp` on every app link from an agent's answer, so the visits can be
432
+ // counted (the app ignores the parameter). Same rule as the hosted server.
433
+ function withRef(url) {
434
+ try {
435
+ const parsed = new URL(url);
436
+ parsed.searchParams.set("ref", "mcp");
437
+ return parsed.toString();
438
+ }
439
+ catch {
440
+ return url;
441
+ }
442
+ }
443
+ // The "Open in OmniSocials" table row, or nothing without an id.
444
+ function appLinkRow(p) {
445
+ const url = postAppUrl(p);
446
+ return url ? `| **Open in OmniSocials** | ${url} |\n` : "";
297
447
  }
298
448
  export function registerPostTools(server, getClient) {
299
449
  server.tool("list_posts", "List all posts in the workspace. Optionally filter by status. The content column shows a preview of the default caption; if a post has per-platform overrides (e.g. a custom X version), the preview is suffixed with *(per-platform)* — call `get_post` to see every variant.", {
@@ -531,6 +681,78 @@ export function registerPostTools(server, getClient) {
531
681
  }
532
682
  md += `\n_Duration: ${poll.duration}_\n`;
533
683
  }
684
+ // Polls on X, Threads and Mastodon: the post text is the question.
685
+ for (const channel of ["x", "threads", "mastodon"]) {
686
+ const poll = p[channel]?.poll;
687
+ if (!poll || !Array.isArray(poll.options) || poll.options.length < 2)
688
+ continue;
689
+ const label = channel === "x" ? "X" : capitalize(channel);
690
+ md += `\n\n### ${label} Poll\n\n`;
691
+ for (const option of poll.options)
692
+ md += `- ${option}\n`;
693
+ const runs = channel === "x"
694
+ ? `${poll.duration_minutes ?? 1440} minutes`
695
+ : channel === "mastodon"
696
+ ? `${poll.expires_in ?? 86400} seconds${poll.multiple ? ", several answers" : ""}`
697
+ : "24 hours (set by Threads)";
698
+ md += `\n_Runs for: ${runs}_\n`;
699
+ }
700
+ const tg = (p.telegram && typeof p.telegram === "object" ? p.telegram : null);
701
+ if (tg) {
702
+ const flagKeys = ["silent", "link_preview", "pin", "protect_content", "spoiler", "caption_above_media"];
703
+ const flags = flagKeys.filter((k) => typeof tg[k] === "boolean").map((k) => `${k}: ${tg[k]}`);
704
+ const buttons = Array.isArray(tg.buttons) ? tg.buttons : [];
705
+ const pollOptions = tg.poll && Array.isArray(tg.poll.options) ? tg.poll.options : [];
706
+ const poll = pollOptions.length >= 2 ? tg.poll : null;
707
+ const result = tg.result && typeof tg.result === "object" ? tg.result : null;
708
+ if (flags.length || buttons.length || poll || result) {
709
+ md += `\n\n### Telegram\n\n`;
710
+ if (flags.length)
711
+ md += `${flags.join(" · ")}\n`;
712
+ for (const b of buttons)
713
+ md += `- Button: ${b.text ?? ""} → ${b.url ?? ""}\n`;
714
+ if (poll) {
715
+ md += `\n**Poll:**\n`;
716
+ for (const option of pollOptions)
717
+ md += `- ${option}\n`;
718
+ md += `\n_Open: ${poll.open_period ? `${poll.open_period} seconds` : "until closed"}${poll.multiple ? ", several answers" : ""}_\n`;
719
+ }
720
+ if (result) {
721
+ const count = Array.isArray(result.message_ids) ? result.message_ids.length : 0;
722
+ const link = result.link_public || result.link_private;
723
+ md += `\nSent: ${count} message${count === 1 ? "" : "s"}${result.pinned ? ", pinned" : result.pin_error ? ` (not pinned: ${result.pin_error})` : ""}${link ? ` · ${link}` : ""}\n`;
724
+ }
725
+ }
726
+ }
727
+ const dc = (p.discord && typeof p.discord === "object" ? p.discord : null);
728
+ if (dc) {
729
+ const flagKeys = ["silent", "link_preview", "allow_mentions", "spoiler"];
730
+ const flags = flagKeys.filter((k) => typeof dc[k] === "boolean").map((k) => `${k}: ${dc[k]}`);
731
+ const buttons = Array.isArray(dc.buttons) ? dc.buttons : [];
732
+ const pollOptions = dc.poll && Array.isArray(dc.poll.options) ? dc.poll.options : [];
733
+ const poll = pollOptions.length >= 2 ? dc.poll : null;
734
+ const result = dc.result && typeof dc.result === "object" ? dc.result : null;
735
+ if (flags.length || dc.thread_name || buttons.length || poll || result) {
736
+ md += `\n\n### Discord\n\n`;
737
+ if (flags.length)
738
+ md += `${flags.join(" · ")}\n`;
739
+ if (dc.thread_name)
740
+ md += `Forum post title: ${dc.thread_name}\n`;
741
+ for (const b of buttons)
742
+ md += `- Button: ${b.text ?? ""} → ${b.url ?? ""}\n`;
743
+ if (poll) {
744
+ md += `\n**Poll:**\n`;
745
+ for (const option of pollOptions)
746
+ md += `- ${option}\n`;
747
+ md += `\n_Open: ${poll.duration_hours ?? 24} hours${poll.multiple ? ", several answers" : ""}_\n`;
748
+ }
749
+ if (result) {
750
+ const links = Array.isArray(result.video_links) ? result.video_links.length : 0;
751
+ const titleDropped = Array.isArray(result.warnings) && result.warnings.includes("thread_name_ignored");
752
+ md += `\nSent${result.url ? `: ${result.url}` : ""}${links ? ` · ${links} video${links === 1 ? "" : "s"} sent as a link (over Discord's 20 MB file limit)` : ""}${titleDropped ? " · the post title was left out (the channel is not a forum)" : ""}\n`;
753
+ }
754
+ }
755
+ }
534
756
  const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
535
757
  ? p.published_urls
536
758
  : {};
@@ -575,7 +797,7 @@ export function registerPostTools(server, getClient) {
575
797
  }
576
798
  // First comments: nested under each platform object by the API. Show the
577
799
  // configured text plus, once published, the outcome (posted/failed/skipped).
578
- const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok"];
800
+ const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok", "telegram"];
579
801
  const firstCommentRows = [];
580
802
  for (const platform of firstCommentPlatforms) {
581
803
  const opts = p[platform];
@@ -747,6 +969,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
747
969
  - TikTok posts: ALWAYS require at least one image or video.
748
970
  - Pinterest posts: ALWAYS require an image AND a board_id.
749
971
  - Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
972
+ - **Image fit (aspect ratio)**: a feed image outside what a channel shows uncropped is handled PER CHANNEL. Instagram shows feed images between 4:5 and 1.91:1: a 9:16 (story-style) image posted to the Instagram feed is CROPPED to 4:5 around the subject by default, because Instagram cuts it to that window anyway and Meta cannot boost a feed post outside it. Pinterest keeps a Pin taller than 2:3 and reports it. Before posting a tall image to Instagram, run \`check_media_compatibility\` (its fit rows say per connected channel whether the image fits and what the default does) and ASK the user: crop to 4:5 (recommended: Meta can boost it) or keep the original (Instagram cuts it itself, no boost)? Pass the answer as \`media_fit: { instagram: "crop" | "keep" }\`. If the user gave no preference, let the default run and RELAY the \`image_fit\` warning from the response (it names the crop and how to undo it with update_post). Stories, reels and videos are never cropped; 'default' and the other channels keep the original. Never drop a channel over this.
750
973
 
751
974
  **Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
752
975
  - Bluesky, X, Mastodon — max **4** items per post; cannot mix images and video
@@ -754,10 +977,12 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
754
977
  - TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
755
978
  - Pinterest carousel — 2–5 images, all same aspect ratio (a video splits off into its own video pin)
756
979
  - Facebook, LinkedIn, LinkedIn Page — cannot mix images and video in one post (images only, or a single video)
980
+ - Telegram — max **10** items per album; photos and videos CAN be mixed, a GIF goes on its own, files do not mix with photos
981
+ - Discord — max **10** files per message; images, GIFs and videos CAN be mixed
757
982
 
758
983
  **Per-platform video caps (duration + size, checked via ffprobe at submit; exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s; yours is 2min 35s. Trim the video or deselect X."):**
759
984
  - X — **140s (2min 20s)** · **512MB** on free/Basic accounts; when the connected X account has **Premium or Premium+** the caps rise to **125min / 16GB** automatically (tier is read from the connection; reconnect X after upgrading). X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images
760
- - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook 240min (Post + Reel; the old 90s Reel cap is gone)
985
+ - Bluesky 10min · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 30min · Pinterest 15min · Facebook 240min (Post + Reel; the old 90s Reel cap is gone) · Telegram no duration cap, **50MB** per file (photos 10MB) · Discord no duration cap; a video or GIF over **20MB** goes as a link in the message instead of a file
761
986
  Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
762
987
 
763
988
  When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
@@ -769,8 +994,10 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
769
994
  If the user named a board ("post to my Marketing board") or specified one in the request, use that one instead of the first — match on name (case-insensitive) against the boards list.
770
995
  - YouTube: Title, privacy status, tags?
771
996
  - TikTok: Privacy level?
772
- - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
773
- - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
997
+ - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free accounts, 25,000 on Basic, Premium and Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Basic, Premium or 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 a free account, 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.
998
+ - **Telegram** (a channel or group the user connected; the OmniSocials bot posts it): options under \`telegram\`: \`silent\`, \`link_preview\`, \`pin\`, \`protect_content\`, \`spoiler\`, \`caption_above_media\`, up to 3 \`buttons\` (\`{ text, url }\`, not on an album or a poll), a native \`poll\` (2 to 12 options; the post text is the question, max 300 chars; no media) and \`first_comment\` (a reply under the post). Text up to 4,096 characters; with media, text over 1,024 characters is sent as a reply under the media. No Stories. Telegram gives bots no view counts: statistics are reactions and the subscriber count.
999
+ - **Discord** (one Discord channel the user connected; the message is sent through the channel's webhook and shows the webhook's name and picture): options under \`discord\`: \`silent\`, \`link_preview\`, \`allow_mentions\` (off by default, so @everyone and role mentions in the text ping nobody; turn it on only when the user asks to ping), \`spoiler\`, \`thread_name\` (the post title, required in a forum or media channel; a text channel takes none, so the post goes out there without it), up to 5 \`buttons\` (\`{ text, url }\`, not with a poll) and a native \`poll\` (2 to 10 options of up to 55 chars, \`duration_hours\` 1 to 768, default 24; the post text is the question, max 300 chars; no media). Text up to 2,000 characters. No Stories; a Reel is one video message. Discord gives webhooks no view counts: statistics are reactions and the poll votes. A sent message can be deleted at any time.
1000
+ - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 20 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 10 images or 1 video. Mastodon: 500 characters per part (instance-dependent), 4 media.
774
1001
  - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). Only a link written with http:// or https:// counts; a bare domain (brand.com) or a www. link is free. The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
775
1002
 
776
1003
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
@@ -778,7 +1005,8 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
778
1005
  **When the user shares an image in chat but you cannot upload it** (e.g. no public URL available): save the post as a draft with the caption, then tell the user: "I saved your post as a draft in OmniSocials. Open it there to add your image and schedule it when ready." Include a link to https://app.omnisocials.com. Do NOT tell the user it's impossible — always save the draft so their caption isn't lost.`, {
779
1006
  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."),
780
1007
  channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
781
- scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
1008
+ scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing. With `draft: true` the time is kept but the post stays a draft (a planned draft on the user's calendar)."),
1009
+ draft: z.boolean().optional().describe("Planned draft. With scheduled_at, the post is stored as a draft that keeps that time: it shows on the user's calendar at that slot with a dashed outline, is never published by the scheduler, and the user schedules it from the composer (or you do with update_post and draft: false). Use it when you propose a time slot that the user should confirm, e.g. 'put a draft on Thursday 10:00 for me to review'. Not allowed with publish_now or approval_workflow_id. Without scheduled_at it changes nothing."),
782
1010
  media_ids: z.union([
783
1011
  z.array(mediaIdEntry),
784
1012
  z.record(z.string(), z.array(mediaIdEntry)),
@@ -795,13 +1023,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
795
1023
  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."),
796
1024
  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."),
797
1025
  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."),
798
- 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."),
799
- 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/TikTok (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
1026
+ 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 5-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
1027
+ 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/TikTok/Telegram (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
800
1028
  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."),
801
1029
  video_cover: VIDEO_COVER_OPTIONS,
1030
+ media_fit: MEDIA_FIT_OPTIONS,
802
1031
  pinterest: PINTEREST_OPTIONS,
803
1032
  youtube: z.object({
804
- title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
1033
+ title: z.string().optional().describe("Short title shown on YouTube (max 100 characters). When omitted, the first line of the caption is used; \"YouTube Short\" only when there is no caption either."),
805
1034
  tags: z.array(z.string()).optional(),
806
1035
  privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
807
1036
  category_id: z.string().optional(),
@@ -832,20 +1061,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
832
1061
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
833
1062
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
834
1063
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
1064
+ poll: X_POLL_OPTIONS,
835
1065
  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."),
836
1066
  }).optional().describe("X (Twitter) options"),
837
1067
  bluesky: z.object({
838
- 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."),
1068
+ 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 10 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
839
1069
  }).optional().describe("Bluesky options"),
840
1070
  mastodon: z.object({
1071
+ poll: MASTODON_POLL_OPTIONS,
841
1072
  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."),
842
1073
  }).optional().describe("Mastodon options"),
843
1074
  threads: z.object({
1075
+ poll: THREADS_POLL_OPTIONS,
844
1076
  location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
845
1077
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
846
1078
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
847
1079
  google_business: GOOGLE_BUSINESS_OPTIONS,
1080
+ telegram: TELEGRAM_OPTIONS,
1081
+ discord: DISCORD_OPTIONS,
848
1082
  approval_workflow_id: z.string().optional().describe("Route the post through a saved approval workflow (id from list_approval_workflows). The post is created as `in_approval` instead of `scheduled`: its approvers are notified, they review it on the Approvals page, and it publishes at scheduled_at only once the workflow's last step approves it (or approve_post). Use this whenever the user wants to review/approve agent-created posts before they go out. Requires scheduled_at; not allowed with publish_now (use create_post, never create_and_publish_post). A workflow with no approvers on its first step is rejected with validation_error."),
1083
+ comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
849
1084
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
850
1085
  const result = await getClient().createPost({ ...params, source: "mcp" });
851
1086
  if (result.error) {
@@ -869,9 +1104,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
869
1104
  const schedAt = p.schedule_at ?? p.scheduled_at;
870
1105
  const channels = selectedChannels(p);
871
1106
  const inApproval = p.status === "in_approval";
1107
+ // A draft that carries a time is a planned draft: on the calendar,
1108
+ // not scheduled. Say so, or the agent reports it as scheduled.
1109
+ const planned = p.status === "draft" && !!schedAt;
872
1110
  let md = inApproval
873
1111
  ? `## Post Submitted for Approval\n\n`
874
- : `## Draft Created (not yet published)\n\n`;
1112
+ : planned
1113
+ ? `## Draft Planned (not scheduled yet)\n\n`
1114
+ : `## Draft Created (not yet published)\n\n`;
875
1115
  md += `| Field | Value |\n`;
876
1116
  md += `|-------|-------|\n`;
877
1117
  md += `| **ID** | \`${p.id}\` |\n`;
@@ -879,15 +1119,19 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
879
1119
  if (inApproval)
880
1120
  md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
881
1121
  if (schedAt)
882
- md += `| **${inApproval ? "Publishes (once approved)" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
1122
+ md += `| **${inApproval ? "Publishes (once approved)" : planned ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
883
1123
  if (channels.length)
884
1124
  md += `| **Channels** | ${channels.join(", ")} |\n`;
1125
+ md += commentDmLine(p);
885
1126
  if (appUrl)
886
1127
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
887
1128
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
888
1129
  if (inApproval) {
889
1130
  md += `_The approvers have been notified. The post publishes at the time above as soon as the workflow's last step approves it (Approvals page in OmniSocials, or approve_post). Nothing else is needed from the user until then._\n\n`;
890
1131
  }
1132
+ else if (planned) {
1133
+ md += `_The draft sits on the user's calendar at that time with a dashed outline. It does not publish until someone schedules it: the user from the composer, or you with update_post and draft: false._\n\n`;
1134
+ }
891
1135
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
892
1136
  for (const w of result.warnings) {
893
1137
  md += `⚠️ ${w.message}\n`;
@@ -928,10 +1172,12 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
928
1172
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
929
1173
  - Pinterest: ALWAYS need an image + board_id.
930
1174
  - Ask the user which media to use. Use list_media to show options.
931
- - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
932
- - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook 240min (Post + Reel; the old 90s Reel cap is gone).
1175
+ - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads/Telegram/Discord ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
1176
+ - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 10min, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 30min, Facebook 240min (Post + Reel; the old 90s Reel cap is gone), Telegram 50MB per file, Discord: a video or GIF over 20MB goes as a link in the message.
1177
+ - **Telegram**: options under \`telegram\` (silent, link_preview, pin, protect_content, spoiler, caption_above_media, buttons, poll, first_comment), same shape as create_post. No Stories.
1178
+ - **Discord**: options under \`discord\` (silent, link_preview, allow_mentions, spoiler, thread_name, buttons, poll), same shape as create_post. No Stories. \`allow_mentions\` stays off unless the user asks to ping @everyone or a role.
933
1179
  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.
934
- 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
1180
+ 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free accounts, 25,000 on Basic, Premium and Premium+). A single long-form post on a Basic, Premium or Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On a free account, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
935
1181
  6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part; only a link written with http:// or https:// counts, a bare domain or www. link is free). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
936
1182
 
937
1183
  Do NOT call without required media — it will fail.`, {
@@ -957,6 +1203,7 @@ Do NOT call without required media — it will fail.`, {
957
1203
  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)."),
958
1204
  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."),
959
1205
  video_cover: VIDEO_COVER_OPTIONS,
1206
+ media_fit: MEDIA_FIT_OPTIONS,
960
1207
  pinterest: PINTEREST_OPTIONS,
961
1208
  youtube: z.object({
962
1209
  title: z.string().optional().describe("Short title shown on YouTube."),
@@ -990,19 +1237,25 @@ Do NOT call without required media — it will fail.`, {
990
1237
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
991
1238
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
992
1239
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
1240
+ poll: X_POLL_OPTIONS,
993
1241
  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."),
994
1242
  }).optional().describe("X (Twitter) options"),
995
1243
  bluesky: z.object({
996
- 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."),
1244
+ 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 10 images per part. Links, mentions and hashtags are made clickable automatically."),
997
1245
  }).optional().describe("Bluesky options"),
998
1246
  mastodon: z.object({
1247
+ poll: MASTODON_POLL_OPTIONS,
999
1248
  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."),
1000
1249
  }).optional().describe("Mastodon options"),
1001
1250
  threads: z.object({
1251
+ poll: THREADS_POLL_OPTIONS,
1002
1252
  location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
1003
1253
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread (2 to 25 parts). Parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids or media_urls. The Threads caption is taken from part 1."),
1004
1254
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1005
1255
  google_business: GOOGLE_BUSINESS_OPTIONS,
1256
+ telegram: TELEGRAM_OPTIONS,
1257
+ discord: DISCORD_OPTIONS,
1258
+ comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
1006
1259
  }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (params) => {
1007
1260
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
1008
1261
  if (result.error) {
@@ -1049,12 +1302,13 @@ Do NOT call without required media — it will fail.`, {
1049
1302
  });
1050
1303
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
1051
1304
 
1052
- **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.
1305
+ **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`, \`telegram\`, \`discord\`)** 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.
1053
1306
 
1054
- **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 on free/Basic, 25,000 on Premium/Premium+). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
1307
+ **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 on free accounts, 25,000 on Basic, Premium and Premium+). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
1055
1308
  id: z.string().describe("The post ID to update"),
1056
1309
  content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
1057
- scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled. A post waiting for approval keeps its in_approval status; only its time moves."),
1310
+ scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled (or stays a planned draft when draft: true is sent with it). A post waiting for approval keeps its in_approval status; only its time moves."),
1311
+ draft: z.boolean().optional().describe("Planned-draft switch. false without scheduled_at schedules a planned draft at the time it already carries (validation_error when it has no time or the time is in the past). true without scheduled_at unschedules a scheduled post: it keeps its time, stays on the calendar as a dashed draft and nothing publishes; a post waiting for approval leaves its workflow. true with scheduled_at moves the time and keeps the post a draft."),
1058
1312
  channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
1059
1313
  media_ids: z.union([
1060
1314
  z.array(mediaIdEntry),
@@ -1072,6 +1326,7 @@ Do NOT call without required media — it will fail.`, {
1072
1326
  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."),
1073
1327
  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."),
1074
1328
  video_cover: VIDEO_COVER_OPTIONS.nullable().describe("Replaces the stored video cover wholesale; pass null to remove it; omit to leave it untouched. facebook.thumbnail_type / thumb_offset / cover_url merge into overrides.facebook on their own."),
1329
+ media_fit: MEDIA_FIT_OPTIONS,
1075
1330
  youtube: z.object({
1076
1331
  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)."),
1077
1332
  tags: z.array(z.string()).optional(),
@@ -1105,19 +1360,25 @@ Do NOT call without required media — it will fail.`, {
1105
1360
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
1106
1361
  paid_partnership: z.boolean().optional(),
1107
1362
  made_with_ai: z.boolean().optional(),
1363
+ poll: X_POLL_OPTIONS,
1108
1364
  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."),
1109
1365
  }).optional().describe("X (Twitter) options"),
1110
1366
  bluesky: z.object({
1111
- 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."),
1367
+ 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 10 images per part), or `null` to revert to single-post mode."),
1112
1368
  }).optional().describe("Bluesky options"),
1113
1369
  mastodon: z.object({
1370
+ poll: MASTODON_POLL_OPTIONS,
1114
1371
  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."),
1115
1372
  }).optional().describe("Mastodon options"),
1116
1373
  threads: z.object({
1374
+ poll: THREADS_POLL_OPTIONS,
1117
1375
  location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
1118
1376
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Threads (Meta) thread shape on this post. Pass an array (2 to 25 parts, 500 characters per part) to update/create the thread (attach up to 10 media to any part via media_ids or media_urls; images and videos can be mixed), or `null` to revert to single-post mode."),
1119
1377
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1120
1378
  google_business: GOOGLE_BUSINESS_OPTIONS,
1379
+ telegram: TELEGRAM_OPTIONS,
1380
+ discord: DISCORD_OPTIONS,
1381
+ comment_dm_automation: COMMENT_DM_AUTOMATION.nullable().optional().describe("Replaces the post's comment-to-DM setup (keywords, DM text, public replies, limits, platforms); a channel no longer listed in platforms loses its automation; pass null to remove them all; omit to leave them untouched. Needs the opt-in automations:write scope."),
1121
1382
  }, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
1122
1383
  const result = await getClient().updatePost(id, data);
1123
1384
  if (result.error) {
@@ -1139,9 +1400,12 @@ Do NOT call without required media — it will fail.`, {
1139
1400
  md += `| **ID** | \`${p.id}\` |\n`;
1140
1401
  md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
1141
1402
  if (schedAt)
1142
- md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
1403
+ md += `| **${p.status === "draft" ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
1143
1404
  if (appUrl)
1144
1405
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
1406
+ if (p.status === "draft" && schedAt) {
1407
+ md += `\n_A planned draft: on the user's calendar at that time, not published until it is scheduled (composer, or update_post with draft: false)._\n`;
1408
+ }
1145
1409
  return {
1146
1410
  content: [{ type: "text", text: md }],
1147
1411
  };
@@ -1176,6 +1440,8 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
1176
1440
  md += `|-------|-------|\n`;
1177
1441
  md += `| **ID** | \`${p.id}\` |\n`;
1178
1442
  md += `| **Status** | ${capitalize(p.status || "publishing")} |\n`;
1443
+ md += appLinkRow({ ...p, id: p.id || id });
1444
+ md += `\n_Publishing runs in the background, usually within a minute. Share the "Open in OmniSocials" link with the user; \`get_post\` lists the live links once each platform has posted._`;
1179
1445
  return {
1180
1446
  content: [{ type: "text", text: md }],
1181
1447
  };
@@ -1205,6 +1471,7 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1205
1471
  if (Array.isArray(p.platforms) && p.platforms.length) {
1206
1472
  md += `| **Retrying** | ${p.platforms.map((x) => capitalize(x.replace(/_/g, " "))).join(", ")} |\n`;
1207
1473
  }
1474
+ md += appLinkRow({ ...p, id: p.id || id });
1208
1475
  md += `\n_Platforms that already succeeded are never re-published. Check \`get_post\` in a minute or two for the outcome._`;
1209
1476
  return {
1210
1477
  content: [{ type: "text", text: md }],
@@ -1267,6 +1534,7 @@ If this is the workflow's last step, the post is finalized immediately: it moves
1267
1534
  md += `| **ID** | \`${p.id}\` |\n`;
1268
1535
  md += `| **Status** | ${capitalize(p.status || "in_approval")} |\n`;
1269
1536
  md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
1537
+ md += appLinkRow({ ...p, id: p.id || id });
1270
1538
  if (p.message)
1271
1539
  md += `\n${p.message}\n`;
1272
1540
  return {
@@ -1295,6 +1563,7 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
1295
1563
  md += `|-------|-------|\n`;
1296
1564
  md += `| **ID** | \`${p?.id || id}\` |\n`;
1297
1565
  md += `| **Status** | Rejected |\n`;
1566
+ md += appLinkRow({ id: p?.id || id, status: "rejected" });
1298
1567
  md += `\n_The approval workflow has stopped. This post will not be published unless it's edited and resubmitted for approval._`;
1299
1568
  return {
1300
1569
  content: [{ type: "text", text: md }],
@@ -1514,9 +1783,9 @@ Notes:
1514
1783
  md += `No product Pins in this part of the Pin list. Call again with bookmark \`${result.bookmark}\` to scan the next part.\n`;
1515
1784
  }
1516
1785
  else if (fromCatalog) {
1517
- md += groups.length
1518
- ? "This product group has no products.\n"
1519
- : "The Pinterest account has no catalog. Try source \"pins\", or pass a product Pin link as check_pin.\n";
1786
+ md += result?.no_catalog || !groups.length
1787
+ ? "The Pinterest account has no catalog yet. The user adds products at https://www.pinterest.com/business/catalogs/ (then call again). Until then, try source \"pins\", or pass a product Pin link as check_pin.\n"
1788
+ : "This product group has no products.\n";
1520
1789
  }
1521
1790
  else {
1522
1791
  md += "No product Pins found. A product Pin comes from the Pinterest catalog, or links to a product page on a website the account claimed on Pinterest.\n";