@omnisocials/mcp-server 1.35.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())
@@ -88,7 +124,7 @@ const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
88
124
  .describe("Per-platform overrides keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). An override wins over the base cover for that platform."),
89
125
  })
90
126
  .optional()
91
- .describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. TikTok only takes a frame: a 'custom' cover is skipped there, so add a tiktok override with type 'frame' when the user wants a specific TikTok frame. The per-platform reel fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. get_post reads it back as `video_cover`.");
127
+ .describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. YouTube Shorts: the cover shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (rolling out since July 2026, Partner Program channels first); on other channels YouTube stores it as the default thumbnail but shows a frame from the video on Shorts. Say that it depends on the channel; do not promise it. TikTok only takes a frame: a 'custom' cover is skipped there, so add a tiktok override with type 'frame' when the user wants a specific TikTok frame. The per-platform reel fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. get_post reads it back as `video_cover`.");
92
128
  const LINKEDIN_OPTIONS = z
93
129
  .object({
94
130
  first_comment: z
@@ -101,6 +137,11 @@ const LINKEDIN_OPTIONS = z
101
137
  .nullable()
102
138
  .optional()
103
139
  .describe("Multi-image style for the profile post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
140
+ document_source: z
141
+ .enum(["original_pdf", "slides"])
142
+ .nullable()
143
+ .optional()
144
+ .describe("Where the profile post's document carousel comes from when its images are exactly the rendered pages of one uploaded PDF (unchanged, in order). 'original_pdf' (default): LinkedIn receives the kept original PDF file: sharp text, working in-document links, the real download, every page even past the 20-slide cap. 'slides': rebuild the document from the slide images (a flattened copy). Ignored when the images are not a PDF's pages, when carousel_as_images is true, for 0-1 images, videos and polls. On update_post: 'slides' sets it, 'original_pdf'/null reverts to the default, omitted keeps the current value."),
104
145
  })
105
146
  .optional()
106
147
  .describe("LinkedIn Profile options");
@@ -116,6 +157,11 @@ const LINKEDIN_PAGE_OPTIONS = z
116
157
  .nullable()
117
158
  .optional()
118
159
  .describe("Multi-image style for the company-page post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
160
+ document_source: z
161
+ .enum(["original_pdf", "slides"])
162
+ .nullable()
163
+ .optional()
164
+ .describe("Same as linkedin.document_source, for the company-page post: 'original_pdf' (default) sends the kept original PDF when the images are its unchanged pages; 'slides' rebuilds the document from the slide images. On update_post: 'slides' sets it, 'original_pdf'/null reverts, omitted keeps the current value."),
119
165
  })
120
166
  .optional()
121
167
  .describe("LinkedIn Company Page options");
@@ -142,6 +188,103 @@ const LINKEDIN_POLL_OPTIONS = z
142
188
  .nullable()
143
189
  .optional()
144
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.");
145
288
  // Per-platform option schemas shared verbatim by create_post,
146
289
  // create_and_publish_post and update_post (they were byte-identical in all
147
290
  // three). Platform blocks that genuinely differ per tool (instagram, youtube,
@@ -174,6 +317,7 @@ const PINTEREST_OPTIONS = z.object({
174
317
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
175
318
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
176
319
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
320
+ product_tags: z.array(z.string()).max(24).optional().describe("Products to tag on the Pin, so people can shop the items in the image. Up to 24 product Pins of the connected Pinterest account, each as a Pin ID string or a Pin link (https://www.pinterest.com/pin/<id>/). Get the IDs with list_pinterest_products. Only the account's own product Pins can be tagged (public, with a link on a website the account claimed on Pinterest); products of other merchants cannot. The tags are added right after the Pin is published; a product Pinterest refuses never fails the post, and get_post shows the outcome (product_tags_result). On update_post the pinterest object replaces the stored one, so leave product_tags out to remove the tags."),
177
321
  }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate.");
178
322
  // One Instagram photo tag. The wrapping z.array(...).describe(...) stays
179
323
  // per-tool because update_post documents replace/clear semantics.
@@ -193,8 +337,8 @@ const X_THREAD_PART = z.object({
193
337
  });
194
338
  const BLUESKY_THREAD_PART = z.object({
195
339
  text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
196
- 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)."),
197
- 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)."),
198
342
  });
199
343
  const MASTODON_THREAD_PART = z.object({
200
344
  text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
@@ -220,7 +364,7 @@ const GOOGLE_BUSINESS_OPTIONS = z.object({
220
364
  endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
221
365
  endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
222
366
  }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
223
- }).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)."),
224
368
  offer: z.object({
225
369
  couponCode: z.string().max(58).optional(),
226
370
  redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
@@ -274,15 +418,32 @@ function postAppUrl(p) {
274
418
  // client URL, so it's correct on staging/production. Fall back to a
275
419
  // hardcoded production URL for older API responses that don't return it.
276
420
  if (typeof p?.app_url === "string" && p.app_url)
277
- return p.app_url;
421
+ return withRef(p.app_url);
278
422
  const id = p?.id;
279
423
  if (!id)
280
424
  return null;
281
425
  const status = String(p?.status || "").toLowerCase();
282
426
  const isEditable = status === "draft" || status === "scheduled" || status === "" || status === "failed";
283
- return isEditable
427
+ return withRef(isEditable
284
428
  ? `https://app.omnisocials.com/create-post/${id}`
285
- : `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` : "";
286
447
  }
287
448
  export function registerPostTools(server, getClient) {
288
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.", {
@@ -520,6 +681,78 @@ export function registerPostTools(server, getClient) {
520
681
  }
521
682
  md += `\n_Duration: ${poll.duration}_\n`;
522
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
+ }
523
756
  const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
524
757
  ? p.published_urls
525
758
  : {};
@@ -564,7 +797,7 @@ export function registerPostTools(server, getClient) {
564
797
  }
565
798
  // First comments: nested under each platform object by the API. Show the
566
799
  // configured text plus, once published, the outcome (posted/failed/skipped).
567
- const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok"];
800
+ const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok", "telegram"];
568
801
  const firstCommentRows = [];
569
802
  for (const platform of firstCommentPlatforms) {
570
803
  const opts = p[platform];
@@ -627,6 +860,18 @@ export function registerPostTools(server, getClient) {
627
860
  optionRows.push(["Pinterest title", pin.title]);
628
861
  if (typeof pin.link === "string" && pin.link)
629
862
  optionRows.push(["Pinterest link", pin.link]);
863
+ if (Array.isArray(pin.product_tags) && pin.product_tags.length) {
864
+ optionRows.push(["Pinterest product tags", pin.product_tags.join(", ")]);
865
+ }
866
+ // Outcome of the tag step, written after the Pin is live.
867
+ const tagResult = pin.product_tags_result;
868
+ if (tagResult && typeof tagResult === "object") {
869
+ const skipped = (tagResult.skipped || []).map((item) => `${item.pin_id} ${item.reason}`).join(", ");
870
+ optionRows.push([
871
+ "Pinterest products tagged",
872
+ `${(tagResult.tagged || []).length} of ${tagResult.requested ?? 0}${skipped ? ` (not tagged: ${skipped})` : ""}${tagResult.error ? ` (Pinterest: ${tagResult.error})` : ""}`,
873
+ ]);
874
+ }
630
875
  // Cross-platform video cover (base + per-platform overrides).
631
876
  const describeCover = (c) => {
632
877
  if (!c || typeof c !== "object")
@@ -662,15 +907,20 @@ export function registerPostTools(server, getClient) {
662
907
  for (const [k, v] of optionRows)
663
908
  md += `| ${k} | ${v.replace(/\|/g, "\\|")} |\n`;
664
909
  }
665
- // LinkedIn multi-image style: nested under each channel's options
666
- // object by the API. Only rendered when the post opts out of the
667
- // default PDF document carousel.
910
+ // LinkedIn multi-image style + document source: nested under each
911
+ // channel's options object by the API. Only rendered when the post
912
+ // opts out of a default (document carousel; kept original PDF).
668
913
  for (const channel of ["linkedin", "linkedin_page"]) {
669
914
  const opts = p[channel];
670
- if (opts && typeof opts === "object" && opts.carousel_as_images === true) {
671
- const label = channel === "linkedin_page" ? "LinkedIn Page" : "LinkedIn Profile";
915
+ if (!opts || typeof opts !== "object")
916
+ continue;
917
+ const label = channel === "linkedin_page" ? "LinkedIn Page" : "LinkedIn Profile";
918
+ if (opts.carousel_as_images === true) {
672
919
  md += `\n\n**${label} multi-image style:** plain image gallery (default is the swipeable PDF document carousel)\n`;
673
920
  }
921
+ if (opts.document_source === "slides") {
922
+ md += `\n\n**${label} document source:** rebuilt from the slide images (default sends the kept original PDF when the images are its unchanged pages)\n`;
923
+ }
674
924
  }
675
925
  // Trial Reel state: nested under the instagram options object by the
676
926
  // API. Only rendered when enabled, so batch-scheduled Trial Reels can be
@@ -719,6 +969,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
719
969
  - TikTok posts: ALWAYS require at least one image or video.
720
970
  - Pinterest posts: ALWAYS require an image AND a board_id.
721
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.
722
973
 
723
974
  **Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
724
975
  - Bluesky, X, Mastodon — max **4** items per post; cannot mix images and video
@@ -726,10 +977,12 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
726
977
  - TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
727
978
  - Pinterest carousel — 2–5 images, all same aspect ratio (a video splits off into its own video pin)
728
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
729
982
 
730
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."):**
731
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
732
- - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
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
733
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.
734
987
 
735
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.
@@ -741,20 +994,23 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
741
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.
742
995
  - YouTube: Title, privacy status, tags?
743
996
  - TikTok: Privacy level?
744
- - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
745
- - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
746
- - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
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.
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.
747
1002
 
748
1003
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
749
1004
 
750
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.`, {
751
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."),
752
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."),
753
- 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."),
754
1010
  media_ids: z.union([
755
1011
  z.array(mediaIdEntry),
756
1012
  z.record(z.string(), z.array(mediaIdEntry)),
757
- ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
1013
+ ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). A media item of type 'document' (a PDF kept as one item) expands into its page images, in order, so one id attaches the whole deck."),
758
1014
  media_urls: z.union([
759
1015
  z.array(mediaUrlEntry),
760
1016
  z.record(z.string(), z.array(mediaUrlEntry)),
@@ -767,13 +1023,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
767
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."),
768
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."),
769
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."),
770
- 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."),
771
- 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."),
772
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."),
773
1029
  video_cover: VIDEO_COVER_OPTIONS,
1030
+ media_fit: MEDIA_FIT_OPTIONS,
774
1031
  pinterest: PINTEREST_OPTIONS,
775
1032
  youtube: z.object({
776
- 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."),
777
1034
  tags: z.array(z.string()).optional(),
778
1035
  privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
779
1036
  category_id: z.string().optional(),
@@ -781,7 +1038,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
781
1038
  notify_subscribers: z.boolean().optional(),
782
1039
  contains_synthetic_media: z.boolean().optional(),
783
1040
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
784
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
1041
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
785
1042
  instagram: z.object({
786
1043
  share_to_feed: z.boolean().optional(),
787
1044
  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."),
@@ -804,20 +1061,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
804
1061
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
805
1062
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
806
1063
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
1064
+ poll: X_POLL_OPTIONS,
807
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."),
808
1066
  }).optional().describe("X (Twitter) options"),
809
1067
  bluesky: z.object({
810
- 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."),
811
1069
  }).optional().describe("Bluesky options"),
812
1070
  mastodon: z.object({
1071
+ poll: MASTODON_POLL_OPTIONS,
813
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."),
814
1073
  }).optional().describe("Mastodon options"),
815
1074
  threads: z.object({
1075
+ poll: THREADS_POLL_OPTIONS,
816
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."),
817
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."),
818
1078
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
819
1079
  google_business: GOOGLE_BUSINESS_OPTIONS,
1080
+ telegram: TELEGRAM_OPTIONS,
1081
+ discord: DISCORD_OPTIONS,
820
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(),
821
1084
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
822
1085
  const result = await getClient().createPost({ ...params, source: "mcp" });
823
1086
  if (result.error) {
@@ -841,9 +1104,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
841
1104
  const schedAt = p.schedule_at ?? p.scheduled_at;
842
1105
  const channels = selectedChannels(p);
843
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;
844
1110
  let md = inApproval
845
1111
  ? `## Post Submitted for Approval\n\n`
846
- : `## 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`;
847
1115
  md += `| Field | Value |\n`;
848
1116
  md += `|-------|-------|\n`;
849
1117
  md += `| **ID** | \`${p.id}\` |\n`;
@@ -851,15 +1119,19 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
851
1119
  if (inApproval)
852
1120
  md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
853
1121
  if (schedAt)
854
- md += `| **${inApproval ? "Publishes (once approved)" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
1122
+ md += `| **${inApproval ? "Publishes (once approved)" : planned ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
855
1123
  if (channels.length)
856
1124
  md += `| **Channels** | ${channels.join(", ")} |\n`;
1125
+ md += commentDmLine(p);
857
1126
  if (appUrl)
858
1127
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
859
1128
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
860
1129
  if (inApproval) {
861
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`;
862
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
+ }
863
1135
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
864
1136
  for (const w of result.warnings) {
865
1137
  md += `⚠️ ${w.message}\n`;
@@ -900,11 +1172,13 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
900
1172
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
901
1173
  - Pinterest: ALWAYS need an image + board_id.
902
1174
  - Ask the user which media to use. Use list_media to show options.
903
- - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
904
- - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
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.
905
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.
906
- 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
907
- 6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
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).
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.
908
1182
 
909
1183
  Do NOT call without required media — it will fail.`, {
910
1184
  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."),
@@ -929,6 +1203,7 @@ Do NOT call without required media — it will fail.`, {
929
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)."),
930
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."),
931
1205
  video_cover: VIDEO_COVER_OPTIONS,
1206
+ media_fit: MEDIA_FIT_OPTIONS,
932
1207
  pinterest: PINTEREST_OPTIONS,
933
1208
  youtube: z.object({
934
1209
  title: z.string().optional().describe("Short title shown on YouTube."),
@@ -939,7 +1214,7 @@ Do NOT call without required media — it will fail.`, {
939
1214
  notify_subscribers: z.boolean().optional(),
940
1215
  contains_synthetic_media: z.boolean().optional(),
941
1216
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
942
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
1217
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
943
1218
  instagram: z.object({
944
1219
  share_to_feed: z.boolean().optional(),
945
1220
  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."),
@@ -962,19 +1237,25 @@ Do NOT call without required media — it will fail.`, {
962
1237
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
963
1238
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
964
1239
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
1240
+ poll: X_POLL_OPTIONS,
965
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."),
966
1242
  }).optional().describe("X (Twitter) options"),
967
1243
  bluesky: z.object({
968
- 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."),
969
1245
  }).optional().describe("Bluesky options"),
970
1246
  mastodon: z.object({
1247
+ poll: MASTODON_POLL_OPTIONS,
971
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."),
972
1249
  }).optional().describe("Mastodon options"),
973
1250
  threads: z.object({
1251
+ poll: THREADS_POLL_OPTIONS,
974
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."),
975
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."),
976
1254
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
977
1255
  google_business: GOOGLE_BUSINESS_OPTIONS,
1256
+ telegram: TELEGRAM_OPTIONS,
1257
+ discord: DISCORD_OPTIONS,
1258
+ comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
978
1259
  }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (params) => {
979
1260
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
980
1261
  if (result.error) {
@@ -1021,12 +1302,13 @@ Do NOT call without required media — it will fail.`, {
1021
1302
  });
1022
1303
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
1023
1304
 
1024
- **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.
1025
1306
 
1026
- **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).`, {
1027
1308
  id: z.string().describe("The post ID to update"),
1028
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\" }."),
1029
- 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."),
1030
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."),
1031
1313
  media_ids: z.union([
1032
1314
  z.array(mediaIdEntry),
@@ -1044,6 +1326,7 @@ Do NOT call without required media — it will fail.`, {
1044
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."),
1045
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."),
1046
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,
1047
1330
  youtube: z.object({
1048
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)."),
1049
1332
  tags: z.array(z.string()).optional(),
@@ -1053,7 +1336,7 @@ Do NOT call without required media — it will fail.`, {
1053
1336
  notify_subscribers: z.boolean().optional(),
1054
1337
  contains_synthetic_media: z.boolean().optional(),
1055
1338
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled. Pass an empty string to clear a previously set first comment."),
1056
- }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
1339
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
1057
1340
  pinterest: PINTEREST_OPTIONS,
1058
1341
  instagram: z.object({
1059
1342
  share_to_feed: z.boolean().optional(),
@@ -1077,19 +1360,25 @@ Do NOT call without required media — it will fail.`, {
1077
1360
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
1078
1361
  paid_partnership: z.boolean().optional(),
1079
1362
  made_with_ai: z.boolean().optional(),
1363
+ poll: X_POLL_OPTIONS,
1080
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."),
1081
1365
  }).optional().describe("X (Twitter) options"),
1082
1366
  bluesky: z.object({
1083
- 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."),
1084
1368
  }).optional().describe("Bluesky options"),
1085
1369
  mastodon: z.object({
1370
+ poll: MASTODON_POLL_OPTIONS,
1086
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."),
1087
1372
  }).optional().describe("Mastodon options"),
1088
1373
  threads: z.object({
1374
+ poll: THREADS_POLL_OPTIONS,
1089
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."),
1090
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."),
1091
1377
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1092
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."),
1093
1382
  }, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
1094
1383
  const result = await getClient().updatePost(id, data);
1095
1384
  if (result.error) {
@@ -1111,9 +1400,12 @@ Do NOT call without required media — it will fail.`, {
1111
1400
  md += `| **ID** | \`${p.id}\` |\n`;
1112
1401
  md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
1113
1402
  if (schedAt)
1114
- md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
1403
+ md += `| **${p.status === "draft" ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
1115
1404
  if (appUrl)
1116
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
+ }
1117
1409
  return {
1118
1410
  content: [{ type: "text", text: md }],
1119
1411
  };
@@ -1148,6 +1440,8 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
1148
1440
  md += `|-------|-------|\n`;
1149
1441
  md += `| **ID** | \`${p.id}\` |\n`;
1150
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._`;
1151
1445
  return {
1152
1446
  content: [{ type: "text", text: md }],
1153
1447
  };
@@ -1177,6 +1471,7 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1177
1471
  if (Array.isArray(p.platforms) && p.platforms.length) {
1178
1472
  md += `| **Retrying** | ${p.platforms.map((x) => capitalize(x.replace(/_/g, " "))).join(", ")} |\n`;
1179
1473
  }
1474
+ md += appLinkRow({ ...p, id: p.id || id });
1180
1475
  md += `\n_Platforms that already succeeded are never re-published. Check \`get_post\` in a minute or two for the outcome._`;
1181
1476
  return {
1182
1477
  content: [{ type: "text", text: md }],
@@ -1239,6 +1534,7 @@ If this is the workflow's last step, the post is finalized immediately: it moves
1239
1534
  md += `| **ID** | \`${p.id}\` |\n`;
1240
1535
  md += `| **Status** | ${capitalize(p.status || "in_approval")} |\n`;
1241
1536
  md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
1537
+ md += appLinkRow({ ...p, id: p.id || id });
1242
1538
  if (p.message)
1243
1539
  md += `\n${p.message}\n`;
1244
1540
  return {
@@ -1267,11 +1563,78 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
1267
1563
  md += `|-------|-------|\n`;
1268
1564
  md += `| **ID** | \`${p?.id || id}\` |\n`;
1269
1565
  md += `| **Status** | Rejected |\n`;
1566
+ md += appLinkRow({ id: p?.id || id, status: "rejected" });
1270
1567
  md += `\n_The approval workflow has stopped. This post will not be published unless it's edited and resubmitted for approval._`;
1271
1568
  return {
1272
1569
  content: [{ type: "text", text: md }],
1273
1570
  };
1274
1571
  });
1572
+ server.tool("get_post_approval", `Read the approval review of a post: every workflow step with its approvers and their decisions, the rejection with its reason, and the comment thread.
1573
+
1574
+ Use it when \`get_post\` shows the status \`rejected\` (who rejected it and why, what to change before sending it again) or \`in_approval\` (who the post waits for, what the reviewers wrote). Read-only. A post without an approval workflow returns status \`none\`.`, {
1575
+ id: z.string().describe("The post ID"),
1576
+ }, { title: "Get Post Approval", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
1577
+ const result = await getClient().getPostApproval(id);
1578
+ if (result.error) {
1579
+ return {
1580
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1581
+ };
1582
+ }
1583
+ const a = result.data;
1584
+ if (!a) {
1585
+ return {
1586
+ content: [{ type: "text", text: "The API returned no approval review for this post. Check its status with get_post." }],
1587
+ };
1588
+ }
1589
+ let md = `## Approval Review\n\n`;
1590
+ md += `| Field | Value |\n`;
1591
+ md += `|-------|-------|\n`;
1592
+ md += `| **Post** | \`${a.post_id || id}\` |\n`;
1593
+ md += `| **Status** | ${capitalize(a.status || "none")} |\n`;
1594
+ if (a.workflow?.name)
1595
+ md += `| **Workflow** | ${a.workflow.name} |\n`;
1596
+ if (a.requested_by?.name)
1597
+ md += `| **Requested by** | ${a.requested_by.name} |\n`;
1598
+ if (a.status === "none") {
1599
+ md += `\n_This post has no approval workflow._`;
1600
+ return {
1601
+ content: [{ type: "text", text: md }],
1602
+ };
1603
+ }
1604
+ if (a.rejection) {
1605
+ md += `\n### Rejected\n\n`;
1606
+ md += `${a.rejection.by?.name || "A reviewer"} rejected this post${a.rejection.at ? ` on ${formatDateTime(a.rejection.at)}` : ""}.\n`;
1607
+ md += a.rejection.reason ? `\n**Reason:** ${a.rejection.reason}\n` : `\n_No reason was given._\n`;
1608
+ }
1609
+ if (Array.isArray(a.steps) && a.steps.length) {
1610
+ md += `\n### Steps\n\n`;
1611
+ md += `| # | Step | Needs | Status | Approvers |\n`;
1612
+ md += `|---|------|-------|--------|-----------|\n`;
1613
+ for (const s of a.steps) {
1614
+ const who = (s.approvers || [])
1615
+ .map((p) => `${p.name || p.email || p.id} (${p.status})`)
1616
+ .join(", ");
1617
+ const active = a.current_step === s.order ? " (waiting now)" : "";
1618
+ md += `| ${s.order} | ${s.name}${active} | ${s.require_mode === "any" ? "one approval" : "all approvers"} | ${capitalize(s.status)} | ${who || "none"} |\n`;
1619
+ }
1620
+ }
1621
+ if (Array.isArray(a.comments) && a.comments.length) {
1622
+ md += `\n### Comments (${a.comments.length}, oldest first)\n\n`;
1623
+ for (const c of a.comments) {
1624
+ const where = c.account ? ` on ${capitalize(c.account.replace(/_/g, " "))}` : "";
1625
+ md += `- **${c.author?.name || "Unknown"}**${where}, ${formatDateTime(c.created_at)}: ${String(c.message).replace(/\n+/g, " / ")}\n`;
1626
+ }
1627
+ }
1628
+ else {
1629
+ md += `\n_No comments in the review thread._`;
1630
+ }
1631
+ if (a.status === "rejected") {
1632
+ md += `\n\n_A rejected post does not publish. To send it again, open it in OmniSocials, edit it and send it for approval there, or create a new post with \`approval_workflow_id\`._`;
1633
+ }
1634
+ return {
1635
+ content: [{ type: "text", text: md }],
1636
+ };
1637
+ });
1275
1638
  server.tool("search_locations", `Search for a location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
1276
1639
 
1277
1640
  Pick the platform with \`platform\`: "instagram" (default) or "threads". The two use DIFFERENT ids (a Facebook Place ID is not a Threads location id), so search with the platform you will tag. If the user wants both, search twice.
@@ -1363,6 +1726,91 @@ Notes:
1363
1726
  });
1364
1727
  return { content: [{ type: "text", text: md }] };
1365
1728
  });
1729
+ server.tool("list_pinterest_products", `List the product Pins of the connected Pinterest account, to tag products on a Pin (people can then shop the items in the image). Use this whenever the user wants to tag products on a Pinterest post.
1730
+
1731
+ Flow: call it → show the products to the user → pass the chosen \`pin_id\` values (max 24) as \`pinterest.product_tags\` on create_post / create_and_publish_post / update_post. If the user gives a Pin link or Pin ID of a product, pass it as \`check_pin\` to check it first, or put it straight in \`pinterest.product_tags\`.
1732
+
1733
+ Notes:
1734
+ - Only the account's own product Pins can be tagged: a public product Pin with a link on a website the account claimed on Pinterest. Products of other merchants cannot be tagged through the API.
1735
+ - \`source\` "catalog" reads the Pinterest catalog (with price and stock). It needs catalog access, which the user gives one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog. \`source\` "pins" reads the account's own Pins and returns the product Pins; one call scans up to 250 Pins, so an empty result with a bookmark means: call again with that bookmark. Without \`source\`, the catalog is used when the connection has catalog access, else the Pins.
1736
+ - A product that Pinterest refuses never fails the post; get_post shows which products were tagged.`, {
1737
+ source: z
1738
+ .enum(["catalog", "pins"])
1739
+ .optional()
1740
+ .describe("Where to read product Pins from. Default: catalog when the Pinterest connection has catalog access, else pins."),
1741
+ product_group_id: z
1742
+ .string()
1743
+ .optional()
1744
+ .describe("Catalog source only. A product group ID from an earlier result. Default: the group named All Products."),
1745
+ bookmark: z
1746
+ .string()
1747
+ .optional()
1748
+ .describe("Cursor from an earlier result, to get the next page."),
1749
+ check_pin: z
1750
+ .string()
1751
+ .optional()
1752
+ .describe("A Pin ID or Pin link (https://www.pinterest.com/pin/<id>/) to check instead of listing: answers whether that Pin can be tagged as a product."),
1753
+ }, { title: "List Pinterest Products", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ source, product_group_id, bookmark, check_pin }) => {
1754
+ const messageOf = (err) => typeof err === "string" ? err : err?.message;
1755
+ if (check_pin) {
1756
+ const check = await getClient().validatePinterestProduct(check_pin);
1757
+ const failed = messageOf(check?.error);
1758
+ if (failed)
1759
+ return { content: [{ type: "text", text: failed }] };
1760
+ const name = check?.title ? ` (${check.title})` : "";
1761
+ let text;
1762
+ if (check?.valid) {
1763
+ text = `Pin \`${check.pin_id}\`${name} is a product Pin of this account. Use it in \`pinterest.product_tags\`.`;
1764
+ }
1765
+ else if (check?.unverified) {
1766
+ text = `Could not check Pin \`${check?.pin_id ?? check_pin}\`: ${check?.reason || "no answer from Pinterest"} You can still pass it in \`pinterest.product_tags\`; Pinterest checks it at publish.`;
1767
+ }
1768
+ else {
1769
+ text = `Pin ${check?.pin_id ? `\`${check.pin_id}\`${name}` : `"${check_pin}"`} cannot be tagged: ${check?.reason || "Pinterest did not accept it."}`;
1770
+ }
1771
+ return { content: [{ type: "text", text }] };
1772
+ }
1773
+ const result = await getClient().listPinterestProducts({ source, product_group_id, bookmark });
1774
+ const failed = messageOf(result?.error);
1775
+ if (failed)
1776
+ return { content: [{ type: "text", text: failed }] };
1777
+ const products = result?.products || [];
1778
+ const fromCatalog = result?.source === "catalog";
1779
+ const groups = result?.product_groups || [];
1780
+ let md = `## Pinterest product Pins (${fromCatalog ? "catalog" : "the account's own Pins"})\n\n`;
1781
+ if (!products.length) {
1782
+ if (result?.bookmark) {
1783
+ md += `No product Pins in this part of the Pin list. Call again with bookmark \`${result.bookmark}\` to scan the next part.\n`;
1784
+ }
1785
+ else if (fromCatalog) {
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";
1789
+ }
1790
+ else {
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";
1792
+ }
1793
+ }
1794
+ else {
1795
+ md += `Ask the user which products to tag, then pass their IDs (max 24) as \`pinterest.product_tags\`:\n\n`;
1796
+ md += `| # | Product | Price | Stock | pin_id |\n|---|---------|-------|-------|--------|\n`;
1797
+ products.forEach((p, i) => {
1798
+ const price = typeof p.price === "number" ? `${p.price}${p.currency ? ` ${p.currency}` : ""}` : "-";
1799
+ md += `| ${i + 1} | ${p.title || p.link || "-"} | ${price} | ${p.availability || "-"} | \`${p.pin_id}\` |\n`;
1800
+ });
1801
+ if (result?.bookmark)
1802
+ md += `\nMore products: call again with bookmark \`${result.bookmark}\`.\n`;
1803
+ }
1804
+ if (fromCatalog && groups.length > 1) {
1805
+ md += `\nProduct groups (pass one as product_group_id): ${groups
1806
+ .map((g) => `${g.name || "Unnamed"} \`${g.id}\`${g.id === result?.product_group_id ? " (shown)" : ""}`)
1807
+ .join(", ")}\n`;
1808
+ }
1809
+ if (!fromCatalog && result?.catalog_access === false) {
1810
+ md += `\nThe connection has no catalog access, so catalog products are not listed. The user can give it one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog.\n`;
1811
+ }
1812
+ return { content: [{ type: "text", text: md }] };
1813
+ });
1366
1814
  server.tool("search_instagram_audio", `Search Instagram's licensed music catalog for a track to attach to a REEL. Use this whenever the user wants to post a reel WITH music/a song/a sound (e.g. "post this reel with trending audio", "add that song to it").
1367
1815
 
1368
1816
  Flow: call with a song/artist/keyword (or NO query for currently trending audio) → present the options → once the user picks, pass that result's \`audio_id\` as \`instagram.audio_id\` on create_post / create_and_publish_post / update_post. Optionally mix with \`instagram.audio_volume\` / \`instagram.video_volume\` (0-100; set video_volume 0 to fully replace the video's own sound). Instagram Reels only — feed posts, carousels, and Stories can't take music via the API (Meta limitation).