posterly-mcp-server 0.20.0 → 0.20.2

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
@@ -108,7 +108,7 @@ Add the same server definition to your Cursor MCP settings:
108
108
 
109
109
  ## Available tools
110
110
 
111
- `posterly-mcp-server@0.20.0` exposes 56 tools.
111
+ `posterly-mcp-server@0.20.2` exposes 57 tools.
112
112
 
113
113
  Public setup tools work before `POSTERLY_API_KEY` exists:
114
114
 
@@ -139,6 +139,7 @@ Authenticated tools require `POSTERLY_API_KEY`:
139
139
  - `list_posts`
140
140
  - `get_post`
141
141
  - `get_post_missing`
142
+ - `ask_support` (authenticated docs-backed support with read-only account/post diagnostics; human tickets require explicit confirmation)
142
143
  - `create_post` (supports `thread_posts: string[]` for X / Threads reply chains, plus `platform_settings` for platform-specific composer controls)
143
144
  - `create_posts_batch` (create up to 25 confirmed posts in one API request)
144
145
  - `update_post` (also accepts `platform_settings`)
@@ -207,6 +208,7 @@ much more reliable than forcing the agent to guess from raw account handles alon
207
208
  - `Post this TikTok with direct-post privacy set to public and stitch disabled`
208
209
  - `Schedule these 5 photos as a TikTok` (image posts auto-detect as a photo slideshow of 1 to 35 images, no post type needed)
209
210
  - `How did Grassroots perform on Instagram in the last 30 days?`
211
+ - `Ask posterly support why post 3041 failed, but do not raise a human ticket unless I confirm`
210
212
 
211
213
  ## Pricing
212
214
 
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { listPostsTool } from './tools/list-posts.js';
20
20
  import { uploadMediaTool } from './tools/upload-media.js';
21
21
  import { getPostTool } from './tools/get-post.js';
22
22
  import { getPostMissingTool } from './tools/get-post-missing.js';
23
+ import { askSupportTool } from './tools/ask-support.js';
23
24
  import { updatePostTool } from './tools/update-post.js';
24
25
  import { updatePostStatusTool } from './tools/update-post-status.js';
25
26
  import { updatePostReleaseIdTool } from './tools/update-post-release-id.js';
@@ -389,6 +390,15 @@ server.tool(getPostMissingTool.name, getPostMissingTool.description, getPostMiss
389
390
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
390
391
  }
391
392
  });
393
+ server.tool(askSupportTool.name, askSupportTool.description, askSupportTool.inputSchema.shape, async (input) => {
394
+ try {
395
+ const text = await askSupportTool.execute(client, input);
396
+ return { content: [{ type: 'text', text }] };
397
+ }
398
+ catch (err) {
399
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
400
+ }
401
+ });
392
402
  server.tool(updatePostTool.name, updatePostTool.description, updatePostTool.inputSchema.shape, async (input) => {
393
403
  try {
394
404
  const text = await updatePostTool.execute(client, input);
@@ -149,6 +149,44 @@ export type GenerateCaptionsResponse = {
149
149
  social_account_id?: number | null;
150
150
  usage?: Record<string, unknown>;
151
151
  };
152
+ export type AskSupportPayload = {
153
+ question: string;
154
+ workspace_id?: string;
155
+ conversation_id?: string;
156
+ page_url?: string;
157
+ post_id?: number;
158
+ referenced_post_ids?: number[];
159
+ request_human?: boolean;
160
+ confirm_escalation?: boolean;
161
+ };
162
+ export type AskSupportResponse = {
163
+ success: boolean;
164
+ workspace_id: string;
165
+ conversation: {
166
+ id: string;
167
+ status: string;
168
+ title: string | null;
169
+ };
170
+ message: {
171
+ id: string;
172
+ role: string;
173
+ content: string;
174
+ citations?: unknown[];
175
+ created_at: string;
176
+ };
177
+ ticket?: {
178
+ id: string;
179
+ status: string;
180
+ priority: string;
181
+ created: boolean;
182
+ } | null;
183
+ support_access?: Record<string, unknown>;
184
+ confidence?: number;
185
+ docs_confidence?: string;
186
+ needs_human?: boolean;
187
+ escalation_reason?: string | null;
188
+ model?: Record<string, unknown>;
189
+ };
152
190
  export interface Slot {
153
191
  time: string;
154
192
  local_time: string;
@@ -590,6 +628,7 @@ export declare class PosterlyClient {
590
628
  posts: CreatePostPayload[];
591
629
  }): Promise<BatchCreatePostsResponse>;
592
630
  generateCaptions(data: GenerateCaptionsPayload): Promise<GenerateCaptionsResponse>;
631
+ askSupport(data: AskSupportPayload): Promise<AskSupportResponse>;
593
632
  getPost(id: number): Promise<{
594
633
  post: Post;
595
634
  }>;
@@ -85,7 +85,7 @@ export class PosterlyClient {
85
85
  const res = await fetch(url, init);
86
86
  if (!res.ok) {
87
87
  const err = await res.json().catch(() => ({ error: res.statusText }));
88
- throw new Error(err.error || `API error: ${res.status}`);
88
+ throw new Error(formatApiError(res, err));
89
89
  }
90
90
  return res.json();
91
91
  }
@@ -100,7 +100,7 @@ export class PosterlyClient {
100
100
  const res = await fetch(url, init);
101
101
  if (!res.ok) {
102
102
  const err = await res.json().catch(() => ({ error: res.statusText }));
103
- throw new Error(err.error || `API error: ${res.status}`);
103
+ throw new Error(formatApiError(res, err));
104
104
  }
105
105
  return res.json();
106
106
  }
@@ -199,6 +199,9 @@ export class PosterlyClient {
199
199
  async generateCaptions(data) {
200
200
  return this.request('POST', '/ai/generate-captions', data);
201
201
  }
202
+ async askSupport(data) {
203
+ return this.request('POST', '/support/chat', data);
204
+ }
202
205
  async getPost(id) {
203
206
  return this.request('GET', `/posts/${id}`);
204
207
  }
@@ -470,6 +473,24 @@ function formatProbeError(data, fallback, statusText) {
470
473
  }
471
474
  return fallback || statusText || 'API request failed';
472
475
  }
476
+ function formatApiError(res, data) {
477
+ const message = data?.error || data?.message || `API error: ${res.status}`;
478
+ if (res.status !== 429) {
479
+ return message;
480
+ }
481
+ const retryAfter = data?.retry_after || res.headers.get('retry-after');
482
+ const requestId = data?.request_id || res.headers.get('x-request-id');
483
+ const limit = data?.limit || res.headers.get('x-ratelimit-limit');
484
+ const reset = data?.reset || res.headers.get('x-ratelimit-reset');
485
+ return [
486
+ 'Rate limit reached.',
487
+ retryAfter ? `Try again in ${retryAfter} seconds.` : '',
488
+ limit ? `Limit: ${limit}.` : '',
489
+ reset ? `Reset: ${reset}.` : '',
490
+ requestId ? `Request ID: ${requestId}.` : '',
491
+ message && message !== 'Rate limit exceeded' ? `Details: ${message}` : '',
492
+ ].filter(Boolean).join(' ');
493
+ }
473
494
  function guessContentType(filename) {
474
495
  const ext = filename.split('.').pop()?.toLowerCase();
475
496
  const map = {
@@ -1 +1 @@
1
- export declare const POSTERLY_MCP_VERSION = "0.20.0";
1
+ export declare const POSTERLY_MCP_VERSION = "0.20.2";
@@ -1 +1 @@
1
- export const POSTERLY_MCP_VERSION = '0.20.0';
1
+ export const POSTERLY_MCP_VERSION = '0.20.2';
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import type { AskSupportPayload, PosterlyClient } from '../lib/api-client.js';
3
+ export declare const askSupportTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ question: z.ZodString;
8
+ workspace_id: z.ZodOptional<z.ZodString>;
9
+ conversation_id: z.ZodOptional<z.ZodString>;
10
+ page_url: z.ZodOptional<z.ZodString>;
11
+ post_id: z.ZodOptional<z.ZodNumber>;
12
+ referenced_post_ids: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
13
+ request_human: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
14
+ confirm_escalation: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ question: string;
17
+ workspace_id?: string | undefined;
18
+ post_id?: number | undefined;
19
+ conversation_id?: string | undefined;
20
+ page_url?: string | undefined;
21
+ referenced_post_ids?: number[] | undefined;
22
+ request_human?: boolean | undefined;
23
+ confirm_escalation?: boolean | undefined;
24
+ }, {
25
+ question: string;
26
+ workspace_id?: string | undefined;
27
+ post_id?: number | undefined;
28
+ conversation_id?: string | undefined;
29
+ page_url?: string | undefined;
30
+ referenced_post_ids?: number[] | undefined;
31
+ request_human?: boolean | undefined;
32
+ confirm_escalation?: boolean | undefined;
33
+ }>;
34
+ execute(client: PosterlyClient, input: AskSupportPayload): Promise<string>;
35
+ };
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { mdBullets, mdQuote, mdSection, mdTitle } from '../lib/format.js';
3
+ function formatSupportAnswer(result) {
4
+ const citations = Array.isArray(result.message?.citations) ? result.message.citations : [];
5
+ const sources = citations.slice(0, 5).map((citation) => {
6
+ const label = citation.heading || citation.title || citation.slug || citation.url;
7
+ const parent = citation.heading && citation.title ? ` (${citation.title})` : '';
8
+ const url = citation.url ? ` - ${citation.url}` : '';
9
+ return `${label}${parent}${url}`;
10
+ });
11
+ const supportAccess = result.support_access || {};
12
+ const status = [
13
+ `Conversation: ${result.conversation.id}`,
14
+ `Confidence: ${typeof result.confidence === 'number' ? `${Math.round(result.confidence * 100)}%` : 'unknown'}`,
15
+ `Human recommended: ${result.needs_human ? 'yes' : 'no'}`,
16
+ supportAccess.confirmation_required ? 'Human ticket not created: confirm_escalation=true is required.' : '',
17
+ supportAccess.ticket_created && result.ticket?.id ? `Ticket: ${result.ticket.id} (${result.ticket.status})` : '',
18
+ ].filter(Boolean);
19
+ return [
20
+ mdTitle('Posterly Support'),
21
+ mdQuote(result.message?.content || 'No support answer returned.'),
22
+ sources.length ? mdSection('Sources', mdBullets(sources)) : '',
23
+ mdSection('Status', mdBullets(status)),
24
+ ].filter(Boolean).join('\n\n');
25
+ }
26
+ export const askSupportTool = {
27
+ name: 'ask_support',
28
+ description: 'Ask Posterly Support AI an authenticated question using Posterly docs plus read-only account/post diagnostics for the caller workspace. Requires POSTERLY_API_KEY with accounts:read and posts:read scopes. Human ticket creation requires request_human=true and confirm_escalation=true after explicit user confirmation.',
29
+ inputSchema: z.object({
30
+ question: z.string().trim().min(1).max(4000),
31
+ workspace_id: z.string().optional().describe('Workspace to inspect. Omit to use the API-key scoped workspace or personal workspace.'),
32
+ conversation_id: z.string().optional().describe('Continue a previous support conversation.'),
33
+ page_url: z.string().max(2000).optional().describe('Optional Posterly page URL for context.'),
34
+ post_id: z.number().int().positive().optional().describe('Optional post ID to inspect directly.'),
35
+ referenced_post_ids: z.array(z.number().int().positive()).max(5).optional().describe('Optional post IDs to include in read-only diagnostics.'),
36
+ request_human: z.boolean().default(false).optional().describe('Ask for human review. Does not create a ticket unless confirm_escalation is also true.'),
37
+ confirm_escalation: z.boolean().default(false).optional().describe('Must be true after explicit user confirmation before a human support ticket can be created.'),
38
+ }),
39
+ async execute(client, input) {
40
+ if (input.confirm_escalation === true && input.request_human !== true) {
41
+ throw new Error('confirm_escalation=true only has an effect when request_human=true.');
42
+ }
43
+ const result = await client.askSupport(input);
44
+ return formatSupportAnswer(result);
45
+ },
46
+ };
@@ -148,14 +148,14 @@ export function buildCreatePostPayload(input) {
148
148
  }
149
149
  export const createPostTool = {
150
150
  name: 'create_post',
151
- description: 'Schedule or immediately publish a social media post. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts once scheduled_at passes, it will be posted publicly and cannot be un-posted.\n\n' +
151
+ description: 'Schedule or immediately publish a social media post. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts - once scheduled_at passes, it will be posted publicly and cannot be un-posted.\n\n' +
152
152
  'REQUIRED BEFORE CALLING:\n' +
153
153
  '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
154
154
  '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' +
155
- '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' +
155
+ '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' +
156
156
  '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' +
157
157
  'AFTER CALLING: Tell the user the post was created, include the Posterly dashboard link returned by the tool, and offer the next natural action. Do not narrate raw HTTP, curl, or API plumbing.\n\n' +
158
- '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' +
158
+ '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' +
159
159
  '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' +
160
160
  '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 post_type (image/video/carousel), board (from pinterest.boards helper), title, link, and cover_image_url (video Pins), 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.\n\n' +
161
161
  '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.',
@@ -194,7 +194,7 @@ export const createPostTool = {
194
194
  lines.push('• Platform settings: yes');
195
195
  }
196
196
  if (ws) {
197
- lines.push(`• Workspace: ${ws.name} (${ws.id}) resolved from ${ws.resolved_from}`);
197
+ lines.push(`• Workspace: ${ws.name} (${ws.id}) - resolved from ${ws.resolved_from}`);
198
198
  }
199
199
  lines.push('', 'Next step for the AI agent: tell the user the post is in Posterly, share the dashboard link above, then ask whether they want to create another post, adjust this one, or connect another account.');
200
200
  return lines.join('\n');
@@ -3,7 +3,7 @@ import { dashboardUrlForPostList, formatLocalDateTime } from '../lib/format.js';
3
3
  import { buildCreatePostPayload, createPostPayloadInputSchema } from './create-post.js';
4
4
  export const createPostsBatchTool = {
5
5
  name: 'create_posts_batch',
6
- description: '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' +
6
+ description: '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' +
7
7
  'REQUIRED BEFORE CALLING:\n' +
8
8
  '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
9
9
  '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' +
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { dashboardUrlForPosts } from '../lib/format.js';
3
3
  export const deletePostTool = {
4
4
  name: 'delete_post',
5
- description: 'Delete a scheduled or draft post. DESTRUCTIVE and IRREVERSIBLE the post and its caption cannot be recovered. Cannot delete published or currently publishing posts.\n\n' +
5
+ description: 'Delete a scheduled or draft post. DESTRUCTIVE and IRREVERSIBLE - the post and its caption cannot be recovered. Cannot delete published or currently publishing posts.\n\n' +
6
6
  'REQUIRED BEFORE CALLING: Fetch the post with `get_post` first and show the user what will be deleted (caption, account, scheduled time). Get explicit confirmation ("yes delete it", "remove it") before calling. Never delete multiple posts in a single batch without listing each one and confirming the full list.',
7
7
  inputSchema: z.object({
8
8
  post_id: z.number().describe('The post ID to delete'),
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { formatDate, mdEmpty, mdTable, mdTitle } from '../lib/format.js';
3
3
  export const findSlotTool = {
4
4
  name: 'find_available_slot',
5
- description: 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am10pm in the given timezone). Returns up to 10 slots. IMPORTANT: pass a timezone explicitly default is America/New_York and slots will be off if the user is elsewhere. Pass workspace_id to only avoid collisions with posts in that workspace.',
5
+ description: 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am-10pm in the given timezone). Returns up to 10 slots. IMPORTANT: pass a timezone explicitly - default is America/New_York and slots will be off if the user is elsewhere. Pass workspace_id to only avoid collisions with posts in that workspace.',
6
6
  inputSchema: z.object({
7
7
  account_ids: z
8
8
  .array(z.string())
@@ -11,7 +11,7 @@ export const findSlotTool = {
11
11
  timezone: z
12
12
  .string()
13
13
  .optional()
14
- .describe('IANA timezone (e.g. Asia/Dubai, Europe/London). Defaults to America/New_York pass the user\'s actual timezone or slots will be wrong.'),
14
+ .describe('IANA timezone (e.g. Asia/Dubai, Europe/London). Defaults to America/New_York - pass the user\'s actual timezone or slots will be wrong.'),
15
15
  count: z
16
16
  .number()
17
17
  .min(1)
@@ -3,8 +3,8 @@ import { mdBullets, mdKeyValue, mdSection, mdTable, mdTitle } from '../lib/forma
3
3
  export const generateImageTool = {
4
4
  name: 'generate_image',
5
5
  description: 'Generate an AI image via Posterly\'s Nano Banana (Gemini) integration. The image is saved to the user\'s media storage and the returned URL can be passed to `create_post` as `media_url`.\n\n' +
6
- 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
7
- 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim do not retry.\n\n' +
6
+ 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively - always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
7
+ 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim - do not retry.\n\n' +
8
8
  'Common aspect ratios by platform:\n' +
9
9
  ' • Instagram feed / LinkedIn: 1:1 (square) or 4:5 (portrait)\n' +
10
10
  ' • Instagram Story/Reel, TikTok, YouTube Shorts: 9:16\n' +
@@ -15,7 +15,7 @@ export const generateImageTool = {
15
15
  .string()
16
16
  .min(5)
17
17
  .max(4000)
18
- .describe('Detailed image description. 54000 characters. Describe subject, scene, lighting, mood, and style.'),
18
+ .describe('Detailed image description. 5-4000 characters. Describe subject, scene, lighting, mood, and style.'),
19
19
  aspect_ratio: z
20
20
  .enum([
21
21
  '1:1', '9:16', '4:5', '3:4', '2:3', '1:4', '1:8',
@@ -36,7 +36,7 @@ export const generateImageTool = {
36
36
  .min(1)
37
37
  .max(4)
38
38
  .optional()
39
- .describe('How many variations to generate. Default 1. Each variation costs credits separately prefer 1 unless the user explicitly wants options.'),
39
+ .describe('How many variations to generate. Default 1. Each variation costs credits separately - prefer 1 unless the user explicitly wants options.'),
40
40
  model: z
41
41
  .enum(['flash', 'pro'])
42
42
  .optional()
@@ -20,7 +20,7 @@ export const getVideoJobTool = {
20
20
  };
21
21
  function formatJob(job, includeRaw) {
22
22
  const lines = [
23
- `${job.id} ${job.status}`,
23
+ `${job.id}: ${job.status}`,
24
24
  `Prompt: ${job.prompt}`,
25
25
  `Model: ${job.model}; duration: ${job.duration_seconds}s; resolution: ${job.resolution || 'n/a'}; cost: ${job.credit_cost}`,
26
26
  job.video_url ? `Video URL: ${job.video_url}` : null,
@@ -13,7 +13,7 @@ export const listPlatformsTool = {
13
13
  const result = await client.listPlatforms(input);
14
14
  const lines = result.platforms.map((platform) => {
15
15
  const helperCount = platform.helper_tools?.length || 0;
16
- return `• ${platform.label} (${platform.id}) ${platform.status}; post types: ${platform.post_types.join(', ') || 'n/a'}; helpers: ${helperCount}; analytics: ${platform.analytics_supported ? 'yes' : 'no'}`;
16
+ return `• ${platform.label} (${platform.id}): ${platform.status}; post types: ${platform.post_types.join(', ') || 'n/a'}; helpers: ${helperCount}; analytics: ${platform.analytics_supported ? 'yes' : 'no'}`;
17
17
  });
18
18
  return `Posterly platforms (${result.platforms.length}):\n${lines.join('\n')}`;
19
19
  },
@@ -39,7 +39,7 @@ export const listPostsTool = {
39
39
  const lines = posts.map((p) => {
40
40
  const date = formatLocalDateTime(p.scheduled_at);
41
41
  const caption = truncateText(p.content, 60);
42
- return `• [${p.status}] #${p.id} ${caption} (${date})`;
42
+ return `• [${p.status}] #${p.id}: ${caption} (${date})`;
43
43
  });
44
44
  return [
45
45
  `Posts (${posts.length} of ${total}):`,
@@ -58,8 +58,8 @@ const platformSettingsSchema = z.object({
58
58
  }).passthrough();
59
59
  export const updatePostTool = {
60
60
  name: 'update_post',
61
- description: 'Update a scheduled or draft post. DESTRUCTIVE WRITE overwrites the existing post\'s caption/media/schedule. Cannot edit published or currently publishing posts.\n\n' +
62
- 'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media) and the PROPOSED changes, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction each edit needs its own confirmation.',
61
+ description: 'Update a scheduled or draft post. DESTRUCTIVE WRITE - overwrites the existing post\'s caption/media/schedule. Cannot edit published or currently publishing posts.\n\n' +
62
+ 'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media) and the PROPOSED changes, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction - each edit needs its own confirmation.',
63
63
  inputSchema: z.object({
64
64
  post_id: z.number().describe('The post ID to update'),
65
65
  caption: z.string().optional().describe('New caption/text content'),
@@ -3,8 +3,8 @@ import { mdError, mdSuccess } from '../lib/format.js';
3
3
  export const uploadMediaTool = {
4
4
  name: 'upload_media',
5
5
  description: 'Upload an image or video file to posterly storage. Returns a URL that can be used with create_post. Supports JPEG, PNG, GIF, WebP, MP4, MOV, WebM. Images up to 10MB, videos up to 50MB.\n\n' +
6
- 'This writes to the user\'s media storage (counts against quota) but does NOT publish the file anywhere. Uploading is safe it\'s the subsequent `create_post` call that publishes content, and that tool has its own confirmation requirements.\n\n' +
7
- 'IMPORTANT: If the user shares an image in the chat, use base64_data (not file_path) since chat-uploaded images are not on the local filesystem. Only use file_path when the user provides an actual local filesystem path never guess paths.',
6
+ 'This writes to the user\'s media storage (counts against quota) but does NOT publish the file anywhere. Uploading is safe - it\'s the subsequent `create_post` call that publishes content, and that tool has its own confirmation requirements.\n\n' +
7
+ 'IMPORTANT: If the user shares an image in the chat, use base64_data (not file_path) since chat-uploaded images are not on the local filesystem. Only use file_path when the user provides an actual local filesystem path - never guess paths.',
8
8
  inputSchema: z.object({
9
9
  file_path: z
10
10
  .string()
@@ -13,7 +13,7 @@ export const uploadMediaTool = {
13
13
  base64_data: z
14
14
  .string()
15
15
  .optional()
16
- .describe('Base64-encoded file data. PREFERRED for images shared directly in chat extract the image data as base64 and pass it here.'),
16
+ .describe('Base64-encoded file data. PREFERRED for images shared directly in chat - extract the image data as base64 and pass it here.'),
17
17
  filename: z
18
18
  .string()
19
19
  .describe('Filename with extension (e.g. photo.jpg, video.mp4)'),
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { code, mdKeyValue, mdTable, mdTitle } from '../lib/format.js';
3
3
  export const whoamiTool = {
4
4
  name: 'whoami',
5
- description: 'Return the authenticated user, API key scopes, the default (personal) workspace, and every workspace the caller can post in. ALWAYS call this at the start of a session before creating, listing, or scheduling posts posts created without an explicit workspace_id land in the default workspace shown here, and confirming with the user first prevents posts from appearing in the wrong workspace.',
5
+ description: 'Return the authenticated user, API key scopes, the default (personal) workspace, and every workspace the caller can post in. ALWAYS call this at the start of a session before creating, listing, or scheduling posts - posts created without an explicit workspace_id land in the default workspace shown here, and confirming with the user first prevents posts from appearing in the wrong workspace.',
6
6
  inputSchema: z.object({}),
7
7
  async execute(client) {
8
8
  const info = await client.whoami();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.20.0",
4
- "description": "MCP server for posterly schedule social media posts from Claude Desktop",
3
+ "version": "0.20.2",
4
+ "description": "MCP server for posterly: schedule social media posts from Claude Desktop",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.poster.ly/mcp",
7
7
  "repository": {
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { listPostsTool } from './tools/list-posts.js';
21
21
  import { uploadMediaTool } from './tools/upload-media.js';
22
22
  import { getPostTool } from './tools/get-post.js';
23
23
  import { getPostMissingTool } from './tools/get-post-missing.js';
24
+ import { askSupportTool } from './tools/ask-support.js';
24
25
  import { updatePostTool } from './tools/update-post.js';
25
26
  import { updatePostStatusTool } from './tools/update-post-status.js';
26
27
  import { updatePostReleaseIdTool } from './tools/update-post-release-id.js';
@@ -573,6 +574,20 @@ server.tool(
573
574
  }
574
575
  );
575
576
 
577
+ server.tool(
578
+ askSupportTool.name,
579
+ askSupportTool.description,
580
+ askSupportTool.inputSchema.shape,
581
+ async (input) => {
582
+ try {
583
+ const text = await askSupportTool.execute(client, input as any);
584
+ return { content: [{ type: 'text' as const, text }] };
585
+ } catch (err: any) {
586
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
587
+ }
588
+ }
589
+ );
590
+
576
591
  server.tool(
577
592
  updatePostTool.name,
578
593
  updatePostTool.description,
@@ -152,6 +152,46 @@ export type GenerateCaptionsResponse = {
152
152
  usage?: Record<string, unknown>;
153
153
  };
154
154
 
155
+ export type AskSupportPayload = {
156
+ question: string;
157
+ workspace_id?: string;
158
+ conversation_id?: string;
159
+ page_url?: string;
160
+ post_id?: number;
161
+ referenced_post_ids?: number[];
162
+ request_human?: boolean;
163
+ confirm_escalation?: boolean;
164
+ };
165
+
166
+ export type AskSupportResponse = {
167
+ success: boolean;
168
+ workspace_id: string;
169
+ conversation: {
170
+ id: string;
171
+ status: string;
172
+ title: string | null;
173
+ };
174
+ message: {
175
+ id: string;
176
+ role: string;
177
+ content: string;
178
+ citations?: unknown[];
179
+ created_at: string;
180
+ };
181
+ ticket?: {
182
+ id: string;
183
+ status: string;
184
+ priority: string;
185
+ created: boolean;
186
+ } | null;
187
+ support_access?: Record<string, unknown>;
188
+ confidence?: number;
189
+ docs_confidence?: string;
190
+ needs_human?: boolean;
191
+ escalation_reason?: string | null;
192
+ model?: Record<string, unknown>;
193
+ };
194
+
155
195
  export interface Slot {
156
196
  time: string;
157
197
  local_time: string;
@@ -611,7 +651,7 @@ export class PosterlyClient {
611
651
 
612
652
  if (!res.ok) {
613
653
  const err = await res.json().catch(() => ({ error: res.statusText }));
614
- throw new Error(err.error || `API error: ${res.status}`);
654
+ throw new Error(formatApiError(res, err));
615
655
  }
616
656
 
617
657
  return res.json() as Promise<T>;
@@ -635,7 +675,7 @@ export class PosterlyClient {
635
675
 
636
676
  if (!res.ok) {
637
677
  const err = await res.json().catch(() => ({ error: res.statusText }));
638
- throw new Error(err.error || `API error: ${res.status}`);
678
+ throw new Error(formatApiError(res, err));
639
679
  }
640
680
 
641
681
  return res.json() as Promise<T>;
@@ -779,6 +819,10 @@ export class PosterlyClient {
779
819
  return this.request('POST', '/ai/generate-captions', data);
780
820
  }
781
821
 
822
+ async askSupport(data: AskSupportPayload): Promise<AskSupportResponse> {
823
+ return this.request('POST', '/support/chat', data);
824
+ }
825
+
782
826
  async getPost(id: number): Promise<{ post: Post }> {
783
827
  return this.request('GET', `/posts/${id}`);
784
828
  }
@@ -1222,6 +1266,27 @@ function formatProbeError(data: any, fallback: string, statusText: string): stri
1222
1266
  return fallback || statusText || 'API request failed';
1223
1267
  }
1224
1268
 
1269
+ function formatApiError(res: Response, data: any): string {
1270
+ const message = data?.error || data?.message || `API error: ${res.status}`;
1271
+ if (res.status !== 429) {
1272
+ return message;
1273
+ }
1274
+
1275
+ const retryAfter = data?.retry_after || res.headers.get('retry-after');
1276
+ const requestId = data?.request_id || res.headers.get('x-request-id');
1277
+ const limit = data?.limit || res.headers.get('x-ratelimit-limit');
1278
+ const reset = data?.reset || res.headers.get('x-ratelimit-reset');
1279
+
1280
+ return [
1281
+ 'Rate limit reached.',
1282
+ retryAfter ? `Try again in ${retryAfter} seconds.` : '',
1283
+ limit ? `Limit: ${limit}.` : '',
1284
+ reset ? `Reset: ${reset}.` : '',
1285
+ requestId ? `Request ID: ${requestId}.` : '',
1286
+ message && message !== 'Rate limit exceeded' ? `Details: ${message}` : '',
1287
+ ].filter(Boolean).join(' ');
1288
+ }
1289
+
1225
1290
  function guessContentType(filename: string): string {
1226
1291
  const ext = filename.split('.').pop()?.toLowerCase();
1227
1292
  const map: Record<string, string> = {
@@ -1 +1 @@
1
- export const POSTERLY_MCP_VERSION = '0.20.0';
1
+ export const POSTERLY_MCP_VERSION = '0.20.2';
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import type { AskSupportPayload, AskSupportResponse, PosterlyClient } from '../lib/api-client.js';
3
+ import { mdBullets, mdQuote, mdSection, mdTitle } from '../lib/format.js';
4
+
5
+ function formatSupportAnswer(result: AskSupportResponse): string {
6
+ const citations = Array.isArray(result.message?.citations) ? result.message.citations : [];
7
+ const sources = citations.slice(0, 5).map((citation: any) => {
8
+ const label = citation.heading || citation.title || citation.slug || citation.url;
9
+ const parent = citation.heading && citation.title ? ` (${citation.title})` : '';
10
+ const url = citation.url ? ` - ${citation.url}` : '';
11
+ return `${label}${parent}${url}`;
12
+ });
13
+ const supportAccess = result.support_access || {};
14
+ const status = [
15
+ `Conversation: ${result.conversation.id}`,
16
+ `Confidence: ${typeof result.confidence === 'number' ? `${Math.round(result.confidence * 100)}%` : 'unknown'}`,
17
+ `Human recommended: ${result.needs_human ? 'yes' : 'no'}`,
18
+ supportAccess.confirmation_required ? 'Human ticket not created: confirm_escalation=true is required.' : '',
19
+ supportAccess.ticket_created && result.ticket?.id ? `Ticket: ${result.ticket.id} (${result.ticket.status})` : '',
20
+ ].filter(Boolean);
21
+
22
+ return [
23
+ mdTitle('Posterly Support'),
24
+ mdQuote(result.message?.content || 'No support answer returned.'),
25
+ sources.length ? mdSection('Sources', mdBullets(sources)) : '',
26
+ mdSection('Status', mdBullets(status)),
27
+ ].filter(Boolean).join('\n\n');
28
+ }
29
+
30
+ export const askSupportTool = {
31
+ name: 'ask_support',
32
+ description:
33
+ 'Ask Posterly Support AI an authenticated question using Posterly docs plus read-only account/post diagnostics for the caller workspace. Requires POSTERLY_API_KEY with accounts:read and posts:read scopes. Human ticket creation requires request_human=true and confirm_escalation=true after explicit user confirmation.',
34
+ inputSchema: z.object({
35
+ question: z.string().trim().min(1).max(4000),
36
+ workspace_id: z.string().optional().describe('Workspace to inspect. Omit to use the API-key scoped workspace or personal workspace.'),
37
+ conversation_id: z.string().optional().describe('Continue a previous support conversation.'),
38
+ page_url: z.string().max(2000).optional().describe('Optional Posterly page URL for context.'),
39
+ post_id: z.number().int().positive().optional().describe('Optional post ID to inspect directly.'),
40
+ referenced_post_ids: z.array(z.number().int().positive()).max(5).optional().describe('Optional post IDs to include in read-only diagnostics.'),
41
+ request_human: z.boolean().default(false).optional().describe('Ask for human review. Does not create a ticket unless confirm_escalation is also true.'),
42
+ confirm_escalation: z.boolean().default(false).optional().describe('Must be true after explicit user confirmation before a human support ticket can be created.'),
43
+ }),
44
+
45
+ async execute(client: PosterlyClient, input: AskSupportPayload) {
46
+ if (input.confirm_escalation === true && input.request_human !== true) {
47
+ throw new Error('confirm_escalation=true only has an effect when request_human=true.');
48
+ }
49
+ const result = await client.askSupport(input);
50
+ return formatSupportAnswer(result);
51
+ },
52
+ };
@@ -164,14 +164,14 @@ export function buildCreatePostPayload(input: CreatePostPayloadInput | CreatePos
164
164
  export const createPostTool = {
165
165
  name: 'create_post',
166
166
  description:
167
- 'Schedule or immediately publish a social media post. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts once scheduled_at passes, it will be posted publicly and cannot be un-posted.\n\n' +
167
+ 'Schedule or immediately publish a social media post. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts - once scheduled_at passes, it will be posted publicly and cannot be un-posted.\n\n' +
168
168
  'REQUIRED BEFORE CALLING:\n' +
169
169
  '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
170
170
  '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' +
171
- '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' +
171
+ '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' +
172
172
  '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' +
173
173
  'AFTER CALLING: Tell the user the post was created, include the Posterly dashboard link returned by the tool, and offer the next natural action. Do not narrate raw HTTP, curl, or API plumbing.\n\n' +
174
- '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' +
174
+ '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' +
175
175
  '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' +
176
176
  '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 post_type (image/video/carousel), board (from pinterest.boards helper), title, link, and cover_image_url (video Pins), 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.\n\n' +
177
177
  '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.',
@@ -217,7 +217,7 @@ export const createPostTool = {
217
217
  lines.push('• Platform settings: yes');
218
218
  }
219
219
  if (ws) {
220
- lines.push(`• Workspace: ${ws.name} (${ws.id}) resolved from ${ws.resolved_from}`);
220
+ lines.push(`• Workspace: ${ws.name} (${ws.id}) - resolved from ${ws.resolved_from}`);
221
221
  }
222
222
  lines.push(
223
223
  '',
@@ -6,7 +6,7 @@ import { buildCreatePostPayload, createPostPayloadInputSchema, type CreatePostPa
6
6
  export const createPostsBatchTool = {
7
7
  name: 'create_posts_batch',
8
8
  description:
9
- '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
+ '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' +
10
10
  'REQUIRED BEFORE CALLING:\n' +
11
11
  '1. Call `whoami` at the start of any new session to confirm which workspace and user you are acting for.\n' +
12
12
  '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' +
@@ -5,7 +5,7 @@ import { dashboardUrlForPosts } from '../lib/format.js';
5
5
  export const deletePostTool = {
6
6
  name: 'delete_post',
7
7
  description:
8
- 'Delete a scheduled or draft post. DESTRUCTIVE and IRREVERSIBLE the post and its caption cannot be recovered. Cannot delete published or currently publishing posts.\n\n' +
8
+ 'Delete a scheduled or draft post. DESTRUCTIVE and IRREVERSIBLE - the post and its caption cannot be recovered. Cannot delete published or currently publishing posts.\n\n' +
9
9
  'REQUIRED BEFORE CALLING: Fetch the post with `get_post` first and show the user what will be deleted (caption, account, scheduled time). Get explicit confirmation ("yes delete it", "remove it") before calling. Never delete multiple posts in a single batch without listing each one and confirming the full list.',
10
10
  inputSchema: z.object({
11
11
  post_id: z.number().describe('The post ID to delete'),
@@ -5,7 +5,7 @@ import { formatDate, mdEmpty, mdTable, mdTitle } from '../lib/format.js';
5
5
  export const findSlotTool = {
6
6
  name: 'find_available_slot',
7
7
  description:
8
- 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am10pm in the given timezone). Returns up to 10 slots. IMPORTANT: pass a timezone explicitly default is America/New_York and slots will be off if the user is elsewhere. Pass workspace_id to only avoid collisions with posts in that workspace.',
8
+ 'Find available time slots for posting. Respects a 1-hour gap between posts and preferred hours (8am-10pm in the given timezone). Returns up to 10 slots. IMPORTANT: pass a timezone explicitly - default is America/New_York and slots will be off if the user is elsewhere. Pass workspace_id to only avoid collisions with posts in that workspace.',
9
9
  inputSchema: z.object({
10
10
  account_ids: z
11
11
  .array(z.string())
@@ -14,7 +14,7 @@ export const findSlotTool = {
14
14
  timezone: z
15
15
  .string()
16
16
  .optional()
17
- .describe('IANA timezone (e.g. Asia/Dubai, Europe/London). Defaults to America/New_York pass the user\'s actual timezone or slots will be wrong.'),
17
+ .describe('IANA timezone (e.g. Asia/Dubai, Europe/London). Defaults to America/New_York - pass the user\'s actual timezone or slots will be wrong.'),
18
18
  count: z
19
19
  .number()
20
20
  .min(1)
@@ -6,8 +6,8 @@ export const generateImageTool = {
6
6
  name: 'generate_image',
7
7
  description:
8
8
  'Generate an AI image via Posterly\'s Nano Banana (Gemini) integration. The image is saved to the user\'s media storage and the returned URL can be passed to `create_post` as `media_url`.\n\n' +
9
- 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
10
- 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim do not retry.\n\n' +
9
+ 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively - always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
10
+ 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim - do not retry.\n\n' +
11
11
  'Common aspect ratios by platform:\n' +
12
12
  ' • Instagram feed / LinkedIn: 1:1 (square) or 4:5 (portrait)\n' +
13
13
  ' • Instagram Story/Reel, TikTok, YouTube Shorts: 9:16\n' +
@@ -18,7 +18,7 @@ export const generateImageTool = {
18
18
  .string()
19
19
  .min(5)
20
20
  .max(4000)
21
- .describe('Detailed image description. 54000 characters. Describe subject, scene, lighting, mood, and style.'),
21
+ .describe('Detailed image description. 5-4000 characters. Describe subject, scene, lighting, mood, and style.'),
22
22
  aspect_ratio: z
23
23
  .enum([
24
24
  '1:1', '9:16', '4:5', '3:4', '2:3', '1:4', '1:8',
@@ -39,7 +39,7 @@ export const generateImageTool = {
39
39
  .min(1)
40
40
  .max(4)
41
41
  .optional()
42
- .describe('How many variations to generate. Default 1. Each variation costs credits separately prefer 1 unless the user explicitly wants options.'),
42
+ .describe('How many variations to generate. Default 1. Each variation costs credits separately - prefer 1 unless the user explicitly wants options.'),
43
43
  model: z
44
44
  .enum(['flash', 'pro'])
45
45
  .optional()
@@ -25,7 +25,7 @@ export const getVideoJobTool = {
25
25
 
26
26
  function formatJob(job: VideoJob, includeRaw: boolean): string {
27
27
  const lines = [
28
- `${job.id} ${job.status}`,
28
+ `${job.id}: ${job.status}`,
29
29
  `Prompt: ${job.prompt}`,
30
30
  `Model: ${job.model}; duration: ${job.duration_seconds}s; resolution: ${job.resolution || 'n/a'}; cost: ${job.credit_cost}`,
31
31
  job.video_url ? `Video URL: ${job.video_url}` : null,
@@ -17,7 +17,7 @@ export const listPlatformsTool = {
17
17
  const result = await client.listPlatforms(input);
18
18
  const lines = result.platforms.map((platform) => {
19
19
  const helperCount = platform.helper_tools?.length || 0;
20
- return `• ${platform.label} (${platform.id}) ${platform.status}; post types: ${platform.post_types.join(', ') || 'n/a'}; helpers: ${helperCount}; analytics: ${platform.analytics_supported ? 'yes' : 'no'}`;
20
+ return `• ${platform.label} (${platform.id}): ${platform.status}; post types: ${platform.post_types.join(', ') || 'n/a'}; helpers: ${helperCount}; analytics: ${platform.analytics_supported ? 'yes' : 'no'}`;
21
21
  });
22
22
 
23
23
  return `Posterly platforms (${result.platforms.length}):\n${lines.join('\n')}`;
@@ -54,7 +54,7 @@ export const listPostsTool = {
54
54
  const lines = posts.map((p) => {
55
55
  const date = formatLocalDateTime(p.scheduled_at);
56
56
  const caption = truncateText(p.content, 60);
57
- return `• [${p.status}] #${p.id} ${caption} (${date})`;
57
+ return `• [${p.status}] #${p.id}: ${caption} (${date})`;
58
58
  });
59
59
 
60
60
  return [
@@ -63,8 +63,8 @@ const platformSettingsSchema = z.object({
63
63
  export const updatePostTool = {
64
64
  name: 'update_post',
65
65
  description:
66
- 'Update a scheduled or draft post. DESTRUCTIVE WRITE overwrites the existing post\'s caption/media/schedule. Cannot edit published or currently publishing posts.\n\n' +
67
- 'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media) and the PROPOSED changes, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction each edit needs its own confirmation.',
66
+ 'Update a scheduled or draft post. DESTRUCTIVE WRITE - overwrites the existing post\'s caption/media/schedule. Cannot edit published or currently publishing posts.\n\n' +
67
+ 'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media) and the PROPOSED changes, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction - each edit needs its own confirmation.',
68
68
  inputSchema: z.object({
69
69
  post_id: z.number().describe('The post ID to update'),
70
70
  caption: z.string().optional().describe('New caption/text content'),
@@ -6,8 +6,8 @@ export const uploadMediaTool = {
6
6
  name: 'upload_media',
7
7
  description:
8
8
  'Upload an image or video file to posterly storage. Returns a URL that can be used with create_post. Supports JPEG, PNG, GIF, WebP, MP4, MOV, WebM. Images up to 10MB, videos up to 50MB.\n\n' +
9
- 'This writes to the user\'s media storage (counts against quota) but does NOT publish the file anywhere. Uploading is safe it\'s the subsequent `create_post` call that publishes content, and that tool has its own confirmation requirements.\n\n' +
10
- 'IMPORTANT: If the user shares an image in the chat, use base64_data (not file_path) since chat-uploaded images are not on the local filesystem. Only use file_path when the user provides an actual local filesystem path never guess paths.',
9
+ 'This writes to the user\'s media storage (counts against quota) but does NOT publish the file anywhere. Uploading is safe - it\'s the subsequent `create_post` call that publishes content, and that tool has its own confirmation requirements.\n\n' +
10
+ 'IMPORTANT: If the user shares an image in the chat, use base64_data (not file_path) since chat-uploaded images are not on the local filesystem. Only use file_path when the user provides an actual local filesystem path - never guess paths.',
11
11
  inputSchema: z.object({
12
12
  file_path: z
13
13
  .string()
@@ -16,7 +16,7 @@ export const uploadMediaTool = {
16
16
  base64_data: z
17
17
  .string()
18
18
  .optional()
19
- .describe('Base64-encoded file data. PREFERRED for images shared directly in chat extract the image data as base64 and pass it here.'),
19
+ .describe('Base64-encoded file data. PREFERRED for images shared directly in chat - extract the image data as base64 and pass it here.'),
20
20
  filename: z
21
21
  .string()
22
22
  .describe('Filename with extension (e.g. photo.jpg, video.mp4)'),
@@ -5,7 +5,7 @@ import { code, mdKeyValue, mdTable, mdTitle } from '../lib/format.js';
5
5
  export const whoamiTool = {
6
6
  name: 'whoami',
7
7
  description:
8
- 'Return the authenticated user, API key scopes, the default (personal) workspace, and every workspace the caller can post in. ALWAYS call this at the start of a session before creating, listing, or scheduling posts posts created without an explicit workspace_id land in the default workspace shown here, and confirming with the user first prevents posts from appearing in the wrong workspace.',
8
+ 'Return the authenticated user, API key scopes, the default (personal) workspace, and every workspace the caller can post in. ALWAYS call this at the start of a session before creating, listing, or scheduling posts - posts created without an explicit workspace_id land in the default workspace shown here, and confirming with the user first prevents posts from appearing in the wrong workspace.',
9
9
  inputSchema: z.object({}),
10
10
 
11
11
  async execute(client: PosterlyClient) {