@omnisocials/mcp-server 1.46.0 → 1.49.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
@@ -109,7 +109,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
109
109
  | `tiktok` | TikTok | Posts and reels |
110
110
  | `pinterest` | Pinterest | Pins (requires `board_id`) |
111
111
  | `x` | X (Twitter) | Posts, chained threads via `x.thread_parts` |
112
- | `threads` | Threads | Posts, chained threads via `threads.thread_parts`, location tagging via `threads.location_id` (rolling out) |
112
+ | `threads` | Threads | Posts, chained threads via `threads.thread_parts`, a topic via `threads.topic_tag`, polls via `threads.poll`, 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
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` |
@@ -212,12 +212,12 @@ Requires an API key with the opt-in `inbox:read` / `inbox:write` scopes — enab
212
212
 
213
213
  | Tool | Description |
214
214
  |------|-------------|
215
- | `list_automations` | List the comment-to-DM automations of the workspace (Instagram and Facebook Page), optionally by `status`, `post_id` or `platform` |
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
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 |
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
218
  | `update_automation` | Pause, resume or change the keywords, DM text, public reply, limits or post of an automation |
219
219
  | `delete_automation` | Delete an automation |
220
- | `get_automation_activity` | One row per comment or message the automation acted on: the DM sent, a skip with its reason, or the platform's error |
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
221
 
222
222
  Requires an API key with the opt-in `automations:read` / `automations:write` scopes (Settings > API, "Automations" on the key).
223
223
 
@@ -244,6 +244,22 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
244
244
 
245
245
  ## Changelog
246
246
 
247
+ ### 1.49.0 (2026-10-27)
248
+
249
+ - **Added: the Threads topic.** `threads.topic_tag` on `create_post`, `create_and_publish_post` and `update_post` (one topic per post, 1 to 50 characters, spaces allowed, no period or ampersand; `null` removes it on update). `get_post` shows it.
250
+
251
+ ### 1.48.0 (2026-10-26)
252
+
253
+ - **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.
254
+ - **Fixed:** Instagram Reels carry `skip_rate` again, and totals across platforms add up counters only (rates and averages show per platform).
255
+
256
+ ### 1.47.0 (2026-10-25)
257
+
258
+ - **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.
259
+ - **Added: `status` on `create_automation`** (`paused` creates it without turning it on).
260
+ - **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`.
261
+ - Also in 1.47.0: the Ads tools listed above (see CHANGELOG.md; that text is a draft until Ads launches).
262
+
247
263
  ### 1.46.0 (2026-10-23)
248
264
 
249
265
  - **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`).
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, Automation, AutomationEvent, AutomationEventsResult, AutomationInput, 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";
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 | {
@@ -229,6 +246,9 @@ export interface ThreadsPostOptions {
229
246
  * the tag goes on the first post. Needs the threads_location_tagging
230
247
  * permission on the Threads connection. */
231
248
  location_id?: string | null;
249
+ /** The topic of the post on Threads (1 to 50 characters, no period and
250
+ * no ampersand; spaces allowed). On a thread it goes on the first post. */
251
+ topic_tag?: string | null;
232
252
  }
233
253
  /**
234
254
  * Update-side variant: `thread_parts: null` clears the thread (revert to a
@@ -240,6 +260,8 @@ export interface ThreadsPostOptionsUpdate {
240
260
  /** New Threads location id, or null to remove the tag. Omit to leave the
241
261
  * existing tag untouched. */
242
262
  location_id?: string | null;
263
+ /** New Threads topic, or null to remove it. Omit to leave it untouched. */
264
+ topic_tag?: string | null;
243
265
  }
244
266
  /** Non-sponsored LinkedIn poll: question + 2-4 options + duration. */
245
267
  export interface LinkedInPollFields {
@@ -467,14 +489,14 @@ export declare class OmniSocialsClient {
467
489
  listAutomations(params?: {
468
490
  status?: string;
469
491
  post_id?: string;
470
- platform?: string;
492
+ platform?: AutomationPlatform;
471
493
  }): Promise<ApiResponse<Automation[]>>;
472
494
  getAutomation(id: string): Promise<ApiResponse<Automation>>;
473
495
  createAutomation(data: AutomationInput): Promise<ApiResponse<Automation>>;
474
496
  updateAutomation(id: string, data: AutomationInput): Promise<ApiResponse<Automation>>;
475
497
  deleteAutomation(id: string): Promise<ApiResponse<unknown>>;
476
498
  listAutomationEvents(id: string, params?: {
477
- outcome?: string;
499
+ outcome?: AutomationEventOutcome;
478
500
  limit?: number;
479
501
  offset?: number;
480
502
  }): Promise<ApiResponse<AutomationEvent[]> & Partial<Omit<AutomationEventsResult, "data">>>;
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,7 +274,7 @@ 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
  }
package/build/index.js CHANGED
@@ -30,7 +30,7 @@ const sessionState = { activeIndex: 0 };
30
30
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
31
31
  const server = new McpServer({
32
32
  name: "OmniSocials",
33
- version: "1.46.0",
33
+ version: "1.49.0",
34
34
  });
35
35
  // Register all tools - pass getter function so tools always use the active workspace's client
36
36
  registerPostTools(server, getActiveClient);
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
2
+ import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels, NON_ADDITIVE_METRIC_KEYS } from "../client.js";
3
3
  import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
4
4
  export function registerAnalyticsTools(server, getClient) {
5
- server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon, Telegram, Discord and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization). Telegram gives bots no view counts: a Telegram post carries likes (all reactions), reactions (per emoji) and, for a poll, the votes. Discord reports the same for a webhook message: likes (all reactions), reactions (per emoji) and the poll votes, no views.", {
5
+ server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon, Telegram, Discord and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization). Video retention: hold_rate_3s (% of viewers still watching at second 3) on TikTok videos (with TikTok comments enabled), Facebook Reels, and Instagram Reels (there 100 minus skip_rate, Meta's 3-second skip rate); a 'Still watching' row sums up the retention curve (TikTok videos, Facebook Reels and videos) at 1, 3, 5, 10, 15 and 30 seconds and at each quarter of the video. Instagram and YouTube give no curve through their APIs. Rates and averages show per platform, never added up across platforms. Telegram gives bots no view counts: a Telegram post carries likes (all reactions), reactions (per emoji) and, for a poll, the votes. Discord reports the same for a webhook message: likes (all reactions), reactions (per emoji) and the poll votes, no views.", {
6
6
  post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
7
7
  }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -47,7 +47,11 @@ export function registerAnalyticsTools(server, getClient) {
47
47
  const denom = getImpressions(platform, m);
48
48
  totalEngagements += engagements;
49
49
  totalDenom += denom;
50
- for (const [label, n] of rows) {
50
+ for (const [label, n, key] of rows) {
51
+ // Rates, averages and lengths do not add up across platforms (two
52
+ // 60% hold rates are not 120%); they show per platform only.
53
+ if (NON_ADDITIVE_METRIC_KEYS.has(key))
54
+ continue;
51
55
  if (!totalsByLabel.has(label))
52
56
  totalOrder.push(label);
53
57
  totalsByLabel.set(label, (totalsByLabel.get(label) || 0) + n);
@@ -1,9 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { OmniSocialsClient } from "../client.js";
3
- import type { Automation, AutomationInput } from "../types.js";
4
- export declare const automationChannelLabel: (platform: string | null | undefined) => "Facebook Page" | "Instagram";
3
+ import type { Automation, AutomationInput, AutomationPlatform } from "../types.js";
4
+ export declare const automationChannelLabel: (platform: string | null | undefined) => "Facebook Page" | "Instagram" | "X";
5
5
  type Fields = {
6
- platform?: "instagram" | "facebook";
6
+ platform?: AutomationPlatform;
7
7
  name?: string;
8
8
  keywords?: string[];
9
9
  match?: "word" | "contains" | "any";
@@ -2,31 +2,34 @@ import { z } from "zod";
2
2
  import { formatDateTime, truncate } from "../client.js";
3
3
  const SCOPE_HINT = "The API key needs the opt-in Automations scopes (OmniSocials Settings > API > create a key with Automations on: automations:read and automations:write).";
4
4
  const errorText = (error) => `Error (${error.code}): ${error.message}${error.code === "insufficient_scope" ? `\n\n${SCOPE_HINT}` : ""}`;
5
- const PLATFORM = z.enum(["instagram", "facebook"]);
5
+ const PLATFORM = z.enum(["instagram", "facebook", "x"]);
6
6
  const KINDS = z.enum(["comment", "dm", "story_reply"]);
7
+ const OUTCOME = z.enum(["sent", "skipped", "failed", "pending", "queued", "opted_out"]);
7
8
  // How the channel reads in a table or a sentence.
8
- export const automationChannelLabel = (platform) => platform === "facebook" ? "Facebook Page" : "Instagram";
9
+ export const automationChannelLabel = (platform) => platform === "facebook" ? "Facebook Page" : platform === "x" ? "X" : "Instagram";
9
10
  const MATCH = z.enum(["word", "contains", "any"]);
10
11
  const SCOPE = z.enum(["post", "next_post", "any"]);
11
12
  // The shared fields of create_automation and update_automation, flat so an
12
13
  // assistant fills them from one sentence ("DM everyone who comments guide").
13
14
  const AUTOMATION_FIELDS = {
14
- platform: PLATFORM.optional().describe("Where the automation runs: 'instagram' (default) = the connected Instagram account, 'facebook' = the connected Facebook Page (the DM lands in Messenger). instagram_media_id implies 'instagram', facebook_post_id implies 'facebook'. It cannot change after create."),
15
+ platform: PLATFORM.optional().describe("Where the automation runs: 'instagram' (default) = the connected Instagram account, 'facebook' = the connected Facebook Page (the DM lands in Messenger), 'x' = the connected X account (it answers DMs only; see create_automation). instagram_media_id implies 'instagram', facebook_post_id implies 'facebook'. It cannot change after create."),
15
16
  name: z.string().max(120).optional().describe("A label for the automations list, e.g. 'Free guide'."),
16
17
  keywords: z.array(z.string()).max(20).optional().describe("Keywords that start the automation (up to 20, matched case-insensitively), e.g. [\"guide\", \"link\"]. Required unless match is 'any'."),
17
18
  match: MATCH.optional().describe("word (default): the keyword stands on its own ('guide', 'Guide!' match; 'linkedin' does not match 'link'). contains: the keyword appears anywhere. any: every comment or message with text (ask the user before using it)."),
18
- dm_text: z.string().max(1000).optional().describe("The DM sent to the person (max 1000 characters), once per person. On Facebook it lands in Messenger. Required on create. Confirm the text with the user before creating the automation: it goes out in the account's name."),
19
- public_replies: z.array(z.string().max(300)).max(10).optional().describe("Optional public replies posted under the comment (one is picked at random per comment), e.g. [\"Sent you a DM!\", \"Check your inbox\"]. Comment trigger only. An empty array turns the public reply off."),
20
- triggers: z.array(KINDS).min(1).optional().describe("What starts the automation. 'comment' (default) = a comment on an Instagram post or reel, or on a Facebook Page post; 'dm' = a keyword in a DM to the Instagram account, or in a Messenger message to the Facebook Page; 'story_reply' = a keyword in a reply to an Instagram story (Instagram only: Facebook has no story replies). One automation can listen to several."),
21
- scope: SCOPE.optional().describe("For the comment trigger: 'post' (default) = one post (pass post_id, instagram_media_id or facebook_post_id), 'next_post' = the next post the workspace publishes on this platform, 'any' = every post of the account or Page."),
22
- post_id: z.string().optional().describe("For scope 'post': the OmniSocials post id (from list_posts) that goes to the automation's platform. A scheduled post starts listening once it is live. One automation per post and platform: a post that goes to Instagram and Facebook can have one of each."),
19
+ dm_text: z.string().max(1000).optional().describe("The DM sent to the person (max 1000 characters), once per person. On Facebook it lands in Messenger. On X, OmniSocials adds the line 'Reply STOP to stop automatic messages.' under it. Required on create. Confirm the text with the user before creating the automation: it goes out in the account's name."),
20
+ public_replies: z.array(z.string().max(300)).max(10).optional().describe("Optional public replies posted under the comment (one is picked at random per comment), e.g. [\"Sent you a DM!\", \"Check your inbox\"]. Comment trigger only (Instagram and Facebook; X has none). An empty array turns the public reply off."),
21
+ triggers: z.array(KINDS).min(1).optional().describe("What starts the automation. 'comment' (default on Instagram and Facebook) = a comment on an Instagram post or reel, or on a Facebook Page post; 'dm' = a keyword in a DM to the Instagram account, in a Messenger message to the Facebook Page, or in a DM to the X account; 'story_reply' = a keyword in a reply to an Instagram story (Instagram only: Facebook has no story replies). On X only 'dm' exists, and it is the default there. One automation can listen to several."),
22
+ scope: SCOPE.optional().describe("For the comment trigger (Instagram and Facebook; an X automation has no post): 'post' (default) = one post (pass post_id, instagram_media_id or facebook_post_id), 'next_post' = the next post the workspace publishes on this platform, 'any' = every post of the account or Page."),
23
+ post_id: z.string().optional().describe("For scope 'post' (Instagram and Facebook): the OmniSocials post id (from list_posts) that goes to the automation's platform. A scheduled post starts listening once it is live. One automation per post and platform: a post that goes to Instagram and Facebook can have one of each."),
23
24
  instagram_media_id: z.string().optional().describe("For scope 'post' on Instagram: the numeric Instagram media id of a post or reel that was NOT published through OmniSocials. OmniSocials reads it from Instagram to check it belongs to the connected account. Pass only one of post_id, instagram_media_id or facebook_post_id."),
24
25
  facebook_post_id: z.string().optional().describe("For scope 'post' on Facebook: the id of a Facebook Page post that was NOT published through OmniSocials ({page-id}_{post-id}, or the post id alone). OmniSocials reads it from Facebook to check it belongs to the connected Page. Implies platform 'facebook'. Pass only one of post_id, instagram_media_id or facebook_post_id."),
25
26
  max_sends: z.number().int().positive().nullable().optional().describe("Stop after this many DMs (the automation becomes 'completed'). null = no limit."),
26
27
  stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601). null = no end."),
27
28
  };
28
29
  // Turn the flat tool fields into the API body. Only the sections that were
29
- // given are sent, so update_automation leaves the rest untouched.
30
+ // given are sent, so update_automation leaves the rest untouched. The API's
31
+ // default trigger is a comment, which X does not have: an X create without
32
+ // triggers sends ["dm"].
30
33
  export function buildAutomationBody(f, { create }) {
31
34
  const body = {};
32
35
  if (f.name !== undefined)
@@ -43,6 +46,8 @@ export function buildAutomationBody(f, { create }) {
43
46
  body.trigger = {};
44
47
  if (f.triggers !== undefined)
45
48
  body.trigger.kinds = f.triggers;
49
+ else if (create && f.platform === "x")
50
+ body.trigger.kinds = ["dm"];
46
51
  if (f.scope !== undefined)
47
52
  body.trigger.scope = f.scope;
48
53
  if (f.keywords !== undefined)
@@ -94,7 +99,7 @@ export function describeAutomation(a) {
94
99
  return md;
95
100
  }
96
101
  export function registerAutomationTools(server, getClient) {
97
- server.tool("list_automations", "List the comment-to-DM automations of this workspace, on Instagram and on the Facebook Page. An automation DMs a person once when they comment a keyword on a post (or send it in a DM, or in an Instagram story reply), with an optional public reply under the comment. Use it to answer 'which automations run', before creating one on a post that may have one already, or to find the id for update_automation / get_automation_activity.", {
102
+ server.tool("list_automations", "List the comment-to-DM automations of this workspace, on Instagram, on the Facebook Page and on X. An automation DMs a person once when they comment a keyword on a post (or send it in a DM, or in an Instagram story reply), with an optional public reply under the comment; on X it answers DMs only. Use it to answer 'which automations run', before creating one on a post that may have one already, or to find the id for update_automation / get_automation_activity.", {
98
103
  status: z.enum(["active", "paused", "completed"]).optional().describe("Only automations with this status."),
99
104
  post_id: z.string().optional().describe("Only the automations bound to this OmniSocials post (one per platform at most)."),
100
105
  platform: PLATFORM.optional().describe("Only the automations of this platform."),
@@ -116,7 +121,7 @@ export function registerAutomationTools(server, getClient) {
116
121
  md += `\nDetails with get_automation, activity with get_automation_activity, pause or edit with update_automation.`;
117
122
  return { content: [{ type: "text", text: md }] };
118
123
  });
119
- server.tool("get_automation", "Get one comment-to-DM automation: its platform (Instagram or the Facebook Page), its trigger, the DM text, the public replies, the limits and the counts.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Get Automation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id }) => {
124
+ server.tool("get_automation", "Get one comment-to-DM automation: its platform (Instagram, the Facebook Page or X), its trigger, the DM text, the public replies, the limits and the counts.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Get Automation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id }) => {
120
125
  const result = await getClient().getAutomation(automation_id);
121
126
  if (result.error)
122
127
  return { content: [{ type: "text", text: errorText(result.error) }] };
@@ -124,10 +129,16 @@ export function registerAutomationTools(server, getClient) {
124
129
  return { content: [{ type: "text", text: "The API returned no automation." }] };
125
130
  return { content: [{ type: "text", text: describeAutomation(result.data) }] };
126
131
  });
127
- server.tool("create_automation", "Create a comment-to-DM automation on the connected Instagram account (default) or on the connected Facebook Page (platform 'facebook'): when somebody comments one of the keywords on the post (or sends it in a DM, or in an Instagram story reply, see triggers), the account DMs them dm_text once, and optionally posts one of public_replies under the comment. On Facebook the DM lands in Messenger. Before creating one, show the user the keyword(s) and the exact DM text and get a yes: the DM goes out in their name. Meta rules, the same on both: one private reply per comment, within 7 days of the comment; a follow-up only after the person answers (24-hour window); OmniSocials caps sends at 180 DMs an hour per account and platform. For scope 'post' pass one of post_id (an OmniSocials post, scheduled or live), instagram_media_id (an Instagram post published outside OmniSocials) or facebook_post_id (a Page post published outside OmniSocials). To attach automations to a NEW post, pass comment_dm_automation on create_post instead.", AUTOMATION_FIELDS, { title: "Create Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
132
+ server.tool("create_automation", "Create a comment-to-DM automation on the connected Instagram account (default), on the connected Facebook Page (platform 'facebook') or on the connected X account (platform 'x'): when somebody comments one of the keywords on the post (or sends it in a DM, or in an Instagram story reply, see triggers), the account DMs them dm_text once, and optionally posts one of public_replies under the comment. On Facebook the DM lands in Messenger. Before creating one, show the user the keyword(s) and the exact DM text and get a yes: the DM goes out in their name. Meta rules, the same on Instagram and Facebook: one private reply per comment, within 7 days of the comment; a follow-up only after the person answers (24-hour window); OmniSocials caps sends at 180 DMs an hour per account and platform, and an answer over the cap waits in a queue and goes out within the hour. For scope 'post' pass one of post_id (an OmniSocials post, scheduled or live), instagram_media_id (an Instagram post published outside OmniSocials) or facebook_post_id (a Page post published outside OmniSocials). On X the automation answers DMs only: triggers ['dm'] (the default there; X allows an automated DM only after the person wrote first), so a DM to the X account with a keyword, or any first message with match 'any', gets dm_text once per person; no post fields and no public replies. An X automation runs only while X DMs are turned on for the workspace in OmniSocials (Social Inbox, X DMs card, or the automation editor): an active create without them answers 409 x_dms_off, so tell the user where to turn them on, or create it with status 'paused'. 409 x_not_connected means the workspace has no X account. Every X answer ends with the line 'Reply STOP to stop automatic messages.', and a person who replies STOP gets no automatic DM from that X account again (the stop list belongs to the account: it holds after the automation is deleted, for later automations and in every workspace with that account). X takes at most 15 answers per 15 minutes and 450 a day per account; more wait in the queue for up to an hour. Each X answer uses X DM credits like an inbox reply (2 credits); when the credits ran out the answer is skipped (no_credits): top up, then answer in the Social Inbox. X does not give apps the messages of an encrypted X Chat conversation, so those get no answer. To attach automations to a NEW post, pass comment_dm_automation on create_post instead (Instagram and Facebook).", {
133
+ ...AUTOMATION_FIELDS,
134
+ status: z.enum(["active", "paused"]).optional().describe("'paused' creates it without turning it on (default 'active'). A paused X automation saves even while X DMs are off; turn it on later with update_automation."),
135
+ }, { title: "Create Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ status, ...params }) => {
128
136
  if (!params.dm_text)
129
137
  return { content: [{ type: "text", text: "dm_text is required: the DM the person receives." }] };
130
- const result = await getClient().createAutomation(buildAutomationBody(params, { create: true }));
138
+ const body = buildAutomationBody(params, { create: true });
139
+ if (status)
140
+ body.status = status;
141
+ const result = await getClient().createAutomation(body);
131
142
  if (result.error)
132
143
  return { content: [{ type: "text", text: errorText(result.error) }] };
133
144
  if (!result.data)
@@ -136,11 +147,13 @@ export function registerAutomationTools(server, getClient) {
136
147
  let md = `Created automation \`${a.id}\` (${a.status}).\n\n${describeAutomation(a)}`;
137
148
  if (a.post && a.post.live === false)
138
149
  md += `\n_The post is not live on ${a.platform === "facebook" ? "the Facebook Page" : "Instagram"} yet; the automation starts listening at publish._`;
150
+ if (a.platform === "x" && a.status === "paused")
151
+ md += `\n_Paused: it answers nothing until it is turned on (update_automation with status 'active'), which needs X DMs on for the workspace._`;
139
152
  return { content: [{ type: "text", text: md }] };
140
153
  });
141
- server.tool("update_automation", "Change a comment-to-DM automation: pause or resume it (status), new keywords or match, a new DM text or public replies, new limits, or move it to another post (post_id, instagram_media_id or facebook_post_id). Only the fields you pass change. The platform cannot change: to move from Instagram to the Facebook Page, create a new automation. A 'completed' automation starts again when its limits or trigger change.", {
154
+ server.tool("update_automation", "Change a comment-to-DM automation: pause or resume it (status), new keywords or match, a new DM text or public replies, new limits, or move it to another post (post_id, instagram_media_id or facebook_post_id). Only the fields you pass change. The platform cannot change: to move to another channel (Instagram, the Facebook Page or X), create a new automation. Turning an X automation on (status 'active') needs X DMs on for the workspace, else 409 x_dms_off. A 'completed' automation starts again when its limits or trigger change.", {
142
155
  automation_id: z.string().describe("The automation id (from list_automations)."),
143
- status: z.enum(["active", "paused"]).optional().describe("'paused' stops it without deleting it; 'active' resumes it."),
156
+ status: z.enum(["active", "paused"]).optional().describe("'paused' stops it without deleting it; 'active' resumes it (on X only while X DMs are on for the workspace)."),
144
157
  ...AUTOMATION_FIELDS,
145
158
  }, { title: "Update Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ automation_id, status, ...fields }) => {
146
159
  const body = buildAutomationBody(fields, { create: false });
@@ -155,15 +168,15 @@ export function registerAutomationTools(server, getClient) {
155
168
  return { content: [{ type: "text", text: "Automation updated, but the API returned no details. Read it with get_automation." }] };
156
169
  return { content: [{ type: "text", text: `Updated automation \`${automation_id}\`.\n\n${describeAutomation(result.data)}` }] };
157
170
  });
158
- server.tool("delete_automation", "Delete a comment-to-DM automation (Instagram or Facebook Page) and its activity log. DMs that were already sent stay sent. To stop it for a while, use update_automation with status 'paused' instead. Confirm with the user first.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Delete Automation", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ automation_id }) => {
171
+ server.tool("delete_automation", "Delete a comment-to-DM automation (Instagram, Facebook Page or X) and its activity log. DMs that were already sent stay sent. To stop it for a while, use update_automation with status 'paused' instead. Confirm with the user first.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Delete Automation", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ automation_id }) => {
159
172
  const result = await getClient().deleteAutomation(automation_id);
160
173
  if (result.error)
161
174
  return { content: [{ type: "text", text: errorText(result.error) }] };
162
175
  return { content: [{ type: "text", text: `Deleted automation \`${automation_id}\`.` }] };
163
176
  });
164
- server.tool("get_automation_activity", "The activity of a comment-to-DM automation, newest first: one row per comment or message it acted on, with the DM sent, a skip and its reason (already got the DM, limit reached, comment older than 7 days, hourly cap), or a failure with the platform's error. On the Facebook Page the person shows by name (Facebook has no @handle). Use it to answer 'how many people got the DM' or 'why did this person not get it'.", {
177
+ server.tool("get_automation_activity", "The activity of a comment-to-DM automation, newest first: one row per comment or message it acted on, with the DM sent, an answer that waits in the queue (queued: the send cap is full or X asked to wait; it goes out within the hour), a skip and its reason (already got the DM, limit reached, comment older than 7 days, the send cap stayed full for an hour, the person does not accept DMs from the X account, credits ran out before the answer, the person replied STOP earlier, the automation was paused while the answer waited), a person who replied STOP on X (opted_out: no automatic DMs to them from that X account again, in any workspace or automation), or a failure with the platform's error. On the Facebook Page the person shows by name (Facebook has no @handle). Use it to answer 'how many people got the DM' or 'why did this person not get it'.", {
165
178
  automation_id: z.string().describe("The automation id (from list_automations)."),
166
- outcome: z.enum(["sent", "skipped", "failed", "pending"]).optional().describe("Only rows with this outcome."),
179
+ outcome: OUTCOME.optional().describe("Only rows with this outcome."),
167
180
  limit: z.number().int().min(1).max(100).optional().describe("Rows per page (default 25, max 100)."),
168
181
  offset: z.number().int().min(0).optional().describe("Rows to skip (for the next page)."),
169
182
  }, { title: "Get Automation Activity", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id, ...params }) => {
@@ -187,7 +200,11 @@ export function registerAutomationTools(server, getClient) {
187
200
  for (const e of rows) {
188
201
  const who = e.person.username ? (onFacebook ? e.person.username : `@${e.person.username}`) : e.person.id;
189
202
  const did = e.kind === "comment" ? "commented" : e.kind === "dm" ? (onFacebook ? "sent a Messenger message" : "sent a DM") : "replied to a story";
190
- const outcome = e.outcome === "skipped" ? `skipped: ${e.reason_label || e.reason || ""}` : e.outcome === "failed" ? `failed: ${truncate(e.reason || "", 60)}` : e.outcome;
203
+ const outcome = e.outcome === "skipped" ? `skipped: ${e.reason_label || e.reason || ""}`
204
+ : e.outcome === "failed" ? `failed: ${truncate(e.reason || "", 60)}`
205
+ : e.outcome === "queued" ? `queued: ${e.reason_label || "waits for room under the send limit"}`
206
+ : e.outcome === "opted_out" ? `opted out: ${e.reason_label || "replied STOP"}`
207
+ : e.outcome;
191
208
  const text = e.trigger_text ? `"${truncate(e.trigger_text, 50)}"` : "";
192
209
  md += `| ${formatDateTime(e.sent_at || e.created_at)} | ${who} | ${did} | ${outcome} | ${text} |\n`;
193
210
  }
@@ -207,6 +207,13 @@ const THREADS_POLL_OPTIONS = z
207
207
  .nullable()
208
208
  .optional()
209
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
+ // The Threads topic (2026-10-26): `threads.topic_tag`, one per post.
211
+ const THREADS_TOPIC_TAG = z
212
+ .string()
213
+ .max(50)
214
+ .nullable()
215
+ .optional()
216
+ .describe("The topic of the post on Threads, one per post, e.g. \"Build in public\". Unlike a #tag in the text it can hold spaces. 1 to 50 characters, no period (.) and no ampersand (&); a leading # is dropped. On a multi-post thread the topic goes on the first post. On update_post, pass null to remove it.");
210
217
  const MASTODON_POLL_OPTIONS = z
211
218
  .object({
212
219
  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)."),
@@ -568,6 +575,10 @@ export function registerPostTools(server, getClient) {
568
575
  const locBits = [threadsLoc.name, threadsLoc.city, threadsLoc.country].filter(Boolean).join(", ");
569
576
  md += `| **Threads location** | ${locBits ? `${locBits} ` : ""}\`${threadsLoc.id}\` |\n`;
570
577
  }
578
+ const threadsTopic = p.threads?.topic_tag;
579
+ if (typeof threadsTopic === "string" && threadsTopic) {
580
+ md += `| **Threads topic** | ${threadsTopic} |\n`;
581
+ }
571
582
  if (Array.isArray(p.collaborators) && p.collaborators.length) {
572
583
  md += `| **IG collaborators** | ${p.collaborators.map((c) => `@${c}`).join(", ")} |\n`;
573
584
  }
@@ -1073,9 +1084,10 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
1073
1084
  }).optional().describe("Mastodon options"),
1074
1085
  threads: z.object({
1075
1086
  poll: THREADS_POLL_OPTIONS,
1087
+ topic_tag: THREADS_TOPIC_TAG,
1076
1088
  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."),
1077
1089
  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."),
1078
- }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1090
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`, a poll via `poll`, the topic via `topic_tag`."),
1079
1091
  google_business: GOOGLE_BUSINESS_OPTIONS,
1080
1092
  telegram: TELEGRAM_OPTIONS,
1081
1093
  discord: DISCORD_OPTIONS,
@@ -1249,9 +1261,10 @@ Do NOT call without required media — it will fail.`, {
1249
1261
  }).optional().describe("Mastodon options"),
1250
1262
  threads: z.object({
1251
1263
  poll: THREADS_POLL_OPTIONS,
1264
+ topic_tag: THREADS_TOPIC_TAG,
1252
1265
  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."),
1253
1266
  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."),
1254
- }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1267
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`, a poll via `poll`, the topic via `topic_tag`."),
1255
1268
  google_business: GOOGLE_BUSINESS_OPTIONS,
1256
1269
  telegram: TELEGRAM_OPTIONS,
1257
1270
  discord: DISCORD_OPTIONS,
@@ -1372,9 +1385,10 @@ Do NOT call without required media — it will fail.`, {
1372
1385
  }).optional().describe("Mastodon options"),
1373
1386
  threads: z.object({
1374
1387
  poll: THREADS_POLL_OPTIONS,
1388
+ topic_tag: THREADS_TOPIC_TAG,
1375
1389
  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."),
1376
1390
  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."),
1377
- }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1391
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`, a poll via `poll`, the topic via `topic_tag`."),
1378
1392
  google_business: GOOGLE_BUSINESS_OPTIONS,
1379
1393
  telegram: TELEGRAM_OPTIONS,
1380
1394
  discord: DISCORD_OPTIONS,
package/build/types.d.ts CHANGED
@@ -631,10 +631,20 @@ export interface HashtagSet {
631
631
  created_at: string;
632
632
  updated_at: string;
633
633
  }
634
- /** The channel an automation runs on: the Instagram account or the Facebook Page. */
635
- export type AutomationPlatform = "instagram" | "facebook";
636
- /** `story_reply` is Instagram only: Facebook has no story replies. */
634
+ /** The channel an automation runs on: the Instagram account, the Facebook Page or the X account. */
635
+ export type AutomationPlatform = "instagram" | "facebook" | "x";
636
+ /** The channels a post-bound automation (`comment_dm_automation`) can run on: X has no comment trigger. */
637
+ export type CommentDmPlatform = "instagram" | "facebook";
638
+ /** `story_reply` is Instagram only: Facebook has no story replies. X has `dm` only. */
637
639
  export type AutomationTriggerKind = "comment" | "dm" | "story_reply";
640
+ /**
641
+ * `queued` = the answer waits for a free slot under the send cap (X 15 per 15
642
+ * minutes and 450 a day, Instagram and Facebook 180 an hour), or for X's
643
+ * reset time after X answered 429, and goes out within the hour, else it
644
+ * becomes `skipped` with `rate_limited`. `opted_out` = the person replied
645
+ * STOP on X (the stop list of that X account).
646
+ */
647
+ export type AutomationEventOutcome = "sent" | "skipped" | "failed" | "pending" | "queued" | "opted_out";
638
648
  export type AutomationScope = "post" | "next_post" | "any";
639
649
  export type AutomationMatch = "word" | "contains" | "any";
640
650
  export interface AutomationPost {
@@ -696,7 +706,11 @@ export interface Automation {
696
706
  /** The body of POST /automations; every section is optional on PATCH. */
697
707
  export interface AutomationInput {
698
708
  name?: string | null;
699
- /** Default instagram; cannot change on PATCH (400 platform_immutable) */
709
+ /**
710
+ * Default instagram; cannot change on PATCH (400 platform_immutable). `x`
711
+ * takes trigger.kinds ["dm"] only, needs X DMs on for an active automation
712
+ * (409 x_dms_off; `status: "paused"` always saves) and has no post fields.
713
+ */
700
714
  platform?: AutomationPlatform;
701
715
  post_id?: string;
702
716
  instagram_media_id?: string;
@@ -726,7 +740,7 @@ export interface AutomationInput {
726
740
  /** `comment_dm_automation` on a post: the automations bound to that post. */
727
741
  export interface CommentDmAutomationInput {
728
742
  /** One automation per listed channel; default Instagram when the post goes there, else Facebook */
729
- platforms?: AutomationPlatform[];
743
+ platforms?: CommentDmPlatform[];
730
744
  trigger?: {
731
745
  keywords?: string[];
732
746
  match?: AutomationMatch;
@@ -748,10 +762,10 @@ export interface CommentDmAutomationInput {
748
762
  /** `comment_dm_automation` on a post: the first automation's fields (Instagram
749
763
  * first), plus the channels and the id of each automation. */
750
764
  export interface PostCommentDmAutomation extends Automation {
751
- platforms?: AutomationPlatform[];
765
+ platforms?: CommentDmPlatform[];
752
766
  automations?: Array<{
753
767
  id: string;
754
- platform: AutomationPlatform;
768
+ platform: CommentDmPlatform;
755
769
  status: Automation["status"];
756
770
  }>;
757
771
  }
@@ -759,10 +773,20 @@ export interface AutomationEvent {
759
773
  id: string;
760
774
  automation_id: string;
761
775
  kind: AutomationTriggerKind;
762
- outcome: "sent" | "skipped" | "failed" | "pending";
776
+ outcome: AutomationEventOutcome;
777
+ /**
778
+ * `skipped`: already_sent, limit_reached, stopped, window_passed,
779
+ * rate_limited (the send cap stayed full for an hour), cannot_message,
780
+ * already_replied, dm_window_passed, dms_closed (X: the person does not
781
+ * accept DMs from the account), no_credits (credits ran out before the
782
+ * answer), opted_out (the person replied STOP before, also while the
783
+ * answer waited), automation_off (paused or finished while the answer
784
+ * waited). `failed`: the platform's error text. A `queued` row carries
785
+ * the reason it waits, an `opted_out` row the reason `opted_out`.
786
+ */
763
787
  reason: string | null;
764
788
  reason_label: string | null;
765
- /** `username` = the Instagram @handle, or the person's name on Facebook */
789
+ /** `username` = the Instagram or X @handle, or the person's name on Facebook */
766
790
  person: {
767
791
  id: string;
768
792
  username: string | null;
@@ -933,6 +957,11 @@ export interface PostAnalyticsStorySlide {
933
957
  export interface PostAnalyticsPlatformEntry {
934
958
  platform: string;
935
959
  platform_post_id: string;
960
+ /** Numbers and strings, plus objects this type leaves out: breakdowns
961
+ * (`reactions`, `impression_sources`, ...) and, on TikTok and Facebook
962
+ * videos, `retention_curve` (RetentionCurve in client.ts). `hold_rate_3s`
963
+ * is the % of viewers still watching at second 3 (TikTok, Facebook Reels,
964
+ * Instagram Reels as 100 - skip_rate). */
936
965
  metrics: Record<string, number | string>;
937
966
  thread_parts: number;
938
967
  collected_at: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.46.0",
3
+ "version": "1.49.0",
4
4
  "mcpName": "io.github.omnisocials/mcp-server",
5
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  "typescript": "^5.8.2"
53
53
  },
54
54
  "overrides": {
55
- "zod": "4.4.0",
55
+ "zod": "4.6.7",
56
56
  "fast-uri": "^3.1.8",
57
57
  "hono": "^4.13.7",
58
58
  "qs": "^6.16.0"