@jclvsh/dropspace 2.36.1 → 2.43.0

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
@@ -269,8 +269,10 @@ Other agents that read skill files can use the same file from `node_modules/@jcl
269
269
  | `reply_to_comment` | Reply as the publishing account: youtube and instagram (send scope, stdio only) |
270
270
  | `hide_comment` | Hide or unhide a comment: instagram (send scope, stdio only) |
271
271
  | `delete_comment` | Permanently delete a comment: instagram, media owner only (send scope, stdio only) |
272
+ | `list_comment_queue` | Every comment still waiting for you across your recent instagram and youtube posts, with claude's suggested action (stdio only) |
273
+ | `act_on_comments` | Hide (instagram, send scope) or dismiss up to 50 queue comments (stdio only) |
272
274
 
273
- CLI: `dropspace launches comments list|reply|hide|delete`.
275
+ CLI: `dropspace launches comments list|reply|hide|delete` and `dropspace comment-queue list|hide|dismiss <platform:post_id:comment_id...>`.
274
276
 
275
277
  ### Reddit
276
278
 
@@ -370,14 +372,26 @@ Reddit engagement (drafted comments in other communities) is paused: its tools a
370
372
  | Tool | Description |
371
373
  | -------------- | ------------------------------------------------------------------------------------------------------ |
372
374
  | `get_audience` | Follower counts and growth per connected account (Instagram, YouTube, TikTok, Twitter, Facebook Pages) |
375
+ | `get_trial_reel_analytics` | Instagram trial reel results: totals, one entry per reel, and net followers since the first trial reel (local package only, not the hosted endpoint) |
376
+ | `get_best_posting_hours` | When your Instagram followers are online (last 30 days): per hour, per weekday and hour, and the busiest hours (local package only, not the hosted endpoint) |
377
+ | `get_audience_demographics` | Age, gender, country, and city of your Instagram followers and engaged audience (local package only, not the hosted endpoint) |
378
+ | `get_reels_read` | Each Instagram account's latest reels read (local package only, not the hosted endpoint) |
379
+ | `generate_reels_read` | Claude compares your top, middle, and bottom reels of the last 90 days; pro and premium, one a day per account (local package only, not the hosted endpoint) |
380
+ | `list_trial_reels` | Trial reel programs: each Instagram account's program, posting hours, and reshared reels with their 1/3/7-day numbers; pro and premium (local package only) |
381
+ | `update_trial_reel_program` | Turn an account's trial reel program on or off, or change reels per day, auto-approve, skip terms, timezone (send scope to turn on; local package only) |
382
+ | `scan_trial_reels` | Scan an account's reels again and queue the strong ones (local package only) |
383
+ | `update_trial_reel` | Approve, hold, re-caption, or remove a waiting reel, or approve all (send scope to approve; local package only) |
373
384
 
374
385
  #### `get_audience` Parameters
375
386
 
376
387
  | Parameter | Type | Required | Description |
377
388
  | ------------- | ------ | -------- | ----------------------------------------- |
378
389
  | `window_days` | number | No | Growth window in days (1-365, default 30) |
390
+ | `include_instagram_insights` | boolean | No | Add Instagram growth analytics to each Instagram entry (default false) |
379
391
 
380
- CLI: `dropspace audience [-w, --window-days <n>]`.
392
+ CLI: `dropspace audience [-w, --window-days <n>] [--instagram-insights]`, `dropspace trial-reels`, `dropspace best-hours [--timezone <zone>]`, `dropspace demographics [--timeframe <this_week|this_month|last_90_days>]`, `dropspace reels-read [--generate] [--connection-id <id>]`, and `dropspace trial-reel-program list|set|scan|approve|approve-all|remove`.
393
+
394
+ With `include_instagram_insights`, each Instagram entry carries `instagram_insights`: follows, unfollows, net, and reach split into followers and non-followers per Pacific-time day, an estimated followers-over-time series rebuilt back from the current count, weekly growth velocity, the share of reach from non-followers, and contact-button taps. Instagram revises the last 7 days (`settled: false`). Each call reads up to 31 days, newest first, so a 90-day window fills over a few calls (`days_pending`). Instagram reports none of this for accounts under 100 followers (`available: false`).
381
395
 
382
396
  Counts come from each platform's official API and are cached for 15 minutes. YouTube works differently: each read spends a shared daily quota, so a connected account's YouTube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that background refresh is delayed; if the daily quota is exhausted, a YouTube read returns the most recent successful count instead of null. `current.captured_at` is always when that count was actually read from the platform, not when it was served from cache - use it to judge freshness, not the response time. YouTube history points are kept for 30 days (YouTube's API storage limit), so YouTube growth reaches back about 30 days even for a longer `window_days`; other platforms keep their full history. LinkedIn is not covered (its API exposes no personal-profile follower count).
383
397
 
package/dist/cli.js CHANGED
File without changes
@@ -4,11 +4,14 @@ export function registerAudienceCommands(program, client) {
4
4
  .command("audience")
5
5
  .description("follower counts + growth per connected account (instagram, youtube, tiktok, twitter, facebook pages); counts cache for 15 min, youtube refreshes about hourly in the background, and captured_at is always the actual read time, not the serve time")
6
6
  .option("-w, --window-days <n>", "growth window in days (max 365)", "30")
7
+ .option("--instagram-insights", "add instagram growth analytics: follows/unfollows per day, followers over time, velocity, reach split, contact taps")
7
8
  .action(async (opts) => {
8
9
  try {
9
10
  const params = {};
10
11
  if (opts.windowDays)
11
12
  params.window_days = opts.windowDays;
13
+ if (opts.instagramInsights)
14
+ params.include = "instagram_insights";
12
15
  const result = await client.get("/audience", params);
13
16
  formatOutput(result, getFormat(program.opts()));
14
17
  }
@@ -16,4 +19,185 @@ export function registerAudienceCommands(program, client) {
16
19
  handleError(error);
17
20
  }
18
21
  });
22
+ program
23
+ .command("trial-reels")
24
+ .description("instagram trial reel results: totals, one entry per reel, and net followers since the first trial reel (stored numbers, no instagram request)")
25
+ .action(async () => {
26
+ try {
27
+ const result = await client.get("/analytics/trial-reels");
28
+ formatOutput(result, getFormat(program.opts()));
29
+ }
30
+ catch (error) {
31
+ handleError(error);
32
+ }
33
+ });
34
+ program
35
+ .command("best-hours")
36
+ .description("when your instagram followers are online (last 30 days): average per hour and per weekday-hour, and the busiest hours at least two hours apart; accounts with 100+ followers")
37
+ .option("--timezone <zone>", "IANA timezone for the hours (default: autopilot timezone, else UTC)")
38
+ .action(async (opts) => {
39
+ try {
40
+ const result = await client.get("/audience/best-hours", opts.timezone ? { timezone: opts.timezone } : undefined);
41
+ formatOutput(result, getFormat(program.opts()));
42
+ }
43
+ catch (error) {
44
+ handleError(error);
45
+ }
46
+ });
47
+ program
48
+ .command("demographics")
49
+ .description("who your instagram audience is: age, gender, country, and city of followers and of accounts that engaged in a timeframe; accounts with 100+ followers")
50
+ .option("--timeframe <period>", "engaged-audience period: this_week, this_month (default), last_90_days")
51
+ .action(async (opts) => {
52
+ try {
53
+ const result = await client.get("/audience/demographics", opts.timeframe ? { timeframe: opts.timeframe } : undefined);
54
+ formatOutput(result, getFormat(program.opts()));
55
+ }
56
+ catch (error) {
57
+ handleError(error);
58
+ }
59
+ });
60
+ program
61
+ .command("reels-read")
62
+ .description("claude's read of your recent instagram reels (top, middle, and bottom by views over the last 90 days); shows the latest stored read, or makes a new one with --generate (pro and premium, one a day per account, 30-60 seconds)")
63
+ .option("--generate", "make a new read instead of showing the stored ones")
64
+ .option("--connection-id <id>", "instagram connection to read with --generate (default: the first instagram account)")
65
+ .action(async (opts) => {
66
+ try {
67
+ const result = opts.generate
68
+ ? await client.post("/analytics/reels-read", opts.connectionId ? { connection_id: opts.connectionId } : {})
69
+ : await client.get("/analytics/reels-read");
70
+ formatOutput(result, getFormat(program.opts()));
71
+ }
72
+ catch (error) {
73
+ handleError(error);
74
+ }
75
+ });
76
+ const queue = program
77
+ .command("comment-queue")
78
+ .description("comments still waiting for you across your recent instagram and youtube posts");
79
+ queue
80
+ .command("list")
81
+ .description("waiting comments, newest first, with claude's suggested action and reply draft")
82
+ .action(async () => {
83
+ try {
84
+ const result = await client.get("/comments/queue");
85
+ formatOutput(result, getFormat(program.opts()));
86
+ }
87
+ catch (error) {
88
+ handleError(error);
89
+ }
90
+ });
91
+ for (const action of ["hide", "dismiss"]) {
92
+ queue
93
+ .command(`${action} <refs...>`)
94
+ .description(action === "hide"
95
+ ? "hide comments on instagram (send scope); refs are platform:post_id:comment_id"
96
+ : "dismiss comments from the queue (nothing changes on the platform); refs are platform:post_id:comment_id")
97
+ .action(async (refs) => {
98
+ try {
99
+ const comments = refs.map((ref) => {
100
+ const [platform, post_id, ...rest] = ref.split(":");
101
+ return { platform, post_id, comment_id: rest.join(":") };
102
+ });
103
+ const result = await client.post("/comments/actions", { action, comments });
104
+ formatOutput(result, getFormat(program.opts()));
105
+ }
106
+ catch (error) {
107
+ handleError(error);
108
+ }
109
+ });
110
+ }
111
+ const trial = program
112
+ .command("trial-reel-program")
113
+ .description("reshare your strongest instagram reels as trial reels (pro and premium)");
114
+ trial
115
+ .command("list")
116
+ .description("each instagram account with its trial reel program, posting hours, and reels")
117
+ .action(async () => {
118
+ try {
119
+ formatOutput(await client.get("/trial-reels"), getFormat(program.opts()));
120
+ }
121
+ catch (error) {
122
+ handleError(error);
123
+ }
124
+ });
125
+ trial
126
+ .command("set <connectionId>")
127
+ .description("change a program; --on needs the send scope and scans the account's reels the first time")
128
+ .option("--on", "turn the program on")
129
+ .option("--off", "turn the program off")
130
+ .option("--per-day <n>", "trial reels a day, 1-3")
131
+ .option("--auto-approve <bool>", "post queued reels without approving each one (true or false)")
132
+ .option("--skip <terms>", "comma-separated: never queue a reel whose caption contains one")
133
+ .option("--timezone <tz>", "IANA timezone for posting hours")
134
+ .action(async (connectionId, options) => {
135
+ try {
136
+ const body = {
137
+ ...(options.on ? { enabled: true } : options.off ? { enabled: false } : {}),
138
+ ...(options.perDay ? { reels_per_day: Number(options.perDay) } : {}),
139
+ ...(options.autoApprove !== undefined
140
+ ? { auto_approve: ["true", "yes", "on", "1"].includes(options.autoApprove.toLowerCase()) }
141
+ : {}),
142
+ ...(options.skip !== undefined
143
+ ? {
144
+ skip_caption_terms: options.skip
145
+ .split(",")
146
+ .map((term) => term.trim())
147
+ .filter(Boolean),
148
+ }
149
+ : {}),
150
+ ...(options.timezone ? { timezone: options.timezone } : {}),
151
+ };
152
+ formatOutput(await client.put(`/trial-reels/programs/${connectionId}`, body), getFormat(program.opts()));
153
+ }
154
+ catch (error) {
155
+ handleError(error);
156
+ }
157
+ });
158
+ trial
159
+ .command("scan <connectionId>")
160
+ .description("scan the account's reels again and queue the strong ones")
161
+ .action(async (connectionId) => {
162
+ try {
163
+ formatOutput(await client.post(`/trial-reels/programs/${connectionId}/scan`), getFormat(program.opts()));
164
+ }
165
+ catch (error) {
166
+ handleError(error);
167
+ }
168
+ });
169
+ trial
170
+ .command("approve <itemId>")
171
+ .description("approve a waiting reel so it posts at the next posting hour (send scope)")
172
+ .action(async (itemId) => {
173
+ try {
174
+ formatOutput(await client.patch(`/trial-reels/items/${itemId}`, { approved: true }), getFormat(program.opts()));
175
+ }
176
+ catch (error) {
177
+ handleError(error);
178
+ }
179
+ });
180
+ trial
181
+ .command("approve-all <connectionId>")
182
+ .description("approve every waiting reel with a caption (send scope)")
183
+ .action(async (connectionId) => {
184
+ try {
185
+ formatOutput(await client.post(`/trial-reels/programs/${connectionId}/approve-all`), getFormat(program.opts()));
186
+ }
187
+ catch (error) {
188
+ handleError(error);
189
+ }
190
+ });
191
+ trial
192
+ .command("remove <itemId>")
193
+ .description("remove a waiting reel for good")
194
+ .action(async (itemId) => {
195
+ try {
196
+ await client.delete(`/trial-reels/items/${itemId}`);
197
+ formatOutput({ data: { id: itemId, status: "removed" } }, getFormat(program.opts()));
198
+ }
199
+ catch (error) {
200
+ handleError(error);
201
+ }
202
+ });
19
203
  }
package/dist/index.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ import { registerRedditTools } from "./tools/reddit.js";
17
17
  import { registerAudienceTools } from "./tools/audience.js";
18
18
  import { registerCommentTools } from "./tools/comments.js";
19
19
  import { registerAccountTools } from "./tools/account.js";
20
+ import { registerTrialReelTools } from "./tools/trial-reels.js";
20
21
  /** Drops tools whose feature is paused before the policy registrar sees them (see PAUSED_TOOLS). */
21
22
  function skipPausedTools(registrar) {
22
23
  return {
@@ -46,5 +47,6 @@ export function createDropspaceServer({ client, version, profile }) {
46
47
  registerAudienceTools(registrar, client);
47
48
  registerCommentTools(registrar, client);
48
49
  registerAccountTools(registrar, client);
50
+ registerTrialReelTools(registrar, client);
49
51
  return server;
50
52
  }
@@ -29,7 +29,7 @@ export declare const REDDIT_ENGAGEMENT_PAUSED = true;
29
29
  export declare const PAUSED_TOOLS: ReadonlySet<string>;
30
30
  /** The tools the package serves - every docs count and category list reads this. */
31
31
  export declare const TOOL_POLICY: PolicyTable<ToolCategory>;
32
- export declare const toolPolicy: (name: string) => ToolPolicy<"audience" | "launches" | "comments" | "personas" | "connections" | "webhooks" | "usage" | "credits" | "media" | "boosts" | "posts" | "API keys" | "dropspace" | "account" | "autopilot" | "recommendations" | "reddit engagement">;
32
+ export declare const toolPolicy: (name: string) => ToolPolicy<"audience" | "comments" | "launches" | "personas" | "connections" | "webhooks" | "usage" | "credits" | "media" | "boosts" | "posts" | "API keys" | "dropspace" | "account" | "autopilot" | "recommendations" | "reddit engagement">;
33
33
  export declare const isToolEnabled: (name: string, profile: ToolProfile) => boolean;
34
34
  export declare const toolAnnotations: (name: string) => {
35
35
  readOnlyHint: boolean;
@@ -74,6 +74,9 @@ const FULL_TOOL_POLICY = {
74
74
  hide_comment: localOnly("comments", "sensitive"),
75
75
  // deletes on the platform with no restore
76
76
  delete_comment: localOnly("comments", "sensitive"),
77
+ list_comment_queue: localOnly("comments", "read"),
78
+ // hide changes the post on Instagram; dismiss only leaves the queue
79
+ act_on_comments: localOnly("comments", "sensitive"),
77
80
  // personas
78
81
  list_personas: hosted("personas", "read"),
79
82
  get_persona: hosted("personas", "read"),
@@ -118,6 +121,18 @@ const FULL_TOOL_POLICY = {
118
121
  dismiss_recommendation: hosted("recommendations", "write"),
119
122
  // audience, usage, status
120
123
  get_audience: hosted("audience", "read"),
124
+ // Local-only while the hosted set submitted to Meta on 10/13/2026 is in review (server.test.ts pins it).
125
+ get_trial_reel_analytics: localOnly("audience", "read"),
126
+ get_best_posting_hours: localOnly("audience", "read"),
127
+ get_audience_demographics: localOnly("audience", "read"),
128
+ get_reels_read: localOnly("audience", "read"),
129
+ // spends a Claude call and up to 60 Instagram requests; changes nothing the user posted
130
+ generate_reels_read: localOnly("audience", "write"),
131
+ list_trial_reels: localOnly("audience", "read"),
132
+ // turning a program on or approving reels leads to instagram posts
133
+ update_trial_reel_program: localOnly("audience", "sensitive"),
134
+ scan_trial_reels: localOnly("audience", "write"),
135
+ update_trial_reel: localOnly("audience", "sensitive"),
121
136
  get_usage: hosted("usage", "read"),
122
137
  get_changelog: hosted("usage", "read"),
123
138
  get_dropspace_status: hosted("dropspace", "read"),
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export function registerAudienceTools(server, client) {
3
- server.tool("get_audience", 'follower counts + growth for every connected instagram / youtube / tiktok / twitter account and facebook page. one entry per ACCOUNT, not per platform: "entity_id" is null for your primary account on a platform and set for each additional one, so a user with two instagram accounts returns two instagram entries. counts come from each platform\'s official API and are cached for 15 minutes. youtube works differently: each youtube read spends a shared daily quota, so while you keep reading a creator\'s audience, their youtube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that refresh is delayed; if the daily quota is exhausted, a youtube read returns the most recent successful count instead of null. "current.captured_at" is always when that count was actually read from the platform, never when it was served: use it to judge freshness. "series" is the stored history; youtube history is kept for 30 days (youtube\'s API storage limit), so youtube growth reaches back about 30 days whatever window_days says, while other platforms keep full history. growth is measured from the OLDEST stored point inside the window, which — because history points are recorded when you read, at most one per account per day — can be much younger than window_days: read "effective_window_days" (the span the delta actually covers) and "baseline_captured_at" before comparing growth across accounts. linkedin is not covered (its API exposes no personal-profile follower count).', {
3
+ server.tool("get_audience", 'follower counts + growth for every connected instagram / youtube / tiktok / twitter account and facebook page. one entry per ACCOUNT, not per platform: "entity_id" is null for your primary account on a platform and set for each additional one, so a user with two instagram accounts returns two instagram entries. counts come from each platform\'s official API and are cached for 15 minutes. youtube works differently: each youtube read spends a shared daily quota, so while you keep reading a creator\'s audience, their youtube count is refreshed in the background about once an hour, and an on-demand read re-fetches at most every 6 hours if that refresh is delayed; if the daily quota is exhausted, a youtube read returns the most recent successful count instead of null. "current.captured_at" is always when that count was actually read from the platform, never when it was served: use it to judge freshness. "series" is the stored history; youtube history is kept for 30 days (youtube\'s API storage limit), so youtube growth reaches back about 30 days whatever window_days says, while other platforms keep full history. growth is measured from the OLDEST stored point inside the window, which — because history points are recorded when you read, at most one per account per day — can be much younger than window_days: read "effective_window_days" (the span the delta actually covers) and "baseline_captured_at" before comparing growth across accounts. linkedin is not covered (its API exposes no personal-profile follower count). set include_instagram_insights to add instagram growth analytics to each instagram entry: follows, unfollows, net, and reach split into followers and non-followers per pacific-time day (the last 7 days are settled: false because instagram still revises them), followers_over_time (an estimate rebuilt back from the current count), weekly velocity with a trend, non_follower_reach_share, and contact-button taps. it reads up to 31 days per call, newest first, so a longer window fills over a few calls (days_pending says how many are left); instagram reports none of it for accounts under 100 followers (available: false).', {
4
4
  window_days: z
5
5
  .number()
6
6
  .int()
@@ -8,9 +8,18 @@ export function registerAudienceTools(server, client) {
8
8
  .max(365)
9
9
  .optional()
10
10
  .describe("growth window in days (default: 30, max: 365)"),
11
- }, async ({ window_days }) => {
11
+ include_instagram_insights: z
12
+ .boolean()
13
+ .optional()
14
+ .describe("add instagram growth analytics to each instagram entry (default: false)"),
15
+ }, async ({ window_days, include_instagram_insights }) => {
12
16
  try {
13
- const result = await client.get("/audience", window_days ? { window_days: String(window_days) } : undefined);
17
+ const params = {};
18
+ if (window_days)
19
+ params.window_days = String(window_days);
20
+ if (include_instagram_insights)
21
+ params.include = "instagram_insights";
22
+ const result = await client.get("/audience", Object.keys(params).length ? params : undefined);
14
23
  return {
15
24
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
16
25
  };
@@ -27,4 +36,105 @@ export function registerAudienceTools(server, client) {
27
36
  };
28
37
  }
29
38
  });
39
+ server.tool("get_trial_reel_analytics", "instagram trial reel results: totals across every trial reel posted through dropspace (views, reach summed per reel, likes, comments, shares, saves, plus views-weighted avg_watch_time_ms and skip_rate, the percent of plays that skipped in the first 3 seconds), one entry per reel newest first (up to 100, with the numbers last read from instagram and when), and followers_since_first: net follows minus unfollows on your instagram accounts since the pacific day of the first trial reel, from the daily counts get_audience with include_instagram_insights stores (days_counted of days_in_span says how complete it is; it counts every new follower, not only ones the trial reels brought). reads stored numbers only, so it makes no instagram request.", {}, async () => {
40
+ try {
41
+ const result = await client.get("/analytics/trial-reels");
42
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
43
+ }
44
+ catch (error) {
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
50
+ },
51
+ ],
52
+ isError: true,
53
+ };
54
+ }
55
+ });
56
+ server.tool("get_best_posting_hours", "when your instagram followers are online, from instagram's online_followers insight over the last 30 days. per connected instagram account: hourly (average followers online in each hour of the day, index = hour 0-23), grid (average by weekday, index 0 = sunday, then hour), days_counted, and best_hours, the busiest hours at least two hours apart (up to 5, busiest first); the top-level best_hours combines every instagram account. every hour is in timezone (default: your autopilot timezone, else UTC). cached about 12 hours per account. instagram reports this only for accounts with 100 or more followers (available: false with unavailable_reason otherwise). to post at these hours, set autopilot posting_times to them with configure_autopilot.", {
57
+ timezone: z
58
+ .string()
59
+ .optional()
60
+ .describe("IANA timezone for every hour in the answer, e.g. America/New_York (default: autopilot timezone, else UTC)"),
61
+ }, async ({ timezone }) => {
62
+ try {
63
+ const result = await client.get("/audience/best-hours", timezone ? { timezone } : undefined);
64
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
65
+ }
66
+ catch (error) {
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
72
+ },
73
+ ],
74
+ isError: true,
75
+ };
76
+ }
77
+ });
78
+ server.tool("get_audience_demographics", "who your instagram audience is: per connected instagram account, the age, gender (F, M, U = unknown), country (ISO code), and city of its current followers (followers) and of the accounts that engaged with it in timeframe (engaged), each split largest first with total = people instagram has demographic data for. instagram reports the top 45 rows per split and nothing for accounts under 100 followers (available: false with unavailable_reason). cached about 12 hours per account.", {
79
+ timeframe: z
80
+ .enum(["this_week", "this_month", "last_90_days"])
81
+ .optional()
82
+ .describe("period for the engaged audience (default this_month); followers are always current"),
83
+ }, async ({ timeframe }) => {
84
+ try {
85
+ const result = await client.get("/audience/demographics", timeframe ? { timeframe } : undefined);
86
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
87
+ }
88
+ catch (error) {
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
94
+ },
95
+ ],
96
+ isError: true,
97
+ };
98
+ }
99
+ });
100
+ server.tool("get_reels_read", "each connected instagram account with its latest reels read (claude's comparison of the top, middle, and bottom reels by views over the last 90 days), read is absent when none was made in the last 30 days; oldest_posted_at says how far back a read reaches (up to 60 reels within 90 days). next_refresh_at is when generate_reels_read can make a new one. makes no instagram or claude request. pro and premium.", {}, async () => {
101
+ try {
102
+ const result = await client.get("/analytics/reels-read");
103
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
104
+ }
105
+ catch (error) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
111
+ },
112
+ ],
113
+ isError: true,
114
+ };
115
+ }
116
+ });
117
+ server.tool("generate_reels_read", "make a reels read for one instagram account: the latest reels (up to 60) from the last 90 days are ranked by views, and claude compares the top, middle, and bottom ten from their captions and numbers (it never sees the videos). returns status ok with read (summary, working and not_working findings with evidence, three next steps), not_enough_reels (needs 8), or unavailable with reason. one new read per account a day: inside that day it returns the existing read with refreshed: false. takes 30-60 seconds. pro and premium.", {
118
+ connection_id: z
119
+ .string()
120
+ .uuid()
121
+ .optional()
122
+ .describe("instagram connection to read (from get_reels_read); default: the first instagram account"),
123
+ }, async ({ connection_id }) => {
124
+ try {
125
+ const result = await client.post("/analytics/reels-read", connection_id ? { connection_id } : {});
126
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
127
+ }
128
+ catch (error) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
134
+ },
135
+ ],
136
+ isError: true,
137
+ };
138
+ }
139
+ });
30
140
  }
@@ -76,4 +76,31 @@ export function registerCommentTools(server, client) {
76
76
  return fail(error);
77
77
  }
78
78
  });
79
+ server.tool("list_comment_queue", "every comment still waiting for you across your instagram and youtube posts made through dropspace in the last 30 days (up to 30 posts, the ones with comments first; newest 50 comments each; posts_in_window says how many posts the window has), newest first, up to 200. waiting: not hidden, not written or answered by your account, and not replied to, hidden, deleted, or dismissed through dropspace. each comment carries its post (platform, post_id, launch_id, launch_name) and claude's read: kind (question, feedback, praise, spam, attack, other; null until sorted), suggested_action (reply, hide, dismiss), and reply_draft for questions and feedback; may_be_answered is true when youtube returned only some replies and none was yours. counts per kind; post_issues lists posts that could not be read. reply with reply_to_comment, delete with delete_comment (launch_id + post_id + comment id); hide or dismiss several with act_on_comments.", {}, async () => {
80
+ try {
81
+ return text(await client.get("/comments/queue"));
82
+ }
83
+ catch (error) {
84
+ return fail(error);
85
+ }
86
+ });
87
+ server.tool("act_on_comments", "hide (instagram only, changes the post, needs the send scope) or dismiss (seen, nothing to do; nothing changes on the platform, needs the write scope) up to 50 comments from list_comment_queue. hides are sent one a second. returns a result per comment (ok, detail); comments not on your own dropspace posts fail with the reason.", {
88
+ action: z.enum(["hide", "dismiss"]).describe("hide on instagram, or dismiss from the queue"),
89
+ comments: z
90
+ .array(z.object({
91
+ platform: z.enum(["instagram", "youtube"]),
92
+ post_id: z.string().min(1).max(200),
93
+ comment_id: z.string().min(1).max(200),
94
+ }))
95
+ .min(1)
96
+ .max(50)
97
+ .describe("comments from list_comment_queue: platform, post_id, and the comment's id"),
98
+ }, async ({ action, comments }) => {
99
+ try {
100
+ return text(await client.post("/comments/actions", { action, comments }));
101
+ }
102
+ catch (error) {
103
+ return fail(error);
104
+ }
105
+ });
79
106
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DropspaceClient } from "../client.js";
3
+ export declare function registerTrialReelTools(server: McpServer, client: DropspaceClient): void;
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ function text(result) {
3
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
4
+ }
5
+ function fail(error) {
6
+ return {
7
+ content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
8
+ isError: true,
9
+ };
10
+ }
11
+ export function registerTrialReelTools(server, client) {
12
+ server.tool("list_trial_reels", "trial reel programs (pro and premium): each connected instagram account with eligible (instagram takes trial reels only from professional accounts with 200+ followers), its program (absent until saved: enabled, auto_approve, reels_per_day 1-3, skip_caption_terms, timezone, last_note), posting_hours (0-23 in the program's timezone: the account's best hours, else 9, 13, 18), and items, newest 100: status queued (waits for approval), posting, posted, won, retired, or failed (failure says why), with reason, caption, the source reel and its views, win_target (half the source's views, capped at 10,000), and checkpoints d1/d3/d7.", {}, async () => {
13
+ try {
14
+ return text(await client.get("/trial-reels"));
15
+ }
16
+ catch (error) {
17
+ return fail(error);
18
+ }
19
+ });
20
+ server.tool("update_trial_reel_program", "create or change the trial reel program for one instagram account. turning it on the first time scans the account's reels and queues the strong ones (returned as scan). nothing posts until a reel is approved (update_trial_reel, or auto_approve). approved reels are reshared as instagram trial reels (shown to non-followers only, graduation manual) one per posting hour, never within 2 hours of another dropspace post to that account; each is a launch on the user's plan. enabled or auto_approve true needs the send scope.", {
21
+ connection_id: z.string().uuid().describe("instagram connection_id from list_trial_reels"),
22
+ enabled: z.boolean().optional().describe("turn the program on or off"),
23
+ auto_approve: z.boolean().optional().describe("post queued reels without approving each one"),
24
+ reels_per_day: z.number().int().min(1).max(3).optional().describe("trial reels a day, 1-3"),
25
+ skip_caption_terms: z
26
+ .array(z.string().max(50))
27
+ .max(20)
28
+ .optional()
29
+ .describe("never queue a reel whose caption contains one of these"),
30
+ timezone: z.string().optional().describe("IANA timezone for posting hours, e.g. America/New_York"),
31
+ }, async ({ connection_id, ...settings }) => {
32
+ try {
33
+ return text(await client.put(`/trial-reels/programs/${connection_id}`, settings));
34
+ }
35
+ catch (error) {
36
+ return fail(error);
37
+ }
38
+ });
39
+ server.tool("scan_trial_reels", "scan an instagram account's reels again (last 180 days, up to 100) and queue the ones with 1.5x the median views or more that are older than 21 days and were never queued. returns queued, rated, median_views, and note (why nothing was queued). one scan per account every 10 minutes.", { connection_id: z.string().uuid().describe("instagram connection_id with a saved program") }, async ({ connection_id }) => {
40
+ try {
41
+ return text(await client.post(`/trial-reels/programs/${connection_id}/scan`));
42
+ }
43
+ catch (error) {
44
+ return fail(error);
45
+ }
46
+ });
47
+ server.tool("update_trial_reel", "change one waiting (queued) trial reel: approved true lets it post at the next posting hour (send scope; needs a caption), false holds it; caption replaces the caption it posts with (up to 2,200 characters) and holds the reel unless approved true is sent with it; remove true takes it out of the queue for good. approve every waiting reel at once with approve_all.", {
48
+ id: z.string().uuid().optional().describe("item id from list_trial_reels (not needed with approve_all)"),
49
+ approved: z.boolean().optional(),
50
+ caption: z.string().max(2200).optional(),
51
+ remove: z.boolean().optional(),
52
+ approve_all: z
53
+ .string()
54
+ .uuid()
55
+ .optional()
56
+ .describe("instead of one item: a connection_id whose waiting reels with captions are all approved"),
57
+ }, async ({ id, approved, caption, remove, approve_all }) => {
58
+ try {
59
+ if (approve_all) {
60
+ return text(await client.post(`/trial-reels/programs/${approve_all}/approve-all`));
61
+ }
62
+ if (!id)
63
+ return fail(new Error("send id, or approve_all with a connection_id"));
64
+ if (remove) {
65
+ await client.delete(`/trial-reels/items/${id}`);
66
+ return text({ data: { id, status: "removed" } });
67
+ }
68
+ return text(await client.patch(`/trial-reels/items/${id}`, {
69
+ ...(approved !== undefined ? { approved } : {}),
70
+ ...(caption !== undefined ? { caption } : {}),
71
+ }));
72
+ }
73
+ catch (error) {
74
+ return fail(error);
75
+ }
76
+ });
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/dropspace",
3
- "version": "2.36.1",
3
+ "version": "2.43.0",
4
4
  "description": "MCP server and CLI for the Dropspace API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@ Dropspace turns one brief into per-platform posts and publishes them on the user
41
41
  - `media_source`: `ai` (generated slides and video), `library` (only the user's own media), or `mixed` (library first). `product_image_url` makes AI slides show the real product.
42
42
  - Media library (stdio server and CLI only): `list_media_library`, `add_to_media_library`, `update_media_library_item`, `delete_media_library_item`. Autopilot chooses items by their description, so describe each one. Items without one are captioned on autopilot's next run (images from the image, videos from their `thumbnail_url`), so a video needs a description or a thumbnail.
43
43
  - Brand kit: `update_persona` with `brand_kit` (text and outline colors, a corner logo) styles the image slides autopilot posts as that persona.
44
- - Audience: `get_audience` for follower counts on Instagram, TikTok, and YouTube.
44
+ - Audience: `get_audience` for follower counts on Instagram, TikTok, and YouTube; its instagram insights option adds Instagram follows and unfollows per day, followers over time, growth velocity, and the non-follower reach share.
45
45
  - Webhooks: `create_webhook` with a Slack incoming webhook URL (`https://hooks.slack.com/services/...`) posts events to that channel as readable messages.
46
46
 
47
47
  ## Spending money - confirm first