@nooticr/mcp 1.26.27 → 1.26.29

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.
@@ -243,12 +243,39 @@ export const LOADING_PLANS = {
243
243
  },
244
244
  ],
245
245
  },
246
- track_competitor: {
246
+ track_creator: {
247
247
  label: "Reading what they shipped",
248
248
  kind: "strip",
249
249
  n: 3,
250
250
  steps: [step("get_user_posts")],
251
251
  },
252
+ find_people_with_problem: {
253
+ label: "Looking for people with this problem",
254
+ kind: "list",
255
+ n: 6,
256
+ // Three query shapes per platform, so the step repeats rather than
257
+ // representing one search — the wait is proportional to platforms asked
258
+ // for, and a single-step plan would under-report it badly.
259
+ steps: [step("discover_social_posts")],
260
+ },
261
+ // Both fan out one post-list fetch per creator, so the price is the size of
262
+ // the list and the wait is drawn per unit — same shape as catch_up_watchlist.
263
+ compare_creators: {
264
+ label: "Scoring each against their own median",
265
+ kind: "list",
266
+ n: 3,
267
+ steps: [],
268
+ perUnit: [CREATOR_UNIT],
269
+ note: "Priced per creator compared.",
270
+ },
271
+ watchlist_standings: {
272
+ label: "Reading the whole watchlist",
273
+ kind: "list",
274
+ n: 5,
275
+ steps: [],
276
+ perUnit: [CREATOR_UNIT],
277
+ note: "Priced per creator on the watchlist.",
278
+ },
252
279
  who_should_i_work_with: {
253
280
  label: "Building a shortlist",
254
281
  kind: "list",
@@ -299,6 +326,7 @@ export const LOADING_PLANS = {
299
326
  show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
300
327
  show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
301
328
  show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
329
+ show_standings: { label: "Drawing the standings", kind: "list", n: 4, steps: [], free: true },
302
330
  show_trend: { label: "Drawing the trend", kind: "text", n: 1, steps: [], free: true },
303
331
  // Reads stored aggregates the sweeps already paid for. Free, and no
304
332
  // upstream step to wait on — the wait is a database read.
@@ -198,7 +198,19 @@ const analysis = open({
198
198
  * marker, what it fetched, and where it came from. Optional throughout,
199
199
  * because the shape of the material itself differs per tool.
200
200
  */
201
+ /**
202
+ * What to do with the material, carried where a host will actually deliver it.
203
+ *
204
+ * Every tool that returns guidance puts the same string in a `content` text
205
+ * block too. That block is the one the MCP spec points at and the one hosts
206
+ * drop: a host rendering `structuredContent` replaces the text blocks with the
207
+ * serialised JSON, so the text copy reaches no model. Declared here rather than
208
+ * left to `passthrough()` because a host reading the schema should be able to
209
+ * see that the field exists.
210
+ */
211
+ const guidance = scalar().describe("What to do with this payload, in prose. Read it: it is the instruction, not a summary.");
201
212
  const evidence = {
213
+ guidance,
202
214
  mode: scalar().describe('Always "evidence": this payload is material you have still to read.'),
203
215
  tool: scalar(),
204
216
  evidenceFrom: listOf(z.string()).describe("The cheap calls this was assembled from."),
@@ -311,6 +323,7 @@ export const OUTPUT_SCHEMAS = {
311
323
  * one should not find the key gone.
312
324
  */
313
325
  analyze_comments: open({
326
+ guidance,
314
327
  summary: scalar(),
315
328
  themes: anyList(),
316
329
  commentsAnalyzed: scalar(),
@@ -516,7 +529,7 @@ export const OUTPUT_SCHEMAS = {
516
529
  threads: anyList().describe("Shaped like search_mentions so one view renders both."),
517
530
  mcpCredits,
518
531
  }),
519
- track_competitor: open({
532
+ track_creator: open({
520
533
  ...evidence,
521
534
  username: scalar(),
522
535
  platform: scalar(),
@@ -525,7 +538,7 @@ export const OUTPUT_SCHEMAS = {
525
538
  since: scalar(),
526
539
  sinceApplied: scalar(),
527
540
  tracked: scalar().describe("True when this creator is on the watchlist, which is what makes a diff possible."),
528
- lastCheckedAt: scalar().describe("When track_competitor last looked, or null on a first look."),
541
+ lastCheckedAt: scalar().describe("When track_creator last looked, or null on a first look."),
529
542
  newSincePreviousCheck: scalar().describe("Posts not seen at the last check; null when there was none."),
530
543
  baseline: open({
531
544
  count: scalar(),
@@ -549,6 +562,21 @@ export const OUTPUT_SCHEMAS = {
549
562
  creditsCharged: scalar(),
550
563
  mcpCredits,
551
564
  }),
565
+ /**
566
+ * Prospect discovery. The unit is a POST that might be someone describing a
567
+ * problem — `posts` rather than a bespoke key, because the gallery view
568
+ * already draws that shape and a wide net does not need a view of its own.
569
+ */
570
+ find_people_with_problem: open({
571
+ ...evidence,
572
+ problem: scalar(),
573
+ platforms: listOf(z.string()),
574
+ posts: listOf(post),
575
+ searchedShapes: listOf(z.string()).describe("Which complaint phrasings were searched: plain, shared, asking."),
576
+ unavailable: anyList().describe("Searches that errored, by platform and shape."),
577
+ creditsCharged: scalar(),
578
+ mcpCredits,
579
+ }),
552
580
  who_should_i_work_with: open({
553
581
  ...evidence,
554
582
  niche: scalar(),
@@ -726,6 +754,84 @@ export const OUTPUT_SCHEMAS = {
726
754
  days: scalar(),
727
755
  mcpCredits,
728
756
  }),
757
+ /**
758
+ * One row per creator, on the axis every creator shares. `window` sits
759
+ * beside every derived number on purpose — a hit rate whose denominator is
760
+ * invisible cannot be told apart from a hit rate that means something.
761
+ */
762
+ compare_creators: open({
763
+ creators: listOf(open({
764
+ handle: scalar(),
765
+ platform: scalar(),
766
+ window: scalar().describe("Posts scored — the denominator of hitRate and the baseline."),
767
+ baseline: open({
768
+ count: scalar(),
769
+ median: scalar(),
770
+ min: scalar(),
771
+ max: scalar(),
772
+ p25: scalar(),
773
+ p75: scalar(),
774
+ }).nullish(),
775
+ hitRate: scalar().describe("Share of the window at or above aboveRatio x their own median."),
776
+ medianWinRatio: scalar().describe("How hard they beat their median when they did."),
777
+ ratios: listOf(z.number()).describe("Every post's ratio to their own median, ascending."),
778
+ best: post.nullish(),
779
+ worst: post.nullish(),
780
+ unavailable: scalar().describe("Set when this creator could not be scored, and why."),
781
+ })).optional(),
782
+ platform: scalar(),
783
+ platformDefaulted: scalar(),
784
+ metric: scalar(),
785
+ thinWindow: scalar().describe("Below this many posts, a hit rate is one post either way."),
786
+ aboveRatio: scalar().describe("The ratio hitRate counts from."),
787
+ billable: scalar(),
788
+ mode: scalar(),
789
+ tool: scalar(),
790
+ evidenceFrom: listOf(z.string()),
791
+ creditsCharged: scalar(),
792
+ mcpCredits,
793
+ }),
794
+ show_standings: open({
795
+ creators: listOf(anyObject()),
796
+ metric: scalar(),
797
+ ranking: scalar().describe("Which axis the order is on — 'how often' and 'how big' disagree."),
798
+ verdict: scalar(),
799
+ tooThin: listOf(z.string()).describe("Handles drawn unranked rather than bottom."),
800
+ // The discriminator the view keys on, since `creators` alone is ambiguous
801
+ // with the two fetching tools' payloads.
802
+ standings: scalar(),
803
+ }),
804
+ watchlist_standings: open({
805
+ creators: listOf(open({
806
+ handle: scalar(),
807
+ platform: scalar(),
808
+ window: scalar(),
809
+ baseline: open({
810
+ count: scalar(),
811
+ median: scalar(),
812
+ min: scalar(),
813
+ max: scalar(),
814
+ p25: scalar(),
815
+ p75: scalar(),
816
+ }).nullish(),
817
+ hitRate: scalar(),
818
+ medianWinRatio: scalar(),
819
+ ratios: listOf(z.number()),
820
+ best: post.nullish(),
821
+ worst: post.nullish(),
822
+ unavailable: scalar(),
823
+ })).optional(),
824
+ watching: scalar().describe("How many creators are on the watchlist."),
825
+ metric: scalar(),
826
+ thinWindow: scalar(),
827
+ aboveRatio: scalar(),
828
+ billable: scalar(),
829
+ mode: scalar(),
830
+ tool: scalar(),
831
+ evidenceFrom: listOf(z.string()),
832
+ creditsCharged: scalar(),
833
+ mcpCredits,
834
+ }),
729
835
  discover_sounds: open({
730
836
  sounds: listOf(open({
731
837
  title: scalar(),
@@ -766,6 +872,7 @@ export const OUTPUT_SCHEMAS = {
766
872
  * documenting a field that never arrives is worse than omitting it.
767
873
  */
768
874
  score_draft: open({
875
+ guidance,
769
876
  draft: scalar(),
770
877
  platform: scalar(),
771
878
  mcpCredits,
@@ -858,6 +965,7 @@ export const OUTPUT_SCHEMAS = {
858
965
  trend: scalar().describe("The discriminator the view keys on."),
859
966
  }),
860
967
  mention_trend: open({
968
+ guidance,
861
969
  runs: listOf(open({
862
970
  ranAt: scalar(),
863
971
  found: scalar().describe("Everything that sweep saw."),
@@ -298,7 +298,7 @@ export function registerPrompts(server) {
298
298
  `over- and under-performs, who the audience is. 2 credits.\n` +
299
299
  `4. watch_creator to add them to your watchlist — free, stores the handle only, fetches ` +
300
300
  `nothing.\n` +
301
- `5. track_competitor on the same handle — what they have shipped recently and which of it ` +
301
+ `5. track_creator on the same handle — what they have shipped recently and which of it ` +
302
302
  `beat THEIR OWN median, not a raw view count that mostly measures follower count. 2 credits, ` +
303
303
  `whatever the window. Because they are now on your watchlist, this also sets its own "since I ` +
304
304
  `last checked" marker — a different one from catch_up_watchlist's.\n` +
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Putting two creators side by side (issue #30).
3
+ *
4
+ * The surface had a strong and correct stance on baselines, applied in one
5
+ * direction only. `track_creator` scores each post against the median of
6
+ * that creator's own window, "because a raw view count mostly measures
7
+ * follower count — outperformance against themselves is the signal".
8
+ * `why_did_this_underperform` does the same for a single post.
9
+ *
10
+ * Both were right, and both then discarded the comparison. A ratio to one's
11
+ * own median is *exactly* the quantity that IS comparable across creators of
12
+ * different sizes — a 300k-follower account beating its median by 2.1× and a
13
+ * 4k-follower account doing the same are the same event — and no tool ever
14
+ * placed two of them next to each other. So "is their hit rate better than
15
+ * mine?" was unanswerable at any price, while every ingredient was computed on
16
+ * every call and thrown away.
17
+ *
18
+ * The comparison itself stays the model's job, as everywhere else here. This
19
+ * module normalises each creator to the same axis and reports how much window
20
+ * it had to do it with; ranking them server-side would hide exactly the thing
21
+ * that decides whether a ranking means anything.
22
+ */
23
+ import { ABOVE_RATIO, BREAKOUT_RATIO, distributionOf, MIN_BASELINE_POSTS, numberOf, standing, } from "./performance.js";
24
+ /** Median of an already-sorted list. */
25
+ function mid(sorted) {
26
+ if (!sorted.length)
27
+ return null;
28
+ const i = Math.floor(sorted.length / 2);
29
+ return sorted.length % 2 ? sorted[i] : (sorted[i - 1] + sorted[i]) / 2;
30
+ }
31
+ const round2 = (n) => Math.round(n * 100) / 100;
32
+ /**
33
+ * One creator, reduced to the axis every creator shares.
34
+ *
35
+ * `hitRate` is a share of a window and not a rate — over six posts it is one
36
+ * post either way — which is why `window` travels beside it everywhere rather
37
+ * than being folded into a score. A caller that cannot see the denominator
38
+ * cannot know the number is noise, and the guidance below leans on it.
39
+ */
40
+ export function creatorStanding(handle, platform, posts, metric, pick) {
41
+ const values = posts.map(pick);
42
+ const baseline = distributionOf(values);
43
+ const empty = {
44
+ handle,
45
+ platform,
46
+ window: posts.length,
47
+ baseline,
48
+ hitRate: null,
49
+ medianWinRatio: null,
50
+ ratios: [],
51
+ best: null,
52
+ worst: null,
53
+ };
54
+ if (!posts.length) {
55
+ return { ...empty, unavailable: "no posts came back for this handle" };
56
+ }
57
+ if (!baseline) {
58
+ // Fewer than MIN_BASELINE_POSTS is not a baseline, and inventing one would
59
+ // put a confident ratio on an account nobody has enough of. The same
60
+ // refusal `distributionOf` already makes for a single post.
61
+ return {
62
+ ...empty,
63
+ unavailable: `only ${posts.length} post${posts.length === 1 ? "" : "s"} in the window — ` +
64
+ `${MIN_BASELINE_POSTS} is the fewest that make a baseline`,
65
+ };
66
+ }
67
+ const scored = posts.map((post) => ({ post, standing: standing(pick(post), values) }));
68
+ const ratios = scored.map((s) => numberOf(s.standing.ratio)).filter((r) => Number.isFinite(r));
69
+ const winners = ratios.filter((r) => r >= ABOVE_RATIO);
70
+ const byRatio = [...scored].sort((a, b) => numberOf(b.standing.ratio) - numberOf(a.standing.ratio));
71
+ return {
72
+ handle,
73
+ platform,
74
+ window: posts.length,
75
+ baseline,
76
+ hitRate: round2(winners.length / posts.length),
77
+ medianWinRatio: winners.length ? round2(mid([...winners].sort((a, b) => a - b)) ?? 0) : null,
78
+ ratios: [...ratios].sort((a, b) => a - b).map(round2),
79
+ best: { ...byRatio[0].post, standing: byRatio[0].standing, metric },
80
+ worst: { ...byRatio[byRatio.length - 1].post, standing: byRatio[byRatio.length - 1].standing, metric },
81
+ unavailable: null,
82
+ };
83
+ }
84
+ /**
85
+ * The window below which a hit rate should not be quoted as one.
86
+ *
87
+ * Not a hard floor — the row is still returned, because "we only have 5 posts"
88
+ * is itself the answer to "who is accelerating?" and hiding it would leave the
89
+ * model guessing. It is the number the guidance names so the model says "too
90
+ * few posts to call this" instead of ranking confidently, the same discipline
91
+ * `find_hook_pattern` applies with "a template that fits one post is not a
92
+ * pattern".
93
+ */
94
+ export const THIN_WINDOW = 8;
95
+ /**
96
+ * What to do with a set of standings.
97
+ *
98
+ * Written to be read by the model that just received them, in the voice
99
+ * `evidence.ts` sets: say what the material is, what is comparable about it,
100
+ * and what would be a mistake to conclude.
101
+ */
102
+ export function standingsGuidance(a) {
103
+ const scored = a.rows.filter((r) => !r.unavailable);
104
+ const thin = scored.filter((r) => r.window < THIN_WINDOW);
105
+ const lines = [];
106
+ lines.push(`${a.rows.length} creator${a.rows.length === 1 ? "" : "s"}` +
107
+ (a.source === "watchlist" ? " from your watchlist" : "") +
108
+ `, each scored against their OWN recent median ${a.metric} — never against each other's raw ` +
109
+ "numbers, because a raw count mostly measures follower count. The ratio is what compares: " +
110
+ "a large account beating its median by 2× and a small one doing the same have done the same " +
111
+ "thing.");
112
+ if (!scored.length) {
113
+ lines.push("", "None of them could be scored — see `unavailable` on each row. That is a gap in what came " +
114
+ "back, not a finding about the creators, so say which ones and why rather than reporting " +
115
+ "an empty comparison.");
116
+ return lines.join("\n");
117
+ }
118
+ lines.push("", "Per creator: `window` is how many posts were scored, `baseline.median` is the middle of " +
119
+ `their own window, \`hitRate\` is the share of it at or above ${ABOVE_RATIO}× that median, ` +
120
+ "`medianWinRatio` is how hard they beat it when they did, and `ratios` is every post's " +
121
+ "ratio ascending so the spread is visible. `best` and `worst` carry the posts themselves.", "", "Two different questions live in those numbers and they usually disagree: how OFTEN someone " +
122
+ "lands one (`hitRate`) and how BIG it is when they do (`medianWinRatio`). A creator with a " +
123
+ "low hit rate and a high win ratio is swinging; the reverse is consistent and capped. Say " +
124
+ "which of the two you are ranking on, because 'who is doing better' has no single answer.");
125
+ if (thin.length) {
126
+ lines.push("", `Do not rank ${thin.map((r) => `@${r.handle} (${r.window} posts)`).join(", ")} confidently. ` +
127
+ `A hit rate over fewer than ${THIN_WINDOW} posts is one post either way, and a median over ` +
128
+ "a window that small moves with the window. Say the sample is too thin to call rather " +
129
+ "than putting them in an order.");
130
+ }
131
+ const unavailable = a.rows.filter((r) => r.unavailable);
132
+ if (unavailable.length) {
133
+ lines.push("", `Not scored: ${unavailable.map((r) => `@${r.handle} — ${r.unavailable}`).join("; ")}. ` +
134
+ "Missing from the comparison is not the same as bottom of it.");
135
+ }
136
+ lines.push("", `Every ratio here is against a window measured now. It cannot tell you whether anyone's ` +
137
+ "baseline is itself moving — that needs a second point in time, which nothing stores yet " +
138
+ "(issue #28). So do not read a high hit rate as 'improving'; it is 'consistent within this " +
139
+ "window' and nothing more.", "", "When you have decided what the numbers say, call show_standings with the rows and the axis " +
140
+ "you ranked on. Free, and it is the one place the ranking is attributed to you rather than " +
141
+ "presented as a nooticr score — it also draws a creator with too thin a window as unranked " +
142
+ "rather than last, which is the mistake a plain table makes on your behalf.");
143
+ return lines.join("\n");
144
+ }
145
+ /** Threshold re-exported so the tools and the view agree on the badge. */
146
+ export { ABOVE_RATIO, BREAKOUT_RATIO };
@@ -349,11 +349,29 @@ export const TOOL_DEFINITIONS = [
349
349
  inputSchema: z.object({ username: z.string().describe("Whose audience this is, with or without @."), summary: z.string().optional(), replies: z.array(z.object({ id: z.string(), comment: z.string(), author: z.string().optional(), likes: z.number().optional(), postUrl: z.string().optional(), postTitle: z.string().optional(), postedAt: z.string().optional(), draft: z.string().optional(), kind: z.string().optional(), why: z.string().optional() })), themes: z.array(z.string()).optional(), nextSteps: z.array(z.string()).optional() }).strict(),
350
350
  },
351
351
  {
352
- name: "track_competitor",
352
+ name: "track_creator",
353
353
  title: "Track Competitor",
354
- description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last track_competitor call and moves that marker forward. Use for a rival you follow over time. Consumes 2 nooticr credits, whatever the window size.",
354
+ description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last track_creator call and moves that marker forward. Use for a rival you follow over time. Consumes 2 nooticr credits, whatever the window size.",
355
355
  inputSchema: z.object({ username: z.string().describe("Creator handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts in the window (default 12, max 30)."), metric: z.enum(["views", "likes", "comments", "shares", "engagementRate"]).optional().describe("Which stat to rank on (default views)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD.") }).strict(),
356
356
  },
357
+ {
358
+ name: "compare_creators",
359
+ title: "Compare Creators",
360
+ description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
361
+ inputSchema: z.object({ usernames: z.array(z.string()).min(2).max(5), platform: z.string().optional(), limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
362
+ },
363
+ {
364
+ name: "watchlist_standings",
365
+ title: "Watchlist Standings",
366
+ description: "The same comparison across everyone on your watchlist, answering 'who moved' in one call instead of one per creator. Each is scored against their own recent median, so the numbers are comparable between accounts of different sizes; the window size travels with each so a short one is not ranked as if it were a season. Reads the watchlist itself for free and fetches one post list per creator on it: 2 nooticr credits each, so the price is the size of your watchlist — it confirms before spending. Use for the weekly 'who is accelerating'; catch_up_watchlist is what is NEW rather than how it did.",
367
+ inputSchema: z.object({ limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
368
+ },
369
+ {
370
+ name: "show_standings",
371
+ title: "Show Standings",
372
+ description: "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes no requests — it only renders what you pass it: one row per creator with their window, their own median, how often they beat it and how hard, and the post that did best. Pass `ranking` to say which axis you ordered on, and `tooThin` for the creators whose window was too short to rank — a table that hides that is the one mistake this view can make on your behalf. Call this after you have decided what the numbers mean, not instead of deciding.",
373
+ inputSchema: z.object({ creators: z.array(z.object({}).passthrough()).min(1).max(25), metric: z.string().optional(), ranking: z.string().optional(), verdict: z.string().optional(), tooThin: z.array(z.string()).optional() }).strict(),
374
+ },
357
375
  {
358
376
  name: "who_should_i_work_with",
359
377
  title: "Who Should I Work With",
@@ -372,6 +390,12 @@ export const TOOL_DEFINITIONS = [
372
390
  description: "Demand and supply in one result. Reads the comments on a creator's own recent posts for what their audience explicitly asks for, sweeps the niche for what is already being made, and returns both with ids so an idea can be traced back to the comment that asked for it \u2014 a gap nobody asked for is noise, a request nobody serves is the opportunity. Names the niche from the creator's most-used hashtag when you do not give one. Use to decide what to film; niche_report covers the supply half alone. Consumes 2 nooticr credits for the post list, 2 per post read, and 2 for the sweep \u2014 12 at the default of 4 posts.",
373
391
  inputSchema: z.object({ username: z.string().describe("Your handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), niche: z.string().optional().describe("Niche for the supply sweep. Defaults to your most frequent hashtag."), limit: z.number().int().optional().describe("Your posts to read comments on (default 4, max 8). This is the price."), commentsPerPost: z.number().int().optional().describe("Comments per post (default 25, max 50)."), supplyLimit: z.number().int().optional().describe("Posts in the niche sweep (default 12, max 30).") }).strict(),
374
392
  },
393
+ {
394
+ name: "find_people_with_problem",
395
+ title: "Find People With A Problem",
396
+ description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume.",
397
+ inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15).") }).strict(),
398
+ },
375
399
  {
376
400
  name: "search_spoken_mentions",
377
401
  title: "Search Spoken Mentions",