posterly-mcp-server 0.8.2 → 0.8.3

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.
@@ -80,6 +80,37 @@ export interface InstagramPostSettings {
80
80
 
81
81
  export type PlatformPostSettings = Record<string, unknown>;
82
82
 
83
+ export type CreatePostPayload = {
84
+ account_id?: string;
85
+ username?: string;
86
+ platform?: string;
87
+ caption: string;
88
+ scheduled_at?: string;
89
+ post_type?: string;
90
+ media_url?: string;
91
+ media_urls?: string[];
92
+ metadata?: Record<string, unknown>;
93
+ settings?: PlatformPostSettings;
94
+ reel_cover_url?: string;
95
+ reel_cover_method?: string;
96
+ reel_thumb_offset?: number;
97
+ workspace_id?: string;
98
+ };
99
+
100
+ export type CreatePostResponse = {
101
+ post: Post;
102
+ workspace: { id: string; name: string; is_personal: boolean; resolved_from: 'explicit' | 'account' | 'personal' };
103
+ };
104
+
105
+ export type BatchCreatePostsResponse = {
106
+ posts: Array<CreatePostResponse & { index: number; replayed: boolean }>;
107
+ errors: Array<{ index: number; status: number; error: string; validation_errors?: unknown }>;
108
+ total: number;
109
+ created: number;
110
+ replayed: number;
111
+ failed: number;
112
+ };
113
+
83
114
  export interface Slot {
84
115
  time: string;
85
116
  local_time: string;
@@ -275,28 +306,14 @@ export class PosterlyClient {
275
306
  return this.request('GET', `/posts${qs ? `?${qs}` : ''}`);
276
307
  }
277
308
 
278
- async createPost(data: {
279
- account_id?: string;
280
- username?: string;
281
- platform?: string;
282
- caption: string;
283
- scheduled_at?: string;
284
- post_type?: string;
285
- media_url?: string;
286
- media_urls?: string[];
287
- metadata?: Record<string, unknown>;
288
- settings?: PlatformPostSettings;
289
- reel_cover_url?: string;
290
- reel_cover_method?: string;
291
- reel_thumb_offset?: number;
292
- workspace_id?: string;
293
- }): Promise<{
294
- post: Post;
295
- workspace: { id: string; name: string; is_personal: boolean; resolved_from: 'explicit' | 'account' | 'personal' };
296
- }> {
309
+ async createPost(data: CreatePostPayload): Promise<CreatePostResponse> {
297
310
  return this.request('POST', '/posts', data);
298
311
  }
299
312
 
313
+ async createPostsBatch(data: { posts: CreatePostPayload[] }): Promise<BatchCreatePostsResponse> {
314
+ return this.request('POST', '/posts/batch', data);
315
+ }
316
+
300
317
  async getPost(id: number): Promise<{ post: Post }> {
301
318
  return this.request('GET', `/posts/${id}`);
302
319
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
3
 
4
- const instagramSettingsSchema = z.object({
4
+ export const instagramSettingsSchema = z.object({
5
5
  __type: z.enum(['instagram', 'instagram-standalone']).optional(),
6
6
  post_type: z.enum(['post', 'feed', 'story', 'reel', 'carousel']).optional(),
7
7
  is_trial_reel: z.boolean().optional(),
@@ -18,7 +18,7 @@ const instagramSettingsSchema = z.object({
18
18
  reel_thumb_offset: z.number().nonnegative().optional(),
19
19
  });
20
20
 
21
- const platformSettingsSchema = z.object({
21
+ export const platformSettingsSchema = z.object({
22
22
  __type: z.string().optional(),
23
23
  post_type: z.string().optional(),
24
24
  content_type: z.string().optional(),
@@ -71,6 +71,88 @@ const platformSettingsSchema = z.object({
71
71
  langs: z.array(z.string()).optional(),
72
72
  }).passthrough();
73
73
 
74
+ export const createPostInputSchema = z.object({
75
+ account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
76
+ username: z.string().optional().describe('Account username (alternative to account_id)'),
77
+ platform: z
78
+ .string()
79
+ .optional()
80
+ .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
81
+ caption: z.string().optional().describe('The post caption/text content. Ignored when `thread_posts` is provided.'),
82
+ scheduled_at: z
83
+ .string()
84
+ .optional()
85
+ .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
86
+ media_url: z.string().optional().describe('URL of media to attach (image or video). For threads, attaches to the lead post only.'),
87
+ media_urls: z
88
+ .array(z.string())
89
+ .optional()
90
+ .describe('Multiple media URLs for carousel posts'),
91
+ post_type: z
92
+ .string()
93
+ .optional()
94
+ .describe('Post type: text, image, video, carousel, reel, story. Auto-set to x_thread/threads_thread when `thread_posts` is provided.'),
95
+ thread_posts: z
96
+ .array(z.string())
97
+ .optional()
98
+ .describe('For X or Threads only: array of 2+ strings, one per post in the thread. The first entry leads, the rest reply in order. When set, the platform must be twitter or threads.'),
99
+ instagram_settings: instagramSettingsSchema
100
+ .optional()
101
+ .describe('Instagram-specific settings: post_type (post/feed/story/reel/carousel), collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy (defaults to MANUAL), reel_cover_url, reel_thumb_offset.'),
102
+ platform_settings: platformSettingsSchema
103
+ .optional()
104
+ .describe('Platform-specific settings for the selected account. Use this for non-Instagram settings and prefer it over raw metadata.'),
105
+ workspace_id: z
106
+ .string()
107
+ .optional()
108
+ .describe('Workspace ID to assign the post to (from whoami). If omitted, uses the account\'s workspace or the caller\'s default workspace.'),
109
+ });
110
+
111
+ export type CreatePostInput = z.infer<typeof createPostInputSchema>;
112
+
113
+ export function buildCreatePostPayload(input: CreatePostInput): Parameters<PosterlyClient['createPost']>[0] {
114
+ const { thread_posts, caption, post_type, instagram_settings, platform_settings, ...rest } = input;
115
+
116
+ if (thread_posts && thread_posts.length > 0) {
117
+ if (thread_posts.length < 2) {
118
+ throw new Error('thread_posts must contain at least 2 entries');
119
+ }
120
+ const platformHint = (input.platform || '').toLowerCase();
121
+ const isTwitter = platformHint === 'twitter' || platformHint === 'x';
122
+ const isThreads = platformHint === 'threads';
123
+ if (!isTwitter && !isThreads && !input.account_id) {
124
+ throw new Error(
125
+ 'thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.',
126
+ );
127
+ }
128
+ const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
129
+ const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
130
+ return {
131
+ ...rest,
132
+ platform: isTwitter ? 'twitter' : isThreads ? 'threads' : input.platform,
133
+ caption: thread_posts[0],
134
+ post_type: isThreads ? 'threads_thread' : 'x_thread',
135
+ metadata: {
136
+ [arrayKey]: thread_posts,
137
+ [totalKey]: thread_posts.length,
138
+ },
139
+ };
140
+ }
141
+
142
+ if (!caption) {
143
+ throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
144
+ }
145
+
146
+ return {
147
+ ...rest,
148
+ caption,
149
+ post_type,
150
+ ...(platform_settings || instagram_settings
151
+ ? { settings: { ...(instagram_settings ? { __type: 'instagram', ...instagram_settings } : {}), ...(platform_settings || {}) } }
152
+ : {}),
153
+ };
154
+ }
155
+
74
156
  export const createPostTool = {
75
157
  name: 'create_post',
76
158
  description:
@@ -79,46 +161,11 @@ export const createPostTool = {
79
161
  '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
80
162
  '2. Show the user a preview containing ALL of: account(s) and platform(s), final caption text, scheduled time (in the user\'s timezone), media attached (if any), and workspace name.\n' +
81
163
  '3. Get explicit confirmation from the user (e.g. "post it", "yes schedule that", "looks good") BEFORE calling this tool. Do NOT infer consent from earlier instructions like "post about X every Monday" — confirm each individual post or the entire batch.\n' +
82
- '4. If scheduling multiple posts in one turn, list every post first and confirm the batch as a whole before calling create_post repeatedly.\n\n' +
164
+ '4. If scheduling multiple posts in one turn, list every post first and confirm the batch as a whole, then prefer `create_posts_batch` so they are created in one API request.\n\n' +
83
165
  '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' +
84
166
  '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' +
85
167
  'PLATFORM SETTINGS: Pass `platform_settings` for composer-equivalent controls: Facebook story/reel/backgrounds, YouTube title/privacy/thumbnail/playlist, LinkedIn document titles/mentions/video thumbnails, TikTok direct-post privacy/toggles/commercial disclosure, Pinterest board/link/title/video cover, GBP event/offer/CTA, X reply settings/polls, Threads reply controls/text attachments, Bluesky languages/alt text, and Instagram feed/story/reel/carousel/collaborators/first comments/trial Reels. `instagram_settings` remains as a backwards-compatible alias.',
86
- inputSchema: z.object({
87
- account_id: z.string().optional().describe('Social account ID (from list_accounts)'),
88
- username: z.string().optional().describe('Account username (alternative to account_id)'),
89
- platform: z
90
- .string()
91
- .optional()
92
- .describe('Platform name (required with username): instagram, twitter, linkedin, facebook, tiktok, threads, youtube, pinterest, google_business'),
93
- caption: z.string().optional().describe('The post caption/text content. Ignored when `thread_posts` is provided.'),
94
- scheduled_at: z
95
- .string()
96
- .optional()
97
- .describe('ISO 8601 datetime for scheduling (e.g. 2026-03-05T09:00:00Z). Omit for immediate publish.'),
98
- media_url: z.string().optional().describe('URL of media to attach (image or video). For threads, attaches to the lead post only.'),
99
- media_urls: z
100
- .array(z.string())
101
- .optional()
102
- .describe('Multiple media URLs for carousel posts'),
103
- post_type: z
104
- .string()
105
- .optional()
106
- .describe('Post type: text, image, video, carousel, reel, story. Auto-set to x_thread/threads_thread when `thread_posts` is provided.'),
107
- thread_posts: z
108
- .array(z.string())
109
- .optional()
110
- .describe('For X or Threads only: array of 2+ strings, one per post in the thread. The first entry leads, the rest reply in order. When set, the platform must be twitter or threads.'),
111
- instagram_settings: instagramSettingsSchema
112
- .optional()
113
- .describe('Instagram-specific settings: post_type (post/feed/story/reel/carousel), collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy, reel_cover_url, reel_thumb_offset.'),
114
- platform_settings: platformSettingsSchema
115
- .optional()
116
- .describe('Platform-specific settings for the selected account. Use this for non-Instagram settings and prefer it over raw metadata.'),
117
- workspace_id: z
118
- .string()
119
- .optional()
120
- .describe('Workspace ID to assign the post to (from whoami). If omitted, uses the account\'s workspace or the caller\'s default workspace.'),
121
- }),
168
+ inputSchema: createPostInputSchema,
122
169
 
123
170
  async execute(
124
171
  client: PosterlyClient,
@@ -137,46 +184,8 @@ export const createPostTool = {
137
184
  workspace_id?: string;
138
185
  }
139
186
  ) {
140
- const { thread_posts, caption, post_type, instagram_settings, platform_settings, ...rest } = input;
141
- let payload: Parameters<typeof client.createPost>[0];
142
-
143
- if (thread_posts && thread_posts.length > 0) {
144
- if (thread_posts.length < 2) {
145
- throw new Error('thread_posts must contain at least 2 entries');
146
- }
147
- const platformHint = (input.platform || '').toLowerCase();
148
- const isTwitter = platformHint === 'twitter' || platformHint === 'x';
149
- const isThreads = platformHint === 'threads';
150
- if (!isTwitter && !isThreads && !input.account_id) {
151
- throw new Error(
152
- 'thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.',
153
- );
154
- }
155
- const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
156
- const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
157
- payload = {
158
- ...rest,
159
- platform: isTwitter ? 'twitter' : isThreads ? 'threads' : input.platform,
160
- caption: thread_posts[0],
161
- post_type: isThreads ? 'threads_thread' : 'x_thread',
162
- metadata: {
163
- [arrayKey]: thread_posts,
164
- [totalKey]: thread_posts.length,
165
- },
166
- };
167
- } else {
168
- if (!caption) {
169
- throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
170
- }
171
- payload = {
172
- ...rest,
173
- caption,
174
- post_type,
175
- ...(platform_settings || instagram_settings
176
- ? { settings: { ...(instagram_settings ? { __type: 'instagram', ...instagram_settings } : {}), ...(platform_settings || {}) } }
177
- : {}),
178
- };
179
- }
187
+ const payload = buildCreatePostPayload(input);
188
+ const { thread_posts, instagram_settings, platform_settings } = input;
180
189
 
181
190
  const result = await client.createPost(payload);
182
191
  const p = result.post as Record<string, any>;
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ import { buildCreatePostPayload, createPostInputSchema, type CreatePostInput } from './create-post.js';
4
+
5
+ export const createPostsBatchTool = {
6
+ name: 'create_posts_batch',
7
+ description:
8
+ 'Create 1-25 scheduled or immediate social posts in one API request. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts — once scheduled_at passes, posts may go public and cannot be un-posted.\n\n' +
9
+ 'REQUIRED BEFORE CALLING:\n' +
10
+ '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
11
+ '2. Show the user a preview of EVERY post: account/platform, final caption or thread text, scheduled time in their timezone, media, platform settings, and workspace.\n' +
12
+ '3. Get explicit confirmation for the whole batch before calling. Do not infer consent from an earlier content plan.\n\n' +
13
+ 'Each item accepts the same fields as `create_post`, including `thread_posts`, `platform_settings`, `instagram_settings`, media URLs, and workspace_id. The endpoint may partially succeed; failed items are returned with their index.',
14
+ inputSchema: z.object({
15
+ posts: z
16
+ .array(createPostInputSchema)
17
+ .min(1)
18
+ .max(25)
19
+ .describe('Posts to create. Each item uses the same schema as create_post.'),
20
+ }),
21
+
22
+ async execute(client: PosterlyClient, input: { posts: CreatePostInput[] }) {
23
+ const payloads = input.posts.map((post) => buildCreatePostPayload(post));
24
+ const result = await client.createPostsBatch({ posts: payloads });
25
+
26
+ const lines = [
27
+ 'Batch create completed.',
28
+ `• Total: ${result.total}`,
29
+ `• Created: ${result.created}`,
30
+ `• Replayed: ${result.replayed}`,
31
+ `• Failed: ${result.failed}`,
32
+ ];
33
+
34
+ for (const item of result.posts.slice(0, 25)) {
35
+ const post = item.post as Record<string, any>;
36
+ const when = post.scheduled_at ? new Date(post.scheduled_at).toLocaleString() : 'now';
37
+ lines.push(
38
+ `• [${item.index}] ID ${post.id} ${post.platform || 'unknown'} ${post.post_type || 'post'} ${post.status || 'created'} at ${when}${item.replayed ? ' (replayed)' : ''}`,
39
+ );
40
+ }
41
+
42
+ for (const error of result.errors.slice(0, 25)) {
43
+ lines.push(`• [${error.index}] ERROR ${error.status}: ${error.error}`);
44
+ }
45
+
46
+ return lines.join('\n');
47
+ },
48
+ };