@jclvsh/dropspace 2.36.1 → 2.42.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,22 @@ 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) |
373
380
 
374
381
  #### `get_audience` Parameters
375
382
 
376
383
  | Parameter | Type | Required | Description |
377
384
  | ------------- | ------ | -------- | ----------------------------------------- |
378
385
  | `window_days` | number | No | Growth window in days (1-365, default 30) |
386
+ | `include_instagram_insights` | boolean | No | Add Instagram growth analytics to each Instagram entry (default false) |
379
387
 
380
- CLI: `dropspace audience [-w, --window-days <n>]`.
388
+ 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>]`, and `dropspace reels-read [--generate] [--connection-id <id>]`.
389
+
390
+ 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
391
 
382
392
  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
393
 
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,93 @@ 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
+ }
19
111
  }
package/dist/index.js CHANGED
File without changes
@@ -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,13 @@ 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"),
121
131
  get_usage: hosted("usage", "read"),
122
132
  get_changelog: hosted("usage", "read"),
123
133
  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 (newest 30 posts, newest 50 comments each), 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/dropspace",
3
- "version": "2.36.1",
3
+ "version": "2.42.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