@omnisocials/mcp-server 1.39.0 → 1.48.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 CHANGED
@@ -112,6 +112,8 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
112
112
  | `threads` | Threads | Posts, chained threads via `threads.thread_parts`, location tagging via `threads.location_id` (rolling out) |
113
113
  | `bluesky` | Bluesky | Posts, chained threads via `bluesky.thread_parts` |
114
114
  | `mastodon` | Mastodon | Posts, chained threads via `mastodon.thread_parts` |
115
+ | `telegram` | Telegram | A channel or group the OmniSocials bot posts to: text, photos, videos, GIFs, albums of up to 10, native polls and link buttons via `telegram` |
116
+ | `discord` | Discord | One Discord channel, posted through its webhook: text, up to 10 images, GIFs and videos, native polls, link buttons and forum post titles via `discord` |
115
117
 
116
118
  ## Available Tools
117
119
 
@@ -206,11 +208,24 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
206
208
 
207
209
  Requires an API key with the opt-in `inbox:read` / `inbox:write` scopes — enable "Social Inbox access" when you create the key.
208
210
 
211
+ ### Automations (6 tools)
212
+
213
+ | Tool | Description |
214
+ |------|-------------|
215
+ | `list_automations` | List the comment-to-DM automations of the workspace (Instagram, Facebook Page and X), optionally by `status`, `post_id` or `platform` |
216
+ | `get_automation` | Get one automation: trigger, keywords, DM text, public reply, limits and stats |
217
+ | `create_automation` | Create a comment-to-DM automation: a keyword in a comment (or a DM) gets a DM back, once per person, with an optional public reply. On X (`platform: "x"`) it answers DMs only, needs X DMs on for the workspace and ends every answer with "Reply STOP to stop automatic messages." |
218
+ | `update_automation` | Pause, resume or change the keywords, DM text, public reply, limits or post of an automation |
219
+ | `delete_automation` | Delete an automation |
220
+ | `get_automation_activity` | One row per comment or message the automation acted on: the DM sent, an answer waiting in the queue, a skip with its reason, a person who replied STOP on X, or the platform's error |
221
+
222
+ Requires an API key with the opt-in `automations:read` / `automations:write` scopes (Settings > API, "Automations" on the key).
223
+
209
224
  ## Environment Variables
210
225
 
211
226
  | Variable | Required | Description |
212
227
  |----------|----------|-------------|
213
- | `OMNISOCIALS_API_KEY` | Yes | Your API key (`omsk_live_*` or `omsk_test_*`). Comma-separated for multiple workspaces. |
228
+ | `OMNISOCIALS_API_KEY` | Yes | Your API key (`omsk_live_*`; there are no test keys). Comma-separated for multiple workspaces. |
214
229
  | `OMNISOCIALS_BASE_URL` | No | Custom API base URL (defaults to production) |
215
230
 
216
231
  ## Alternative: Agent Skills
@@ -229,6 +244,56 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
229
244
 
230
245
  ## Changelog
231
246
 
247
+ ### 1.48.0 (2026-10-26)
248
+
249
+ - **Added: video retention in `get_post_analytics`:** a `3-second hold (%)` row (`hold_rate_3s`: TikTok videos with TikTok comments enabled, Facebook Reels, Instagram Reels as 100 minus `skip_rate`) and a `Still watching` row that sums up the retention curve of TikTok videos and Facebook Reels and videos (1, 3, 5, 10, 15, 30 seconds and each quarter). Instagram and YouTube give no curve through their APIs.
250
+ - **Fixed:** Instagram Reels carry `skip_rate` again, and totals across platforms add up counters only (rates and averages show per platform).
251
+
252
+ ### 1.47.0 (2026-10-25)
253
+
254
+ - **Added: automations on X.** The automation tools take `platform: "x"`: the connected X account answers a DM that contains a keyword (or any first message, with `match: "any"`) with `dm_text`, once per person. On X only `triggers: ["dm"]` exists (the default when `platform` is `x`), with no post fields and no public replies. An active X automation needs X DMs turned on for the workspace in OmniSocials (else `409 x_dms_off`; `status: "paused"` always saves). Every X answer ends with "Reply STOP to stop automatic messages.", and a person who replies with only STOP gets no automatic DM from that X account again (in every workspace, also after the automation is deleted). X sends at most 15 answers per 15 minutes and 450 a day per account, and each answer uses 2 X DM credits. Encrypted X Chat conversations get no answer: X does not give apps their messages.
255
+ - **Added: `status` on `create_automation`** (`paused` creates it without turning it on).
256
+ - **Changed: answers over the send cap wait in a queue** (`queued`, out within the hour; also when X answers 429) instead of being skipped at once. `get_automation_activity` filters on `queued` and `opted_out`; new skip reasons `dms_closed`, `no_credits`, `opted_out` and `automation_off`.
257
+ - Also in 1.47.0: the Ads tools listed above (see CHANGELOG.md; that text is a draft until Ads launches).
258
+
259
+ ### 1.46.0 (2026-10-23)
260
+
261
+ - **Added: comment-to-DM on a Facebook Page.** The automation tools now run on the connected Facebook Page as well as on Instagram. `create_automation` takes `platform` (`instagram`, the default, or `facebook`) and `facebook_post_id` (a Page post published outside OmniSocials, `{page-id}_{post-id}` or the post id alone; it implies `facebook`). On Facebook the DM lands in Messenger, `triggers` takes `comment` and `dm` (a keyword in a Messenger message to the Page), and `story_reply` is refused (Facebook has no story replies). Meta's rules are the same on both: one private reply per comment within 7 days, a follow-up only after the person answers. `list_automations` takes a `platform` filter and shows a Platform column; `get_automation` and `create_automation` print the platform. `update_automation` takes `facebook_post_id` to move a Facebook automation to another Page post; the platform itself cannot change (`platform_immutable`). `get_automation_activity` shows a Facebook person by name, without an @. Same on the companion server (`mcp.omnisocials.com`).
262
+ - **Added: `platforms` on `comment_dm_automation`.** `create_post`, `create_and_publish_post` and `update_post` take `comment_dm_automation.platforms`: `["instagram"]`, `["facebook"]` or both, one automation per channel with the same keywords, DM, public reply and limits. Each listed channel must be a channel of the post. Without it, the post gets one automation: Instagram when the post goes there, else Facebook. On `update_post` a channel no longer listed loses its automation, and `null` removes them all. The post shows every automation id with its channel; `get_post` shows them for a post that goes to Instagram or Facebook.
263
+ - **Added: Discord.** `discord` is a new channel ID: one Discord channel the user connected in OmniSocials (with Continue with Discord, or by pasting a webhook URL). The post is sent through the channel's webhook, so it shows the webhook's name and picture. `create_post`, `create_and_publish_post` and `update_post` take a `discord` object: `silent` (no notification), `link_preview` (`false` hides the link previews), `allow_mentions` (off by default: @everyone, @here and role mentions in the text ping nobody until it is `true`), `spoiler`, `thread_name` (the post title in a forum or media channel, where Discord requires one; max 100 chars), up to 5 `buttons` (`{ text, url }`, labels up to 80 chars, not with a poll) and a native `poll` (`{ options: 2-10 of up to 55 chars, duration_hours: 1-768 (default 24), multiple }`; the post text is the question, max 300 chars; no media). Text up to 2,000 characters; up to 10 images, GIFs and videos per message, which may mix; a video or GIF over Discord's 20 MB file limit is sent as a link in the message (listed in `result.video_links`); no Stories; a Reel is sent as one video message. On `update_post` every key merges into the stored options and `null` removes it. `get_post` prints the options and, once sent, the link to the message. `list_accounts` and `get_account` show the server, the channel name and type (`platform_details.channel_name` and `channel_type`: read by the OmniSocials bot, which Continue with Discord adds with no permissions, or given by the person when connecting), the webhook name, the channel id, how it was connected and the channel link; with the bot in the server, `get_account_analytics` shows the member count as followers. Discord gives webhooks no view counts: `get_post_analytics` shows the reactions (total and per emoji) and the poll votes. Same on the companion server (`mcp.omnisocials.com`), whose version is 1.46.0 too.
264
+
265
+ ### 1.45.0 (not published; included in 1.46.0)
266
+
267
+ - **Added: a link to the post on every post tool.** `publish_post`, `retry_post`, `approve_post` and `reject_post` now end with the "Open in OmniSocials" row that `create_post`, `create_and_publish_post`, `get_post` and `update_post` already had, and `publish_post` says that `get_post` lists the live links once each platform has posted. Every app link carries `?ref=mcp`, so OmniSocials can count the visits from an agent's answer (the app ignores the parameter).
268
+ - **Added on the companion server (`mcp.omnisocials.com`): the post card.** In apps that show MCP Apps (Claude, ChatGPT, Cursor, VS Code), the results of `get_post`, `create_post`, `create_and_publish_post`, `update_post`, `publish_post`, `retry_post`, `approve_post` and `reject_post` appear as a card: the post the way each platform will show it, a channel list with each channel's state (the planned time, Posting, Posted with its live link, or Failed with the reason), and a link into OmniSocials. While a post publishes, the card follows it until every channel is done. Other clients (terminal agents, automations) get the same text answer as before. The card is built as an MCP App resource, `ui://omnisocials/post-card.html`; a new tool, `get_post_status`, is only for the card (hidden from the model in apps that support MCP Apps).
269
+
270
+ ### 1.44.0 (2026-10-17, not published; included in 1.46.0)
271
+
272
+ - **Added: Telegram.** `telegram` is a new channel ID: a Telegram channel or group the user connected in OmniSocials, where the OmniSocials bot is an admin that can post. `create_post`, `create_and_publish_post` and `update_post` take a `telegram` object: `silent`, `link_preview`, `pin`, `protect_content`, `spoiler`, `caption_above_media`, up to 3 `buttons` (`{ text, url }`, one per row, not on an album or a poll), a native `poll` (`{ options: 2-12 of up to 100 chars, open_period: 5-2628000 seconds, multiple }`; 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. Albums hold up to 10 photos and videos; a GIF goes on its own; files up to 50 MB; no Stories; a Reel is sent as a video message. On `update_post` every key merges into the stored options and `null` removes it. `get_post` prints the options and, once sent, the message count, the pin and the link. `list_accounts` and `get_account` show the chat type, the subscribers and the link. Telegram gives bots no view counts: `get_post_analytics` shows the reactions (total and per emoji) and the poll votes. Same on the companion server (`mcp.omnisocials.com`).
273
+ - **Changed: the three version strings match again.** `package.json`, the stdio server and the companion server all say 1.44.0 (the stdio server said 1.42.0 and the companion server 1.41.0 after the last releases).
274
+
275
+ ### 1.43.0 (2026-10-12, not published; included in 1.46.0)
276
+
277
+ - **Polls on X, Threads and Mastodon.** `create_post`, `create_and_publish_post` and `update_post` take `poll` under `x`, `threads` and `mastodon` (the post text is the question): X `{ options: 2-4 of up to 25 chars, duration_minutes: 5-10080 }`, Threads `{ options: 2-4 of 1-25 chars }` (closed by Threads after 24 hours), Mastodon `{ options, expires_in, multiple, hide_totals }` within the instance's limits. The API refuses a channel with a poll and media; on a thread the poll goes on the first part. `null` on `update_post` removes it. `get_post` prints the poll per channel.
278
+
279
+ ### 1.42.0 (not published; included in 1.46.0)
280
+
281
+ - **Added: comment-to-DM automations.** Six new tools manage the Instagram comment-to-DM automations of the workspace: `list_automations`, `get_automation`, `create_automation`, `update_automation` (pause, resume, new keywords, new DM text, new limits, another post), `delete_automation` and `get_automation_activity` (one row per comment or message the automation acted on: the DM sent, a skip with its reason, or Instagram's error). An automation DMs a person once when they comment a keyword on a post, and can post a public reply under the comment; `triggers` also takes `dm` (a keyword in a DM to the account) and `story_reply` (a keyword in a reply to a story). `scope` is one post (`post_id` = an OmniSocials post, scheduled or live; `instagram_media_id` = a post published outside OmniSocials, read from Instagram to check it belongs to the account), `next_post` (the next Instagram post the workspace publishes) or `any` (every post). Backed by the new `/automations` endpoints, which need the opt-in API key scopes `automations:read` and `automations:write` (Settings > API, "Automations" on the key); the tools say so on a 403. Same on the companion server (`mcp.omnisocials.com`), where the OAuth session needs the `create_schedule_post` permission for the writes.
282
+ - **Added: `comment_dm_automation` on `create_post`, `create_and_publish_post` and `update_post`.** A comment-to-DM automation bound to the new post (Instagram must be a channel): `{ trigger: { keywords, match }, actions: { dm: { text }, public_reply: { enabled, variations } }, limits: { max_sends, stop_at } }`. The automation starts listening once the post is live on Instagram. On `update_post` the object replaces the post's automation and `null` removes it. The created automation comes back as `comment_dm_automation` on the post, and `get_post` shows it for a post that goes to Instagram. Same on the companion server.
283
+
284
+ ### 1.41.0 (not published; included in 1.46.0)
285
+
286
+ - **Added: image fit per channel (`media_fit`).** A feed image whose ratio is outside what a channel shows uncropped is handled per channel at create time. Instagram shows feed images between 4:5 and 1.91:1: a 9:16 (story-style) or very wide image posted to the Instagram feed is now cropped to 4:5 / 1.91:1 around the subject by default, because Instagram cut it to that window anyway (a blind centre crop) and Meta cannot boost a feed post outside it. Pinterest keeps a Pin taller than 2:3 and reports it. `create_post`, `create_and_publish_post` and `update_post` take `media_fit: { instagram: "auto" | "crop" | "keep", pinterest: ... }`; every decision comes back as a `warnings` entry with code `image_fit` (`action` cropped / kept / crop_failed, the crop's url, and how to undo it). The crop is a separate file for that channel only; `default` and the other channels keep the original; stories, reels and videos are never cropped. On `update_post`, `media_fit` alone re-decides the stored images (`keep` gives the original back). Same on the companion server (`mcp.omnisocials.com`).
287
+ - **Changed: `check_media_compatibility` reports image fit.** For an image whose size is known (a library file of the workspace) the answer lists, per connected channel with a rule, whether its feed shows the image uncropped and what a post does by default, so an assistant can ask "crop to 4:5 or keep the original?" before it posts. Media items (`list_media`, `get_media`, uploads) now carry `width` and `height`.
288
+
289
+ ### 1.40.0 (not published; included in 1.46.0)
290
+
291
+ - **Changed: the MCP SDK dependency moves to 1.32.1.** GHSA-6qxp-vccf-f47h (CVE-2026-104850) is in the SDK's OAuth client, which let an MCP server name the authorization server that received the client's credentials. This package is a server and talks to OmniSocials with an API key, so it was not affected; the move clears the advisory for everyone who installs it.
292
+
293
+ ### 1.39.1 (2026-10-06, not published; included in 1.46.0)
294
+
295
+ - **Changed: `list_pinterest_products` names an account without a catalog.** Pinterest answers HTTP 409 ("Can't access this feature without an existing catalog") when the connection has catalog access but the account has no catalog yet. The API now returns `no_catalog: true` with empty `products` and `product_groups` instead of a `platform_error`, and the tool says where the user adds products (https://www.pinterest.com/business/catalogs/). Seen live on 2026-10-06, the first day of the feature.
296
+
232
297
  ### 1.39.0 (2026-10-05)
233
298
 
234
299
  - **Added: tag products on a Pinterest Pin.** `pinterest.product_tags` on `create_post`, `create_and_publish_post` and `update_post` takes up to 24 product Pins of the connected Pinterest account (Pin IDs or Pin links), so people can shop the items in the image. Pinterest accepts a product only when its Pin is public, belongs to the same account and links to a website that account claimed; products of other merchants cannot be tagged. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the other products are still tagged, and `get_post` shows the outcome (`product_tags_result`, with Pinterest's reason for each product that was not tagged).
@@ -365,7 +430,7 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
365
430
  ### 1.16.0
366
431
 
367
432
  - **Hashtag sets:** save reusable, named groups of hashtags per workspace and apply one to a new post in a single call. Four new tools: `list_hashtag_sets`, `create_hashtag_set`, `update_hashtag_set`, `delete_hashtag_set`.
368
- - `create_post` / `create_and_publish_post` accept `hashtag_set`, `hashtag_placement` (`caption_append` default, or `first_comment`), and `hashtag_platforms`. The set expands server-side once at create time — editing a set later never changes existing posts; duplicate tags are skipped; Instagram's 30-hashtag cap fails fast with `hashtag_limit_exceeded`.
433
+ - `create_post` / `create_and_publish_post` accept `hashtag_set`, `hashtag_placement` (`caption_append` default, or `first_comment`), and `hashtag_platforms`. The set expands server-side once at create time — editing a set later never changes existing posts; duplicate tags are skipped; Instagram's 5-hashtag cap fails fast with `hashtag_limit_exceeded`.
369
434
  - Same tools on the companion server (`mcp.omnisocials.com`).
370
435
 
371
436
  ### 1.15.0
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
1
+ import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, Automation, AutomationEvent, AutomationEventOutcome, AutomationEventsResult, AutomationInput, AutomationPlatform, BestTimesResult, CommentDmAutomationInput, HashtagSet, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -9,17 +9,34 @@ export declare function formatDate(iso: string | null): string;
9
9
  export declare function formatDateTime(iso: string | null): string;
10
10
  export declare function truncate(value: unknown, len: number): string;
11
11
  export declare function capitalize(str: string): string;
12
+ /** Rates, averages and lengths: shown per platform, never added up across
13
+ * platforms (two 60% hold rates are not 120%). Mirrors
14
+ * NON_ADDITIVE_METRIC_KEYS in the backend's analyticsAggregate.js. */
15
+ export declare const NON_ADDITIVE_METRIC_KEYS: Set<string>;
16
+ /** `retention_curve` as the API returns it (TikTok, Facebook Reels and
17
+ * videos): the % of viewers still watching at each point of the video. */
18
+ export interface RetentionCurve {
19
+ x: "seconds" | "percent";
20
+ base?: "views" | "plays" | "views_3s";
21
+ duration?: number;
22
+ points: Array<[number, number]>;
23
+ }
24
+ /** One line for a retention curve: the opening seconds, then the quarters
25
+ * ("1s 92% · 3s 71% · 10s 48% · 50% 40% · end 22%"). Mirrors
26
+ * retentionSummary in the backend's utils/analytics/retentionCurve.js. */
27
+ export declare function retentionSummary(curve: unknown): string;
12
28
  /** Breakdown objects (reactions by type, traffic sources, ...) rendered as
13
29
  * "Label: a 12, b 3" lines under the metric table. */
14
30
  export declare function metricBreakdownRows(m: Record<string, unknown> | null | undefined): Array<[string, string]>;
15
31
  export declare function sortMetricLabels(labels: string[]): string[];
16
32
  /**
17
- * Flatten a raw per-platform metrics object into ordered [label, value] rows,
18
- * keeping every numeric counter the platform reported. Strings (note, title,
19
- * cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
20
- * back-compat key so a zero there is noise, not data.
33
+ * Flatten a raw per-platform metrics object into ordered [label, value, key]
34
+ * rows, keeping every numeric counter the platform reported. Strings (note,
35
+ * title, cover_image_url, …) and objects (breakdowns, the retention curve) are
36
+ * skipped; `plays` is a legacy always-0 Instagram back-compat key so a zero
37
+ * there is noise, not data.
21
38
  */
22
- export declare function metricRows(m: Record<string, unknown> | null | undefined): Array<[string, number]>;
39
+ export declare function metricRows(m: Record<string, unknown> | null | undefined): Array<[string, number, string]>;
23
40
  /** A `media_urls` entry — a plain external URL, or `{ url, alt }` to attach
24
41
  * an accessibility description (alt text, max 1500 chars) to that file. */
25
42
  export type MediaUrlEntry = string | {
@@ -44,10 +61,27 @@ export interface XThreadPartInput {
44
61
  * Entries may be `{ url, alt }` to attach alt text (photos/GIFs only). */
45
62
  media_urls?: MediaUrlEntry[];
46
63
  }
64
+ /**
65
+ * A poll on an X, Threads or Mastodon post (the post text is the question).
66
+ * X: 2 to 4 options of up to 25 characters, duration_minutes 5 to 10080
67
+ * (default 1440). Threads: 2 to 4 options of 1 to 25 characters, closed by
68
+ * Threads after 24 hours. Mastodon: options and expires_in (seconds) within
69
+ * the instance's limits, plus multiple and hide_totals. No media together
70
+ * with a poll; on a thread the poll goes on the first part. On update,
71
+ * `null` removes the poll.
72
+ */
73
+ export interface ChannelPollInput {
74
+ options: string[];
75
+ duration_minutes?: number;
76
+ expires_in?: number;
77
+ multiple?: boolean;
78
+ hide_totals?: boolean;
79
+ }
47
80
  export interface XPostOptions {
48
81
  reply_settings?: "" | "following" | "mentionedUsers";
49
82
  paid_partnership?: boolean;
50
83
  made_with_ai?: boolean;
84
+ poll?: ChannelPollInput | null;
51
85
  /** Provide 2–25 parts to publish as a thread. Omit for a single tweet. */
52
86
  thread_parts?: XThreadPartInput[];
53
87
  }
@@ -93,6 +127,7 @@ export interface MastodonThreadPartInput {
93
127
  media_urls?: MediaUrlEntry[];
94
128
  }
95
129
  export interface MastodonPostOptions {
130
+ poll?: ChannelPollInput | null;
96
131
  /** Provide 2–25 parts to publish as a thread. Omit for a single status. */
97
132
  thread_parts?: MastodonThreadPartInput[];
98
133
  }
@@ -101,8 +136,96 @@ export interface MastodonPostOptions {
101
136
  * single status); `undefined` leaves the existing thread untouched.
102
137
  */
103
138
  export interface MastodonPostOptionsUpdate {
139
+ poll?: ChannelPollInput | null;
104
140
  thread_parts?: MastodonThreadPartInput[] | null;
105
141
  }
142
+ export interface TelegramButtonInput {
143
+ /** Button label, 1 to 64 characters. */
144
+ text: string;
145
+ /** https://, http:// or tg:// link. */
146
+ url: string;
147
+ }
148
+ export interface TelegramPollInput {
149
+ /** 2 to 12 answer options of up to 100 characters, all different. */
150
+ options: string[];
151
+ /** Seconds the poll stays open (5 to 2628000). Omit to keep it open. */
152
+ open_period?: number;
153
+ /** Allow several answers per person. */
154
+ multiple?: boolean;
155
+ }
156
+ export interface TelegramPostOptions {
157
+ silent?: boolean | null;
158
+ link_preview?: boolean | null;
159
+ pin?: boolean | null;
160
+ protect_content?: boolean | null;
161
+ spoiler?: boolean | null;
162
+ caption_above_media?: boolean | null;
163
+ /** Up to 3 link buttons, one per row. Not on an album or a poll. */
164
+ buttons?: TelegramButtonInput[] | null;
165
+ /** A native poll; the post text is the question (max 300 characters). */
166
+ poll?: TelegramPollInput | null;
167
+ /** A reply under the post, sent right after it is published. */
168
+ first_comment?: string;
169
+ }
170
+ /** What GET /posts/{id} returns under `telegram` once the post went out. */
171
+ export interface TelegramPostResult {
172
+ chat_id: string | null;
173
+ message_ids: string[];
174
+ pinned: boolean;
175
+ pin_error?: string;
176
+ text_message_id?: string;
177
+ link_public: string | null;
178
+ link_private: string | null;
179
+ sent_at: string | null;
180
+ }
181
+ export interface DiscordButtonInput {
182
+ /** Button label, 1 to 80 characters. */
183
+ text: string;
184
+ /** https:// or http:// link. */
185
+ url: string;
186
+ }
187
+ export interface DiscordPollInput {
188
+ /** 2 to 10 answer options of up to 55 characters, all different. */
189
+ options: string[];
190
+ /** Hours the poll stays open (1 to 768). Default 24. */
191
+ duration_hours?: number;
192
+ /** Allow several answers per person. */
193
+ multiple?: boolean;
194
+ }
195
+ export interface DiscordPostOptions {
196
+ /** Send without a push or desktop notification (@silent). */
197
+ silent?: boolean | null;
198
+ /** false hides the previews of the links in the text. */
199
+ link_preview?: boolean | null;
200
+ /** true lets @everyone, @here, role and user mentions ping. Off by default. */
201
+ allow_mentions?: boolean | null;
202
+ /** Blur every image and video until someone clicks it. */
203
+ spoiler?: boolean | null;
204
+ /** Post title in a forum or media channel (required there), max 100 characters. */
205
+ thread_name?: string | null;
206
+ /** Up to 5 link buttons in one row. Not with a poll. */
207
+ buttons?: DiscordButtonInput[] | null;
208
+ /** A native poll; the post text is the question (max 300 characters). */
209
+ poll?: DiscordPollInput | null;
210
+ }
211
+ /** What GET /posts/{id} returns under `discord` once the message went out. */
212
+ export interface DiscordPostResult {
213
+ webhook_id: string | null;
214
+ guild_id: string | null;
215
+ channel_id: string | null;
216
+ /** The forum post the message opened, in a forum or media channel. */
217
+ thread_id: string | null;
218
+ message_id: string | null;
219
+ /** discord.com/channels/<server>/<channel or thread>/<message>. */
220
+ url: string | null;
221
+ sent_at: string | null;
222
+ /** Media sent as a link in the text (videos and GIFs over 20 MB). */
223
+ video_links: string[];
224
+ /** A second message with the media links, when they did not fit in the text. */
225
+ link_message_id: string | null;
226
+ /** Codes such as "media_sent_as_links" or "links_sent_as_follow_up". */
227
+ warnings: string[];
228
+ }
106
229
  export interface ThreadsThreadPartInput {
107
230
  /** Part text, 1 to 500 characters. */
108
231
  text: string;
@@ -114,6 +237,7 @@ export interface ThreadsThreadPartInput {
114
237
  media_urls?: MediaUrlEntry[];
115
238
  }
116
239
  export interface ThreadsPostOptions {
240
+ poll?: ChannelPollInput | null;
117
241
  /** Provide 2 to 25 parts to publish as a thread. Omit for a single post.
118
242
  * When set, the Threads caption is taken from part 1. */
119
243
  thread_parts?: ThreadsThreadPartInput[];
@@ -128,6 +252,7 @@ export interface ThreadsPostOptions {
128
252
  * single post); `undefined` leaves the existing thread untouched.
129
253
  */
130
254
  export interface ThreadsPostOptionsUpdate {
255
+ poll?: ChannelPollInput | null;
131
256
  thread_parts?: ThreadsThreadPartInput[] | null;
132
257
  /** New Threads location id, or null to remove the tag. Omit to leave the
133
258
  * existing tag untouched. */
@@ -152,6 +277,10 @@ export interface CreatePostInput {
152
277
  content: string | Record<string, string>;
153
278
  channels?: string[];
154
279
  scheduled_at?: string;
280
+ /** With scheduled_at: keep the time but store the post as a draft (planned draft). */
281
+ draft?: boolean;
282
+ /** Image fit per channel: { instagram: "auto" | "crop" | "keep" }. */
283
+ media_fit?: Record<string, "auto" | "crop" | "keep">;
155
284
  media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
156
285
  media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
157
286
  type?: string;
@@ -183,11 +312,15 @@ export interface CreatePostInput {
183
312
  mastodon?: MastodonPostOptions;
184
313
  threads?: ThreadsPostOptions;
185
314
  google_business?: Record<string, unknown>;
315
+ telegram?: TelegramPostOptions | null;
316
+ discord?: DiscordPostOptions | null;
186
317
  hashtag_set?: string;
187
318
  hashtag_placement?: "caption_append" | "first_comment";
188
319
  hashtag_platforms?: string[];
189
320
  /** Route the post through a saved approval workflow (GET /approval-workflows). */
190
321
  approval_workflow_id?: string;
322
+ /** Comment-to-DM automations bound to this post (Instagram or Facebook must be a channel). `null` removes them on update. */
323
+ comment_dm_automation?: CommentDmAutomationInput | null;
191
324
  }
192
325
  export interface ApprovalWorkflowApprover {
193
326
  id: string;
@@ -234,6 +367,8 @@ export declare class OmniSocialsClient {
234
367
  updatePost(id: string, data: {
235
368
  content?: string | Record<string, string>;
236
369
  scheduled_at?: string;
370
+ /** false = schedule a planned draft at its stored time; true = unschedule, keep the time. */
371
+ draft?: boolean;
237
372
  channels?: string[];
238
373
  media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
239
374
  media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
@@ -258,12 +393,15 @@ export declare class OmniSocialsClient {
258
393
  linkedin?: Record<string, unknown>;
259
394
  linkedin_page?: Record<string, unknown>;
260
395
  linkedin_poll?: LinkedInPollInput | null;
396
+ comment_dm_automation?: CommentDmAutomationInput | null;
261
397
  tiktok?: Record<string, unknown>;
262
398
  x?: XPostOptionsUpdate;
263
399
  bluesky?: BlueskyPostOptionsUpdate;
264
400
  mastodon?: MastodonPostOptionsUpdate;
265
401
  threads?: ThreadsPostOptionsUpdate;
266
402
  google_business?: Record<string, unknown>;
403
+ telegram?: TelegramPostOptions | null;
404
+ discord?: DiscordPostOptions | null;
267
405
  }): Promise<ApiResponse<ApiPost>>;
268
406
  deletePost(id: string): Promise<ApiResponse<unknown>>;
269
407
  publishPost(id: string): Promise<ApiResponse<PublishPostResult>>;
@@ -343,6 +481,20 @@ export declare class OmniSocialsClient {
343
481
  hashtags?: string[];
344
482
  }): Promise<ApiResponse<HashtagSet>>;
345
483
  deleteHashtagSet(id: string): Promise<ApiResponse<unknown>>;
484
+ listAutomations(params?: {
485
+ status?: string;
486
+ post_id?: string;
487
+ platform?: AutomationPlatform;
488
+ }): Promise<ApiResponse<Automation[]>>;
489
+ getAutomation(id: string): Promise<ApiResponse<Automation>>;
490
+ createAutomation(data: AutomationInput): Promise<ApiResponse<Automation>>;
491
+ updateAutomation(id: string, data: AutomationInput): Promise<ApiResponse<Automation>>;
492
+ deleteAutomation(id: string): Promise<ApiResponse<unknown>>;
493
+ listAutomationEvents(id: string, params?: {
494
+ outcome?: AutomationEventOutcome;
495
+ limit?: number;
496
+ offset?: number;
497
+ }): Promise<ApiResponse<AutomationEvent[]> & Partial<Omit<AutomationEventsResult, "data">>>;
346
498
  listAccounts(): Promise<ApiResponse<Account[]> & {
347
499
  workspace_id?: number | string | null;
348
500
  workspace_name?: string | null;
package/build/client.js CHANGED
@@ -123,11 +123,87 @@ const METRIC_LABELS = [
123
123
  ["watch_time_percentage", "Watched (%)"],
124
124
  ["completion_rate", "Completion rate (%)"],
125
125
  ["skip_rate", "Skip rate (%)"],
126
+ ["hold_rate_3s", "3-second hold (%)"],
126
127
  ["replays", "Replays"],
127
128
  ["subscribers_gained", "Subscribers gained"],
128
129
  ["subscribers_lost", "Subscribers lost"],
129
130
  ["duration", "Duration (s)"],
130
131
  ];
132
+ /** Rates, averages and lengths: shown per platform, never added up across
133
+ * platforms (two 60% hold rates are not 120%). Mirrors
134
+ * NON_ADDITIVE_METRIC_KEYS in the backend's analyticsAggregate.js. */
135
+ export const NON_ADDITIVE_METRIC_KEYS = new Set([
136
+ "engagement_rate",
137
+ "save_rate",
138
+ "pin_click_rate",
139
+ "outbound_click_rate",
140
+ "avg_watch_time",
141
+ "average_time_watched",
142
+ "average_view_duration",
143
+ "average_view_percentage",
144
+ "watch_time_percentage",
145
+ "completion_rate",
146
+ "full_video_watched_rate",
147
+ "skip_rate",
148
+ "hold_rate_3s",
149
+ "duration",
150
+ "upvote_ratio",
151
+ "story_slide_index",
152
+ ]);
153
+ function interpolate(points, at) {
154
+ if (points.length === 0 || at < points[0][0] || at > points[points.length - 1][0])
155
+ return undefined;
156
+ for (let i = 0; i < points.length; i++) {
157
+ const [x1, y1] = points[i];
158
+ if (x1 === at)
159
+ return y1;
160
+ if (x1 > at) {
161
+ const [x0, y0] = points[i - 1];
162
+ return y0 + ((y1 - y0) * (at - x0)) / (x1 - x0);
163
+ }
164
+ }
165
+ return undefined;
166
+ }
167
+ function curveValueAtSecond(curve, second) {
168
+ if (curve.x === "seconds")
169
+ return interpolate(curve.points, second);
170
+ if (curve.x === "percent" && curve.duration && curve.duration > 0) {
171
+ return interpolate(curve.points, (second / curve.duration) * 100);
172
+ }
173
+ return undefined;
174
+ }
175
+ function curveValueAtShare(curve, percent) {
176
+ if (curve.x === "percent")
177
+ return interpolate(curve.points, percent);
178
+ if (curve.duration && curve.duration > 0)
179
+ return interpolate(curve.points, (percent / 100) * curve.duration);
180
+ return undefined;
181
+ }
182
+ /** One line for a retention curve: the opening seconds, then the quarters
183
+ * ("1s 92% · 3s 71% · 10s 48% · 50% 40% · end 22%"). Mirrors
184
+ * retentionSummary in the backend's utils/analytics/retentionCurve.js. */
185
+ export function retentionSummary(curve) {
186
+ const c = curve;
187
+ if (!c || typeof c !== "object" || !Array.isArray(c.points) || c.points.length < 2)
188
+ return "";
189
+ const parts = [];
190
+ const of3sViews = c.base === "views_3s";
191
+ if (!of3sViews) {
192
+ for (const s of [1, 3, 5, 10, 15, 30]) {
193
+ const v = curveValueAtSecond(c, s);
194
+ if (v !== undefined)
195
+ parts.push(`${s}s ${Math.round(v)}%`);
196
+ }
197
+ }
198
+ for (const share of [25, 50, 75, 100]) {
199
+ const v = curveValueAtShare(c, share);
200
+ if (v !== undefined)
201
+ parts.push(`${share === 100 ? "end" : `${share}%`} ${Math.round(v)}%`);
202
+ }
203
+ if (parts.length === 0)
204
+ return "";
205
+ return of3sViews ? `${parts.join(" · ")} (of 3-second views)` : parts.join(" · ");
206
+ }
131
207
  // Context keys that ride along in stored metrics, not counters.
132
208
  const NON_COUNTER_KEYS = new Set(["create_time", "story_slide_index"]);
133
209
  /** Breakdown objects (reactions by type, traffic sources, ...) rendered as
@@ -159,6 +235,9 @@ export function metricBreakdownRows(m) {
159
235
  if (parts.length)
160
236
  rows.push([label, parts.join(", ")]);
161
237
  }
238
+ const retention = retentionSummary(m?.retention_curve);
239
+ if (retention)
240
+ rows.push(["Still watching", retention]);
162
241
  return rows;
163
242
  }
164
243
  /** Canonical position of a metric label — for sorting totals rows collected
@@ -168,10 +247,11 @@ export function sortMetricLabels(labels) {
168
247
  return [...labels].sort((a, b) => (LABEL_ORDER.get(a) ?? 999) - (LABEL_ORDER.get(b) ?? 999));
169
248
  }
170
249
  /**
171
- * Flatten a raw per-platform metrics object into ordered [label, value] rows,
172
- * keeping every numeric counter the platform reported. Strings (note, title,
173
- * cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
174
- * back-compat key so a zero there is noise, not data.
250
+ * Flatten a raw per-platform metrics object into ordered [label, value, key]
251
+ * rows, keeping every numeric counter the platform reported. Strings (note,
252
+ * title, cover_image_url, …) and objects (breakdowns, the retention curve) are
253
+ * skipped; `plays` is a legacy always-0 Instagram back-compat key so a zero
254
+ * there is noise, not data.
175
255
  */
176
256
  export function metricRows(m) {
177
257
  const rows = [];
@@ -186,7 +266,7 @@ export function metricRows(m) {
186
266
  seen.add(key);
187
267
  if (key === "plays" && n === 0)
188
268
  continue;
189
- rows.push([label, n]);
269
+ rows.push([label, n, key]);
190
270
  }
191
271
  for (const [key, v] of Object.entries(m || {})) {
192
272
  if (seen.has(key) || NON_COUNTER_KEYS.has(key))
@@ -194,10 +274,17 @@ export function metricRows(m) {
194
274
  const n = Number(v);
195
275
  if (!Number.isFinite(n))
196
276
  continue;
197
- rows.push([capitalize(key.replace(/_/g, " ")), n]);
277
+ rows.push([capitalize(key.replace(/_/g, " ")), n, key]);
198
278
  }
199
279
  return rows;
200
280
  }
281
+ // Query params as strings, without the undefined ones (request() takes
282
+ // Record<string, string>).
283
+ const queryString = (params) => params
284
+ ? Object.fromEntries(Object.entries(params)
285
+ .filter(([, v]) => v !== undefined && v !== null)
286
+ .map(([k, v]) => [k, String(v)]))
287
+ : undefined;
201
288
  export class OmniSocialsClient {
202
289
  baseUrl;
203
290
  apiKey;
@@ -391,6 +478,26 @@ export class OmniSocialsClient {
391
478
  async deleteHashtagSet(id) {
392
479
  return this.request("DELETE", `/hashtag-sets/${id}`);
393
480
  }
481
+ // Comment-to-DM automations (opt-in scopes automations:read / automations:write)
482
+ async listAutomations(params) {
483
+ return this.request("GET", "/automations", undefined, queryString(params));
484
+ }
485
+ async getAutomation(id) {
486
+ return this.request("GET", `/automations/${id}`);
487
+ }
488
+ async createAutomation(data) {
489
+ return this.request("POST", "/automations", data);
490
+ }
491
+ async updateAutomation(id, data) {
492
+ return this.request("PATCH", `/automations/${id}`, data);
493
+ }
494
+ async deleteAutomation(id) {
495
+ return this.request("DELETE", `/automations/${id}`);
496
+ }
497
+ // The events response is not the plain list envelope: { data, counts, pagination }.
498
+ async listAutomationEvents(id, params) {
499
+ return this.request("GET", `/automations/${id}/events`, undefined, queryString(params));
500
+ }
394
501
  // Accounts. Alongside `data`, the list response carries the workspace's
395
502
  // identity top-level (backend/routes/api/v1/accounts.js) — the workspaces
396
503
  // tool reads it to label multi-workspace setups.
package/build/index.js CHANGED
@@ -9,6 +9,7 @@ import { registerAccountTools } from "./tools/accounts.js";
9
9
  import { registerAnalyticsTools } from "./tools/analytics.js";
10
10
  import { registerWebhookTools } from "./tools/webhooks.js";
11
11
  import { registerHashtagSetTools } from "./tools/hashtag-sets.js";
12
+ import { registerAutomationTools } from "./tools/automations.js";
12
13
  import { registerInboxTools } from "./tools/inbox.js";
13
14
  import { registerWorkspaceTools } from "./tools/workspaces.js";
14
15
  const apiKeyEnv = process.env.OMNISOCIALS_API_KEY;
@@ -29,7 +30,7 @@ const sessionState = { activeIndex: 0 };
29
30
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
30
31
  const server = new McpServer({
31
32
  name: "OmniSocials",
32
- version: "1.39.0",
33
+ version: "1.48.0",
33
34
  });
34
35
  // Register all tools - pass getter function so tools always use the active workspace's client
35
36
  registerPostTools(server, getActiveClient);
@@ -38,6 +39,7 @@ registerAccountTools(server, getActiveClient);
38
39
  registerAnalyticsTools(server, getActiveClient);
39
40
  registerWebhookTools(server, getActiveClient);
40
41
  registerHashtagSetTools(server, getActiveClient);
42
+ registerAutomationTools(server, getActiveClient);
41
43
  registerInboxTools(server, getActiveClient);
42
44
  registerWorkspaceTools(server, workspaceClients, sessionState);
43
45
  // Register prompts
package/build/metrics.js CHANGED
@@ -57,6 +57,14 @@ export function getEngagement(platform, metrics) {
57
57
  return sum("likes", "comments", "reposts", "quotes", "bookmarks");
58
58
  case "mastodon":
59
59
  return sum("likes", "comments", "reposts", "quotes");
60
+ case "telegram":
61
+ // Reactions are the only post number a bot gets (message_reaction_count),
62
+ // stored as `likes` (total) next to `reactions` (per emoji).
63
+ return sum("likes");
64
+ case "discord":
65
+ // A webhook reads back its own message: reactions only (2026-10-22),
66
+ // stored the Telegram way, `likes` (total) next to `reactions` (per emoji).
67
+ return sum("likes");
60
68
  case "reddit":
61
69
  // likes == upvote score; shares == crossposts.
62
70
  return sum("likes", "comments", "shares");
@@ -177,6 +185,28 @@ export const UNSUPPORTED_POST_METRICS = {
177
185
  video_views: "Mastodon does not report video views.",
178
186
  engagement_rate: "Engagement rate needs impressions, which Mastodon does not report.",
179
187
  },
188
+ telegram: {
189
+ impressions: "Telegram does not give view counts to bots. Open the post in Telegram to see its views.",
190
+ reach: "Telegram does not report reach to bots.",
191
+ comments: "Telegram does not report comment counts to bots.",
192
+ shares: "Telegram does not report forwards to bots.",
193
+ saves: "Telegram does not report saves.",
194
+ clicks: "Telegram does not report clicks.",
195
+ link_clicks: "Telegram does not report clicks.",
196
+ video_views: "Telegram does not give view counts to bots.",
197
+ engagement_rate: "Engagement rate needs views, which Telegram does not give to bots.",
198
+ },
199
+ discord: {
200
+ impressions: "Discord does not report views. Open the message in Discord to see who reacted.",
201
+ reach: "Discord does not report reach.",
202
+ comments: "Discord does not report replies to a webhook message.",
203
+ shares: "Discord does not report shares.",
204
+ saves: "Discord does not report saves.",
205
+ clicks: "Discord does not report clicks.",
206
+ link_clicks: "Discord does not report clicks.",
207
+ video_views: "Discord does not report video views.",
208
+ engagement_rate: "Engagement rate needs views, which Discord does not report.",
209
+ },
180
210
  google_business: {
181
211
  impressions: "Google discontinued per-post metrics for Business Profiles in 2023.",
182
212
  reach: "Google discontinued per-post metrics for Business Profiles in 2023.",