posterly-mcp-server 0.46.0 → 0.47.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "mcpName": "io.github.awpthorp/posterly",
5
5
  "description": "MCP server for posterly: schedule and publish social media posts across 18 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
6
6
  "license": "MIT",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.awpthorp/posterly",
4
4
  "title": "posterly",
5
5
  "description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
6
- "version": "0.46.0",
6
+ "version": "0.47.1",
7
7
  "websiteUrl": "https://www.poster.ly/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/awpthorp/posterly",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "posterly-mcp-server",
17
- "version": "0.46.0",
17
+ "version": "0.47.1",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -190,6 +190,7 @@ export interface ApiPostApproval {
190
190
  asset_status: ApiPostApprovalStatus;
191
191
  caption_status: ApiPostApprovalStatus;
192
192
  waiting_on: 'client' | 'team' | null;
193
+ pending_client_approval: boolean;
193
194
  requested_at: string | null;
194
195
  completed_at: string | null;
195
196
  }
@@ -227,6 +228,23 @@ export interface InstagramPostSettings {
227
228
  media_accessibility?: Array<{ index: number; asset_id?: string; source_url?: string; alt_text: string }>;
228
229
  reel_cover_url?: string;
229
230
  reel_thumb_offset?: number;
231
+ is_ai_generated?: boolean;
232
+ audio_id?: string;
233
+ caption_addon?: {
234
+ type?: 'none' | 'poll' | 'comment_prompt';
235
+ kind?: 'none' | 'poll' | 'comment_prompt';
236
+ options?: string[];
237
+ extended_duration?: boolean;
238
+ } | null;
239
+ poll_attachment?: {
240
+ option_a?: string;
241
+ option_b?: string;
242
+ option_c?: string;
243
+ option_d?: string;
244
+ options?: string[];
245
+ };
246
+ is_comment_prompt_used?: boolean;
247
+ is_poll_duration_extended?: boolean;
230
248
  }
231
249
 
232
250
  export type PlatformPostSettings = Record<string, unknown>;
@@ -1544,6 +1562,7 @@ export class PosterlyClient {
1544
1562
  offset?: number;
1545
1563
  workspace_id?: string;
1546
1564
  approval_status?: string;
1565
+ approval_waiting_on?: string;
1547
1566
  brand_id?: string;
1548
1567
  }): Promise<{ posts: Post[]; total: number }> {
1549
1568
  const searchParams = new URLSearchParams();
@@ -1554,6 +1573,7 @@ export class PosterlyClient {
1554
1573
  if (params?.offset) searchParams.set('offset', String(params.offset));
1555
1574
  if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
1556
1575
  if (params?.approval_status) searchParams.set('approval_status', params.approval_status);
1576
+ if (params?.approval_waiting_on) searchParams.set('approval_waiting_on', params.approval_waiting_on);
1557
1577
  if (params?.brand_id) searchParams.set('brand_id', params.brand_id);
1558
1578
 
1559
1579
  const qs = searchParams.toString();
@@ -1614,8 +1634,27 @@ export class PosterlyClient {
1614
1634
  return this.request('PATCH', `/posts/${id}`, data);
1615
1635
  }
1616
1636
 
1617
- async deletePost(id: number): Promise<{ deleted: boolean; id: number }> {
1618
- return this.request('DELETE', `/posts/${id}`);
1637
+ async deletePost(
1638
+ id: number,
1639
+ options?: { confirmPublished?: boolean },
1640
+ ): Promise<{ deleted: boolean; id: number; platform_delete?: string }> {
1641
+ const qs = options?.confirmPublished ? '?confirm_published=true' : '';
1642
+ return this.request('DELETE', `/posts/${id}${qs}`);
1643
+ }
1644
+
1645
+ async setPostApproval(
1646
+ id: number,
1647
+ data: {
1648
+ action: 'request' | 'approve' | 'request_changes' | 'reject';
1649
+ axis?: 'asset' | 'caption';
1650
+ note?: string;
1651
+ },
1652
+ ): Promise<Record<string, unknown>> {
1653
+ return this.request('POST', `/posts/${id}/approval`, {
1654
+ action: data.action,
1655
+ ...(data.axis ? { axis: data.axis } : {}),
1656
+ ...(data.note ? { note: data.note } : {}),
1657
+ });
1619
1658
  }
1620
1659
 
1621
1660
  async updatePostStatus(
@@ -5,4 +5,4 @@
5
5
  // tool set (minus the intentional pre-auth signup tools that only this stdio
6
6
  // package exposes). `npm run check:mcp-parity` enforces both the version match
7
7
  // and the tool-list match, and runs in the pre-commit hook.
8
- export const POSTERLY_MCP_VERSION = '0.46.0';
8
+ export const POSTERLY_MCP_VERSION = '0.47.1';
@@ -31,6 +31,31 @@ export const instagramSettingsSchema = z.object({
31
31
  reel_thumb_offset: z.number().nonnegative().optional(),
32
32
  is_ai_generated: z.boolean().optional().describe('Label the parent Instagram media container as AI-generated. For carousels, set on the parent only.'),
33
33
  audio_id: z.string().optional().describe('Licensed Instagram audio ID for Reels. Meta-linked Facebook Login accounts only.'),
34
+ caption_addon: z.union([
35
+ z.object({
36
+ type: z.enum(['none', 'poll', 'comment_prompt']).optional(),
37
+ kind: z.enum(['none', 'poll', 'comment_prompt']).optional(),
38
+ options: z.array(z.string()).max(4).optional(),
39
+ extended_duration: z.boolean().optional(),
40
+ poll_attachment: z.object({
41
+ option_a: z.string().optional(),
42
+ option_b: z.string().optional(),
43
+ option_c: z.string().optional(),
44
+ option_d: z.string().optional(),
45
+ options: z.array(z.string()).max(4).optional(),
46
+ }).optional(),
47
+ }),
48
+ z.null(),
49
+ ]).optional().describe('Optional Instagram caption add-on. type is none, poll, or comment_prompt. A poll needs 2 to 4 options of 1 to 25 characters (an emoji counts as one) and the caption is the question. A comment prompt uses the caption as the prompt. Facebook Login only. Not available on Stories. At most one add-on per post. Pass null or type none to clear it.'),
50
+ poll_attachment: z.object({
51
+ option_a: z.string().optional(),
52
+ option_b: z.string().optional(),
53
+ option_c: z.string().optional(),
54
+ option_d: z.string().optional(),
55
+ options: z.array(z.string()).max(4).optional(),
56
+ }).optional().describe('Instagram poll choices. option_a and option_b are required, option_c and option_d are optional, and option_d requires option_c. Mutually exclusive with a comment prompt. The caption is the question. Serialised JSON must be 200 characters or fewer.'),
57
+ is_comment_prompt_used: z.boolean().optional().describe('Set true to attach an Instagram comment prompt. The caption is the prompt. Mutually exclusive with a poll. Facebook Login only. Not available on Stories.'),
58
+ is_poll_duration_extended: z.boolean().optional().describe('Set true only together with a poll, to request the longer Instagram poll duration.'),
34
59
  });
35
60
 
36
61
  export const platformSettingsSchema = z.object({
@@ -124,6 +149,17 @@ export const platformSettingsSchema = z.object({
124
149
  paid_partnership: z.boolean().optional(), // X paid partnership
125
150
  is_ai_generated: z.boolean().optional(),
126
151
  audio_id: z.string().optional(),
152
+ caption_addon: z.union([
153
+ z.object({
154
+ type: z.enum(['none', 'poll', 'comment_prompt']).optional(),
155
+ options: z.array(z.string()).max(4).optional(),
156
+ extended_duration: z.boolean().optional(),
157
+ }),
158
+ z.null(),
159
+ ]).optional(),
160
+ poll_attachment: z.record(z.string(), z.unknown()).optional(),
161
+ is_comment_prompt_used: z.boolean().optional(),
162
+ is_poll_duration_extended: z.boolean().optional(),
127
163
  // tags (z.array of strings) also serve as Bluesky hidden discovery tags
128
164
  // AI-content disclosure (TikTok is_aigc, X made_with_ai, YouTube containsSyntheticMedia, Pinterest ai_disclosures)
129
165
  ai_generated: z.boolean().optional(),
@@ -314,7 +350,7 @@ export const createPostTool = {
314
350
  'Provide either account_id OR username+platform to identify the account. If scheduled_at is omitted, the post publishes immediately. If workspace_id is omitted, the server resolves one from the social account, falling back to the caller\'s default (personal) workspace - pass workspace_id explicitly if the user has more than one workspace.\n\n' +
315
351
  'MEDIA URLS: Posterly storage URLs are used as-is. Third-party HTTP(S) media URLs are copied into posterly storage before the post is saved, so API-created posts do not later fail on expired signed URLs. Short-lived signed URLs must still be live when this tool runs. You can also call `upload_media` with `url` first if you want to upload once and reuse the returned posterly URL across posts.\n\n' +
316
352
  'THREADS: Pass `thread_posts` (an array of 2+ strings) to schedule a multi-post thread on X (Twitter) or Threads (Meta). The first entry is the lead post; the rest are published as replies in the same chain. X entries are capped at 280 characters each (4000 for verified, 25000 for organization accounts); Threads entries are capped at 500 characters each. When `thread_posts` is set, `caption` is ignored.\n\n' +
317
- 'PLATFORM SETTINGS: Pass `platform_settings` for composer-equivalent controls: Facebook story/reel/backgrounds, YouTube title/privacy/thumbnail/brandPartner, LinkedIn document titles/mentions/polls/content_call_to_action_label (including BUY_NOW and SHOP_NOW), TikTok direct-post privacy/toggles/commercial disclosure, Pinterest board/title/link/product_tags, GBP event/offer/CTA/recurrence, X reply settings/polls/paid_partnership, Threads reply controls/text attachments/ghost posts/spoilers/reply approvals/GIPHY, Telegram polls/buttons/video cover/live photo, Slack markdown/blocks, Mastodon visibility/quote/poll (media+poll allowed), and Instagram feed/story/reel/carousel/native media tags/collaborators/first comments/trial Reels/is_ai_generated/audio_id. For Instagram, @username in caption is only a caption mention; `user_tags` creates native media tags and `collaborators` sends co-author invitations only when the selected Meta-linked account reports those capabilities. Call `list_platforms` with `view: "schema"` and the account ID first. `instagram_settings` remains as a backwards-compatible alias.\n\n' +
353
+ 'PLATFORM SETTINGS: Pass `platform_settings` for composer-equivalent controls: Facebook story/reel/backgrounds, YouTube title/privacy/thumbnail/brandPartner, LinkedIn document titles/mentions/polls/content_call_to_action_label (including BUY_NOW and SHOP_NOW), TikTok direct-post privacy/toggles/commercial disclosure, Pinterest board/title/link/product_tags, GBP event/offer/CTA/recurrence, X reply settings/polls/paid_partnership, Threads reply controls/text attachments/ghost posts/spoilers/reply approvals/GIPHY, Telegram polls/buttons/video cover/live photo, Slack markdown/blocks, Mastodon visibility/quote/poll (media+poll allowed), and Instagram feed/story/reel/carousel/native media tags/collaborators/first comments/trial Reels/is_ai_generated/audio_id/caption add-ons. Instagram caption add-ons (`caption_addon`, or `poll_attachment` / `is_comment_prompt_used`) need a Facebook Login account, a non-empty caption, and a feed photo, Reel, or carousel parent. Stories are rejected. A poll and a comment prompt cannot be combined. For Instagram, @username in caption is only a caption mention; `user_tags` creates native media tags and `collaborators` sends co-author invitations only when the selected Meta-linked account reports those capabilities. Call `list_platforms` with `view: "schema"` and the account ID first. `instagram_settings` remains as a backwards-compatible alias.\n\n' +
318
354
  'TIKTOK MEDIA: TikTok supports a single video or a photo slideshow of 1 to 35 images, never multiple videos. Image media auto-detects as a slideshow, so you usually do not set post_type or media_type for photos and every image is posted. Pass `platform_settings.media_type: "PHOTO"` to force a slideshow explicitly.',
319
355
  inputSchema: createPostInputSchema,
320
356
 
@@ -363,6 +399,16 @@ export const createPostTool = {
363
399
  if (instagram_settings?.first_comment) {
364
400
  lines.push('• Instagram first comment: yes');
365
401
  }
402
+ const captionAddonType = instagram_settings?.caption_addon && typeof instagram_settings.caption_addon === 'object'
403
+ ? instagram_settings.caption_addon.type || instagram_settings.caption_addon.kind
404
+ : null;
405
+ if (captionAddonType && captionAddonType !== 'none') {
406
+ lines.push(`• Instagram caption add-on: ${captionAddonType}`);
407
+ } else if (instagram_settings?.is_comment_prompt_used) {
408
+ lines.push('• Instagram caption add-on: comment_prompt');
409
+ } else if (instagram_settings?.poll_attachment) {
410
+ lines.push('• Instagram caption add-on: poll');
411
+ }
366
412
  if (platform_settings) {
367
413
  lines.push('• Platform settings: yes');
368
414
  }
@@ -6,6 +6,7 @@ import { previewThenConfirm, validateArgs, type ToolContext } from '../lib/tool-
6
6
  type DeletePostInput = {
7
7
  post_id?: number;
8
8
  group_id?: string;
9
+ confirm_published?: boolean;
9
10
  confirm?: boolean;
10
11
  preview_id?: string;
11
12
  };
@@ -13,10 +14,14 @@ type DeletePostInput = {
13
14
  export const deletePostTool = {
14
15
  name: 'delete_post',
15
16
  description:
16
- 'Delete posts. DESTRUCTIVE and IRREVERSIBLE: deleted posts and captions cannot be recovered, and published or currently publishing posts are never deleted. post_id: delete one scheduled or draft post; fetch it with `get_post` first, show the user what will be deleted (caption, account, scheduled time), and pass confirm=true only after explicit confirmation ("yes delete it", "remove it"). group_id: delete every draft, scheduled, failed, or paused post matching a caller-defined group_id, post_group_id, api_group_id, or release_id; preview then confirm: call without confirm to get a preview and preview_id, show it, then call again with confirm=true and that preview_id. Never delete several posts one by one without listing each and confirming the full list.',
17
+ 'Delete posts. DESTRUCTIVE and IRREVERSIBLE: deleted posts and captions cannot be recovered. post_id: delete one post; fetch it with `get_post` first, show the user what will be deleted (caption, account, scheduled time), and pass confirm=true only after explicit confirmation ("yes delete it", "remove it"). A post that is currently publishing is never deleted. A published post is taken down only when the user also explicitly confirms the live removal and you pass confirm_published=true. Instagram and Facebook are attempted when the stored id and token allow it. Every other platform, or a live URL with no id, returns platform_delete_unsupported and the posterly row is kept. A published post with no live URL and no platform id is removed from posterly only. group_id: delete every draft, scheduled, failed, or paused post matching a caller-defined group_id, post_group_id, api_group_id, or release_id. A group delete never removes published posts. Preview then confirm: call without confirm to get a preview and preview_id, show it, then call again with confirm=true and that preview_id. Never delete several posts one by one without listing each and confirming the full list.',
17
18
  inputSchema: z.object({
18
19
  post_id: z.number().optional().describe('Delete this one post.'),
19
- group_id: z.string().min(1).max(160).optional().describe('Delete every unpublished post in this group or release instead.'),
20
+ group_id: z.string().min(1).max(160).optional().describe('Delete every unpublished post in this group or release instead. Never deletes published posts.'),
21
+ confirm_published: z
22
+ .boolean()
23
+ .optional()
24
+ .describe('With post_id only. True only after the user explicitly confirms taking a live Instagram or Facebook post down. Without it, a published post is refused and kept.'),
20
25
  confirm: z
21
26
  .boolean()
22
27
  .optional()
@@ -44,7 +49,7 @@ export const deletePostTool = {
44
49
  // GET /api/v1/posts has no group filter, so the preview says in plain
45
50
  // words which posts match instead of listing them.
46
51
  describe: async () => [
47
- `Deletes every draft, scheduled, failed, or paused post whose group_id, post_group_id, api_group_id, or release_id is ${code(args.group_id)}. Published posts are never deleted. This cannot be undone.`,
52
+ `Deletes every draft, scheduled, failed, or paused post whose group_id, post_group_id, api_group_id, or release_id is ${code(args.group_id)}. A group delete never removes published posts. This cannot be undone.`,
48
53
  'To see the posts first, call list_posts and check each post\'s group or release ID.',
49
54
  ].join('\n\n'),
50
55
  run: async () => {
@@ -53,17 +58,21 @@ export const deletePostTool = {
53
58
  },
54
59
  });
55
60
  }
56
- return deleteOne(client, args as { post_id: number; confirm?: boolean });
61
+ return deleteOne(client, args as { post_id: number; confirm?: boolean; confirm_published?: boolean });
57
62
  },
58
63
  };
59
64
 
60
- async function deleteOne(client: PosterlyClient, input: { post_id: number; confirm?: boolean }): Promise<string> {
65
+ async function deleteOne(
66
+ client: PosterlyClient,
67
+ input: { post_id: number; confirm?: boolean; confirm_published?: boolean },
68
+ ): Promise<string> {
61
69
  if (input.confirm !== true) {
62
70
  throw new Error('Refusing to delete post without confirm=true after explicit user confirmation.');
63
71
  }
64
- const result = await client.deletePost(input.post_id);
72
+ const result = await client.deletePost(input.post_id, { confirmPublished: input.confirm_published === true });
73
+ const platformDelete = result.platform_delete ? ` Platform delete: ${result.platform_delete}.` : '';
65
74
  return [
66
- `Post #${result.id} deleted successfully.`,
75
+ `Post #${result.id} deleted successfully.${platformDelete}`,
67
76
  'It has been removed from the posterly queue.',
68
77
  `View queue in posterly: ${dashboardUrlForPosts()}`,
69
78
  '',
@@ -29,7 +29,7 @@ type ListAnalyticsInput = {
29
29
  export const listAnalyticsTool = {
30
30
  name: 'list_analytics',
31
31
  description:
32
- 'Analytics for connected social accounts (Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads). view=accounts (account_id): daily snapshots and a period summary with platform-native display_metrics labels. view=posts (account_id): per-post likes, comments/replies, reach, impressions/views, saves, shares, reposts, quotes, plays, clicks, and watch time, most recent first, with limit/offset. view=insights: per-post feedback insights with a performance tier (great/good/mixed/poor), diagnosis, next action, and baseline, filtered by account_id, post_id, or checkpoint (1h, 6h, 24h, 72h, 7d); read-only, Pro plan or higher. presentation (accounts and posts): compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
32
+ 'Analytics for connected social accounts (Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads). view=accounts (account_id): daily snapshots and a period summary with platform-native display_metrics labels. Google Business Profile account analytics includes Calls, Website clicks, and Directions along with Profile Views, Search Views, Maps Views, Customer Actions, and Posts. view=posts (account_id): per-post likes, comments/replies, reach, impressions/views, saves, shares, reposts, quotes, plays, clicks, and watch time, most recent first, with limit/offset. view=insights: per-post feedback insights with a performance tier (great/good/mixed/poor), diagnosis, next action, and baseline, filtered by account_id, post_id, or checkpoint (1h, 6h, 24h, 72h, 7d); read-only, Pro plan or higher. presentation (accounts and posts): compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
33
33
  inputSchema: z.object({
34
34
  view: z.enum(['accounts', 'posts', 'insights']).describe('accounts, posts, or insights.'),
35
35
  account_id: z
@@ -194,6 +194,9 @@ function accountMetricRows(summary: AccountAnalyticsSummary, platform: string):
194
194
  ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
195
195
  ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
196
196
  ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
197
+ ['Calls', formatNumber(metrics.call_clicks ?? summary.total_call_clicks)],
198
+ ['Directions', formatNumber(metrics.direction_requests ?? summary.total_direction_requests)],
199
+ ['Website clicks', formatNumber(metrics.website_clicks ?? summary.total_website_clicks)],
197
200
  ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
198
201
  ];
199
202
  }
@@ -6,7 +6,7 @@ import { SUPPORTED_PLATFORM_INPUTS, SUPPORTED_PLATFORM_IDS } from '../generated/
6
6
  export const listPostsTool = {
7
7
  name: 'list_posts',
8
8
  description:
9
- 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, workspace_id, approval_status, or brand_id. Returns posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to.',
9
+ 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, workspace_id, approval_status, approval_waiting_on, or brand_id. approval_waiting_on=client is the same queue as approval_status=pending_client. Each post includes approval.pending_client_approval. Returns posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to.',
10
10
  inputSchema: z.object({
11
11
  status: z
12
12
  .string()
@@ -34,6 +34,10 @@ export const listPostsTool = {
34
34
  .enum(['none', 'pending_client', 'approved', 'changes_requested', 'rejected'])
35
35
  .optional()
36
36
  .describe('Filter by client review approval status. pending_client means the post is waiting on the client.'),
37
+ approval_waiting_on: z
38
+ .enum(['client', 'team'])
39
+ .optional()
40
+ .describe('client: approval_status pending_client. team: changes_requested or rejected. Conflicts with a non-overlapping approval_status.'),
37
41
  brand_id: z
38
42
  .string()
39
43
  .optional()
@@ -49,6 +53,7 @@ export const listPostsTool = {
49
53
  limit?: number;
50
54
  workspace_id?: string;
51
55
  approval_status?: string;
56
+ approval_waiting_on?: 'client' | 'team';
52
57
  brand_id?: string;
53
58
  }
54
59
  ) {
@@ -32,6 +32,31 @@ const instagramSettingsSchema = z.object({
32
32
  reel_thumb_offset: z.number().nonnegative().optional(),
33
33
  is_ai_generated: z.boolean().optional().describe('Label the parent Instagram media container as AI-generated. For carousels, set on the parent only.'),
34
34
  audio_id: z.string().optional().describe('Licensed Instagram audio ID for Reels. Meta-linked Facebook Login accounts only.'),
35
+ caption_addon: z.union([
36
+ z.object({
37
+ type: z.enum(['none', 'poll', 'comment_prompt']).optional(),
38
+ kind: z.enum(['none', 'poll', 'comment_prompt']).optional(),
39
+ options: z.array(z.string()).max(4).optional(),
40
+ extended_duration: z.boolean().optional(),
41
+ poll_attachment: z.object({
42
+ option_a: z.string().optional(),
43
+ option_b: z.string().optional(),
44
+ option_c: z.string().optional(),
45
+ option_d: z.string().optional(),
46
+ options: z.array(z.string()).max(4).optional(),
47
+ }).optional(),
48
+ }),
49
+ z.null(),
50
+ ]).optional().describe('Optional Instagram caption add-on. type is none, poll, or comment_prompt. A poll needs 2 to 4 options of 1 to 25 characters (an emoji counts as one) and the caption is the question. A comment prompt uses the caption as the prompt. Facebook Login only. Not available on Stories. At most one add-on per post. Pass null or type none to clear it.'),
51
+ poll_attachment: z.object({
52
+ option_a: z.string().optional(),
53
+ option_b: z.string().optional(),
54
+ option_c: z.string().optional(),
55
+ option_d: z.string().optional(),
56
+ options: z.array(z.string()).max(4).optional(),
57
+ }).optional().describe('Instagram poll choices. option_a and option_b are required, option_c and option_d are optional, and option_d requires option_c. Mutually exclusive with a comment prompt. The caption is the question. Serialised JSON must be 200 characters or fewer.'),
58
+ is_comment_prompt_used: z.boolean().optional().describe('Set true to attach an Instagram comment prompt. The caption is the prompt. Mutually exclusive with a poll. Facebook Login only. Not available on Stories.'),
59
+ is_poll_duration_extended: z.boolean().optional().describe('Set true only together with a poll, to request the longer Instagram poll duration.'),
35
60
  });
36
61
 
37
62
  const platformSettingsSchema = z.object({
@@ -108,6 +133,17 @@ const platformSettingsSchema = z.object({
108
133
  paid_partnership: z.boolean().optional(),
109
134
  is_ai_generated: z.boolean().optional(),
110
135
  audio_id: z.string().optional(),
136
+ caption_addon: z.union([
137
+ z.object({
138
+ type: z.enum(['none', 'poll', 'comment_prompt']).optional(),
139
+ options: z.array(z.string()).max(4).optional(),
140
+ extended_duration: z.boolean().optional(),
141
+ }),
142
+ z.null(),
143
+ ]).optional(),
144
+ poll_attachment: z.record(z.string(), z.unknown()).optional(),
145
+ is_comment_prompt_used: z.boolean().optional(),
146
+ is_poll_duration_extended: z.boolean().optional(),
111
147
  ai_generated: z.boolean().optional(),
112
148
  }).passthrough();
113
149
 
@@ -127,12 +163,15 @@ type UpdatePostInput = EditInput & {
127
163
  status?: 'scheduled' | 'paused' | 'draft';
128
164
  release_id?: string;
129
165
  group_id?: string;
166
+ approval_action?: 'request' | 'approve' | 'request_changes' | 'reject';
167
+ approval_axis?: 'asset' | 'caption';
168
+ approval_note?: string;
130
169
  };
131
170
 
132
171
  export const updatePostTool = {
133
172
  name: 'update_post',
134
173
  description:
135
- 'Change a scheduled or draft post. DESTRUCTIVE WRITE. Edit (default): caption, media, schedule, post_type, or settings; overwrites the existing post; cannot edit published or currently publishing posts; if a client has already approved the post, a caption edit reopens caption approval, a media or thumbnail edit reopens asset approval, and a reschedule, account change or settings change reopens both. status (scheduled, paused, or draft, with scheduled_at when scheduling a draft): pause, resume, or move back to draft; send it on its own. release_id (with optional group_id): set or repair external release/group IDs; send it on its own.\n\n' +
174
+ 'Change a scheduled or draft post. DESTRUCTIVE WRITE. Edit (default): caption, media, schedule, post_type, or settings; overwrites the existing post; cannot edit published or currently publishing posts; if a client has already approved the post, a caption edit reopens caption approval, a media or thumbnail edit reopens asset approval, and a reschedule, account change or settings change reopens both. status (scheduled, paused, or draft, with scheduled_at when scheduling a draft): pause, resume, or move back to draft; send it on its own. release_id (with optional group_id): set or repair external release/group IDs; send it on its own. approval_action (request, approve, request_changes, or reject): change client approval. Only a workspace owner or admin may do this. The actor is the API key user, never a brand guest. request emails the brand reviewers. approve, request_changes, and reject are team-side decisions. request_changes needs approval_note. Optional approval_axis is asset or caption; omit it for both. Send approval_action on its own.\n\n' +
136
175
  'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media, status) and the PROPOSED change, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction - each change needs its own confirmation.',
137
176
  inputSchema: z.object({
138
177
  post_id: z.number().describe('The post ID to update'),
@@ -170,6 +209,18 @@ export const updatePostTool = {
170
209
  .optional()
171
210
  .describe('Set an external release ID instead of editing the post. Send with post_id and optional group_id only.'),
172
211
  group_id: z.string().min(1).max(160).optional().describe('With release_id only: an external group/campaign ID.'),
212
+ approval_action: z
213
+ .enum(['request', 'approve', 'request_changes', 'reject'])
214
+ .optional()
215
+ .describe('Change client approval instead of editing the post. Owner or admin only. The actor is the API key user, never a brand guest. Send on its own.'),
216
+ approval_axis: z
217
+ .enum(['asset', 'caption'])
218
+ .optional()
219
+ .describe('With approval_action approve, request_changes, or reject: which axis to decide. Omit for both. Not allowed with request.'),
220
+ approval_note: z
221
+ .string()
222
+ .optional()
223
+ .describe('Note stored with the approval change. Required for request_changes.'),
173
224
  confirm: z.literal(true).describe('Must be true after explicit user confirmation of the proposed change.'),
174
225
  }),
175
226
 
@@ -193,6 +244,15 @@ export const updatePostTool = {
193
244
  const { mode, args } = validateArgs(ctx, 'update_post', input);
194
245
  if (mode === 'status') return updateStatus(client, args as { post_id: number; status: 'scheduled' | 'paused' | 'draft'; scheduled_at?: string; confirm: boolean });
195
246
  if (mode === 'release_id') return updateReleaseId(client, args as { post_id: number; release_id: string; group_id?: string; confirm: true });
247
+ if (mode === 'approval') {
248
+ return updateApproval(client, args as {
249
+ post_id: number;
250
+ approval_action: 'request' | 'approve' | 'request_changes' | 'reject';
251
+ approval_axis?: 'asset' | 'caption';
252
+ approval_note?: string;
253
+ confirm: true;
254
+ });
255
+ }
196
256
  return editPost(client, args as EditInput);
197
257
  },
198
258
  };
@@ -241,6 +301,27 @@ async function updateStatus(
241
301
  return `Post #${input.post_id} status changed from ${transition.old_status || 'unknown'} to ${transition.new_status || input.status}.`;
242
302
  }
243
303
 
304
+ async function updateApproval(
305
+ client: PosterlyClient,
306
+ input: {
307
+ post_id: number;
308
+ approval_action: 'request' | 'approve' | 'request_changes' | 'reject';
309
+ approval_axis?: 'asset' | 'caption';
310
+ approval_note?: string;
311
+ confirm: true;
312
+ },
313
+ ): Promise<string> {
314
+ if (input.confirm !== true) {
315
+ throw new Error('Refusing to change client approval without confirm=true after explicit user confirmation. Only a workspace owner or admin can do this, and the actor is the API key user, never a brand guest.');
316
+ }
317
+ const result = await client.setPostApproval(input.post_id, {
318
+ action: input.approval_action,
319
+ axis: input.approval_axis,
320
+ note: input.approval_note,
321
+ });
322
+ return JSON.stringify(result, null, 2);
323
+ }
324
+
244
325
  async function updateReleaseId(
245
326
  client: PosterlyClient,
246
327
  input: { post_id: number; release_id: string; group_id?: string; confirm: true },
@@ -736,6 +736,43 @@
736
736
  "type": "string"
737
737
  }
738
738
  }
739
+ },
740
+ "approval": {
741
+ "label": "Request, approve, request changes, or reject client review",
742
+ "destructive": true,
743
+ "idempotent": true,
744
+ "openWorld": false,
745
+ "confirm": "confirm",
746
+ "when": [
747
+ "approval_action"
748
+ ],
749
+ "params": {
750
+ "post_id": {
751
+ "type": "integer",
752
+ "min": 1,
753
+ "required": true
754
+ },
755
+ "approval_action": {
756
+ "type": "string",
757
+ "enum": [
758
+ "request",
759
+ "approve",
760
+ "request_changes",
761
+ "reject"
762
+ ],
763
+ "required": true
764
+ },
765
+ "approval_axis": {
766
+ "type": "string",
767
+ "enum": [
768
+ "asset",
769
+ "caption"
770
+ ]
771
+ },
772
+ "approval_note": {
773
+ "type": "string"
774
+ }
775
+ }
739
776
  }
740
777
  }
741
778
  },
@@ -760,6 +797,9 @@
760
797
  "type": "integer",
761
798
  "min": 1,
762
799
  "required": true
800
+ },
801
+ "confirm_published": {
802
+ "type": "boolean"
763
803
  }
764
804
  }
765
805
  },