@omnisocials/mcp-server 1.3.7 → 1.3.9
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 +5 -0
- package/build/client.d.ts +11 -0
- package/build/tools/accounts.js +25 -11
- package/build/tools/analytics.js +57 -34
- package/build/tools/media.js +3 -3
- package/build/tools/posts.js +110 -19
- package/build/tools/webhooks.js +6 -6
- package/build/types.d.ts +17 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,6 +191,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
191
191
|
|
|
192
192
|
## Changelog
|
|
193
193
|
|
|
194
|
+
### 1.3.8
|
|
195
|
+
|
|
196
|
+
- **Fixed:** Pinterest pins created via `create_post` / `create_and_publish_post` / `update_post` now publish with title, link, video cover, and alt text. Companion server fix — the API was storing those fields under unprefixed keys while the publisher read them under `pinterest_*` prefixes, so pins published with no metadata even when the agent passed it. Existing clients on 1.3.7 benefit automatically once the backend deploys.
|
|
197
|
+
- **Added:** `pinterest.video_cover` and `pinterest.alt_text` to the tool schemas. `alt_text` improves accessibility + Pinterest's discoverability for visually impaired users; `video_cover` sets a custom cover image for video pins (otherwise Pinterest uses a 1s video keyframe).
|
|
198
|
+
|
|
194
199
|
### 1.3.7
|
|
195
200
|
|
|
196
201
|
- **Fixed:** `update_post` now accepts per-platform options (`youtube`, `pinterest`, `instagram`, `tiktok`) — same shape as `create_post`. Previously these were missing from the `update_post` schema, so agents that tried to rename a scheduled YouTube Short via `youtube.title` had the field silently stripped before it reached the server. Renaming a Short, changing privacy, adjusting a Pinterest board, etc. on an existing draft now work.
|
package/build/client.d.ts
CHANGED
|
@@ -49,11 +49,16 @@ export declare class OmniSocialsClient {
|
|
|
49
49
|
media_urls?: string[] | Record<string, string[]>;
|
|
50
50
|
type?: string;
|
|
51
51
|
source?: string;
|
|
52
|
+
link_url?: string;
|
|
53
|
+
link_title?: string;
|
|
54
|
+
link_description?: string;
|
|
55
|
+
link_thumbnail_url?: string;
|
|
52
56
|
pinterest?: Record<string, unknown>;
|
|
53
57
|
youtube?: Record<string, unknown>;
|
|
54
58
|
instagram?: Record<string, unknown>;
|
|
55
59
|
tiktok?: Record<string, unknown>;
|
|
56
60
|
x?: XPostOptions;
|
|
61
|
+
google_business?: Record<string, unknown>;
|
|
57
62
|
}): Promise<ApiResponse<unknown>>;
|
|
58
63
|
createAndPublishPost(data: {
|
|
59
64
|
content: string | Record<string, string>;
|
|
@@ -62,11 +67,16 @@ export declare class OmniSocialsClient {
|
|
|
62
67
|
media_urls?: string[] | Record<string, string[]>;
|
|
63
68
|
type?: string;
|
|
64
69
|
source?: string;
|
|
70
|
+
link_url?: string;
|
|
71
|
+
link_title?: string;
|
|
72
|
+
link_description?: string;
|
|
73
|
+
link_thumbnail_url?: string;
|
|
65
74
|
pinterest?: Record<string, unknown>;
|
|
66
75
|
youtube?: Record<string, unknown>;
|
|
67
76
|
instagram?: Record<string, unknown>;
|
|
68
77
|
tiktok?: Record<string, unknown>;
|
|
69
78
|
x?: XPostOptions;
|
|
79
|
+
google_business?: Record<string, unknown>;
|
|
70
80
|
}): Promise<ApiResponse<unknown>>;
|
|
71
81
|
updatePost(id: string, data: {
|
|
72
82
|
content?: string | Record<string, string>;
|
|
@@ -80,6 +90,7 @@ export declare class OmniSocialsClient {
|
|
|
80
90
|
instagram?: Record<string, unknown>;
|
|
81
91
|
tiktok?: Record<string, unknown>;
|
|
82
92
|
x?: XPostOptionsUpdate;
|
|
93
|
+
google_business?: Record<string, unknown>;
|
|
83
94
|
}): Promise<ApiResponse<unknown>>;
|
|
84
95
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
85
96
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
package/build/tools/accounts.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fetchImageAsBase64, capitalize } from "../client.js";
|
|
3
3
|
export function registerAccountTools(server, getClient) {
|
|
4
|
-
server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post),
|
|
4
|
+
server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
|
|
5
5
|
const result = await getClient().listAccounts();
|
|
6
6
|
if (result.error) {
|
|
7
7
|
return {
|
|
8
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
8
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
11
|
const accounts = Array.isArray(result.data) ? result.data : result.data?.accounts || [];
|
|
@@ -16,17 +16,27 @@ export function registerAccountTools(server, getClient) {
|
|
|
16
16
|
}
|
|
17
17
|
// Build markdown table
|
|
18
18
|
let md = `## Connected Accounts (${accounts.length})\n\n`;
|
|
19
|
-
md += `| # | Platform | Account | Content Types | Channel ID |\n`;
|
|
20
|
-
md +=
|
|
19
|
+
md += `| # | Platform | Account | Content Types | Channel ID | Status |\n`;
|
|
20
|
+
md += `|---|----------|---------|---------------|------------|--------|\n`;
|
|
21
21
|
for (let i = 0; i < accounts.length; i++) {
|
|
22
22
|
const a = accounts[i];
|
|
23
23
|
const name = a.username ? `@${a.username}` : a.display_name || "—";
|
|
24
24
|
const types = (a.content_types || []).join(", ");
|
|
25
25
|
const platform = capitalize(a.platform);
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const subType = a.platform_details?.subscription_type;
|
|
27
|
+
const sub = subType === "Premium" || subType === "PremiumPlus"
|
|
28
|
+
? ` (${subType})`
|
|
28
29
|
: "";
|
|
29
|
-
|
|
30
|
+
const statusCell = a.needs_reconnect
|
|
31
|
+
? `⚠️ needs_reconnect`
|
|
32
|
+
: "active";
|
|
33
|
+
md += `| ${i + 1} | ${platform}${sub} | ${name} | ${types} | \`${a.id}\` | ${statusCell} |\n`;
|
|
34
|
+
}
|
|
35
|
+
const reconnects = accounts.filter((a) => a.needs_reconnect);
|
|
36
|
+
if (reconnects.length) {
|
|
37
|
+
md += `\n⚠️ ${reconnects.length} account(s) need reconnecting before posting will succeed: ${reconnects
|
|
38
|
+
.map((a) => capitalize(a.platform))
|
|
39
|
+
.join(", ")}. Ask the user to reconnect in OmniSocials (Settings → Organisation → Workspaces).\n`;
|
|
30
40
|
}
|
|
31
41
|
md += `\nUse the **Channel ID** as the \`channels\` parameter when creating posts.`;
|
|
32
42
|
// Fetch profile pictures
|
|
@@ -57,7 +67,7 @@ export function registerAccountTools(server, getClient) {
|
|
|
57
67
|
const result = await getClient().getAccount(id);
|
|
58
68
|
if (result.error) {
|
|
59
69
|
return {
|
|
60
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
70
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
73
|
const a = result.data;
|
|
@@ -74,10 +84,14 @@ export function registerAccountTools(server, getClient) {
|
|
|
74
84
|
md += `| **Username** | ${a.username ? `@${a.username}` : "—"} |\n`;
|
|
75
85
|
md += `| **Display Name** | ${a.display_name || "—"} |\n`;
|
|
76
86
|
md += `| **Content Types** | ${(a.content_types || []).join(", ")} |\n`;
|
|
77
|
-
md += `| **Status** | ${a.status} |\n`;
|
|
87
|
+
md += `| **Status** | ${a.needs_reconnect ? "⚠️ needs_reconnect" : a.status} |\n`;
|
|
88
|
+
if (a.needs_reconnect && a.reauth_reason) {
|
|
89
|
+
md += `| **Reconnect reason** | ${a.reauth_reason} |\n`;
|
|
90
|
+
}
|
|
78
91
|
md += `| **Connected** | ${a.connected_at ? new Date(a.connected_at).toLocaleDateString() : "—"} |\n`;
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
const getSubType = a.platform_details?.subscription_type;
|
|
93
|
+
if (getSubType === "Premium" || getSubType === "PremiumPlus") {
|
|
94
|
+
md += `| **Subscription** | ${getSubType} |\n`;
|
|
81
95
|
}
|
|
82
96
|
if (a.boards?.length) {
|
|
83
97
|
md += `\n### Pinterest Boards\n\n`;
|
package/build/tools/analytics.js
CHANGED
|
@@ -7,62 +7,85 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
7
7
|
const result = await getClient().getPostAnalytics(post_id);
|
|
8
8
|
if (result.error) {
|
|
9
9
|
return {
|
|
10
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
10
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
const d = result.data;
|
|
14
|
-
|
|
14
|
+
// API returns { post_id, platforms: { <platform>: { metrics: {...} } } }.
|
|
15
|
+
// Aggregate the per-platform metrics into totals; the legacy flat shape
|
|
16
|
+
// (d.impressions / d.platform_stats) doesn't exist on this endpoint and
|
|
17
|
+
// reading those keys returned "—" for every field.
|
|
18
|
+
const platforms = d?.platforms || {};
|
|
19
|
+
const platformEntries = Object.entries(platforms);
|
|
20
|
+
if (platformEntries.length === 0) {
|
|
15
21
|
return {
|
|
16
|
-
content: [{
|
|
22
|
+
content: [{
|
|
23
|
+
type: "text",
|
|
24
|
+
text: "No analytics collected yet for this post. Stats are fetched periodically after publishing; check back in a few hours, or verify the post finished publishing successfully.",
|
|
25
|
+
}],
|
|
17
26
|
};
|
|
18
27
|
}
|
|
28
|
+
const totals = { impressions: 0, engagements: 0, likes: 0, comments: 0, shares: 0 };
|
|
29
|
+
const perPlatform = [];
|
|
30
|
+
for (const [platform, entry] of platformEntries) {
|
|
31
|
+
const m = entry?.metrics || {};
|
|
32
|
+
const impressions = platform === "instagram"
|
|
33
|
+
? Number(m.reach ?? m.impressions ?? 0)
|
|
34
|
+
: Number(m.views ?? m.impressions ?? 0);
|
|
35
|
+
const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
36
|
+
const comments = Number(m.comments ?? m.replies ?? 0);
|
|
37
|
+
const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
38
|
+
const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
|
|
39
|
+
totals.impressions += impressions;
|
|
40
|
+
totals.engagements += engagements;
|
|
41
|
+
totals.likes += likes;
|
|
42
|
+
totals.comments += comments;
|
|
43
|
+
totals.shares += shares;
|
|
44
|
+
perPlatform.push({ platform, impressions, engagements, likes, comments, shares });
|
|
45
|
+
}
|
|
19
46
|
let md = `## Post Analytics\n\n`;
|
|
20
47
|
md += `| Metric | Value |\n`;
|
|
21
48
|
md += `|--------|-------|\n`;
|
|
22
|
-
md += `| **Impressions** | ${formatNumber(
|
|
23
|
-
md += `| **Engagements** | ${formatNumber(
|
|
24
|
-
md += `| **Likes** | ${formatNumber(
|
|
25
|
-
md += `| **Comments** | ${formatNumber(
|
|
26
|
-
md += `| **Shares** | ${formatNumber(
|
|
27
|
-
if (
|
|
28
|
-
const rate = ((
|
|
49
|
+
md += `| **Impressions** | ${formatNumber(totals.impressions)} |\n`;
|
|
50
|
+
md += `| **Engagements** | ${formatNumber(totals.engagements)} |\n`;
|
|
51
|
+
md += `| **Likes** | ${formatNumber(totals.likes)} |\n`;
|
|
52
|
+
md += `| **Comments** | ${formatNumber(totals.comments)} |\n`;
|
|
53
|
+
md += `| **Shares** | ${formatNumber(totals.shares)} |\n`;
|
|
54
|
+
if (totals.impressions > 0) {
|
|
55
|
+
const rate = ((totals.engagements / totals.impressions) * 100).toFixed(2);
|
|
29
56
|
md += `| **Engagement Rate** | ${rate}% |\n`;
|
|
30
57
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const parts = [];
|
|
37
|
-
if (s.impressions !== undefined)
|
|
38
|
-
parts.push(`${formatNumber(s.impressions)} impressions`);
|
|
39
|
-
if (s.engagements !== undefined)
|
|
40
|
-
parts.push(`${formatNumber(s.engagements)} engagements`);
|
|
41
|
-
if (s.likes !== undefined)
|
|
42
|
-
parts.push(`${formatNumber(s.likes)} likes`);
|
|
43
|
-
if (s.comments !== undefined)
|
|
44
|
-
parts.push(`${formatNumber(s.comments)} comments`);
|
|
45
|
-
md += parts.join(", ") + "\n\n";
|
|
46
|
-
}
|
|
58
|
+
md += `\n### Per-Platform Breakdown\n\n`;
|
|
59
|
+
md += `| Platform | Impressions | Engagements | Likes | Comments | Shares |\n`;
|
|
60
|
+
md += `|----------|-------------|-------------|-------|----------|--------|\n`;
|
|
61
|
+
for (const p of perPlatform) {
|
|
62
|
+
md += `| ${capitalize(p.platform)} | ${formatNumber(p.impressions)} | ${formatNumber(p.engagements)} | ${formatNumber(p.likes)} | ${formatNumber(p.comments)} | ${formatNumber(p.shares)} |\n`;
|
|
47
63
|
}
|
|
48
64
|
return {
|
|
49
65
|
content: [{ type: "text", text: md }],
|
|
50
66
|
};
|
|
51
67
|
});
|
|
52
|
-
server.tool("get_analytics_overview", "Get
|
|
53
|
-
period: z.string().optional().describe("
|
|
54
|
-
start_date: z.string().optional().describe(
|
|
55
|
-
end_date: z.string().optional().describe(
|
|
68
|
+
server.tool("get_analytics_overview", "Get analytics overview with total posts, impressions, engagements, engagement rate, and per-platform breakdown. Use `period` for a rolling window (7d / 30d / 90d) or `start_date` + `end_date` for a custom range. The response echoes the resolved range and today's date — read those before assuming a year from training data (e.g. when the user says 'April', use the most recent April, not April from your training cutoff).", {
|
|
69
|
+
period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
|
|
70
|
+
start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
|
|
71
|
+
end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
|
|
56
72
|
}, async (params) => {
|
|
57
73
|
const result = await getClient().getAnalyticsOverview(params);
|
|
58
74
|
if (result.error) {
|
|
59
75
|
return {
|
|
60
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
76
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
61
77
|
};
|
|
62
78
|
}
|
|
63
79
|
const d = result.data;
|
|
64
|
-
const
|
|
65
|
-
|
|
80
|
+
const resolvedStart = result.start_date;
|
|
81
|
+
const resolvedEnd = result.end_date;
|
|
82
|
+
const currentDate = result.current_date;
|
|
83
|
+
const periodLabel = resolvedStart && resolvedEnd
|
|
84
|
+
? `${resolvedStart} → ${resolvedEnd}`
|
|
85
|
+
: result.period || params.period || "30d";
|
|
86
|
+
let md = `## Analytics Overview (${periodLabel})\n\n`;
|
|
87
|
+
if (currentDate)
|
|
88
|
+
md += `_Today: ${currentDate}_\n\n`;
|
|
66
89
|
md += `**${d.total_posts} posts** across **${d.total_platforms} platforms** | `;
|
|
67
90
|
md += `**${formatNumber(d.total_impressions)}** impressions | `;
|
|
68
91
|
md += `**${formatNumber(d.total_engagement)}** engagements | `;
|
|
@@ -95,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
95
118
|
const result = await getClient().getAccountAnalytics(params);
|
|
96
119
|
if (result.error) {
|
|
97
120
|
return {
|
|
98
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
121
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
99
122
|
};
|
|
100
123
|
}
|
|
101
124
|
const accounts = result.data || [];
|
package/build/tools/media.js
CHANGED
|
@@ -8,7 +8,7 @@ export function registerMediaTools(server, getClient) {
|
|
|
8
8
|
const result = await getClient().listMedia(params);
|
|
9
9
|
if (result.error) {
|
|
10
10
|
return {
|
|
11
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
11
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
const items = Array.isArray(result.data) ? result.data : result.data?.media || [];
|
|
@@ -75,7 +75,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
75
75
|
}
|
|
76
76
|
if (result.error) {
|
|
77
77
|
return {
|
|
78
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
78
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
const m = result.data;
|
|
@@ -96,7 +96,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
96
96
|
}, async ({ id }) => {
|
|
97
97
|
const result = await getClient().deleteMedia(id);
|
|
98
98
|
return {
|
|
99
|
-
content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Media deleted successfully." }],
|
|
99
|
+
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Media deleted successfully." }],
|
|
100
100
|
};
|
|
101
101
|
});
|
|
102
102
|
}
|
package/build/tools/posts.js
CHANGED
|
@@ -61,7 +61,7 @@ export function registerPostTools(server, getClient) {
|
|
|
61
61
|
const result = await getClient().listPosts(params);
|
|
62
62
|
if (result.error) {
|
|
63
63
|
return {
|
|
64
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
64
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
|
|
@@ -106,7 +106,7 @@ export function registerPostTools(server, getClient) {
|
|
|
106
106
|
const result = await getClient().getPost(id);
|
|
107
107
|
if (result.error) {
|
|
108
108
|
return {
|
|
109
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
109
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
const p = result.data;
|
|
@@ -185,6 +185,21 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
185
185
|
- TikTok posts: ALWAYS require at least one image or video.
|
|
186
186
|
- Pinterest posts: ALWAYS require an image AND a board_id.
|
|
187
187
|
- Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
|
|
188
|
+
|
|
189
|
+
**Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
|
|
190
|
+
- Bluesky, X, Mastodon — max **4** items per post
|
|
191
|
+
- Instagram, Threads — max **10** items per carousel
|
|
192
|
+
- TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
|
|
193
|
+
- Pinterest carousel — 2–5 images, all same aspect ratio
|
|
194
|
+
|
|
195
|
+
**Per-platform video file-size caps (validated via ffprobe at schedule/publish time; drafts exempt):**
|
|
196
|
+
Mastodon **99 MB** · Bluesky **100 MB** · Instagram **300 MB** · X **512 MB** (free tier) · Threads / Reddit **1 GB** · Pinterest **2 GB** · Facebook / TikTok **4 GB** · LinkedIn **5 GB** · YouTube **256 GB**. Uploads themselves are capped at 1 GB on top.
|
|
197
|
+
|
|
198
|
+
**Per-platform video duration caps (validated via ffprobe at schedule/publish time; drafts exempt):**
|
|
199
|
+
Facebook Reel **90 s** · YouTube Short **3 min** · X **140 s** · Bluesky **180 s** · Threads **5 min** · TikTok **10 min** · LinkedIn **10 min** · Instagram Reel **15 min** · Pinterest **15 min** · Reddit **15 min** · Facebook Post **240 min** · Mastodon (instance-dependent).
|
|
200
|
+
When a cap is exceeded, the API returns \`400 { code: "validation_error", message: "<Platform> only allows videos up to <cap> — yours is <duration>. Trim the video or deselect <Platform>." }\`. If you know the video is over a platform's cap before sending, warn the user and either trim, deselect that platform, or skip publishing to it.
|
|
201
|
+
|
|
202
|
+
When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
|
|
188
203
|
5. **Platform-specific options** (ask only when relevant):
|
|
189
204
|
- **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking the user — and do NOT skip Pinterest. Instead:
|
|
190
205
|
1. Call \`get_account\` on the Pinterest account ID — the response includes a \`Pinterest Boards\` table with each board's name and ID.
|
|
@@ -210,11 +225,17 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
210
225
|
z.record(z.string(), z.array(z.string())),
|
|
211
226
|
]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Max 10 total. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media."),
|
|
212
227
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
|
|
228
|
+
link_url: z.string().optional().describe("URL to share as a rich preview card on platforms that support link-share posts (LinkedIn and Facebook). The URL renders as a tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares, and ignored on posts that already have media attached (media wins)."),
|
|
229
|
+
link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
|
|
230
|
+
link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
|
|
231
|
+
link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
|
|
213
232
|
pinterest: z.object({
|
|
214
|
-
board_id: z.string().optional(),
|
|
215
|
-
title: z.string().optional(),
|
|
216
|
-
link: z.string().optional(),
|
|
217
|
-
|
|
233
|
+
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
234
|
+
title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
|
|
235
|
+
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
236
|
+
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
237
|
+
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
238
|
+
}).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
|
|
218
239
|
youtube: z.object({
|
|
219
240
|
title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
|
|
220
241
|
tags: z.array(z.string()).optional(),
|
|
@@ -247,11 +268,32 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
247
268
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
248
269
|
})).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media for that specific tweet. For a single tweet, omit thread_parts and use content."),
|
|
249
270
|
}).optional().describe("X (Twitter) options"),
|
|
271
|
+
google_business: z.object({
|
|
272
|
+
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
273
|
+
cta: z.object({
|
|
274
|
+
actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
|
|
275
|
+
url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
|
|
276
|
+
}).optional().describe("Optional call-to-action button."),
|
|
277
|
+
event: z.object({
|
|
278
|
+
title: z.string().max(58).describe("Event title (max 58 chars)."),
|
|
279
|
+
schedule: z.object({
|
|
280
|
+
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
281
|
+
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
282
|
+
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
283
|
+
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
284
|
+
}).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
|
|
285
|
+
}).optional().describe("Required when topic_type is EVENT."),
|
|
286
|
+
offer: z.object({
|
|
287
|
+
couponCode: z.string().max(58).optional(),
|
|
288
|
+
redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
|
|
289
|
+
termsConditions: z.string().max(4000).optional(),
|
|
290
|
+
}).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
|
|
291
|
+
}).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400."),
|
|
250
292
|
}, async (params) => {
|
|
251
293
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
252
294
|
if (result.error) {
|
|
253
295
|
return {
|
|
254
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
296
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
255
297
|
};
|
|
256
298
|
}
|
|
257
299
|
const p = result.data;
|
|
@@ -308,6 +350,9 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
308
350
|
- Instagram/TikTok posts: ALWAYS need at least one image or video.
|
|
309
351
|
- Pinterest: ALWAYS need an image + board_id.
|
|
310
352
|
- Ask the user which media to use. Use list_media to show options.
|
|
353
|
+
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos with no photo/video mix, Pinterest carousel 2–5 same-ratio images.
|
|
354
|
+
- **Video duration caps** (same as create_post): FB Reel **90 s** · YouTube Short **3 min** · X 140 s · Bluesky 180 s · Threads 5 min · TikTok 10 min · IG Reel 15 min. Returns 400 with "<Platform> only allows videos up to <cap> — yours is <duration>" when exceeded.
|
|
355
|
+
- **Video size caps** (same as create_post): Mastodon 99 MB · Bluesky 100 MB · IG 300 MB · X 512 MB · Threads/Reddit 1 GB · Pinterest 2 GB · Facebook/TikTok 4 GB · LinkedIn 5 GB. Returns 400 with "This video (<size>) is too large for <Platform>" when exceeded.
|
|
311
356
|
4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
|
|
312
357
|
5. **X threads**: When the user asks for an X/Twitter "thread" (or anything > 280 chars on X), pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
|
|
313
358
|
|
|
@@ -324,10 +369,12 @@ Do NOT call without required media — it will fail.`, {
|
|
|
324
369
|
]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
325
370
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
326
371
|
pinterest: z.object({
|
|
327
|
-
board_id: z.string().optional(),
|
|
328
|
-
title: z.string().optional(),
|
|
329
|
-
link: z.string().optional(),
|
|
330
|
-
|
|
372
|
+
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
373
|
+
title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
|
|
374
|
+
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
375
|
+
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
376
|
+
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
377
|
+
}).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
|
|
331
378
|
youtube: z.object({
|
|
332
379
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
333
380
|
tags: z.array(z.string()).optional(),
|
|
@@ -345,11 +392,32 @@ Do NOT call without required media — it will fail.`, {
|
|
|
345
392
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
346
393
|
})).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media."),
|
|
347
394
|
}).optional().describe("X (Twitter) options"),
|
|
395
|
+
google_business: z.object({
|
|
396
|
+
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
397
|
+
cta: z.object({
|
|
398
|
+
actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
|
|
399
|
+
url: z.string().optional(),
|
|
400
|
+
}).optional(),
|
|
401
|
+
event: z.object({
|
|
402
|
+
title: z.string().max(58),
|
|
403
|
+
schedule: z.object({
|
|
404
|
+
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
405
|
+
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
406
|
+
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
407
|
+
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
408
|
+
}).optional(),
|
|
409
|
+
}).optional(),
|
|
410
|
+
offer: z.object({
|
|
411
|
+
couponCode: z.string().max(58).optional(),
|
|
412
|
+
redeemOnlineUrl: z.string().optional(),
|
|
413
|
+
termsConditions: z.string().max(4000).optional(),
|
|
414
|
+
}).optional(),
|
|
415
|
+
}).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
|
|
348
416
|
}, async (params) => {
|
|
349
417
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
350
418
|
if (result.error) {
|
|
351
419
|
return {
|
|
352
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
420
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
353
421
|
};
|
|
354
422
|
}
|
|
355
423
|
const p = result.data;
|
|
@@ -403,10 +471,12 @@ Do NOT call without required media — it will fail.`, {
|
|
|
403
471
|
contains_synthetic_media: z.boolean().optional(),
|
|
404
472
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
405
473
|
pinterest: z.object({
|
|
406
|
-
board_id: z.string().optional(),
|
|
407
|
-
title: z.string().optional(),
|
|
408
|
-
link: z.string().optional(),
|
|
409
|
-
|
|
474
|
+
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
475
|
+
title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
|
|
476
|
+
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
477
|
+
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
478
|
+
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
479
|
+
}).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
|
|
410
480
|
instagram: z.object({
|
|
411
481
|
share_to_feed: z.boolean().optional(),
|
|
412
482
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
|
|
@@ -430,11 +500,32 @@ Do NOT call without required media — it will fail.`, {
|
|
|
430
500
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
431
501
|
})).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread, or `null` to revert to single-tweet mode."),
|
|
432
502
|
}).optional().describe("X (Twitter) options"),
|
|
503
|
+
google_business: z.object({
|
|
504
|
+
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
505
|
+
cta: z.object({
|
|
506
|
+
actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
|
|
507
|
+
url: z.string().optional(),
|
|
508
|
+
}).optional(),
|
|
509
|
+
event: z.object({
|
|
510
|
+
title: z.string().max(58),
|
|
511
|
+
schedule: z.object({
|
|
512
|
+
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
513
|
+
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
514
|
+
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
515
|
+
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
516
|
+
}).optional(),
|
|
517
|
+
}).optional(),
|
|
518
|
+
offer: z.object({
|
|
519
|
+
couponCode: z.string().max(58).optional(),
|
|
520
|
+
redeemOnlineUrl: z.string().optional(),
|
|
521
|
+
termsConditions: z.string().max(4000).optional(),
|
|
522
|
+
}).optional(),
|
|
523
|
+
}).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
|
|
433
524
|
}, async ({ id, ...data }) => {
|
|
434
525
|
const result = await getClient().updatePost(id, data);
|
|
435
526
|
if (result.error) {
|
|
436
527
|
return {
|
|
437
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
528
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
438
529
|
};
|
|
439
530
|
}
|
|
440
531
|
const p = result.data;
|
|
@@ -458,7 +549,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
458
549
|
}, async ({ id }) => {
|
|
459
550
|
const result = await getClient().deletePost(id);
|
|
460
551
|
return {
|
|
461
|
-
content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Post deleted successfully." }],
|
|
552
|
+
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
|
|
462
553
|
};
|
|
463
554
|
});
|
|
464
555
|
server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
|
|
@@ -469,7 +560,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
469
560
|
const result = await getClient().publishPost(id);
|
|
470
561
|
if (result.error) {
|
|
471
562
|
return {
|
|
472
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
563
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
473
564
|
};
|
|
474
565
|
}
|
|
475
566
|
const p = result.data;
|
package/build/tools/webhooks.js
CHANGED
|
@@ -5,7 +5,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
5
5
|
const result = await getClient().listWebhooks();
|
|
6
6
|
if (result.error) {
|
|
7
7
|
return {
|
|
8
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
8
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
11
|
const webhooks = Array.isArray(result.data) ? result.data : result.data?.webhooks || [];
|
|
@@ -35,7 +35,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
35
35
|
const result = await getClient().createWebhook(params);
|
|
36
36
|
if (result.error) {
|
|
37
37
|
return {
|
|
38
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
38
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
const w = result.data;
|
|
@@ -60,7 +60,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
60
60
|
const result = await getClient().getWebhook(id);
|
|
61
61
|
if (result.error) {
|
|
62
62
|
return {
|
|
63
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
63
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
const w = result.data;
|
|
@@ -87,7 +87,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
87
87
|
const result = await getClient().updateWebhook(id, data);
|
|
88
88
|
if (result.error) {
|
|
89
89
|
return {
|
|
90
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
90
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
const w = result.data;
|
|
@@ -107,7 +107,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
107
107
|
}, async ({ id }) => {
|
|
108
108
|
const result = await getClient().deleteWebhook(id);
|
|
109
109
|
return {
|
|
110
|
-
content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Webhook deleted successfully." }],
|
|
110
|
+
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Webhook deleted successfully." }],
|
|
111
111
|
};
|
|
112
112
|
});
|
|
113
113
|
server.tool("rotate_webhook_secret", "Rotate the signing secret for a webhook. The new secret will only be shown once.", {
|
|
@@ -116,7 +116,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
116
116
|
const result = await getClient().rotateWebhookSecret(id);
|
|
117
117
|
if (result.error) {
|
|
118
118
|
return {
|
|
119
|
-
content: [{ type: "text", text: `Error: ${result.error.message}` }],
|
|
119
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
const w = result.data;
|
package/build/types.d.ts
CHANGED
|
@@ -14,6 +14,14 @@ export interface Post {
|
|
|
14
14
|
channels: string[];
|
|
15
15
|
media: string[];
|
|
16
16
|
created_at: string;
|
|
17
|
+
/**
|
|
18
|
+
* Per-platform user-friendly error messages, keyed by platform identifier
|
|
19
|
+
* (facebook, instagram, linkedin, linkedin_page, youtube, tiktok, pinterest,
|
|
20
|
+
* x, threads, bluesky, mastodon, google_business). Populated when `status`
|
|
21
|
+
* is `failed` or `warning`. Only failed platforms appear. `null` while the
|
|
22
|
+
* post is still draft/scheduled/processing or every platform succeeded.
|
|
23
|
+
*/
|
|
24
|
+
errors?: Record<string, string> | null;
|
|
17
25
|
x?: {
|
|
18
26
|
reply_settings?: string;
|
|
19
27
|
paid_partnership?: boolean;
|
|
@@ -41,7 +49,15 @@ export interface Account {
|
|
|
41
49
|
display_name: string;
|
|
42
50
|
profile_picture: string | null;
|
|
43
51
|
content_types: string[];
|
|
44
|
-
|
|
52
|
+
/**
|
|
53
|
+
* "active" while the OAuth token is healthy. Flips to "needs_reconnect"
|
|
54
|
+
* when the platform has revoked/expired the token — posts to this account
|
|
55
|
+
* will fail until the user reconnects.
|
|
56
|
+
*/
|
|
57
|
+
status: "active" | "needs_reconnect" | string;
|
|
58
|
+
needs_reconnect: boolean;
|
|
59
|
+
/** Short reason from the platform; only present when needs_reconnect is true. */
|
|
60
|
+
reauth_reason?: string | null;
|
|
45
61
|
connected_at: string | null;
|
|
46
62
|
boards?: {
|
|
47
63
|
id: string;
|
package/package.json
CHANGED