@omnisocials/mcp-server 1.34.1 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -6
- package/build/client.d.ts +15 -1
- package/build/client.js +24 -0
- package/build/index.js +1 -1
- package/build/metrics.d.ts +10 -2
- package/build/metrics.js +100 -2
- package/build/tools/analytics.js +1 -1
- package/build/tools/inbox.js +17 -8
- package/build/tools/media.js +39 -5
- package/build/tools/posts.js +251 -13
- package/build/tools/webhooks.js +3 -3
- package/build/types.d.ts +165 -6
- package/package.json +2 -2
package/build/tools/posts.js
CHANGED
|
@@ -60,9 +60,35 @@ const FACEBOOK_OPTIONS = z
|
|
|
60
60
|
.max(8000)
|
|
61
61
|
.optional()
|
|
62
62
|
.describe("Text auto-posted as the first comment on the Facebook post right after it publishes. Page posts only (the API cannot comment on personal-profile posts). Not posted for Stories."),
|
|
63
|
+
thumbnail_type: z
|
|
64
|
+
.enum(["from-video", "from-library"])
|
|
65
|
+
.nullable()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Video thumbnail for Facebook feed videos and reels: 'from-video' uses the frame at thumb_offset, 'from-library' uploads the image at cover_url. Applied after the video is live; a thumbnail failure never fails the post. On update_post, null removes the Facebook override."),
|
|
68
|
+
thumb_offset: z
|
|
69
|
+
.number()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
|
|
72
|
+
cover_url: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Custom thumbnail image URL (JPEG/PNG, max 10 MB). Used with thumbnail_type 'from-library'."),
|
|
63
76
|
})
|
|
64
77
|
.optional()
|
|
65
78
|
.describe("Facebook options");
|
|
79
|
+
const VIDEO_COVER_ENTRY = z.object({
|
|
80
|
+
type: z.enum(["frame", "custom"]).describe("'frame' uses the video frame at thumb_offset; 'custom' uploads the image at cover_url."),
|
|
81
|
+
thumb_offset: z.number().int().min(0).optional().describe("Milliseconds into the video (type 'frame'). 3000 = 0:03."),
|
|
82
|
+
cover_url: z.string().optional().describe("Public image URL, JPEG or PNG (type 'custom')."),
|
|
83
|
+
});
|
|
84
|
+
const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
|
|
85
|
+
overrides: z
|
|
86
|
+
.record(z.string(), VIDEO_COVER_ENTRY.nullable())
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Per-platform overrides keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). An override wins over the base cover for that platform."),
|
|
89
|
+
})
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. 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`.");
|
|
66
92
|
const LINKEDIN_OPTIONS = z
|
|
67
93
|
.object({
|
|
68
94
|
first_comment: z
|
|
@@ -75,6 +101,11 @@ const LINKEDIN_OPTIONS = z
|
|
|
75
101
|
.nullable()
|
|
76
102
|
.optional()
|
|
77
103
|
.describe("Multi-image style for the profile post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
|
|
104
|
+
document_source: z
|
|
105
|
+
.enum(["original_pdf", "slides"])
|
|
106
|
+
.nullable()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Where the profile post's document carousel comes from when its images are exactly the rendered pages of one uploaded PDF (unchanged, in order). 'original_pdf' (default): LinkedIn receives the kept original PDF file: sharp text, working in-document links, the real download, every page even past the 20-slide cap. 'slides': rebuild the document from the slide images (a flattened copy). Ignored when the images are not a PDF's pages, when carousel_as_images is true, for 0-1 images, videos and polls. On update_post: 'slides' sets it, 'original_pdf'/null reverts to the default, omitted keeps the current value."),
|
|
78
109
|
})
|
|
79
110
|
.optional()
|
|
80
111
|
.describe("LinkedIn Profile options");
|
|
@@ -90,6 +121,11 @@ const LINKEDIN_PAGE_OPTIONS = z
|
|
|
90
121
|
.nullable()
|
|
91
122
|
.optional()
|
|
92
123
|
.describe("Multi-image style for the company-page post. By default 2+ images publish as LinkedIn's swipeable PDF document carousel; true publishes them as a plain multi-image gallery instead. Ignored for 0-1 images, videos, and polls. On update_post: true sets it, false/null reverts to the document carousel, omitted keeps the current value."),
|
|
124
|
+
document_source: z
|
|
125
|
+
.enum(["original_pdf", "slides"])
|
|
126
|
+
.nullable()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("Same as linkedin.document_source, for the company-page post: 'original_pdf' (default) sends the kept original PDF when the images are its unchanged pages; 'slides' rebuilds the document from the slide images. On update_post: 'slides' sets it, 'original_pdf'/null reverts, omitted keeps the current value."),
|
|
93
129
|
})
|
|
94
130
|
.optional()
|
|
95
131
|
.describe("LinkedIn Company Page options");
|
|
@@ -148,6 +184,7 @@ const PINTEREST_OPTIONS = z.object({
|
|
|
148
184
|
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
149
185
|
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
150
186
|
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
187
|
+
product_tags: z.array(z.string()).max(24).optional().describe("Products to tag on the Pin, so people can shop the items in the image. Up to 24 product Pins of the connected Pinterest account, each as a Pin ID string or a Pin link (https://www.pinterest.com/pin/<id>/). Get the IDs with list_pinterest_products. Only the account's own product Pins can be tagged (public, with a link on a website the account claimed on Pinterest); products of other merchants cannot. The tags are added right after the Pin is published; a product Pinterest refuses never fails the post, and get_post shows the outcome (product_tags_result). On update_post the pinterest object replaces the stored one, so leave product_tags out to remove the tags."),
|
|
151
188
|
}).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate.");
|
|
152
189
|
// One Instagram photo tag. The wrapping z.array(...).describe(...) stays
|
|
153
190
|
// per-tool because update_post documents replace/clear semantics.
|
|
@@ -601,20 +638,67 @@ export function registerPostTools(server, getClient) {
|
|
|
601
638
|
optionRows.push(["Pinterest title", pin.title]);
|
|
602
639
|
if (typeof pin.link === "string" && pin.link)
|
|
603
640
|
optionRows.push(["Pinterest link", pin.link]);
|
|
641
|
+
if (Array.isArray(pin.product_tags) && pin.product_tags.length) {
|
|
642
|
+
optionRows.push(["Pinterest product tags", pin.product_tags.join(", ")]);
|
|
643
|
+
}
|
|
644
|
+
// Outcome of the tag step, written after the Pin is live.
|
|
645
|
+
const tagResult = pin.product_tags_result;
|
|
646
|
+
if (tagResult && typeof tagResult === "object") {
|
|
647
|
+
const skipped = (tagResult.skipped || []).map((item) => `${item.pin_id} ${item.reason}`).join(", ");
|
|
648
|
+
optionRows.push([
|
|
649
|
+
"Pinterest products tagged",
|
|
650
|
+
`${(tagResult.tagged || []).length} of ${tagResult.requested ?? 0}${skipped ? ` (not tagged: ${skipped})` : ""}${tagResult.error ? ` (Pinterest: ${tagResult.error})` : ""}`,
|
|
651
|
+
]);
|
|
652
|
+
}
|
|
653
|
+
// Cross-platform video cover (base + per-platform overrides).
|
|
654
|
+
const describeCover = (c) => {
|
|
655
|
+
if (!c || typeof c !== "object")
|
|
656
|
+
return null;
|
|
657
|
+
const entry = c;
|
|
658
|
+
if (entry.type === "frame") {
|
|
659
|
+
const ms = Number(entry.thumb_offset) || 0;
|
|
660
|
+
return `video frame at ${ms} ms (${(ms / 1000).toFixed(1)}s into the video)`;
|
|
661
|
+
}
|
|
662
|
+
if (entry.type === "custom" && typeof entry.cover_url === "string")
|
|
663
|
+
return `custom image ${entry.cover_url}`;
|
|
664
|
+
return null;
|
|
665
|
+
};
|
|
666
|
+
const vc = p.video_cover;
|
|
667
|
+
const baseCover = describeCover(vc);
|
|
668
|
+
if (baseCover)
|
|
669
|
+
optionRows.push(["Video cover", baseCover]);
|
|
670
|
+
const coverOverrides = vc && typeof vc === "object" ? vc.overrides : null;
|
|
671
|
+
if (coverOverrides && typeof coverOverrides === "object") {
|
|
672
|
+
for (const [platform, entry] of Object.entries(coverOverrides)) {
|
|
673
|
+
const text = describeCover(entry);
|
|
674
|
+
if (text)
|
|
675
|
+
optionRows.push([`Video cover (${platform})`, text]);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const fbCoverMs = Number(p.facebook?.thumb_offset) || 0;
|
|
679
|
+
const fbCoverUrl = p.facebook?.cover_url;
|
|
680
|
+
if (!coverOverrides?.facebook && (fbCoverMs > 0 || typeof fbCoverUrl === "string")) {
|
|
681
|
+
optionRows.push(["Facebook cover", typeof fbCoverUrl === "string" ? `custom image ${fbCoverUrl}` : `video frame at ${fbCoverMs} ms`]);
|
|
682
|
+
}
|
|
604
683
|
if (optionRows.length) {
|
|
605
684
|
md += `\n\n### Platform Options\n\n| Option | Value |\n|--------|-------|\n`;
|
|
606
685
|
for (const [k, v] of optionRows)
|
|
607
686
|
md += `| ${k} | ${v.replace(/\|/g, "\\|")} |\n`;
|
|
608
687
|
}
|
|
609
|
-
// LinkedIn multi-image style: nested under each
|
|
610
|
-
// object by the API. Only rendered when the post
|
|
611
|
-
// default
|
|
688
|
+
// LinkedIn multi-image style + document source: nested under each
|
|
689
|
+
// channel's options object by the API. Only rendered when the post
|
|
690
|
+
// opts out of a default (document carousel; kept original PDF).
|
|
612
691
|
for (const channel of ["linkedin", "linkedin_page"]) {
|
|
613
692
|
const opts = p[channel];
|
|
614
|
-
if (opts
|
|
615
|
-
|
|
693
|
+
if (!opts || typeof opts !== "object")
|
|
694
|
+
continue;
|
|
695
|
+
const label = channel === "linkedin_page" ? "LinkedIn Page" : "LinkedIn Profile";
|
|
696
|
+
if (opts.carousel_as_images === true) {
|
|
616
697
|
md += `\n\n**${label} multi-image style:** plain image gallery (default is the swipeable PDF document carousel)\n`;
|
|
617
698
|
}
|
|
699
|
+
if (opts.document_source === "slides") {
|
|
700
|
+
md += `\n\n**${label} document source:** rebuilt from the slide images (default sends the kept original PDF when the images are its unchanged pages)\n`;
|
|
701
|
+
}
|
|
618
702
|
}
|
|
619
703
|
// Trial Reel state: nested under the instagram options object by the
|
|
620
704
|
// API. Only rendered when enabled, so batch-scheduled Trial Reels can be
|
|
@@ -673,7 +757,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
673
757
|
|
|
674
758
|
**Per-platform video caps (duration + size, checked via ffprobe at submit; exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s; yours is 2min 35s. Trim the video or deselect X."):**
|
|
675
759
|
- X — **140s (2min 20s)** · **512MB** on free/Basic accounts; when the connected X account has **Premium or Premium+** the caps rise to **125min / 16GB** automatically (tier is read from the connection; reconnect X after upgrading). X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images
|
|
676
|
-
- Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
|
|
760
|
+
- Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook 240min (Post + Reel; the old 90s Reel cap is gone)
|
|
677
761
|
Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
|
|
678
762
|
|
|
679
763
|
When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
|
|
@@ -687,7 +771,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
687
771
|
- TikTok: Privacy level?
|
|
688
772
|
- **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
|
|
689
773
|
- **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
|
|
690
|
-
- **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
|
|
774
|
+
- **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). Only a link written with http:// or https:// counts; a bare domain (brand.com) or a www. link is free. The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
|
|
691
775
|
|
|
692
776
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
693
777
|
|
|
@@ -698,7 +782,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
698
782
|
media_ids: z.union([
|
|
699
783
|
z.array(mediaIdEntry),
|
|
700
784
|
z.record(z.string(), z.array(mediaIdEntry)),
|
|
701
|
-
]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
|
|
785
|
+
]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). A media item of type 'document' (a PDF kept as one item) expands into its page images, in order, so one id attaches the whole deck."),
|
|
702
786
|
media_urls: z.union([
|
|
703
787
|
z.array(mediaUrlEntry),
|
|
704
788
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
@@ -714,6 +798,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
714
798
|
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
|
|
715
799
|
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube/TikTok (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
|
|
716
800
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
|
|
801
|
+
video_cover: VIDEO_COVER_OPTIONS,
|
|
717
802
|
pinterest: PINTEREST_OPTIONS,
|
|
718
803
|
youtube: z.object({
|
|
719
804
|
title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
|
|
@@ -724,7 +809,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
724
809
|
notify_subscribers: z.boolean().optional(),
|
|
725
810
|
contains_synthetic_media: z.boolean().optional(),
|
|
726
811
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
|
|
727
|
-
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
812
|
+
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
|
|
728
813
|
instagram: z.object({
|
|
729
814
|
share_to_feed: z.boolean().optional(),
|
|
730
815
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
|
|
@@ -844,10 +929,10 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
844
929
|
- Pinterest: ALWAYS need an image + board_id.
|
|
845
930
|
- Ask the user which media to use. Use list_media to show options.
|
|
846
931
|
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
|
|
847
|
-
- **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
|
|
932
|
+
- **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook 240min (Post + Reel; the old 90s Reel cap is gone).
|
|
848
933
|
4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
|
|
849
934
|
5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
|
|
850
|
-
6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
|
|
935
|
+
6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part; only a link written with http:// or https:// counts, a bare domain or www. link is free). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
|
|
851
936
|
|
|
852
937
|
Do NOT call without required media — it will fail.`, {
|
|
853
938
|
content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
|
|
@@ -871,6 +956,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
871
956
|
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
|
|
872
957
|
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)."),
|
|
873
958
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
|
|
959
|
+
video_cover: VIDEO_COVER_OPTIONS,
|
|
874
960
|
pinterest: PINTEREST_OPTIONS,
|
|
875
961
|
youtube: z.object({
|
|
876
962
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
@@ -881,7 +967,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
881
967
|
notify_subscribers: z.boolean().optional(),
|
|
882
968
|
contains_synthetic_media: z.boolean().optional(),
|
|
883
969
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
|
|
884
|
-
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
970
|
+
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
|
|
885
971
|
instagram: z.object({
|
|
886
972
|
share_to_feed: z.boolean().optional(),
|
|
887
973
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
|
|
@@ -985,6 +1071,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
985
1071
|
location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
|
|
986
1072
|
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."),
|
|
987
1073
|
user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
|
|
1074
|
+
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."),
|
|
988
1075
|
youtube: z.object({
|
|
989
1076
|
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)."),
|
|
990
1077
|
tags: z.array(z.string()).optional(),
|
|
@@ -994,7 +1081,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
994
1081
|
notify_subscribers: z.boolean().optional(),
|
|
995
1082
|
contains_synthetic_media: z.boolean().optional(),
|
|
996
1083
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled. Pass an empty string to clear a previously set first comment."),
|
|
997
|
-
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
1084
|
+
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels. A custom Shorts thumbnail is set with the top-level `video_cover` (a `youtube` override for a YouTube-only image); it shows on Shorts only on channels where YouTube has enabled custom Shorts thumbnails (Partner Program channels first, since July 2026), other channels show a frame from the video."),
|
|
998
1085
|
pinterest: PINTEREST_OPTIONS,
|
|
999
1086
|
instagram: z.object({
|
|
1000
1087
|
share_to_feed: z.boolean().optional(),
|
|
@@ -1213,6 +1300,72 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
|
|
|
1213
1300
|
content: [{ type: "text", text: md }],
|
|
1214
1301
|
};
|
|
1215
1302
|
});
|
|
1303
|
+
server.tool("get_post_approval", `Read the approval review of a post: every workflow step with its approvers and their decisions, the rejection with its reason, and the comment thread.
|
|
1304
|
+
|
|
1305
|
+
Use it when \`get_post\` shows the status \`rejected\` (who rejected it and why, what to change before sending it again) or \`in_approval\` (who the post waits for, what the reviewers wrote). Read-only. A post without an approval workflow returns status \`none\`.`, {
|
|
1306
|
+
id: z.string().describe("The post ID"),
|
|
1307
|
+
}, { title: "Get Post Approval", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
|
|
1308
|
+
const result = await getClient().getPostApproval(id);
|
|
1309
|
+
if (result.error) {
|
|
1310
|
+
return {
|
|
1311
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
const a = result.data;
|
|
1315
|
+
if (!a) {
|
|
1316
|
+
return {
|
|
1317
|
+
content: [{ type: "text", text: "The API returned no approval review for this post. Check its status with get_post." }],
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
let md = `## Approval Review\n\n`;
|
|
1321
|
+
md += `| Field | Value |\n`;
|
|
1322
|
+
md += `|-------|-------|\n`;
|
|
1323
|
+
md += `| **Post** | \`${a.post_id || id}\` |\n`;
|
|
1324
|
+
md += `| **Status** | ${capitalize(a.status || "none")} |\n`;
|
|
1325
|
+
if (a.workflow?.name)
|
|
1326
|
+
md += `| **Workflow** | ${a.workflow.name} |\n`;
|
|
1327
|
+
if (a.requested_by?.name)
|
|
1328
|
+
md += `| **Requested by** | ${a.requested_by.name} |\n`;
|
|
1329
|
+
if (a.status === "none") {
|
|
1330
|
+
md += `\n_This post has no approval workflow._`;
|
|
1331
|
+
return {
|
|
1332
|
+
content: [{ type: "text", text: md }],
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (a.rejection) {
|
|
1336
|
+
md += `\n### Rejected\n\n`;
|
|
1337
|
+
md += `${a.rejection.by?.name || "A reviewer"} rejected this post${a.rejection.at ? ` on ${formatDateTime(a.rejection.at)}` : ""}.\n`;
|
|
1338
|
+
md += a.rejection.reason ? `\n**Reason:** ${a.rejection.reason}\n` : `\n_No reason was given._\n`;
|
|
1339
|
+
}
|
|
1340
|
+
if (Array.isArray(a.steps) && a.steps.length) {
|
|
1341
|
+
md += `\n### Steps\n\n`;
|
|
1342
|
+
md += `| # | Step | Needs | Status | Approvers |\n`;
|
|
1343
|
+
md += `|---|------|-------|--------|-----------|\n`;
|
|
1344
|
+
for (const s of a.steps) {
|
|
1345
|
+
const who = (s.approvers || [])
|
|
1346
|
+
.map((p) => `${p.name || p.email || p.id} (${p.status})`)
|
|
1347
|
+
.join(", ");
|
|
1348
|
+
const active = a.current_step === s.order ? " (waiting now)" : "";
|
|
1349
|
+
md += `| ${s.order} | ${s.name}${active} | ${s.require_mode === "any" ? "one approval" : "all approvers"} | ${capitalize(s.status)} | ${who || "none"} |\n`;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (Array.isArray(a.comments) && a.comments.length) {
|
|
1353
|
+
md += `\n### Comments (${a.comments.length}, oldest first)\n\n`;
|
|
1354
|
+
for (const c of a.comments) {
|
|
1355
|
+
const where = c.account ? ` on ${capitalize(c.account.replace(/_/g, " "))}` : "";
|
|
1356
|
+
md += `- **${c.author?.name || "Unknown"}**${where}, ${formatDateTime(c.created_at)}: ${String(c.message).replace(/\n+/g, " / ")}\n`;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
md += `\n_No comments in the review thread._`;
|
|
1361
|
+
}
|
|
1362
|
+
if (a.status === "rejected") {
|
|
1363
|
+
md += `\n\n_A rejected post does not publish. To send it again, open it in OmniSocials, edit it and send it for approval there, or create a new post with \`approval_workflow_id\`._`;
|
|
1364
|
+
}
|
|
1365
|
+
return {
|
|
1366
|
+
content: [{ type: "text", text: md }],
|
|
1367
|
+
};
|
|
1368
|
+
});
|
|
1216
1369
|
server.tool("search_locations", `Search for a location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
|
|
1217
1370
|
|
|
1218
1371
|
Pick the platform with \`platform\`: "instagram" (default) or "threads". The two use DIFFERENT ids (a Facebook Place ID is not a Threads location id), so search with the platform you will tag. If the user wants both, search twice.
|
|
@@ -1304,6 +1457,91 @@ Notes:
|
|
|
1304
1457
|
});
|
|
1305
1458
|
return { content: [{ type: "text", text: md }] };
|
|
1306
1459
|
});
|
|
1460
|
+
server.tool("list_pinterest_products", `List the product Pins of the connected Pinterest account, to tag products on a Pin (people can then shop the items in the image). Use this whenever the user wants to tag products on a Pinterest post.
|
|
1461
|
+
|
|
1462
|
+
Flow: call it → show the products to the user → pass the chosen \`pin_id\` values (max 24) as \`pinterest.product_tags\` on create_post / create_and_publish_post / update_post. If the user gives a Pin link or Pin ID of a product, pass it as \`check_pin\` to check it first, or put it straight in \`pinterest.product_tags\`.
|
|
1463
|
+
|
|
1464
|
+
Notes:
|
|
1465
|
+
- Only the account's own product Pins can be tagged: a public product Pin with a link on a website the account claimed on Pinterest. Products of other merchants cannot be tagged through the API.
|
|
1466
|
+
- \`source\` "catalog" reads the Pinterest catalog (with price and stock). It needs catalog access, which the user gives one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog. \`source\` "pins" reads the account's own Pins and returns the product Pins; one call scans up to 250 Pins, so an empty result with a bookmark means: call again with that bookmark. Without \`source\`, the catalog is used when the connection has catalog access, else the Pins.
|
|
1467
|
+
- A product that Pinterest refuses never fails the post; get_post shows which products were tagged.`, {
|
|
1468
|
+
source: z
|
|
1469
|
+
.enum(["catalog", "pins"])
|
|
1470
|
+
.optional()
|
|
1471
|
+
.describe("Where to read product Pins from. Default: catalog when the Pinterest connection has catalog access, else pins."),
|
|
1472
|
+
product_group_id: z
|
|
1473
|
+
.string()
|
|
1474
|
+
.optional()
|
|
1475
|
+
.describe("Catalog source only. A product group ID from an earlier result. Default: the group named All Products."),
|
|
1476
|
+
bookmark: z
|
|
1477
|
+
.string()
|
|
1478
|
+
.optional()
|
|
1479
|
+
.describe("Cursor from an earlier result, to get the next page."),
|
|
1480
|
+
check_pin: z
|
|
1481
|
+
.string()
|
|
1482
|
+
.optional()
|
|
1483
|
+
.describe("A Pin ID or Pin link (https://www.pinterest.com/pin/<id>/) to check instead of listing: answers whether that Pin can be tagged as a product."),
|
|
1484
|
+
}, { title: "List Pinterest Products", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ source, product_group_id, bookmark, check_pin }) => {
|
|
1485
|
+
const messageOf = (err) => typeof err === "string" ? err : err?.message;
|
|
1486
|
+
if (check_pin) {
|
|
1487
|
+
const check = await getClient().validatePinterestProduct(check_pin);
|
|
1488
|
+
const failed = messageOf(check?.error);
|
|
1489
|
+
if (failed)
|
|
1490
|
+
return { content: [{ type: "text", text: failed }] };
|
|
1491
|
+
const name = check?.title ? ` (${check.title})` : "";
|
|
1492
|
+
let text;
|
|
1493
|
+
if (check?.valid) {
|
|
1494
|
+
text = `Pin \`${check.pin_id}\`${name} is a product Pin of this account. Use it in \`pinterest.product_tags\`.`;
|
|
1495
|
+
}
|
|
1496
|
+
else if (check?.unverified) {
|
|
1497
|
+
text = `Could not check Pin \`${check?.pin_id ?? check_pin}\`: ${check?.reason || "no answer from Pinterest"} You can still pass it in \`pinterest.product_tags\`; Pinterest checks it at publish.`;
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
text = `Pin ${check?.pin_id ? `\`${check.pin_id}\`${name}` : `"${check_pin}"`} cannot be tagged: ${check?.reason || "Pinterest did not accept it."}`;
|
|
1501
|
+
}
|
|
1502
|
+
return { content: [{ type: "text", text }] };
|
|
1503
|
+
}
|
|
1504
|
+
const result = await getClient().listPinterestProducts({ source, product_group_id, bookmark });
|
|
1505
|
+
const failed = messageOf(result?.error);
|
|
1506
|
+
if (failed)
|
|
1507
|
+
return { content: [{ type: "text", text: failed }] };
|
|
1508
|
+
const products = result?.products || [];
|
|
1509
|
+
const fromCatalog = result?.source === "catalog";
|
|
1510
|
+
const groups = result?.product_groups || [];
|
|
1511
|
+
let md = `## Pinterest product Pins (${fromCatalog ? "catalog" : "the account's own Pins"})\n\n`;
|
|
1512
|
+
if (!products.length) {
|
|
1513
|
+
if (result?.bookmark) {
|
|
1514
|
+
md += `No product Pins in this part of the Pin list. Call again with bookmark \`${result.bookmark}\` to scan the next part.\n`;
|
|
1515
|
+
}
|
|
1516
|
+
else if (fromCatalog) {
|
|
1517
|
+
md += groups.length
|
|
1518
|
+
? "This product group has no products.\n"
|
|
1519
|
+
: "The Pinterest account has no catalog. Try source \"pins\", or pass a product Pin link as check_pin.\n";
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
md += "No product Pins found. A product Pin comes from the Pinterest catalog, or links to a product page on a website the account claimed on Pinterest.\n";
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
else {
|
|
1526
|
+
md += `Ask the user which products to tag, then pass their IDs (max 24) as \`pinterest.product_tags\`:\n\n`;
|
|
1527
|
+
md += `| # | Product | Price | Stock | pin_id |\n|---|---------|-------|-------|--------|\n`;
|
|
1528
|
+
products.forEach((p, i) => {
|
|
1529
|
+
const price = typeof p.price === "number" ? `${p.price}${p.currency ? ` ${p.currency}` : ""}` : "-";
|
|
1530
|
+
md += `| ${i + 1} | ${p.title || p.link || "-"} | ${price} | ${p.availability || "-"} | \`${p.pin_id}\` |\n`;
|
|
1531
|
+
});
|
|
1532
|
+
if (result?.bookmark)
|
|
1533
|
+
md += `\nMore products: call again with bookmark \`${result.bookmark}\`.\n`;
|
|
1534
|
+
}
|
|
1535
|
+
if (fromCatalog && groups.length > 1) {
|
|
1536
|
+
md += `\nProduct groups (pass one as product_group_id): ${groups
|
|
1537
|
+
.map((g) => `${g.name || "Unnamed"} \`${g.id}\`${g.id === result?.product_group_id ? " (shown)" : ""}`)
|
|
1538
|
+
.join(", ")}\n`;
|
|
1539
|
+
}
|
|
1540
|
+
if (!fromCatalog && result?.catalog_access === false) {
|
|
1541
|
+
md += `\nThe connection has no catalog access, so catalog products are not listed. The user can give it one time in OmniSocials: create a post, open the Pinterest options, select Add products, then Connect catalog.\n`;
|
|
1542
|
+
}
|
|
1543
|
+
return { content: [{ type: "text", text: md }] };
|
|
1544
|
+
});
|
|
1307
1545
|
server.tool("search_instagram_audio", `Search Instagram's licensed music catalog for a track to attach to a REEL. Use this whenever the user wants to post a reel WITH music/a song/a sound (e.g. "post this reel with trending audio", "add that song to it").
|
|
1308
1546
|
|
|
1309
1547
|
Flow: call with a song/artist/keyword (or NO query for currently trending audio) → present the options → once the user picks, pass that result's \`audio_id\` as \`instagram.audio_id\` on create_post / create_and_publish_post / update_post. Optionally mix with \`instagram.audio_volume\` / \`instagram.video_volume\` (0-100; set video_volume 0 to fully replace the video's own sound). Instagram Reels only — feed posts, carousels, and Stories can't take music via the API (Meta limitation).
|
package/build/tools/webhooks.js
CHANGED
|
@@ -28,9 +28,9 @@ export function registerWebhookTools(server, getClient) {
|
|
|
28
28
|
content: [{ type: "text", text: md }],
|
|
29
29
|
};
|
|
30
30
|
});
|
|
31
|
-
server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed", {
|
|
31
|
+
server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed, post.approved (the last approval of a post's approval workflow is in), post.rejected (an approver rejected the post)", {
|
|
32
32
|
url: z.string().describe("The HTTPS URL to send webhook events to"),
|
|
33
|
-
events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed"),
|
|
33
|
+
events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed, post.approved, post.rejected"),
|
|
34
34
|
}, { title: "Create Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
35
35
|
const result = await getClient().createWebhook(params);
|
|
36
36
|
if (result.error) {
|
|
@@ -95,7 +95,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
95
95
|
server.tool("update_webhook", "Update a webhook's URL, events, or active status.", {
|
|
96
96
|
id: z.string().describe("The webhook ID to update"),
|
|
97
97
|
url: z.string().optional().describe("Updated HTTPS URL"),
|
|
98
|
-
events: z.array(z.string()).optional().describe("Updated event list"),
|
|
98
|
+
events: z.array(z.string()).optional().describe("Updated event list: post.scheduled, post.published, post.failed, post.approved, post.rejected"),
|
|
99
99
|
is_active: z.boolean().optional().describe("Enable or disable the webhook"),
|
|
100
100
|
}, { title: "Update Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
101
101
|
const result = await getClient().updateWebhook(id, data);
|