@nooticr/mcp 1.26.19

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.
@@ -0,0 +1,1046 @@
1
+ /**
2
+ * Output schemas — what a caller gets back, declared.
3
+ *
4
+ * Every tool here already returns structuredContent and none of them said what
5
+ * shape it was, so anything chaining two Nooticr tools had to call one, look at
6
+ * the result, and guess. Declaring the shape is what lets a host validate it
7
+ * and an agent plan against it.
8
+ *
9
+ * These describe rather than constrain, deliberately. The payloads come from
10
+ * the nooticr backend, not from this repo: a field it adds tomorrow must not
11
+ * turn a working call into a hard failure, because the SDK throws on a
12
+ * structuredContent that fails its schema and the user would see a tool that
13
+ * simply stopped working. So every field is optional and every object passes
14
+ * unknown keys through. What is written down is what was measured against the
15
+ * live server — shapes taken from real responses, and from the fields the view
16
+ * itself reads — not what would be tidy.
17
+ */
18
+ import { z } from "zod";
19
+ /** Loose by construction: see the note above. */
20
+ const open = (shape) => z.object(shape).passthrough();
21
+ /**
22
+ * One scalar type for every leaf, and it accepts null.
23
+ *
24
+ * The first version of this file typed each leaf from a sampled response —
25
+ * duration a number, videoUrl a string — with `.optional()`, which accepts
26
+ * `undefined` and rejects `null`. A slideshow post carries `videoUrl: null` and
27
+ * no duration, so discover_social_posts started failing outright the moment a
28
+ * feed contained one. Not "returned less": the SDK throws on a structuredContent
29
+ * that fails its schema, so the whole call errored.
30
+ *
31
+ * The lesson is not "add null" — it is that asserting a wire type for a field
32
+ * scraped from eight platforms is a liability that buys almost nothing. What an
33
+ * agent needs from this file is which keys exist and what they mean. So the
34
+ * keys and their descriptions are the contract, and the types are deliberately
35
+ * as wide as the data can be.
36
+ */
37
+ const scalar = () => z.union([z.string(), z.number(), z.boolean()]).nullish();
38
+ /** Same reasoning for lists: a null list, and a null element, must both pass. */
39
+ const listOf = (item) => z.array(z.union([item, z.null()])).nullish();
40
+ /**
41
+ * A list whose elements are not one shape — themes, fixes, failures.
42
+ *
43
+ * Not `anyList()`. Zod is only half of this: the server validates
44
+ * with Zod, but the client validates against the JSON Schema generated from
45
+ * it, and `z.unknown()` inside a union serialises to an empty schema that the
46
+ * converter drops — leaving `anyOf: [{ type: "null" }]`, an items rule that
47
+ * accepts null and nothing else. Every call carrying a real theme then failed
48
+ * on the client while passing every server-side test. `z.any()` renders as an
49
+ * empty schema in item position, which accepts anything, which is the point.
50
+ */
51
+ const anyList = () => z.array(z.any()).nullish();
52
+ /** Every paid tool reports what it charged. */
53
+ const mcpCredits = open({
54
+ cost: scalar().describe("Credits this call consumed."),
55
+ adminBypass: scalar(),
56
+ balance: scalar(),
57
+ }).nullish();
58
+ /** The post object every feed, comparison and single-post tool returns. */
59
+ const post = open({
60
+ id: scalar(),
61
+ // The goal tools in jobs.ts attach an addressable id beside the platform's
62
+ // own rather than over it, so a caller keeps the value it needs to dedupe
63
+ // against its own records. Absent on the tools that do not address posts,
64
+ // which is why it is documented here rather than made required.
65
+ postId: scalar().describe("Addressable — `post:<platform>:<slug>`. Quote this one back."),
66
+ platform: scalar(),
67
+ contentType: scalar().describe("video, image, carousel or slideshow."),
68
+ detectedFormat: scalar(),
69
+ title: scalar(),
70
+ caption: scalar(),
71
+ creatorHandle: scalar(),
72
+ externalUrl: scalar().describe("Permalink — the URL to feed back into another tool."),
73
+ embedUrl: scalar(),
74
+ duration: scalar(),
75
+ views: scalar(),
76
+ likes: scalar(),
77
+ comments: scalar(),
78
+ shares: scalar(),
79
+ engagementRate: scalar(),
80
+ hashtags: listOf(z.string()),
81
+ // Media comes in threes: the platform URL, an nooticr proxy, and a resolver
82
+ // that re-fetches when a signed link has expired.
83
+ videoUrl: scalar(),
84
+ videoProxyUrl: scalar(),
85
+ videoFallbackUrl: scalar(),
86
+ thumbnailUrl: scalar(),
87
+ thumbnailProxyUrl: scalar(),
88
+ thumbnailFallbackUrl: scalar(),
89
+ musicUrl: scalar(),
90
+ musicProxyUrl: scalar(),
91
+ musicFallbackUrl: scalar(),
92
+ musicTitle: scalar(),
93
+ musicAuthor: scalar(),
94
+ mediaItems: listOf(open({})),
95
+ });
96
+ /**
97
+ * One link found in a creator's bio, typed so a host knows what opening it buys.
98
+ *
99
+ * `opaque` is the one a caller must not ignore: a shortener's destination is
100
+ * unknowable from the URL and changeable after we read it.
101
+ */
102
+ const bioLink = open({
103
+ url: scalar(),
104
+ host: scalar(),
105
+ kind: scalar().describe("code, video, social, writing, shop, link_hub, shortener or website."),
106
+ readable: scalar().describe("What opening it will actually tell you."),
107
+ opaque: scalar().describe("True when the destination cannot be known from the URL."),
108
+ });
109
+ const creator = open({
110
+ username: scalar(),
111
+ nickname: scalar(),
112
+ platform: scalar(),
113
+ followers: scalar(),
114
+ signature: scalar(),
115
+ verified: scalar(),
116
+ avatarUrl: scalar(),
117
+ externalUrl: scalar(),
118
+ });
119
+ /** The prose blocks an AI tool returns; keys vary by tool, so keep it open. */
120
+ const analysis = open({
121
+ summary: scalar(),
122
+ hookStrength: scalar(),
123
+ whyItWorks: scalar(),
124
+ viralTriggers: listOf(z.string()),
125
+ keyQuotes: listOf(z.string()),
126
+ variationIdeas: listOf(z.string()),
127
+ suggestedHook: scalar(),
128
+ suggestedHashtags: listOf(z.string()),
129
+ niche: scalar(),
130
+ }).nullish();
131
+ /**
132
+ * What every tool that returns material rather than a conclusion adds: the
133
+ * marker, what it fetched, and where it came from. Optional throughout,
134
+ * because the shape of the material itself differs per tool.
135
+ */
136
+ const evidence = {
137
+ mode: scalar().describe('Always "evidence": this payload is material you have still to read.'),
138
+ tool: scalar(),
139
+ evidenceFrom: listOf(z.string()).describe("The cheap calls this was assembled from."),
140
+ frameIndex: anyList().describe("Where each returned frame sits in the video."),
141
+ get_post_transcript: open({}).nullish(),
142
+ get_social_media: open({}).nullish(),
143
+ };
144
+ const feed = { platform: scalar(), posts: listOf(post), mcpCredits };
145
+ const singlePost = { post: post.nullish(), platform: scalar(), url: scalar(), mcpCredits };
146
+ const analyzed = { ...singlePost, analysis, analyzed: scalar() };
147
+ /**
148
+ * One entry per tool. A tool missing from here declares nothing, which is the
149
+ * honest option when the shape has not been checked against a real response.
150
+ */
151
+ export const OUTPUT_SCHEMAS = {
152
+ analyze_post: open({ ...analyzed, ...evidence }),
153
+ analyze_post_fast: open({ ...evidence, ...analyzed }),
154
+ understand_social_post: open({ ...analyzed, ...evidence }),
155
+ // No `provider`: the field named the upstream supplier and is no longer
156
+ // returned. Documenting a field that does not exist is worse than omitting it.
157
+ get_social_media: open({ ...singlePost, fetchedAt: scalar() }),
158
+ discover_social_posts: open(feed),
159
+ /**
160
+ * Brand monitoring. The unit is a *comment* that names the term, grouped
161
+ * under the post it was left on — a brand is mentioned far more often in the
162
+ * replies than in a caption, and the same post can carry several mentions.
163
+ */
164
+ search_mentions: open({
165
+ term: scalar(),
166
+ searched: listOf(z.string()).describe("Platforms actually queried."),
167
+ since: scalar().describe("The date window applied, if any."),
168
+ totalMentions: scalar().describe("Comments naming the term, across every group."),
169
+ totalThreads: scalar().describe("Posts carrying at least one mention."),
170
+ byPlatform: open({}).nullish().describe("Mentions per platform."),
171
+ threads: listOf(open({
172
+ post: post.nullish(),
173
+ postIsAboutTerm: scalar().describe("True when the post itself names the term, not just its comments."),
174
+ postHits: scalar(),
175
+ mentionCount: scalar(),
176
+ mentions: listOf(open({
177
+ id: scalar().describe("Addressable — pass it to another tool to reply or escalate."),
178
+ text: scalar(),
179
+ username: scalar(),
180
+ avatarUrl: scalar().describe("The commenter's picture, on the platform's own CDN."),
181
+ avatarProxyUrl: scalar().describe("The same picture through nooticr, for a sandboxed view."),
182
+ likes: scalar(),
183
+ replies: scalar().describe("How many people replied to this comment."),
184
+ postedAt: scalar(),
185
+ hits: scalar().describe("How many times this comment names the term."),
186
+ })),
187
+ })).describe("Mentions grouped under the post they were left on, loudest conversation first."),
188
+ posts: listOf(post).describe("The posts of this page, flattened for the card view."),
189
+ offset: scalar(),
190
+ nextOffset: scalar().describe("Pass back as `offset` to load the next page; null when done."),
191
+ hasMore: scalar(),
192
+ creditsCharged: scalar(),
193
+ unavailable: anyList().describe("Platforms that could not answer, with the reason."),
194
+ mcpCredits,
195
+ }),
196
+ get_user_posts: open({ ...feed, username: scalar() }),
197
+ /** Frames a caller looks at itself. The pixels ride in the content blocks. */
198
+ // What the frames COVER matters as much as the frames: a model that does not
199
+ // know shots were dropped will describe the video as if it saw all of it.
200
+ get_post_frames: open({
201
+ url: scalar(),
202
+ platform: scalar(),
203
+ contentType: scalar(),
204
+ durationSeconds: scalar(),
205
+ frameCount: scalar(),
206
+ frames: anyList().describe("Base64 frames; also delivered as image content blocks."),
207
+ selection: scalar().describe("'scene' (one frame per distinct shot), 'even' (fixed interval) or 'images' (a carousel's own pictures)."),
208
+ scenesDetected: scalar().describe("Distinct shots found. Null when no scan ran, which is not the same as zero."),
209
+ truncated: scalar().describe("True when shots were found that are not in `frames`."),
210
+ scannedSeconds: scalar(),
211
+ scanComplete: scalar().describe("False when a bound stopped the read before the video ended."),
212
+ coverageNote: scalar().describe("The above as one sentence, including that a still cannot show motion within a shot."),
213
+ post: post.nullish(),
214
+ mcpCredits,
215
+ }),
216
+ analyze_creator_profile: open({
217
+ ...evidence,
218
+ creator: creator.nullish(),
219
+ posts: listOf(post),
220
+ profile: open({}).nullish(),
221
+ analysis,
222
+ mcpCredits,
223
+ }),
224
+ search_creators: open({ creators: listOf(creator), platform: scalar(), mcpCredits }),
225
+ get_similar_creators: open({ creators: listOf(creator), platform: scalar(), mcpCredits }),
226
+ find_hook_pattern: open({
227
+ ...evidence,
228
+ report: open({}).nullish(),
229
+ username: scalar(),
230
+ platform: scalar(),
231
+ postsAnalyzed: scalar(),
232
+ mcpCredits,
233
+ }),
234
+ get_post_comments: open({
235
+ comments: listOf(open({ text: scalar(), author: scalar(), likes: scalar() })).optional(),
236
+ themes: anyList(),
237
+ summary: scalar(),
238
+ url: scalar(),
239
+ platform: scalar(),
240
+ mcpCredits,
241
+ }),
242
+ /**
243
+ * The comments themselves, with the reading left to the caller. `summary`
244
+ * and `report` are what the synthesis used to fill in; they are kept and
245
+ * left optional because the schema is passthrough and a caller that stored
246
+ * one should not find the key gone.
247
+ */
248
+ analyze_comments: open({
249
+ summary: scalar(),
250
+ themes: anyList(),
251
+ commentsAnalyzed: scalar(),
252
+ report: open({}).nullish(),
253
+ mode: scalar().describe('Always "evidence": the comments come back unanalysed.'),
254
+ url: scalar(),
255
+ platform: scalar(),
256
+ commentCount: scalar(),
257
+ comments: listOf(open({
258
+ id: scalar().describe("Addressable — pass it back to show_comment_review."),
259
+ text: scalar(),
260
+ author: scalar(),
261
+ likes: scalar(),
262
+ })).describe("The comments, unanalysed, each with an id you can pass back."),
263
+ mcpCredits,
264
+ }),
265
+ /** What the caller concluded, drawn. Nothing here was fetched. */
266
+ /**
267
+ * What another server needs to file one of these, per item.
268
+ *
269
+ * `body` is the load-bearing field and the reason the shape is pinned: it
270
+ * carries the quote fenced and framed as a third-party report, and a caller
271
+ * that rebuilds it from `title` + the original comment loses exactly that.
272
+ */
273
+ prepare_handoff: open({
274
+ handoff: scalar(),
275
+ destination: scalar().describe("github, jira, linear or generic."),
276
+ totalMentions: scalar().describe("Items prepared."),
277
+ worthFiling: scalar().describe("How many are a kind a tracker is the right home for."),
278
+ withWarnings: scalar(),
279
+ nextStep: scalar().describe("How to file these on the other server."),
280
+ items: listOf(open({
281
+ sourceId: scalar().describe("The evidence tool's id — what makes the issue traceable."),
282
+ kind: scalar(),
283
+ title: scalar().describe("Ready to use as the issue title."),
284
+ body: scalar().describe("Ready to use as the issue body. File it unmodified."),
285
+ labels: listOf(z.string()),
286
+ sourceUrl: scalar().describe("Permalink, or null when the network gives none."),
287
+ dedupeKey: scalar().describe("The marker written into the body, so a second run can find it."),
288
+ searchFirst: scalar().describe("Search the tracker for this before filing."),
289
+ worthFiling: scalar(),
290
+ warnings: listOf(open({
291
+ code: scalar(),
292
+ detail: scalar().describe("What to know before this is filed."),
293
+ })),
294
+ })),
295
+ term: scalar(),
296
+ threads: anyList(),
297
+ }),
298
+ /** The vetted shortlist, ranked, with the scores attributed to the caller. */
299
+ show_collab_shortlist: open({
300
+ shortlist: scalar(),
301
+ niche: scalar(),
302
+ platform: scalar(),
303
+ summary: scalar(),
304
+ question: scalar().describe("What the user is being asked to decide."),
305
+ recommended: scalar().describe("The candidate id to approach first, if the caller had a view."),
306
+ scoredCount: scalar(),
307
+ unverifiedCount: scalar().describe("Candidates scored without anything having been opened."),
308
+ creators: listOf(creator.extend({
309
+ id: scalar(),
310
+ rank: scalar(),
311
+ score: scalar().describe("The calling model's score out of 100 — not a nooticr rating."),
312
+ scoredBy: scalar().describe('Always "the assistant", so the card can attribute it.'),
313
+ verdict: scalar().describe("approach, maybe or pass."),
314
+ why: scalar(),
315
+ checked: listOf(z.string()).describe("What was actually opened and read."),
316
+ concerns: listOf(z.string()),
317
+ unverifiedScore: scalar().describe("True when nothing was opened to reach the score."),
318
+ })),
319
+ audienceOverlap: open({
320
+ attempted: scalar(),
321
+ reason: scalar(),
322
+ howTo: scalar(),
323
+ }).nullish(),
324
+ }),
325
+ show_comment_review: open({
326
+ review: scalar(),
327
+ term: scalar(),
328
+ url: scalar(),
329
+ summary: scalar(),
330
+ totalMentions: scalar(),
331
+ byCategory: open({}).nullish().describe("How many comments fell into each category."),
332
+ bySentiment: open({}).nullish(),
333
+ themes: listOf(z.string()),
334
+ nextSteps: listOf(z.string()),
335
+ threads: anyList().describe("Shaped like search_mentions so one view renders both."),
336
+ mcpCredits,
337
+ }),
338
+ // The five below close the loop the evidence-only tools open: nooticr
339
+ // fetches material and prices at the fetch, "your own model does the
340
+ // thinking" (README) — but until these existed, the thinking had nowhere
341
+ // to land except chat text. Free, and make no requests, same as
342
+ // show_comment_review: they only draw what they're handed.
343
+ show_comparison: open({
344
+ posts: listOf(post).describe("The 2-5 posts being compared, same shape as compare_posts returned."),
345
+ comparison: open({
346
+ winner: scalar().describe("1-indexed position of the post that won, matching the posts array."),
347
+ winnerReason: scalar(),
348
+ differences: listOf(open({ factor: scalar(), detail: scalar() })),
349
+ lessons: listOf(z.string()),
350
+ nextTest: scalar(),
351
+ }).nullish(),
352
+ mcpCredits,
353
+ }),
354
+ show_analysis: open({
355
+ url: scalar(),
356
+ post: post.nullish().describe("The post analyze_post/analyze_post_fast/understand_social_post handed back."),
357
+ analysis: open({}).passthrough().nullish().describe("Your own analysis, any of the fields analyze_post's guidance asked for — summary, " +
358
+ "hookStrength, scriptStructure, whyItWorks, suggestedHook, keyQuotes, suggestedHashtags, " +
359
+ "variationIdeas, viralTriggers, targetAudience and more all render if present; every field " +
360
+ "is optional, and none is required to have used all of them."),
361
+ mcpCredits,
362
+ }),
363
+ show_hooks: open({
364
+ url: scalar().describe("The post the hooks were grounded in, if any."),
365
+ topic: scalar().describe("The topic the hooks were grounded in, if given instead of a url."),
366
+ hooks: listOf(open({
367
+ hook: scalar().describe("Under 15 words, speakable aloud."),
368
+ mechanism: scalar().describe("e.g. accusation, number, mistake, before/after, receipt, question."),
369
+ why: scalar().describe("Who it stops, and why."),
370
+ })),
371
+ mcpCredits,
372
+ }),
373
+ show_variants: open({
374
+ sourceUrl: scalar(),
375
+ post: post.nullish(),
376
+ variants: listOf(open({
377
+ title: scalar().describe("A short label for this variant."),
378
+ hook: scalar(),
379
+ angle: scalar().describe("What changes versus the original."),
380
+ beats: listOf(z.string()).describe("Shot or talking beats, in order."),
381
+ cta: scalar(),
382
+ whyItCouldWork: scalar(),
383
+ })),
384
+ mcpCredits,
385
+ }),
386
+ show_repurposed_post: open({
387
+ sourceUrl: scalar(),
388
+ versions: listOf(open({ surface: scalar().describe("e.g. 'X thread', 'LinkedIn post'."), content: scalar() })),
389
+ mcpCredits,
390
+ }),
391
+ /**
392
+ * The five job tools, and the one view that draws what a model concluded
393
+ * from the first of them.
394
+ *
395
+ * They return evidence rather than an answer, so what is declared here is
396
+ * the material and where it came from: the groups, the ids that address each
397
+ * item, the arithmetic done over the stats, and what could not be fetched.
398
+ * Same rule as everywhere else in this file — the keys and their
399
+ * descriptions are the contract, the types are as wide as the data can be.
400
+ */
401
+ answer_my_audience: open({
402
+ ...evidence,
403
+ /** term + threads is the search_mentions shape, so one view draws both. */
404
+ term: scalar().describe("The creator, as @handle — what the view puts in its header."),
405
+ username: scalar(),
406
+ platform: scalar(),
407
+ since: scalar().describe("The date window asked for, if any."),
408
+ sinceApplied: scalar().describe("False when the platform returned no dates, so the window could not be honoured."),
409
+ postsChecked: scalar().describe("Posts whose comments were actually read."),
410
+ repliesCanBeSent: scalar().describe("Always false. No nooticr connection carries comment-write permission, so the drafts this " +
411
+ "produces are for a person to paste in themselves."),
412
+ totalMentions: scalar().describe("Comments returned across every group."),
413
+ totalThreads: scalar(),
414
+ wantsReplyCount: scalar().describe("How many carry a reply signal. A sort, not a verdict."),
415
+ byCategory: open({}).nullish().describe("Flagged against unclear, as the view's filter chips."),
416
+ threads: listOf(open({
417
+ post: post.nullish(),
418
+ mentionCount: scalar(),
419
+ mentions: listOf(open({
420
+ id: scalar().describe("Addressable, and stable across calls — pass it back to show_audience_replies. " +
421
+ "Built from the platform's own comment id, or from a fingerprint of post, " +
422
+ "author and text where there is none. Not the positional scheme " +
423
+ "search_mentions uses, whose ids move when the paging or window changes."),
424
+ text: scalar(),
425
+ username: scalar(),
426
+ likes: scalar(),
427
+ postedAt: scalar().describe("Null wherever the date could not be believed. Most networks stamp every comment " +
428
+ "with the moment of the fetch, so anything indistinguishable from now is " +
429
+ "dropped rather than repeated as fact."),
430
+ wantsReply: scalar().describe("True when it reads like a question or a request."),
431
+ signals: listOf(z.string()).describe("Which phrasing test matched, so the flag can be argued with."),
432
+ category: scalar(),
433
+ })),
434
+ })).describe("Comments grouped under the post they were left on."),
435
+ posts: listOf(post).describe("The posts of this run, flattened for the card view."),
436
+ unavailable: anyList().describe("Posts whose comments could not be read, with the reason."),
437
+ creditsCharged: scalar(),
438
+ mcpCredits,
439
+ }),
440
+ /** What the caller drafted, drawn. Nothing here was fetched. */
441
+ show_audience_replies: open({
442
+ review: scalar(),
443
+ term: scalar(),
444
+ username: scalar(),
445
+ summary: scalar(),
446
+ totalMentions: scalar(),
447
+ drafted: scalar().describe("How many rows carry an actual draft reply."),
448
+ byCategory: open({}).nullish().describe("How many comments fell to each decision."),
449
+ themes: listOf(z.string()),
450
+ nextSteps: listOf(z.string()),
451
+ threads: anyList().describe("Shaped like search_mentions so one view renders both."),
452
+ mcpCredits,
453
+ }),
454
+ track_competitor: open({
455
+ ...evidence,
456
+ username: scalar(),
457
+ platform: scalar(),
458
+ metric: scalar().describe("Which stat everything here is ranked on."),
459
+ window: scalar().describe("Posts in the window actually scored."),
460
+ since: scalar(),
461
+ sinceApplied: scalar(),
462
+ tracked: scalar().describe("True when this creator is on the watchlist, which is what makes a diff possible."),
463
+ lastCheckedAt: scalar().describe("When track_competitor last looked, or null on a first look."),
464
+ newSincePreviousCheck: scalar().describe("Posts not seen at the last check; null when there was none."),
465
+ baseline: open({
466
+ count: scalar(),
467
+ median: scalar().describe("The creator's own median — what outperformance is measured against."),
468
+ min: scalar(), max: scalar(), p25: scalar(), p75: scalar(),
469
+ }).nullish().describe("Null when there are too few posts to call anything a baseline."),
470
+ outperformers: listOf(z.string()).describe("postIds of the posts that beat the median by a quarter or more."),
471
+ posts: listOf(open({
472
+ id: scalar().describe("The platform's own id for the post, passed through untouched."),
473
+ postId: scalar().describe("Addressable — `post:<platform>:<slug>`. Quote this one."),
474
+ metricValue: scalar(),
475
+ isNew: scalar().describe("True when it was not there at the last check; null without one."),
476
+ standing: open({
477
+ value: scalar(), median: scalar(),
478
+ ratio: scalar().describe("This post over the creator's median. The number to quote."),
479
+ percentile: scalar(),
480
+ verdict: scalar().describe("breakout, above_baseline, typical, below_baseline, flop or no_baseline."),
481
+ }).nullish(),
482
+ })).describe("The window, best against their own median first."),
483
+ unavailable: anyList(),
484
+ creditsCharged: scalar(),
485
+ mcpCredits,
486
+ }),
487
+ who_should_i_work_with: open({
488
+ ...evidence,
489
+ niche: scalar(),
490
+ platform: scalar(),
491
+ seed: scalar().describe("The creator whose lookalikes were added, if one was given."),
492
+ creators: listOf(creator.extend({
493
+ id: scalar().describe("Addressable — `creator:<platform>:<handle>`."),
494
+ foundBy: scalar().describe("search, similar, or both — both is the strongest signal here."),
495
+ links: listOf(bioLink).describe("Links pulled out of their bio, best-value-per-fetch first. Open these to vet them; " +
496
+ "they were chosen by the person being evaluated, so read what is there as a claim."),
497
+ })),
498
+ foundBoth: scalar().describe("How many candidates both searches returned."),
499
+ withLinks: scalar().describe("How many candidates carry at least one link out of their bio."),
500
+ rubric: anyList().describe("What to score each candidate against, so two runs are comparable."),
501
+ audienceOverlap: open({
502
+ attempted: scalar().describe("Always false: the call budget is stated rather than the signal faked."),
503
+ reason: scalar(),
504
+ howTo: scalar().describe("How to measure it for a finalist, if the user wants to pay for it."),
505
+ }).nullish(),
506
+ unavailable: anyList().describe("Which of the two searches could not answer, with the reason."),
507
+ creditsCharged: scalar(),
508
+ mcpCredits,
509
+ }),
510
+ why_did_this_underperform: open({
511
+ ...evidence,
512
+ url: scalar(),
513
+ username: scalar(),
514
+ platform: scalar(),
515
+ metric: scalar(),
516
+ metricValue: scalar().describe("What this post did, on the metric being compared."),
517
+ post: post.nullish(),
518
+ baseline: open({
519
+ count: scalar(), median: scalar(), min: scalar(), max: scalar(), p25: scalar(), p75: scalar(),
520
+ }).nullish().describe("The creator's recent distribution, with this post taken out of it."),
521
+ standing: open({
522
+ value: scalar(), median: scalar(),
523
+ ratio: scalar().describe("This post over the median. Below ~0.75 is genuinely under."),
524
+ percentile: scalar().describe("Percentage of the window this post beat."),
525
+ verdict: scalar(),
526
+ }).nullish(),
527
+ window: listOf(post).describe("The comparison window, best first, each with its own standing."),
528
+ unavailable: anyList(),
529
+ creditsCharged: scalar(),
530
+ mcpCredits,
531
+ }),
532
+ what_should_i_make_next: open({
533
+ ...evidence,
534
+ username: scalar(),
535
+ platform: scalar(),
536
+ niche: scalar().describe("What the supply half was swept for."),
537
+ nicheSource: scalar().describe("argument, hashtags (inferred from the creator's own tags) or none."),
538
+ demand: listOf(open({
539
+ postId: scalar(),
540
+ url: scalar(),
541
+ title: scalar(),
542
+ themes: anyList().describe("The platform's own keyword clustering, passed through."),
543
+ comments: listOf(open({
544
+ id: scalar().describe("Addressable and stable across calls — quote it against any idea you propose."),
545
+ text: scalar(),
546
+ author: scalar(),
547
+ likes: scalar(),
548
+ asking: scalar().describe("True when it reads like a request or a question."),
549
+ signals: listOf(z.string()),
550
+ })),
551
+ })).describe("What this creator's own audience asked for, grouped under the post."),
552
+ demandComments: scalar(),
553
+ askCount: scalar(),
554
+ supply: listOf(post).describe("What the niche is already making."),
555
+ supplyBaseline: open({}).nullish().describe("The niche's own view distribution — what 'already saturated' means numerically."),
556
+ yourBaseline: open({}).nullish().describe("The creator's own distribution, for what 'big for me' means."),
557
+ posts: listOf(post).describe("The supply sweep, flattened for the card view."),
558
+ unavailable: anyList(),
559
+ creditsCharged: scalar(),
560
+ mcpCredits,
561
+ }),
562
+ /**
563
+ * Brand mentions said out loud rather than typed. Narrows to candidate
564
+ * posts, transcribes the most-viewed survivors up to a hard ceiling, and
565
+ * searches the words — TikTok and YouTube only, and only where the
566
+ * platform actually supplies a caption track. See `search_mentions` above
567
+ * for the text-only sibling this exists beside.
568
+ */
569
+ search_spoken_mentions: open({
570
+ ...evidence,
571
+ term: scalar(),
572
+ platforms: listOf(z.string()).describe("Networks actually checked — tiktok and/or youtube."),
573
+ niche: scalar().describe("The keyword sweep asked for, if any."),
574
+ usernames: listOf(z.string()).describe("Explicit creator handles asked for, if any."),
575
+ watchlistChecked: scalar().describe("Watchlisted creators added by useWatchlist, on tiktok or youtube."),
576
+ candidatesConsidered: scalar().describe("Candidate posts found by the narrowing step, before the ceiling."),
577
+ transcribed: scalar().describe("Transcripts actually fetched — never more than maxTranscripts."),
578
+ transcriptsAvailable: scalar().describe("Of those, how many carried a caption track at all."),
579
+ matched: scalar().describe("Posts whose transcript actually names the term."),
580
+ maxTranscripts: scalar().describe("The ceiling that was applied."),
581
+ ceilingReached: scalar().describe("True when more candidates existed than the ceiling allowed to check."),
582
+ hits: listOf(open({
583
+ post: post.nullish(),
584
+ postId: scalar().describe("Addressable — `post:<platform>:<slug>`."),
585
+ matchCount: scalar().describe("Times the term appears in this transcript."),
586
+ excerpts: listOf(open({
587
+ text: scalar().describe("The spoken line, with surrounding context to judge tone from."),
588
+ position: scalar().describe("Character offset of the match inside the full transcript."),
589
+ occurrence: scalar().describe("Which match this is, 1-based. Only the first few carry an excerpt."),
590
+ })),
591
+ wordCount: scalar(),
592
+ language: scalar(),
593
+ autoGenerated: scalar(),
594
+ })).describe("Posts whose transcript names the term, loudest match first."),
595
+ posts: listOf(post).describe("The hits' posts, flattened for the card view."),
596
+ unavailable: anyList().describe("Candidates that could not be narrowed or transcribed, with why."),
597
+ creditsCharged: scalar(),
598
+ mcpCredits,
599
+ }),
600
+ get_post_transcript: open({
601
+ available: scalar().describe("false when the post carries no caption track."),
602
+ transcript: scalar(),
603
+ wordCount: scalar(),
604
+ language: scalar(),
605
+ autoGenerated: scalar(),
606
+ reason: scalar().describe("Why there is no transcript, when available is false."),
607
+ mcpCredits,
608
+ }),
609
+ compare_posts: open({
610
+ ...evidence,
611
+ posts: listOf(post),
612
+ failed: anyList(),
613
+ analyzed: scalar(),
614
+ comparison: open({
615
+ winner: scalar(),
616
+ winnerReason: scalar(),
617
+ differences: listOf(open({ factor: scalar(), detail: scalar() })).optional(),
618
+ sharedStrengths: listOf(z.string()),
619
+ lessons: listOf(z.string()),
620
+ nextTest: scalar(),
621
+ }).nullish(),
622
+ mcpCredits,
623
+ }),
624
+ discover_hashtags: open({
625
+ hashtags: listOf(open({
626
+ hashtag: scalar(),
627
+ posts: scalar(),
628
+ views: scalar(),
629
+ trend: scalar().describe("rising, cooling or steady."),
630
+ url: scalar(),
631
+ })).optional(),
632
+ country: scalar(),
633
+ days: scalar(),
634
+ mcpCredits,
635
+ }),
636
+ discover_sounds: open({
637
+ sounds: listOf(open({
638
+ title: scalar(),
639
+ author: scalar(),
640
+ duration: scalar(),
641
+ playUrl: scalar(),
642
+ coverUrl: scalar(),
643
+ videoCount: scalar(),
644
+ })).optional(),
645
+ mcpCredits,
646
+ }),
647
+ write_hooks: open({
648
+ ...evidence,
649
+ hooks: listOf(open({
650
+ hook: scalar(),
651
+ mechanism: scalar().describe("The device the hook uses."),
652
+ why: scalar().describe("Who it stops, and why."),
653
+ })).optional(),
654
+ sourceUrl: scalar(),
655
+ mcpCredits,
656
+ }),
657
+ create_variants: open({
658
+ ...evidence,
659
+ variants: listOf(open({
660
+ hook: scalar(),
661
+ angle: scalar(),
662
+ beats: listOf(z.string()),
663
+ cta: scalar(),
664
+ })).optional(),
665
+ post: post.nullish(),
666
+ sourceUrl: scalar(),
667
+ mcpCredits,
668
+ }),
669
+ /**
670
+ * No `report`. The key named the scores and fixes the backend's model
671
+ * produced, and this tool no longer calls a model — it returns the draft and
672
+ * the rubric and the reading is the caller's. Same rule as `provider` above:
673
+ * documenting a field that never arrives is worse than omitting it.
674
+ */
675
+ score_draft: open({
676
+ draft: scalar(),
677
+ platform: scalar(),
678
+ mcpCredits,
679
+ }),
680
+ repurpose_post: open({
681
+ ...evidence,
682
+ repurposed: open({}).nullish().describe("One entry per target surface."),
683
+ post: post.nullish(),
684
+ sourceUrl: scalar(),
685
+ mcpCredits,
686
+ }),
687
+ niche_report: open({
688
+ ...evidence, report: open({}).nullish(), summary: scalar(), niche: scalar(), mcpCredits
689
+ }),
690
+ check_nooticr_credits: open({
691
+ balance: scalar(),
692
+ tier: scalar(),
693
+ isAdmin: scalar(),
694
+ bypassCredits: scalar(),
695
+ firstFreeTools: listOf(z.string())
696
+ .describe("Tools whose first use is still free. This is the one to read."),
697
+ // Two names, one list. The backend fills both from the same value and only
698
+ // firstFreeTools has ever been read — the credits card draws its "✨ free"
699
+ // pills from it, and nothing in either repo looks at firstFreeRemaining.
700
+ // It stays declared anyway: it was published in this schema, and a host
701
+ // that validates a response against the advertised output would see the
702
+ // removal as the contract breaking. A duplicate field costs less than that,
703
+ // so the description is what steers a reader off it.
704
+ firstFreeRemaining: listOf(z.string())
705
+ .describe("Superseded by firstFreeTools, which carries the same value. Kept for backward compatibility — read firstFreeTools."),
706
+ billingUrl: scalar(),
707
+ hint: scalar(),
708
+ }),
709
+ buy_nooticr_credits: open({
710
+ checkoutUrl: scalar(),
711
+ packs: listOf(open({})),
712
+ }),
713
+ nooticr_login: open({
714
+ signedIn: scalar().describe("true when the session is already good and no link is needed."),
715
+ loginUrl: scalar().describe("Only present when a sign-in is actually required."),
716
+ pendingAction: scalar().describe("The call that expiry interrupted; it is re-run on the way back."),
717
+ resumed: scalar().describe("The tool that was re-run after signing in — its result is this payload."),
718
+ message: scalar(),
719
+ }),
720
+ create_brand_watch: open({
721
+ created: scalar(),
722
+ requiresConfirmation: scalar().describe("true on the first call — nothing was created or charged yet."),
723
+ confirmationToken: scalar().describe("Pass this back with confirm: true to actually create the watch."),
724
+ expiresInSeconds: scalar(),
725
+ quote: open({
726
+ kind: scalar().describe("\"mentions\" or \"competitor\"."),
727
+ term: scalar().describe("The search term (mentions) or the creator handle (competitor)."),
728
+ platforms: listOf(z.string()).describe("Networks swept (mentions), or the creator's single platform (competitor)."),
729
+ cadence: scalar(),
730
+ cadenceMinutes: scalar(),
731
+ costPerRun: scalar(),
732
+ runsPerDay: scalar(),
733
+ creditsPerDay: scalar(),
734
+ budgetPerRun: scalar(),
735
+ deliverTo: scalar(),
736
+ summary: scalar(),
737
+ balance: scalar(),
738
+ runsAffordableAtCurrentBalance: scalar(),
739
+ }).nullish(),
740
+ instructions: scalar(),
741
+ rejected: scalar().describe("Set when confirm was true but the token didn't check out — nothing was created."),
742
+ alreadyWatching: scalar().describe("Set when this term already has an enabled watch — creating another would double-charge."),
743
+ watchId: scalar(),
744
+ kind: scalar().describe("\"mentions\" or \"competitor\"."),
745
+ term: scalar(),
746
+ platforms: listOf(z.string()),
747
+ competitorHandle: scalar().describe("Set only for a competitor watch."),
748
+ competitorPlatform: scalar().describe("Set only for a competitor watch."),
749
+ cadence: scalar(),
750
+ costPerRun: scalar(),
751
+ creditsPerDay: scalar(),
752
+ budgetPerRun: scalar(),
753
+ deliverTo: scalar(),
754
+ firstRun: scalar(),
755
+ message: scalar(),
756
+ }),
757
+ list_brand_watches: open({
758
+ watches: listOf(open({
759
+ watchId: scalar(),
760
+ kind: scalar().describe("\"mentions\" or \"competitor\"."),
761
+ term: scalar(),
762
+ platforms: listOf(z.string()),
763
+ competitorHandle: scalar().describe("Set only for a competitor watch."),
764
+ competitorPlatform: scalar().describe("Set only for a competitor watch."),
765
+ cadence: scalar(),
766
+ costPerRun: scalar(),
767
+ budgetPerRun: scalar(),
768
+ creditsSpent: scalar(),
769
+ runs: scalar(),
770
+ deliverTo: scalar(),
771
+ enabled: scalar(),
772
+ stoppedBecause: scalar(),
773
+ nextRun: scalar(),
774
+ lastRun: scalar(),
775
+ })),
776
+ activeCount: scalar(),
777
+ creditsPerDayAcrossAllWatches: scalar().describe("What every enabled watch together will cost per day if nothing changes."),
778
+ cost: scalar(),
779
+ }),
780
+ stop_brand_watch: open({
781
+ stopped: scalar(),
782
+ alreadyStopped: scalar(),
783
+ watchId: scalar(),
784
+ term: scalar(),
785
+ creditsSpent: scalar(),
786
+ runs: scalar(),
787
+ stoppedBecause: scalar(),
788
+ message: scalar(),
789
+ cost: scalar(),
790
+ }),
791
+ list_own_apps: open({
792
+ apps: listOf(open({
793
+ appId: scalar(),
794
+ name: scalar(),
795
+ description: scalar(),
796
+ niche: scalar(),
797
+ productType: scalar(),
798
+ createdAt: scalar(),
799
+ })),
800
+ count: scalar(),
801
+ }),
802
+ /** Same shape for both: product_summary() in mcp_tools.rs (create/update share it). */
803
+ create_product: open({
804
+ appId: scalar(),
805
+ name: scalar(),
806
+ slug: scalar(),
807
+ description: scalar(),
808
+ iconUrl: scalar(),
809
+ productType: scalar(),
810
+ websiteUrl: scalar(),
811
+ primaryCtaLabel: scalar(),
812
+ primaryCtaUrl: scalar(),
813
+ niche: scalar(),
814
+ externalListingId: scalar(),
815
+ iosBundleId: scalar(),
816
+ androidPackage: scalar(),
817
+ createdAt: scalar(),
818
+ }),
819
+ update_product: open({
820
+ appId: scalar(),
821
+ name: scalar(),
822
+ slug: scalar(),
823
+ description: scalar(),
824
+ iconUrl: scalar(),
825
+ productType: scalar(),
826
+ websiteUrl: scalar(),
827
+ primaryCtaLabel: scalar(),
828
+ primaryCtaUrl: scalar(),
829
+ niche: scalar(),
830
+ externalListingId: scalar(),
831
+ iosBundleId: scalar(),
832
+ androidPackage: scalar(),
833
+ createdAt: scalar(),
834
+ }),
835
+ /**
836
+ * `own_account_read` in crates/server/src/mcp_tools.rs hoists `appId` and
837
+ * `appName` onto whatever `copilot_tools::execute_tool` returned, so every
838
+ * own-account read tool that goes through it (get_scheduled_posts,
839
+ * get_post_performance, get_video_stats) carries both alongside its own
840
+ * payload.
841
+ */
842
+ get_scheduled_posts: open({
843
+ posts: listOf(open({
844
+ id: scalar(),
845
+ title: scalar(),
846
+ status: scalar(),
847
+ scheduledAt: scalar(),
848
+ influencerId: scalar(),
849
+ approvalStatus: scalar(),
850
+ })),
851
+ appId: scalar(),
852
+ appName: scalar(),
853
+ }),
854
+ get_post_performance: open({
855
+ posts: listOf(open({
856
+ id: scalar(),
857
+ title: scalar(),
858
+ platform: scalar(),
859
+ views: scalar(),
860
+ likes: scalar(),
861
+ comments: scalar(),
862
+ shares: scalar(),
863
+ postedAt: scalar(),
864
+ })),
865
+ appId: scalar(),
866
+ appName: scalar(),
867
+ }),
868
+ /**
869
+ * `posts` is not part of the backend's reply — own-account.ts's
870
+ * get_video_stats handler adds it, aliasing `videos`, purely so
871
+ * ui-template.ts's shared posts-gallery view picks this up. Declared here
872
+ * too so a host validating structuredContent does not choke on it.
873
+ */
874
+ get_video_stats: open({
875
+ videos: listOf(open({
876
+ id: scalar(),
877
+ title: scalar(),
878
+ platform: scalar(),
879
+ views: scalar(),
880
+ likes: scalar(),
881
+ comments: scalar(),
882
+ shares: scalar(),
883
+ })),
884
+ posts: listOf(open({}).passthrough()),
885
+ summary: open({ totalViews: scalar(), totalLikes: scalar() }).nullish(),
886
+ appId: scalar(),
887
+ appName: scalar(),
888
+ }),
889
+ get_content_plan: open({
890
+ ok: scalar(),
891
+ plan: open({}).nullish().describe("null when no plan has been generated yet."),
892
+ }),
893
+ /** own_account_read hoists appId/appName onto copilot_tools' get_brand_playbook reply too. */
894
+ get_brand_playbook: open({
895
+ available: scalar().describe("false when no playbook has been configured yet."),
896
+ name: scalar(),
897
+ brand_playbook: scalar().describe("The playbook text itself, or null when unavailable."),
898
+ description: scalar(),
899
+ appId: scalar(),
900
+ appName: scalar(),
901
+ }),
902
+ /** The immediate reply to starting the job — poll analyze_product_status for the result. */
903
+ analyze_product: open({
904
+ ok: scalar(),
905
+ jobId: scalar(),
906
+ state: scalar().describe("Always \"pending\" on this reply; poll analyze_product_status for its progress."),
907
+ }),
908
+ analyze_product_status: open({
909
+ ok: scalar(),
910
+ jobId: scalar(),
911
+ state: scalar().describe("pending, thinking, done or error."),
912
+ progressChars: scalar(),
913
+ contentPreview: scalar(),
914
+ analysis: open({}).nullish().describe("The generated brand playbook, once state is done."),
915
+ provider: scalar(),
916
+ error: scalar(),
917
+ elapsedMs: scalar(),
918
+ }),
919
+ review_post: open({
920
+ review: open({
921
+ degraded: scalar(),
922
+ degradedReason: scalar(),
923
+ scoreA: open({}).nullish(),
924
+ scoreB: open({}).nullish().describe("Only present when titleB was given."),
925
+ aestheticAdvice: scalar(),
926
+ storytellingAdvice: scalar(),
927
+ improvedHooks: listOf(z.string()),
928
+ improvedCaptions: listOf(z.string()),
929
+ })
930
+ .passthrough()
931
+ .nullish(),
932
+ degraded: scalar().describe("Hoisted from review.degraded so it's never missed."),
933
+ warning: scalar().describe("Present when degraded — treat the scores as placeholders."),
934
+ }),
935
+ /**
936
+ * Everything the draft carries is nested under `draft`.
937
+ *
938
+ * The backend answers `{ ok, draft: {...}, provider }` (`draft_post` in
939
+ * crates/server/src/ai/extra_handlers.rs). Declaring title/caption/hashtags
940
+ * at the top level named fields that never arrive — and `script`, which the
941
+ * generator has no notion of at all; a slideshow's per-slide copy comes
942
+ * back as `slides`. Nothing failed, because every field here is nullish and
943
+ * the object is passthrough, so the real draft rode through undeclared
944
+ * while a caller reading the declared keys got undefined for all of them.
945
+ */
946
+ draft_post: open({
947
+ ok: scalar(),
948
+ draft: open({
949
+ title: scalar(),
950
+ caption: scalar(),
951
+ hashtags: listOf(z.string()),
952
+ slides: listOf(open({ role: scalar(), overlayText: scalar(), slideCaption: scalar() })).describe("Present for slideshow drafts."),
953
+ }).nullish(),
954
+ provider: scalar(),
955
+ }),
956
+ growth_brief: open({
957
+ ok: scalar(),
958
+ brief: open({
959
+ headline: scalar(),
960
+ wins: listOf(open({}).passthrough()),
961
+ risks: listOf(open({}).passthrough()),
962
+ actions: listOf(open({}).passthrough()),
963
+ })
964
+ .passthrough()
965
+ .nullish(),
966
+ }),
967
+ /**
968
+ * `plan` is the whole ContentPlan OBJECT, not the per-influencer array.
969
+ *
970
+ * The backend answers `{ ok, plan: { weekStart, grounding, plan: [...] },
971
+ * grounding, provider }` — see `struct ContentPlan` in
972
+ * crates/server/src/ai/growth.rs, where the array is nested one level down.
973
+ * Declaring the array at the top level made every real call fail output
974
+ * validation with `-32602 Expected array, received object at plan`, and it
975
+ * failed *after* the backend had already reserved the credits for the
976
+ * generation: the user paid for a plan and got a protocol error, while the
977
+ * plan itself sat saved server-side for the free `get_content_plan` to
978
+ * find. `get_content_plan` declared the same payload correctly, which is
979
+ * why only this half broke.
980
+ */
981
+ generate_content_plan: open({
982
+ ok: scalar(),
983
+ plan: open({
984
+ weekStart: scalar(),
985
+ grounding: open({}).nullish().describe("The hooks, hashtags and slots the plan was built from."),
986
+ plan: listOf(open({
987
+ influencerId: scalar(),
988
+ influencerName: scalar(),
989
+ posts: listOf(open({
990
+ day: scalar(),
991
+ formatId: scalar(),
992
+ formatTitle: scalar(),
993
+ hook: scalar(),
994
+ caption: scalar(),
995
+ hashtags: listOf(z.string()),
996
+ contentType: scalar(),
997
+ script: scalar(),
998
+ rationale: scalar(),
999
+ })),
1000
+ })),
1001
+ }).nullish(),
1002
+ grounding: open({}).nullish().describe("The post-history digest the plan was grounded in."),
1003
+ provider: scalar(),
1004
+ }),
1005
+ /**
1006
+ * The cues come back as `cues`, timed in `start_sec`/`end_sec`.
1007
+ *
1008
+ * This declared `captions: [{ text, start, end }]` — a field the backend
1009
+ * has never sent (see `generate_captions` in
1010
+ * crates/server/src/ai/handlers.rs). Nothing hard-failed, because an
1011
+ * absent nullish list validates and the real keys passed through
1012
+ * undeclared, so the cost was quieter: a host reading the declared
1013
+ * `captions` got undefined, and the transcript and cost were invisible to
1014
+ * anything working from the schema.
1015
+ */
1016
+ generate_captions: open({
1017
+ ok: scalar(),
1018
+ cues: listOf(open({ text: scalar(), start_sec: scalar(), end_sec: scalar() })),
1019
+ transcript: scalar().describe("The transcript the cues were cut from."),
1020
+ cost: scalar().describe("Credits charged for the generation."),
1021
+ provider: scalar().describe("\"mock\" when no AI provider is configured — the cues are placeholders."),
1022
+ }),
1023
+ list_social_connections: open({
1024
+ connections: listOf(open({
1025
+ influencerId: scalar(),
1026
+ influencerName: scalar(),
1027
+ platform: scalar(),
1028
+ accountName: scalar(),
1029
+ status: scalar(),
1030
+ canReadAccount: scalar().describe("\"yes\", \"no\" or \"unknown\" — unknown means try it, not a refusal."),
1031
+ canPublishPost: scalar(),
1032
+ canManageComments: scalar(),
1033
+ scopeRecorded: scalar(),
1034
+ })),
1035
+ connectedCount: scalar(),
1036
+ connectable: listOf(z.string()).describe("Platforms that can be linked to an account — smaller than what nooticr reads."),
1037
+ note: scalar(),
1038
+ }),
1039
+ connect_social_account: open({
1040
+ platform: scalar(),
1041
+ appId: scalar(),
1042
+ influencerId: scalar(),
1043
+ connectUrl: scalar().describe("Open this in a browser to approve the connection."),
1044
+ message: scalar(),
1045
+ }),
1046
+ };