@xreplyai/mcp 0.3.11 → 0.3.17
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 +54 -33
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +4 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/communities.d.ts.map +1 -1
- package/dist/tools/communities.js +1 -7
- package/dist/tools/communities.js.map +1 -1
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +100 -8
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/helpers.d.ts +4 -0
- package/dist/tools/helpers.d.ts.map +1 -0
- package/dist/tools/helpers.js +8 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/media.d.ts +6 -0
- package/dist/tools/media.d.ts.map +1 -0
- package/dist/tools/media.js +312 -0
- package/dist/tools/media.js.map +1 -0
- package/dist/tools/pinterest.d.ts +3 -0
- package/dist/tools/pinterest.d.ts.map +1 -0
- package/dist/tools/pinterest.js +31 -0
- package/dist/tools/pinterest.js.map +1 -0
- package/dist/tools/posts.d.ts.map +1 -1
- package/dist/tools/posts.js +261 -97
- package/dist/tools/posts.js.map +1 -1
- package/dist/tools/publish.d.ts.map +1 -1
- package/dist/tools/publish.js +1 -7
- package/dist/tools/publish.js.map +1 -1
- package/dist/tools/viral-library.d.ts.map +1 -1
- package/dist/tools/viral-library.js +1 -7
- package/dist/tools/viral-library.js.map +1 -1
- package/dist/types.d.ts +20 -29
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/tools/posts.js
CHANGED
|
@@ -2,20 +2,14 @@ import { z } from "zod";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { validateScheduledAt } from "../validation.js";
|
|
5
|
-
|
|
6
|
-
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
7
|
-
}
|
|
8
|
-
function err(error) {
|
|
9
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
10
|
-
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
11
|
-
}
|
|
5
|
+
import { ok, err } from "./helpers.js";
|
|
12
6
|
const PostContentSchema = z.object({
|
|
13
|
-
platform: z.enum(["twitter", "linkedin", "youtube"]).describe("Target platform"),
|
|
14
|
-
body: z.string().min(1).max(3000).optional().describe("Post body text (max 280 for X, max 3000 for LinkedIn). Optional for YouTube video posts (used as video description)."),
|
|
7
|
+
platform: z.enum(["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook", "google_business"]).describe("Target platform"),
|
|
8
|
+
body: z.string().min(1).max(3000).optional().describe("Post body text (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 1500 for Google Business). Optional for YouTube video posts (used as video description)."),
|
|
15
9
|
content_type: z
|
|
16
|
-
.enum(["text", "single_image", "multi_image", "video"])
|
|
10
|
+
.enum(["text", "single_image", "multi_image", "video", "document"])
|
|
17
11
|
.optional()
|
|
18
|
-
.describe("Content type (default: text). Use video for LinkedIn and YouTube video posts."),
|
|
12
|
+
.describe("Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF)."),
|
|
19
13
|
metadata: z
|
|
20
14
|
.object({
|
|
21
15
|
asset_urn: z.string().optional().describe("LinkedIn only — asset_urn from xreply_media_upload or xreply_video_upload"),
|
|
@@ -29,17 +23,23 @@ const PostContentSchema = z.object({
|
|
|
29
23
|
category_id: z.string().optional().describe("YouTube only — numeric category ID (default: 22 = People & Blogs)"),
|
|
30
24
|
tags: z.array(z.string()).optional().describe("YouTube only — array of tags"),
|
|
31
25
|
thumbnail_url: z.string().optional().describe("YouTube only — URL of thumbnail image to set after upload"),
|
|
26
|
+
image_url: z.string().optional().describe("Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads). Instagram only — public JPEG URL for single_image posts"),
|
|
27
|
+
image_urls: z.array(z.string()).optional().describe("Instagram only — 2-10 public JPEG URLs for carousel (multi_image) posts"),
|
|
28
|
+
video_url: z.string().optional().describe("Instagram only — public MOV/MP4 URL for Reels (video) posts"),
|
|
29
|
+
cover_url: z.string().optional().describe("Instagram only — custom thumbnail URL for Reels"),
|
|
30
|
+
share_to_feed: z.boolean().optional().describe("Instagram only — true to share Reel to Feed tab as well as Reels tab"),
|
|
31
|
+
document_urn: z.string().optional().describe("LinkedIn only — document_urn from xreply_linkedin_document_upload, for carousel (document) posts"),
|
|
32
32
|
})
|
|
33
33
|
.optional()
|
|
34
|
-
.describe("Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: asset_urn. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For X auto-retweet: auto_rt_hours. For X communities: community_id."),
|
|
34
|
+
.describe("Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: asset_urn. For LinkedIn carousel: document_urn. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL). For X auto-retweet: auto_rt_hours. For X communities: community_id. For Instagram single_image: image_url. For Instagram multi_image carousel: image_urls (2-10 URLs). For Instagram video/reel: video_url, cover_url, share_to_feed."),
|
|
35
35
|
});
|
|
36
36
|
const CreatePostSchema = z.object({
|
|
37
|
-
body: z.string().min(1).max(280).optional().describe("Post body text for X only (max 280 chars). Use post_contents for LinkedIn or cross-posting."),
|
|
37
|
+
body: z.string().min(1).max(280).optional().describe("Post body text for X only (max 280 chars). Use post_contents for LinkedIn, Threads, Instagram, or cross-posting."),
|
|
38
38
|
post_contents: z
|
|
39
39
|
.array(PostContentSchema)
|
|
40
40
|
.min(1)
|
|
41
41
|
.optional()
|
|
42
|
-
.describe("Per-platform content. Use this for LinkedIn posts or when posting different text to multiple platforms."),
|
|
42
|
+
.describe("Per-platform content. Use this for LinkedIn, Threads, or Instagram posts, or when posting different text to multiple platforms."),
|
|
43
43
|
account_id: z
|
|
44
44
|
.number()
|
|
45
45
|
.int()
|
|
@@ -51,6 +51,7 @@ const CreatePostSchema = z.object({
|
|
|
51
51
|
.optional()
|
|
52
52
|
.describe("Array of social account IDs to post from. Takes precedence over account_id."),
|
|
53
53
|
});
|
|
54
|
+
const PLATFORM_ENUM = ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky", "facebook", "google_business"];
|
|
54
55
|
const GeneratePostSchema = z.object({
|
|
55
56
|
topic: z
|
|
56
57
|
.string()
|
|
@@ -62,13 +63,19 @@ const GeneratePostSchema = z.object({
|
|
|
62
63
|
.optional()
|
|
63
64
|
.describe("Writing angle for the post"),
|
|
64
65
|
platform: z
|
|
65
|
-
.enum(["twitter", "linkedin"])
|
|
66
|
+
.enum(["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"])
|
|
66
67
|
.optional()
|
|
67
|
-
.describe("Target platform —
|
|
68
|
+
.describe("Target platform for a single-platform post — controls output length and style. Use platforms (array) instead to generate for multiple platforms at once."),
|
|
69
|
+
platforms: z
|
|
70
|
+
.array(z.enum(PLATFORM_ENUM))
|
|
71
|
+
.min(2)
|
|
72
|
+
.max(5)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Generate platform-native variants for multiple platforms at once (2-5 platforms). Each variant counts as 1 quota. Returns variants array instead of body."),
|
|
68
75
|
});
|
|
69
76
|
const GenerateBatchSchema = z.object({
|
|
70
77
|
category: z
|
|
71
|
-
.enum(["personalized", "trending"
|
|
78
|
+
.enum(["personalized", "trending"])
|
|
72
79
|
.describe("Category of posts to generate"),
|
|
73
80
|
count: z
|
|
74
81
|
.number()
|
|
@@ -76,10 +83,14 @@ const GenerateBatchSchema = z.object({
|
|
|
76
83
|
.min(1)
|
|
77
84
|
.max(9)
|
|
78
85
|
.describe("Number of posts to generate (1-9). Cannot exceed your remaining daily quota."),
|
|
86
|
+
platform: z
|
|
87
|
+
.enum(PLATFORM_ENUM)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Target platform — controls output length and style (default: twitter)."),
|
|
79
90
|
});
|
|
80
91
|
const EditPostSchema = z.object({
|
|
81
92
|
id: z.number().int().positive().describe("Post ID"),
|
|
82
|
-
body: z.string().min(1).max(280).optional().describe("New X post body text (max 280 chars). Use post_contents to update LinkedIn or multiple platforms."),
|
|
93
|
+
body: z.string().min(1).max(280).optional().describe("New X post body text (max 280 chars). Use post_contents to update LinkedIn, Threads, Instagram, or multiple platforms."),
|
|
83
94
|
post_contents: z
|
|
84
95
|
.array(PostContentSchema)
|
|
85
96
|
.min(1)
|
|
@@ -94,6 +105,22 @@ const EditPostSchema = z.object({
|
|
|
94
105
|
const DeletePostSchema = z.object({
|
|
95
106
|
id: z.number().int().positive().describe("Post ID to delete"),
|
|
96
107
|
});
|
|
108
|
+
const CarouselGenerateSchema = z.object({
|
|
109
|
+
topic: z.string().max(280).optional(),
|
|
110
|
+
slide_count: z.number().int().min(3).max(12).optional(),
|
|
111
|
+
theme: z.enum(["dark", "light", "blue", "green"]).optional(),
|
|
112
|
+
cta_text: z.string().max(100).optional(),
|
|
113
|
+
});
|
|
114
|
+
const ThreadGenerateSchema = z.object({
|
|
115
|
+
topic: z.string().max(280).optional().describe("Optional topic for the thread (max 280 chars)"),
|
|
116
|
+
tweet_count: z
|
|
117
|
+
.number()
|
|
118
|
+
.int()
|
|
119
|
+
.min(2)
|
|
120
|
+
.max(10)
|
|
121
|
+
.optional()
|
|
122
|
+
.describe("Number of tweets in the thread (2-10, default 5). Each tweet counts as 1 quota."),
|
|
123
|
+
});
|
|
97
124
|
export const postTools = [
|
|
98
125
|
{
|
|
99
126
|
name: "xreply_posts_list",
|
|
@@ -119,7 +146,10 @@ export const postTools = [
|
|
|
119
146
|
"For X images: call xreply_media_upload first, include media_id in metadata. " +
|
|
120
147
|
"For LinkedIn images: call xreply_media_upload first, include asset_urn in metadata. " +
|
|
121
148
|
"For LinkedIn video: call xreply_video_upload first, include asset_urn in metadata, set content_type to 'video'. " +
|
|
122
|
-
"For YouTube video: set platform to 'youtube', content_type to 'video', include title in metadata (required), privacy_status (default: private), and optionally tags/category_id/thumbnail_url. Body is used as the video description. After
|
|
149
|
+
"For YouTube video: set platform to 'youtube', content_type to 'video', include title in metadata (required), privacy_status (default: private), and optionally tags/category_id/thumbnail_url. Body is used as the video description. After creating the draft, call xreply_youtube_upload with the post_id and video file path BEFORE publishing — then call xreply_posts_publish. " +
|
|
150
|
+
"For Threads: set platform to 'threads', body (max 500 chars). Image posts are supported via image_url in metadata. " +
|
|
151
|
+
"For Bluesky: set platform to 'bluesky', body (max 300 chars). Text-only posts supported. " +
|
|
152
|
+
"For Facebook Page: set platform to 'facebook', body (max 63206 chars). Supports text and single_image (image_url in metadata).",
|
|
123
153
|
inputSchema: {
|
|
124
154
|
type: "object",
|
|
125
155
|
properties: {
|
|
@@ -127,21 +157,21 @@ export const postTools = [
|
|
|
127
157
|
type: "string",
|
|
128
158
|
minLength: 1,
|
|
129
159
|
maxLength: 280,
|
|
130
|
-
description: "Post body text for X only (max 280 chars). Use post_contents for LinkedIn or cross-posting.",
|
|
160
|
+
description: "Post body text for X only (max 280 chars). Use post_contents for LinkedIn, Threads, or cross-posting.",
|
|
131
161
|
},
|
|
132
162
|
post_contents: {
|
|
133
163
|
type: "array",
|
|
134
164
|
minItems: 1,
|
|
135
|
-
description: "Per-platform content. Use this for LinkedIn posts or when posting different text to multiple platforms.",
|
|
165
|
+
description: "Per-platform content. Use this for LinkedIn or Threads posts, or when posting different text to multiple platforms.",
|
|
136
166
|
items: {
|
|
137
167
|
type: "object",
|
|
138
168
|
properties: {
|
|
139
|
-
platform: { type: "string", enum: ["twitter", "linkedin", "youtube"], description: "Target platform" },
|
|
140
|
-
body: { type: "string", minLength: 1, maxLength:
|
|
141
|
-
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts." },
|
|
169
|
+
platform: { type: "string", enum: ["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook"], description: "Target platform" },
|
|
170
|
+
body: { type: "string", minLength: 1, maxLength: 63206, description: "Post body (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 63206 for Facebook). For YouTube, used as video description (optional)." },
|
|
171
|
+
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video", "document", "poll"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF). Use poll for LinkedIn polls." },
|
|
142
172
|
metadata: {
|
|
143
173
|
type: "object",
|
|
144
|
-
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For X auto-retweet: auto_rt_hours. For X communities: community_id.",
|
|
174
|
+
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL — no upload step needed). For X auto-retweet: auto_rt_hours. For X communities: community_id. For LinkedIn polls: question (max 140 chars), options (2-4 strings, max 30 chars each), duration (ONE_DAY, THREE_DAYS, SEVEN_DAYS, or FOURTEEN_DAYS).",
|
|
145
175
|
properties: {
|
|
146
176
|
media_id: { type: "string", description: "X only — media_id from xreply_media_upload (single image)" },
|
|
147
177
|
media_ids: { type: "array", items: { type: "string" }, description: "X only — multiple media_ids from xreply_media_upload" },
|
|
@@ -154,6 +184,11 @@ export const postTools = [
|
|
|
154
184
|
category_id: { type: "string", description: "YouTube only — numeric category ID (default: 22 = People & Blogs)" },
|
|
155
185
|
tags: { type: "array", items: { type: "string" }, description: "YouTube only — video tags" },
|
|
156
186
|
thumbnail_url: { type: "string", description: "YouTube only — URL of thumbnail image to set after upload completes" },
|
|
187
|
+
image_url: { type: "string", description: "Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads)" },
|
|
188
|
+
question: { type: "string", maxLength: 140, description: "LinkedIn poll only — the poll question (max 140 chars)" },
|
|
189
|
+
options: { type: "array", items: { type: "string", maxLength: 30 }, minItems: 2, maxItems: 4, description: "LinkedIn poll only — poll answer options (2-4 items, each max 30 chars)" },
|
|
190
|
+
duration: { type: "string", enum: ["ONE_DAY", "THREE_DAYS", "SEVEN_DAYS", "FOURTEEN_DAYS"], description: "LinkedIn poll only — how long the poll stays open" },
|
|
191
|
+
linkedin_org_id: { type: "string", description: "LinkedIn only — numeric org ID to post as an organization page instead of personal profile" },
|
|
157
192
|
},
|
|
158
193
|
},
|
|
159
194
|
},
|
|
@@ -198,7 +233,7 @@ export const postTools = [
|
|
|
198
233
|
},
|
|
199
234
|
{
|
|
200
235
|
name: "xreply_posts_generate",
|
|
201
|
-
description: "Generate
|
|
236
|
+
description: "Generate AI post(s) in the user's voice and auto-save as draft(s). Use platform (single) for one platform or platforms (array) for platform-native variants across multiple platforms at once. Each platform variant counts as 1 quota. Returns body + post for single-platform, variants + post for multi-platform.",
|
|
202
237
|
inputSchema: {
|
|
203
238
|
type: "object",
|
|
204
239
|
properties: {
|
|
@@ -214,8 +249,15 @@ export const postTools = [
|
|
|
214
249
|
},
|
|
215
250
|
platform: {
|
|
216
251
|
type: "string",
|
|
217
|
-
enum: ["twitter", "linkedin"],
|
|
218
|
-
description: "
|
|
252
|
+
enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"],
|
|
253
|
+
description: "Single target platform. Use platforms (array) instead for multi-platform generation.",
|
|
254
|
+
},
|
|
255
|
+
platforms: {
|
|
256
|
+
type: "array",
|
|
257
|
+
items: { type: "string", enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"] },
|
|
258
|
+
minItems: 2,
|
|
259
|
+
maxItems: 5,
|
|
260
|
+
description: "Generate for multiple platforms at once — each gets platform-native content. 2-5 platforms. Each counts as 1 quota.",
|
|
219
261
|
},
|
|
220
262
|
},
|
|
221
263
|
required: [],
|
|
@@ -225,16 +267,21 @@ export const postTools = [
|
|
|
225
267
|
const params = GeneratePostSchema.parse(args);
|
|
226
268
|
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
227
269
|
const remaining = billing.quota.replies_remaining_today;
|
|
228
|
-
|
|
229
|
-
|
|
270
|
+
const platformCount = params.platforms ? params.platforms.length : 1;
|
|
271
|
+
if (remaining !== undefined && remaining !== null && remaining < platformCount) {
|
|
272
|
+
return err(`Insufficient quota: need ${platformCount} but only ${remaining} remaining today (${billing.quota.daily_limit}/day on ${billing.tier} plan). Resets at midnight.`);
|
|
273
|
+
}
|
|
274
|
+
if (params.platforms && params.platforms.length >= 2) {
|
|
275
|
+
const generated = await client.post("/api/v1/posts/generate", params);
|
|
276
|
+
const variants = generated.variants ?? [];
|
|
277
|
+
const postContents = variants.map((v) => ({ platform: v.platform, body: v.body, content_type: "text" }));
|
|
278
|
+
const saved = await client.post("/api/v1/posts", { post: {}, post_contents: postContents });
|
|
279
|
+
return ok({ variants, post: saved.post, quota_used: variants.length });
|
|
230
280
|
}
|
|
231
281
|
const generated = await client.post("/api/v1/posts/generate", params);
|
|
232
282
|
const platform = params.platform ?? "twitter";
|
|
233
283
|
const postContents = [{ platform, body: generated.body, content_type: "text" }];
|
|
234
|
-
const saved = await client.post("/api/v1/posts", {
|
|
235
|
-
post: {},
|
|
236
|
-
post_contents: postContents,
|
|
237
|
-
});
|
|
284
|
+
const saved = await client.post("/api/v1/posts", { post: {}, post_contents: postContents });
|
|
238
285
|
return ok({ body: generated.body, post: saved.post });
|
|
239
286
|
}
|
|
240
287
|
catch (e) {
|
|
@@ -244,13 +291,13 @@ export const postTools = [
|
|
|
244
291
|
},
|
|
245
292
|
{
|
|
246
293
|
name: "xreply_posts_generate_batch",
|
|
247
|
-
description: "Generate multiple AI posts at once in the user's voice. Use 'personalized' for posts tailored to your voice profile
|
|
294
|
+
description: "Generate multiple AI posts at once in the user's voice. Use 'personalized' for posts tailored to your voice profile or 'trending' for timely content. Returns an array of post body strings. Each post counts as 1 generation against the daily quota (5/day free, 100/day pro) — a single batch of 9 will exhaust a free account. Check xreply_billing_status first if quota is a concern.",
|
|
248
295
|
inputSchema: {
|
|
249
296
|
type: "object",
|
|
250
297
|
properties: {
|
|
251
298
|
category: {
|
|
252
299
|
type: "string",
|
|
253
|
-
enum: ["personalized", "trending"
|
|
300
|
+
enum: ["personalized", "trending"],
|
|
254
301
|
description: "Category of posts to generate",
|
|
255
302
|
},
|
|
256
303
|
count: {
|
|
@@ -259,12 +306,17 @@ export const postTools = [
|
|
|
259
306
|
maximum: 9,
|
|
260
307
|
description: "Number of posts to generate (1-9). Cannot exceed your remaining daily quota.",
|
|
261
308
|
},
|
|
309
|
+
platform: {
|
|
310
|
+
type: "string",
|
|
311
|
+
enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"],
|
|
312
|
+
description: "Target platform — controls output length and style (default: twitter).",
|
|
313
|
+
},
|
|
262
314
|
},
|
|
263
315
|
required: ["category", "count"],
|
|
264
316
|
},
|
|
265
317
|
async handler(args, client) {
|
|
266
318
|
try {
|
|
267
|
-
const { category, count } = GenerateBatchSchema.parse(args);
|
|
319
|
+
const { category, count, platform } = GenerateBatchSchema.parse(args);
|
|
268
320
|
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
269
321
|
const remaining = billing.quota.replies_remaining_today;
|
|
270
322
|
if (remaining !== undefined && remaining !== null && remaining <= 0) {
|
|
@@ -281,7 +333,7 @@ export const postTools = [
|
|
|
281
333
|
const safeCount = remaining !== undefined && remaining !== null
|
|
282
334
|
? Math.min(count, remaining)
|
|
283
335
|
: count;
|
|
284
|
-
const result = await client.post("/api/v1/posts/generate_batch", { category, count: safeCount });
|
|
336
|
+
const result = await client.post("/api/v1/posts/generate_batch", { category, count: safeCount, ...(platform ? { platform } : {}) });
|
|
285
337
|
return ok(result);
|
|
286
338
|
}
|
|
287
339
|
catch (e) {
|
|
@@ -289,9 +341,120 @@ export const postTools = [
|
|
|
289
341
|
}
|
|
290
342
|
},
|
|
291
343
|
},
|
|
344
|
+
{
|
|
345
|
+
name: "xreply_posts_generate_thread",
|
|
346
|
+
description: "Generate an X (Twitter) thread as a sequence of connected tweets in the user's voice. Returns an array of tweet strings — each under 240 chars, standalone but connected to the thread. Does NOT auto-save as a draft (unlike single-post generation); the caller is expected to review the tweets and create/publish posts separately. Each tweet counts as 1 generation against the daily quota.",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
type: "object",
|
|
349
|
+
properties: {
|
|
350
|
+
topic: {
|
|
351
|
+
type: "string",
|
|
352
|
+
maxLength: 280,
|
|
353
|
+
description: "Optional topic for the thread. If omitted, AI picks from the user's voice profile.",
|
|
354
|
+
},
|
|
355
|
+
tweet_count: {
|
|
356
|
+
type: "integer",
|
|
357
|
+
minimum: 2,
|
|
358
|
+
maximum: 10,
|
|
359
|
+
description: "Number of tweets in the thread (2-10, default 5). Each tweet counts as 1 quota.",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
required: [],
|
|
363
|
+
},
|
|
364
|
+
async handler(args, client) {
|
|
365
|
+
try {
|
|
366
|
+
const params = ThreadGenerateSchema.parse(args);
|
|
367
|
+
const tweetCount = params.tweet_count ?? 5;
|
|
368
|
+
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
369
|
+
const remaining = billing.quota.replies_remaining_today;
|
|
370
|
+
if (remaining !== undefined && remaining !== null && remaining < tweetCount) {
|
|
371
|
+
return err(`Insufficient quota: need ${tweetCount} but only ${remaining} remaining today (${billing.quota.daily_limit}/day on ${billing.tier} plan). Resets at midnight.`);
|
|
372
|
+
}
|
|
373
|
+
const result = await client.post("/api/v1/posts/generate_thread", { topic: params.topic, tweet_count: tweetCount });
|
|
374
|
+
return ok({ tweets: result.tweets, count: result.tweets.length });
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
return err(e);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "xreply_carousel_generate",
|
|
383
|
+
description: "Generate an AI-written LinkedIn carousel post from a topic/prompt. The AI creates structured slides in the user's voice, renders them as a PDF, and auto-saves as a draft LinkedIn post. Returns the slides, post, and document_urn. Counts as 1 generation against the daily quota.",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: "object",
|
|
386
|
+
properties: {
|
|
387
|
+
topic: {
|
|
388
|
+
type: "string",
|
|
389
|
+
maxLength: 280,
|
|
390
|
+
description: "Optional topic or prompt for the carousel. If omitted, AI picks from the user's voice profile.",
|
|
391
|
+
},
|
|
392
|
+
slide_count: {
|
|
393
|
+
type: "integer",
|
|
394
|
+
minimum: 3,
|
|
395
|
+
maximum: 12,
|
|
396
|
+
description: "Number of slides (3-12, default 3). Includes cover and CTA slides.",
|
|
397
|
+
},
|
|
398
|
+
theme: {
|
|
399
|
+
type: "string",
|
|
400
|
+
enum: ["dark", "light", "blue", "green"],
|
|
401
|
+
description: "Visual theme for the PDF (default: dark).",
|
|
402
|
+
},
|
|
403
|
+
cta_text: {
|
|
404
|
+
type: "string",
|
|
405
|
+
maxLength: 100,
|
|
406
|
+
description: "Custom text for the last slide (e.g. 'Follow me for more async tips'). If omitted, AI generates one.",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
required: [],
|
|
410
|
+
},
|
|
411
|
+
async handler(args, client) {
|
|
412
|
+
try {
|
|
413
|
+
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
414
|
+
const remaining = billing.quota.replies_remaining_today;
|
|
415
|
+
if (remaining !== undefined && remaining !== null && remaining < 1) {
|
|
416
|
+
return err(`Insufficient quota: 0 remaining today (${billing.quota.daily_limit}/day on ${billing.tier} plan). Resets at midnight.`);
|
|
417
|
+
}
|
|
418
|
+
const accounts = await client.get("/api/v1/social_accounts");
|
|
419
|
+
const linkedinAccount = accounts.social_accounts.find((a) => a.platform === "linkedin");
|
|
420
|
+
if (!linkedinAccount) {
|
|
421
|
+
return err("No LinkedIn account connected. Please connect LinkedIn in settings first.");
|
|
422
|
+
}
|
|
423
|
+
const parsed = CarouselGenerateSchema.parse(args);
|
|
424
|
+
const body = { social_account_id: linkedinAccount.id };
|
|
425
|
+
if (parsed.topic !== undefined)
|
|
426
|
+
body.topic = parsed.topic;
|
|
427
|
+
if (parsed.slide_count !== undefined)
|
|
428
|
+
body.slide_count = parsed.slide_count;
|
|
429
|
+
if (parsed.theme !== undefined)
|
|
430
|
+
body.theme = parsed.theme;
|
|
431
|
+
if (parsed.cta_text !== undefined)
|
|
432
|
+
body.cta_text = parsed.cta_text;
|
|
433
|
+
const generated = await client.post("/api/v1/posts/generate_carousel", body);
|
|
434
|
+
const saved = await client.post("/api/v1/posts", {
|
|
435
|
+
post: {},
|
|
436
|
+
post_contents: [{
|
|
437
|
+
platform: "linkedin",
|
|
438
|
+
body: generated.caption,
|
|
439
|
+
content_type: "document",
|
|
440
|
+
metadata: { document_urn: generated.document_urn, title: generated.title },
|
|
441
|
+
}],
|
|
442
|
+
});
|
|
443
|
+
return ok({
|
|
444
|
+
slides: generated.slides,
|
|
445
|
+
caption: generated.caption,
|
|
446
|
+
document_urn: generated.document_urn,
|
|
447
|
+
post: saved.post,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
return err(e);
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
},
|
|
292
455
|
{
|
|
293
456
|
name: "xreply_posts_edit",
|
|
294
|
-
description: "Edit a post's content, scheduled time, or auto-retweet setting. Use `body` to update X-only text, or `post_contents` to update per-platform content (LinkedIn, YouTube, or cross-platform). Set scheduled_at to an ISO 8601 datetime to schedule, or null to move back to draft. Cannot edit posts that are processing or already posted. " +
|
|
457
|
+
description: "Edit a post's content, scheduled time, or auto-retweet setting. Use `body` to update X-only text, or `post_contents` to update per-platform content (LinkedIn, YouTube, Threads, or cross-platform). Set scheduled_at to an ISO 8601 datetime to schedule, or null to move back to draft. Cannot edit posts that are processing or already posted. " +
|
|
295
458
|
"For X images: call xreply_media_upload first, include media_id in metadata. " +
|
|
296
459
|
"For LinkedIn images: call xreply_media_upload first, include asset_urn in metadata. " +
|
|
297
460
|
"For LinkedIn video: call xreply_video_upload first, include asset_urn in metadata, set content_type to 'video'. " +
|
|
@@ -308,12 +471,12 @@ export const postTools = [
|
|
|
308
471
|
items: {
|
|
309
472
|
type: "object",
|
|
310
473
|
properties: {
|
|
311
|
-
platform: { type: "string", enum: ["twitter", "linkedin", "youtube"], description: "Target platform" },
|
|
312
|
-
body: { type: "string", minLength: 1, maxLength:
|
|
313
|
-
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts." },
|
|
474
|
+
platform: { type: "string", enum: ["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook"], description: "Target platform" },
|
|
475
|
+
body: { type: "string", minLength: 1, maxLength: 63206, description: "Post body (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 63206 for Facebook). For YouTube, used as video description (optional)." },
|
|
476
|
+
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video", "document", "poll"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF). Use poll for LinkedIn polls." },
|
|
314
477
|
metadata: {
|
|
315
478
|
type: "object",
|
|
316
|
-
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For X auto-retweet: auto_rt_hours. For X communities: community_id.",
|
|
479
|
+
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL — no upload step needed). For X auto-retweet: auto_rt_hours. For X communities: community_id. For LinkedIn polls: question (max 140 chars), options (2-4 strings, max 30 chars each), duration (ONE_DAY, THREE_DAYS, SEVEN_DAYS, or FOURTEEN_DAYS).",
|
|
317
480
|
properties: {
|
|
318
481
|
media_id: { type: "string", description: "X only — media_id from xreply_media_upload (single image)" },
|
|
319
482
|
media_ids: { type: "array", items: { type: "string" }, description: "X only — multiple media_ids from xreply_media_upload" },
|
|
@@ -326,6 +489,11 @@ export const postTools = [
|
|
|
326
489
|
category_id: { type: "string", description: "YouTube only — numeric category ID (default: 22 = People & Blogs)" },
|
|
327
490
|
tags: { type: "array", items: { type: "string" }, description: "YouTube only — video tags" },
|
|
328
491
|
thumbnail_url: { type: "string", description: "YouTube only — URL of thumbnail image to set after upload completes" },
|
|
492
|
+
image_url: { type: "string", description: "Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads)" },
|
|
493
|
+
question: { type: "string", maxLength: 140, description: "LinkedIn poll only — the poll question (max 140 chars)" },
|
|
494
|
+
options: { type: "array", items: { type: "string", maxLength: 30 }, minItems: 2, maxItems: 4, description: "LinkedIn poll only — poll answer options (2-4 items, each max 30 chars)" },
|
|
495
|
+
duration: { type: "string", enum: ["ONE_DAY", "THREE_DAYS", "SEVEN_DAYS", "FOURTEEN_DAYS"], description: "LinkedIn poll only — how long the poll stays open" },
|
|
496
|
+
linkedin_org_id: { type: "string", description: "LinkedIn only — numeric org ID to post as an organization page instead of personal profile" },
|
|
329
497
|
},
|
|
330
498
|
},
|
|
331
499
|
},
|
|
@@ -512,43 +680,48 @@ export const postTools = [
|
|
|
512
680
|
},
|
|
513
681
|
{
|
|
514
682
|
name: "xreply_youtube_upload",
|
|
515
|
-
description: "Upload a local video file
|
|
516
|
-
"
|
|
517
|
-
"
|
|
518
|
-
"
|
|
519
|
-
"
|
|
520
|
-
"then confirms the upload. For scheduled posts, YouTube automatically publishes the video " +
|
|
521
|
-
"at the scheduled time — no further action needed after upload. " +
|
|
522
|
-
"The post_account_id is the id field inside post_accounts[] in the publish response where platform is 'youtube'. " +
|
|
523
|
-
"Supports all YouTube-accepted video formats (MP4, MOV, AVI, WMV, FLV, WebM, MPEG, 3GPP). Maximum file size: 100 MB. " +
|
|
683
|
+
description: "Upload a local video file for a YouTube post draft BEFORE publishing. " +
|
|
684
|
+
"Call this after xreply_posts_create (with platform 'youtube' and content_type 'video') but BEFORE xreply_posts_publish. " +
|
|
685
|
+
"The tool uploads the video to staging storage, then patches the post metadata with the storage key so the backend can finish the YouTube upload automatically after you publish. " +
|
|
686
|
+
"Once this succeeds, call xreply_posts_publish — the backend handles the rest asynchronously. " +
|
|
687
|
+
"Supports MP4, MOV, M4V, WebM, MPEG, 3GPP. Maximum file size: 50 MB. " +
|
|
524
688
|
"Note: requires filesystem access — works in Claude Code, Cursor, and mcporter (CLI). Not available in Claude.ai.",
|
|
525
689
|
inputSchema: {
|
|
526
690
|
type: "object",
|
|
527
691
|
properties: {
|
|
528
|
-
|
|
692
|
+
post_id: {
|
|
529
693
|
type: "integer",
|
|
530
|
-
description: "The
|
|
694
|
+
description: "The id of the YouTube post draft (from xreply_posts_create)",
|
|
531
695
|
},
|
|
532
696
|
video_path: {
|
|
533
697
|
type: "string",
|
|
534
|
-
description: "Absolute or relative path to the video file on disk (max
|
|
698
|
+
description: "Absolute or relative path to the video file on disk (max 50 MB)",
|
|
535
699
|
},
|
|
536
700
|
},
|
|
537
|
-
required: ["
|
|
701
|
+
required: ["post_id", "video_path"],
|
|
538
702
|
},
|
|
539
703
|
async handler(args, client) {
|
|
540
704
|
try {
|
|
541
|
-
const {
|
|
705
|
+
const { post_id, video_path } = z
|
|
542
706
|
.object({
|
|
543
|
-
|
|
707
|
+
post_id: z.number().int().positive(),
|
|
544
708
|
video_path: z.string().min(1),
|
|
545
709
|
})
|
|
546
710
|
.parse(args);
|
|
547
711
|
const resolvedPath = path.resolve(video_path);
|
|
548
|
-
const
|
|
712
|
+
const contentTypeMap = {
|
|
713
|
+
".mp4": "video/mp4",
|
|
714
|
+
".mov": "video/quicktime",
|
|
715
|
+
".m4v": "video/x-m4v",
|
|
716
|
+
".webm": "video/webm",
|
|
717
|
+
".mpeg": "video/mpeg",
|
|
718
|
+
".mpg": "video/mpeg",
|
|
719
|
+
".3gp": "video/3gpp",
|
|
720
|
+
};
|
|
549
721
|
const ext = path.extname(resolvedPath).toLowerCase();
|
|
550
|
-
|
|
551
|
-
|
|
722
|
+
const contentType = contentTypeMap[ext];
|
|
723
|
+
if (!contentType) {
|
|
724
|
+
return err(`Unsupported video format "${ext}". Supported: ${Object.keys(contentTypeMap).join(", ")}`);
|
|
552
725
|
}
|
|
553
726
|
let fileData;
|
|
554
727
|
try {
|
|
@@ -557,34 +730,18 @@ export const postTools = [
|
|
|
557
730
|
catch (e) {
|
|
558
731
|
return err(`Cannot read file at ${resolvedPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
559
732
|
}
|
|
560
|
-
const MAX_SIZE =
|
|
733
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
561
734
|
if (fileData.byteLength > MAX_SIZE) {
|
|
562
|
-
return err(`Video file is ${(fileData.byteLength / 1024 / 1024).toFixed(1)} MB — maximum allowed is
|
|
735
|
+
return err(`Video file is ${(fileData.byteLength / 1024 / 1024).toFixed(1)} MB — maximum allowed is 50 MB.`);
|
|
563
736
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
if (!status.upload_url) {
|
|
569
|
-
return err("No upload_url found for this post account. The backend may not have successfully initiated the YouTube upload.");
|
|
570
|
-
}
|
|
571
|
-
const contentTypeMap = {
|
|
572
|
-
".mp4": "video/mp4",
|
|
573
|
-
".mov": "video/quicktime",
|
|
574
|
-
".avi": "video/x-msvideo",
|
|
575
|
-
".wmv": "video/x-ms-wmv",
|
|
576
|
-
".flv": "video/x-flv",
|
|
577
|
-
".webm": "video/webm",
|
|
578
|
-
".mpeg": "video/mpeg",
|
|
579
|
-
".mpg": "video/mpeg",
|
|
580
|
-
".3gp": "video/3gpp",
|
|
581
|
-
};
|
|
582
|
-
const contentType = contentTypeMap[ext] ?? "video/mp4";
|
|
737
|
+
// Get a presigned R2 URL from the backend
|
|
738
|
+
const presign = await client.post("/api/v1/youtube/uploads/presign", { content_type: contentType });
|
|
739
|
+
// PUT the video directly to R2
|
|
583
740
|
const uploadController = new AbortController();
|
|
584
741
|
const uploadTimer = setTimeout(() => uploadController.abort(), 300_000);
|
|
585
742
|
let uploadResponse;
|
|
586
743
|
try {
|
|
587
|
-
uploadResponse = await fetch(
|
|
744
|
+
uploadResponse = await fetch(presign.upload_url, {
|
|
588
745
|
method: "PUT",
|
|
589
746
|
headers: { "Content-Type": contentType },
|
|
590
747
|
body: fileData,
|
|
@@ -594,26 +751,33 @@ export const postTools = [
|
|
|
594
751
|
catch (e) {
|
|
595
752
|
clearTimeout(uploadTimer);
|
|
596
753
|
if (e instanceof Error && e.name === "AbortError") {
|
|
597
|
-
return err("
|
|
754
|
+
return err("Video upload timed out after 5 minutes. Try a smaller file or check your connection.");
|
|
598
755
|
}
|
|
599
|
-
return err(`
|
|
756
|
+
return err(`Video upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
600
757
|
}
|
|
601
758
|
clearTimeout(uploadTimer);
|
|
602
759
|
if (!uploadResponse.ok) {
|
|
603
760
|
const body = await uploadResponse.text().catch(() => "");
|
|
604
|
-
return err(`
|
|
605
|
-
}
|
|
606
|
-
const location = uploadResponse.headers.get("Location") ?? uploadResponse.headers.get("location");
|
|
607
|
-
if (!location) {
|
|
608
|
-
return err("YouTube did not return a video location after upload. The upload may have succeeded — check your YouTube Studio.");
|
|
609
|
-
}
|
|
610
|
-
const videoIdMatch = location.match(/[?&]id=([A-Za-z0-9_-]{11})/);
|
|
611
|
-
if (!videoIdMatch) {
|
|
612
|
-
return err(`Could not extract video_id from YouTube response location: ${location}`);
|
|
761
|
+
return err(`Video upload failed with HTTP ${uploadResponse.status}: ${body}`);
|
|
613
762
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
763
|
+
// Patch the post's YouTube content metadata with the r2_key so the backend
|
|
764
|
+
// can pick it up when the post is published
|
|
765
|
+
const post = await client.get(`/api/v1/posts/${post_id}`);
|
|
766
|
+
const ytContent = post.post?.post_contents?.find((pc) => pc.platform === "youtube");
|
|
767
|
+
const existingMeta = { ...ytContent?.metadata };
|
|
768
|
+
await client.patch(`/api/v1/posts/${post_id}`, {
|
|
769
|
+
post_contents: [
|
|
770
|
+
{
|
|
771
|
+
platform: "youtube",
|
|
772
|
+
metadata: { ...existingMeta, r2_key: presign.r2_key },
|
|
773
|
+
},
|
|
774
|
+
],
|
|
775
|
+
});
|
|
776
|
+
return ok({
|
|
777
|
+
success: true,
|
|
778
|
+
r2_key: presign.r2_key,
|
|
779
|
+
message: "Video uploaded to staging. Call xreply_posts_publish to complete the post.",
|
|
780
|
+
});
|
|
617
781
|
}
|
|
618
782
|
catch (e) {
|
|
619
783
|
return err(e);
|