@@ -52,6 +70,47 @@ export function ToolPart({ part }: { part: ToolPartLike }) {
);
}
+ if (part.type === "tool-playlist_agent") {
+ const output = part.output as PlaylistAgentOutput | undefined;
+ return (
+
+ );
+ }
+
+ if (part.type === "tool-audience_agent") {
+ const output = part.output as AudienceAgentOutput | undefined;
+ return (
+
+ );
+ }
+
+ if (part.type === "tool-creator_agent") {
+ const output = part.output as CreatorAgentOutput | undefined;
+ return (
+
+
+ {output && }
+
+ );
+ }
+
+ if (part.type === "tool-insights_agent") {
+ const output = part.output as InsightsAgentOutput | undefined;
+ return (
+
+
+ {output && }
+
+ );
+ }
+
+ // Fallback for unknown tool types — defensive only, shouldn't fire in prod.
return (
diff --git a/lib/agent/system-prompt.ts b/lib/agent/system-prompt.ts
index b0986c4..9870120 100644
--- a/lib/agent/system-prompt.ts
+++ b/lib/agent/system-prompt.ts
@@ -3,68 +3,87 @@ import { siteConfig } from "@/lib/config";
/**
* System prompt for the marketing hero demo agent.
*
- * Designed for a short, demo-shaped conversation: the user asks a music
- * question, the agent calls a tool, the UI renders a rich card, and the
- * agent writes ONE short sentence framing the result.
+ * Architecture: a single LLM acts as an *orchestrator* that fans out to
+ * specialized "agent" tools in parallel based on the user's intent. The
+ * agent loop has a generous step budget so the model can (1) emit multiple
+ * tool calls in one step, (2) receive all results, and (3) write a
+ * synthesis sentence in a second step.
*
- * Keep tool routing rules explicit. The hero demo is high-traffic and
- * latency-sensitive — we want the model to call tools immediately rather
- * than narrate its plan first.
+ * For the user experience to feel like a hero-demo, the model must:
+ * - Fan out (call multiple tools in one step) when the prompt is broad
+ * - Single-shot (call one tool) when the prompt is narrow
+ * - Never narrate "I'm going to call these tools…" before doing it
+ * - Tie all parallel agent results together in a single synthesis line
*/
-export const RECOUP_DEMO_SYSTEM_PROMPT = `You are the ${siteConfig.name} agent, embedded in the marketing site hero demo.
+export const RECOUP_DEMO_SYSTEM_PROMPT = `You are the ${siteConfig.name} orchestrator, embedded in the marketing site hero demo.
-${siteConfig.name} is an AI-native music intelligence platform for artists, labels, distributors, and catalog owners.
+${siteConfig.name} is an AI-native music intelligence platform for artists, labels, distributors, and catalog owners. The demo shows what happens when a single prompt spawns multiple specialized agents in parallel.
-# Tools
+# Agents you can spawn
-- \`similar_artists\` — Returns up to 6 sonically and audience-adjacent artists from Recoup's similarity model. Each artist comes with: monthly Spotify listeners, follower count, primary genre, career stage (superstar/mainstream/mid_level/developing/early), recent momentum (growth/stable/decline), label, and a 0-1 similarity score. Use whenever the user asks for similar artists, comparable acts, alternatives, sonic neighbors, or anything in that family.
+You have five specialist agents available. Each is a tool you can call. Multiple agents can — and SHOULD — be called in parallel in a single step when the prompt benefits from it.
-# Tool routing rules
+- \`similar_artists\` — **Sleeper Agent**. Returns up to 6 sonically and audience-adjacent artists with monthly listeners, similarity score, career stage, momentum, label. Use when the user wants comps, neighbors, sonic adjacents, or "find me artists like X".
+- \`playlist_agent\` — **Playlist Agent**. Returns up to 10 playlist-fit matches (editorial + indie) with curator, follower count, fit score, submission URLs where available. Use when the user wants playlists, pitching, placement, or to launch a song.
+- \`audience_agent\` — **Audience Agent**. Returns top 5 cities with trend direction, age + gender split, total monthly listeners. Use when the user wants audience intel, demographics, fans, geography, top markets.
+- \`creator_agent\` — **Creator Agent**. Returns up to 6 TikTok / IG creators with audience-match score, recent music-post count, follower count. Use when the user wants promo creators, influencers, TikTok seeding, viral outreach.
+- \`insights_agent\` — **Insights Agent**. Returns 3-5 sentiment-tagged highlights + a 12-month listener trend. Use when the user wants what's happening now, recent activity, trends, growth trajectory.
-- When a request maps cleanly to a tool, call it IMMEDIATELY. Do not narrate what you are about to do.
-- Pass the artist name exactly as the user mentioned it.
-- After the tool returns, write ONE short sentence (≤25 words) framing the result. Use the rich structured data in the tool output — pick out a momentum trend, a sleeper, or a label-mate worth flagging. Do NOT re-list the artists; the UI already shows them.
- - Good close: "Strong overlap, but Snoh Aalegra is the sleeper here — superstar lane, rising momentum."
- - Good close: "Three OVO-adjacent picks plus two younger inheritors of the melodic-trap formula."
- - Bad close: "Here are 6 similar artists." (too generic; don't waste the close sentence)
-- If the request does not map to a tool, answer in plain prose under 80 words.
+# Orchestration rules
-# Available demo capabilities (DO NOT OVER-PROMISE)
+**Fan out when the prompt is open-ended.** Multi-agent fan-out is the demo's "wow" moment — embrace it whenever the user's intent maps to more than one agent.
-The marketing demo only has the \`similar_artists\` tool. You can also analyze the data already returned by a prior \`similar_artists\` call without calling the tool again (e.g. "who's the sleeper?", "list the independents", "rank by momentum"). EVERYTHING ELSE — playlist building, campaign briefs, audience overlap analysis, tour planning, sync pitching, growth forecasts, pitch decks — is NOT available in this demo.
+| Intent pattern | Spawn these agents in parallel |
+|---|---|
+| "Help me launch [artist]'s single" | playlist_agent + audience_agent + creator_agent + insights_agent (4-way fan-out) |
+| "Tell me everything about [artist]" | audience_agent + insights_agent + similar_artists + creator_agent (4-way fan-out) |
+| "Plan a campaign for [artist]" | playlist_agent + creator_agent + audience_agent + insights_agent (4-way fan-out) |
+| "Who should [artist] tour with" | similar_artists + audience_agent (2-way) |
+| "Find similar to [artist]" | similar_artists (1) |
+| "Who's listening to [artist]" | audience_agent (1) |
+| "What's new with [artist]" | insights_agent (1) |
+| "Playlist ideas for [artist]" | playlist_agent (1) |
-When the user asks for something the demo can't do:
-1. Don't apologize and don't say "I can't".
-2. Briefly acknowledge the request (one phrase).
-3. Pivot to what \`similar_artists\` *can* show, framed as a useful next step.
-4. Optionally hint that the full Recoup product at chat.recoupable.com handles the deeper analysis — but ONLY as a single phrase, not a sales pitch.
+**Call all relevant agents in a SINGLE step.** Do NOT call one agent, wait for its result, then call another. Emit all tool_calls together so they fire in parallel — the UI is designed to render them as a fan-out tree.
-Example close-sentence handoffs (use the working surface, never the missing one):
-- ✅ "Want me to find the sleeper in this list?"
-- ✅ "Should I dig into Brent Faiyaz next?"
-- ✅ "Want me to filter to the independents?"
-- ❌ "Want me to build a playlist from these?" — playlist building isn't in this demo
-- ❌ "Should I sketch a release campaign?" — campaigns aren't in this demo
-- ❌ "Want me to map their tour?" — tour data isn't in this demo
+**Never narrate the plan.** Don't write "Let me check the audience and playlists for you…" before calling tools. Just call them. The UI shows the agents spinning up.
-# Context resolution (conversational memory)
+# Synthesis sentence (after all agents return)
-The conversation may span multiple turns. The full prior tool output (every artist's listener counts, momentum, label, similarity score, etc.) is available to you in the message history — **do not re-call the tool just to re-read data you already have**. Analyze the prior result directly and answer in prose.
+After the parallel agents complete, write ONE synthesis sentence (≤35 words) that **ties together findings across multiple agents into a single story**. Use specific data points. Do not re-list what's already in the cards.
-Cases:
+- ✅ "East Coast act with Brazilian upside — RapCaviar just added them, 4 TikTok creators are warm, Snoh Aalegra is the bridge to alt-R&B."
+- ✅ "Strong momentum despite softening TikTok signal — playlist coverage is at an all-time high, indie curators in Brazil are the next play."
+- ❌ "Here are the playlists, audience, creators, and insights." (lists agents, says nothing)
+- ❌ "I found 8 playlists, 5 markets, 4 creators, and 5 insights." (recites counts)
+- ❌ "Let me know what you'd like to explore next." (no insight)
-- **Analytical follow-ups about prior results** ("who's the sleeper?", "show only independents", "who's rising fastest?", "rank by momentum") → answer from memory, by name, citing one specific stat per artist. Do NOT call the tool. Do NOT re-list everyone — give a tight prose answer (≤60 words). Examples:
- - "Snoh Aalegra — superstar lane, rising momentum, but only 7M monthly listeners. Most upside per current reach."
- - "Just two: Brent Faiyaz and Frank Ocean. Both independent, both rising. Faiyaz at 22M, Ocean at 40M."
-- **Drill-down with a new tool call** ("more like them", "find similar to [Brent Faiyaz]", "what about [name]") → call \`similar_artists\` again with the chosen artist as the new reference.
-- **Pronoun resolution**: "their", "them", "those", "these" → the artists in the last \`similar_artists\` result; "the [Nth] one" → that specific artist by index; "that artist" → the reference artist from the most recent call.
-- **Bare continuation** like "what else?" or "keep going" → call \`similar_artists\` again on the same reference, framing it as a deeper second pass.
+# Single-tool responses
-If a follow-up needs data we don't have a tool for (tour data, audience overlap, growth forecast, playlist building, campaign briefs), don't apologize — pivot to what \`similar_artists\` *can* show, and pitch the deeper analysis as a one-phrase teaser for chat.recoupable.com.
+When the prompt maps to just one agent, call that agent and write a tight ≤25-word framing sentence (same rules as before — use the rich structured data, pick out something specific, don't re-list).
+
+# Context resolution (conversational memory across turns)
+
+Prior tool outputs are in your context window. **Do not re-call a tool to re-read data you already have.**
+
+- **Analytical follow-ups** ("who's the sleeper?", "show only independents", "rank by momentum", "which market is growing fastest?") → answer from memory in ≤60 words, by name, with one specific stat per item. Do NOT call the tool again.
+- **Drill-down with a new tool call** ("more like Brent Faiyaz", "audience for Snoh Aalegra", "playlists for Tyla") → call the appropriate agent on the new reference. Single tool, not fan-out, unless the drill is also broad.
+- **Pronoun resolution**: "their", "them", "those" → entities in the most recent agent result; "the [Nth] one" → by index; "that artist" → the reference of the most recent call.
+- **Bare continuation** like "what else?" or "keep going" → re-spawn the most recent agent on the same reference for a second pass.
+
+# Capabilities NOT in the demo (do not over-promise)
+
+You have five agents only: similar_artists, playlist_agent, audience_agent, creator_agent, insights_agent. EVERYTHING ELSE — tour routing, sync pitching, brand matching, growth forecasts, A&R scoring, catalog valuation, content generation, royalty analysis, contract review — is NOT in this demo.
+
+When the user asks for something out-of-scope:
+1. Don't apologize. Don't say "I can't".
+2. Acknowledge in one phrase.
+3. Pivot to what your agents *can* show.
+4. Optionally hint that the full Recoup product at chat.recoupable.com handles deeper analysis — ONE phrase, not a sales pitch.
# Voice
-- Plain prose. No headers, no bullets, no markdown lists.
-- Keep all responses under 100 words.
-- End with a soft handoff to a working demo capability (e.g., "Want me to find the sleeper here?", "Should I dig into [artist] next?", "Want me to filter to the independents?"). See the "Available demo capabilities" section for the full list of allowed handoffs.
+- Plain prose. No headers, no bullets, no markdown lists in responses.
+- Synthesis sentences ≤35 words. Single-tool framing sentences ≤25 words. Analytical follow-ups ≤60 words.
+- End with a soft handoff that points at a working agent ("Want me to find the sleeper in this list?", "Want playlists for Brent Faiyaz?", "Should I dig into who's listening to Tyla?").
- Never apologize. Never say "as an AI".`;
diff --git a/lib/agent/tools/audience-agent.ts b/lib/agent/tools/audience-agent.ts
new file mode 100644
index 0000000..dc5f547
--- /dev/null
+++ b/lib/agent/tools/audience-agent.ts
@@ -0,0 +1,82 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+const audienceAgentInputSchema = z.object({
+ artist: z
+ .string()
+ .min(1)
+ .describe("Artist name to pull audience intelligence for"),
+});
+
+const marketSchema = z.object({
+ city: z.string(),
+ country: z.string(),
+ listeners: z.number().int().nonnegative(),
+ trend: z
+ .enum(["growth", "stable", "decline"])
+ .describe("30-day listener trend in this market"),
+});
+
+const audienceAgentOutputSchema = z.object({
+ reference: z.string(),
+ totalMonthlyListeners: z.number().int().nonnegative(),
+ topMarkets: z.array(marketSchema).min(1).max(5),
+ ageSplit: z.object({
+ "18-24": z.number().min(0).max(1),
+ "25-34": z.number().min(0).max(1),
+ "35-44": z.number().min(0).max(1),
+ "45+": z.number().min(0).max(1),
+ }),
+ genderSplit: z.object({
+ female: z.number().min(0).max(1),
+ male: z.number().min(0).max(1),
+ other: z.number().min(0).max(1),
+ }),
+});
+
+export type AudienceAgentOutput = z.infer;
+export type Market = AudienceAgentOutput["topMarkets"][number];
+
+const MOCK_AUDIENCE: Record = {
+ sza: {
+ reference: "SZA",
+ totalMonthlyListeners: 69_937_628,
+ topMarkets: [
+ { city: "New York", country: "US", listeners: 4_120_000, trend: "stable" },
+ { city: "Los Angeles", country: "US", listeners: 3_870_000, trend: "growth" },
+ { city: "São Paulo", country: "BR", listeners: 2_640_000, trend: "growth" },
+ { city: "London", country: "GB", listeners: 2_180_000, trend: "stable" },
+ { city: "Mexico City", country: "MX", listeners: 1_920_000, trend: "growth" },
+ ],
+ ageSplit: { "18-24": 0.42, "25-34": 0.36, "35-44": 0.16, "45+": 0.06 },
+ genderSplit: { female: 0.61, male: 0.36, other: 0.03 },
+ },
+};
+
+function getMockAudience(reference: string): AudienceAgentOutput {
+ const key = reference.toLowerCase().split(/\s+/)[0];
+ return {
+ ...(MOCK_AUDIENCE[key] ?? MOCK_AUDIENCE["sza"]),
+ reference,
+ };
+}
+
+/**
+ * Audience Agent — pulls demographics + geographic concentration for an
+ * artist. Surfaces top 5 cities with trend direction, age split,
+ * gender split, and total monthly listeners.
+ *
+ * Day 1 ships with mock data so we can build the parallel-fan-out UI
+ * without juggling endpoint quirks. Day 4 swaps to
+ * `/research/audience?artist=...` + `/research/cities?artist=...`
+ * against the live Recoup API.
+ */
+export const audienceAgentTool = tool({
+ description:
+ "Pull audience intelligence for an artist: top 5 cities with 30-day trend direction, age and gender splits, and total monthly listeners. Use whenever the user asks about audience, demographics, fans, where their listeners live, top markets, or who's listening.",
+ inputSchema: audienceAgentInputSchema,
+ outputSchema: audienceAgentOutputSchema,
+ execute: async ({ artist }) => {
+ return getMockAudience(artist);
+ },
+});
diff --git a/lib/agent/tools/creator-agent.ts b/lib/agent/tools/creator-agent.ts
new file mode 100644
index 0000000..8cab781
--- /dev/null
+++ b/lib/agent/tools/creator-agent.ts
@@ -0,0 +1,97 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+const creatorAgentInputSchema = z.object({
+ artist: z
+ .string()
+ .min(1)
+ .describe("Artist name to find audience-matched creators for"),
+});
+
+const creatorSchema = z.object({
+ handle: z.string(),
+ platform: z.enum(["tiktok", "instagram"]),
+ followers: z.number().int().nonnegative(),
+ audienceMatch: z
+ .number()
+ .min(0)
+ .max(1)
+ .describe("0-1 audience-overlap score with the reference artist's listeners"),
+ recentMusicPosts: z
+ .number()
+ .int()
+ .nonnegative()
+ .describe("Count of music-related posts in last 30 days"),
+ avatarUrl: z.string().url().nullable(),
+});
+
+const creatorAgentOutputSchema = z.object({
+ reference: z.string(),
+ creators: z.array(creatorSchema).min(1).max(6),
+});
+
+export type CreatorAgentOutput = z.infer;
+export type Creator = CreatorAgentOutput["creators"][number];
+
+const MOCK_CREATORS: Record = {
+ sza: [
+ {
+ handle: "@notlinawaithe",
+ platform: "tiktok",
+ followers: 2_100_000,
+ audienceMatch: 0.94,
+ recentMusicPosts: 12,
+ avatarUrl: null,
+ },
+ {
+ handle: "@kennedy.eurich",
+ platform: "tiktok",
+ followers: 1_400_000,
+ audienceMatch: 0.91,
+ recentMusicPosts: 8,
+ avatarUrl: null,
+ },
+ {
+ handle: "@aliyahsinterlude",
+ platform: "instagram",
+ followers: 880_000,
+ audienceMatch: 0.93,
+ recentMusicPosts: 6,
+ avatarUrl: null,
+ },
+ {
+ handle: "@theshyloft",
+ platform: "tiktok",
+ followers: 320_000,
+ audienceMatch: 0.89,
+ recentMusicPosts: 14,
+ avatarUrl: null,
+ },
+ ],
+};
+
+function getMockCreators(reference: string): CreatorAgentOutput {
+ const key = reference.toLowerCase().split(/\s+/)[0];
+ const creators = MOCK_CREATORS[key] ?? MOCK_CREATORS["sza"];
+ return { reference, creators };
+}
+
+/**
+ * Creator Agent — surfaces TikTok and Instagram creators whose audience
+ * profile overlaps with the artist's listeners. Ranked by audience-match
+ * percentage, with recent music-post count as a "warm-lead" signal.
+ *
+ * Day 1 ships with mock data so we can build the parallel-fan-out UI
+ * without juggling endpoint quirks. Day 4 swaps to
+ * `/research/people?artist=...` + `/research/instagram-posts?artist=...`
+ * against the live Recoup API.
+ */
+export const creatorAgentTool = tool({
+ description:
+ "Find TikTok and Instagram creators whose audience profile overlaps with the artist's listeners — useful for promo outreach, influencer matching, and viral seeding. Returns up to 6 creators ranked by audience-match score, with handle, platform, follower count, and recent music-post count as a warm-lead signal. Use when the user asks about creators, influencers, TikTok promo, IG promo, virality, or seeding a song.",
+ inputSchema: creatorAgentInputSchema,
+ outputSchema: creatorAgentOutputSchema,
+ execute: async ({ artist }) => {
+ return getMockCreators(artist);
+ },
+});
diff --git a/lib/agent/tools/index.ts b/lib/agent/tools/index.ts
index d9fedcd..5671b64 100644
--- a/lib/agent/tools/index.ts
+++ b/lib/agent/tools/index.ts
@@ -1,14 +1,26 @@
+import { audienceAgentTool } from "./audience-agent";
+import { creatorAgentTool } from "./creator-agent";
+import { insightsAgentTool } from "./insights-agent";
+import { playlistAgentTool } from "./playlist-agent";
import { similarArtistsTool } from "./similar-artists";
/**
* Tool registry for the marketing hero demo agent.
*
- * Keys here become the tool names exposed to the LLM. They also become the
- * part type discriminator on the client side (e.g. `similar_artists` here
- * surfaces as `tool-similar_artists` parts in UIMessage.parts).
+ * Each key here becomes the tool name exposed to the LLM and the part-type
+ * discriminator on the client side (e.g. `playlist_agent` here surfaces as
+ * `tool-playlist_agent` parts in UIMessage.parts).
+ *
+ * Naming convention: `_agent` for the new fan-out agents so the UI
+ * can render each as a labeled specialist. `similar_artists` stays as-is
+ * since it predates the agent framing (it's the "Sleeper Agent" in the UI).
*/
export const demoTools = {
similar_artists: similarArtistsTool,
+ playlist_agent: playlistAgentTool,
+ audience_agent: audienceAgentTool,
+ creator_agent: creatorAgentTool,
+ insights_agent: insightsAgentTool,
} as const;
export type DemoToolName = keyof typeof demoTools;
diff --git a/lib/agent/tools/insights-agent.ts b/lib/agent/tools/insights-agent.ts
new file mode 100644
index 0000000..8602f81
--- /dev/null
+++ b/lib/agent/tools/insights-agent.ts
@@ -0,0 +1,116 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+const insightsAgentInputSchema = z.object({
+ artist: z
+ .string()
+ .min(1)
+ .describe("Artist name to pull noteworthy highlights + trends for"),
+});
+
+const insightSchema = z.object({
+ headline: z.string().describe("One-line takeaway"),
+ detail: z
+ .string()
+ .describe("One sentence of supporting context with a specific stat"),
+ sentiment: z
+ .enum(["positive", "neutral", "warning"])
+ .describe("Color-codes the insight in the UI"),
+ recency: z
+ .enum(["last_24h", "last_7d", "last_30d", "longer"])
+ .describe("When the underlying event happened"),
+});
+
+const trendPointSchema = z.object({
+ month: z.string().describe("YYYY-MM"),
+ listeners: z.number().int().nonnegative(),
+});
+
+const insightsAgentOutputSchema = z.object({
+ reference: z.string(),
+ insights: z.array(insightSchema).min(1).max(5),
+ monthlyListenerTrend: z.array(trendPointSchema).min(2).max(12),
+});
+
+export type InsightsAgentOutput = z.infer;
+export type Insight = InsightsAgentOutput["insights"][number];
+
+const MOCK_INSIGHTS: Record = {
+ sza: {
+ reference: "SZA",
+ insights: [
+ {
+ headline: "Just added to RapCaviar",
+ detail: "Added 2 hours ago, currently at position #4 with 14.2M reach.",
+ sentiment: "positive",
+ recency: "last_24h",
+ },
+ {
+ headline: "São Paulo audience is breaking out",
+ detail: "Brazil listeners +34% MoM — fastest-growing market in the top 5.",
+ sentiment: "positive",
+ recency: "last_30d",
+ },
+ {
+ headline: "Catalog reach hit a new high",
+ detail: "Total Spotify playlist reach crossed 610M — first time over that line.",
+ sentiment: "positive",
+ recency: "last_7d",
+ },
+ {
+ headline: "TikTok engagement softening",
+ detail: "Music-tagged posts -12% week-over-week despite stable listener count.",
+ sentiment: "warning",
+ recency: "last_7d",
+ },
+ {
+ headline: "Stable in core US markets",
+ detail: "NY + LA listener counts flat — no decay, no growth in the home markets.",
+ sentiment: "neutral",
+ recency: "last_30d",
+ },
+ ],
+ monthlyListenerTrend: [
+ { month: "2025-06", listeners: 62_400_000 },
+ { month: "2025-07", listeners: 63_100_000 },
+ { month: "2025-08", listeners: 64_800_000 },
+ { month: "2025-09", listeners: 65_300_000 },
+ { month: "2025-10", listeners: 66_100_000 },
+ { month: "2025-11", listeners: 67_900_000 },
+ { month: "2025-12", listeners: 68_400_000 },
+ { month: "2026-01", listeners: 69_000_000 },
+ { month: "2026-02", listeners: 69_400_000 },
+ { month: "2026-03", listeners: 69_600_000 },
+ { month: "2026-04", listeners: 69_800_000 },
+ { month: "2026-05", listeners: 69_937_628 },
+ ],
+ },
+};
+
+function getMockInsights(reference: string): InsightsAgentOutput {
+ const key = reference.toLowerCase().split(/\s+/)[0];
+ return {
+ ...(MOCK_INSIGHTS[key] ?? MOCK_INSIGHTS["sza"]),
+ reference,
+ };
+}
+
+/**
+ * Insights Agent — surfaces trending highlights, recent activity, and
+ * a 12-month listener trend for an artist. Each insight is sentiment-tagged
+ * (positive / neutral / warning) and recency-tagged so the UI can prioritize.
+ *
+ * Day 1 ships with mock data so we can build the parallel-fan-out UI
+ * without juggling endpoint quirks. Day 4 swaps to
+ * `/research/insights?artist=...` + `/research/metrics?artist=...`
+ * against the live Recoup API.
+ */
+export const insightsAgentTool = tool({
+ description:
+ "Pull noteworthy highlights, recent activity, and a 12-month listener trend for an artist. Returns 3-5 sentiment-tagged insights (playlist adds, audience trend shifts, TikTok signals, market growth/decline) plus a monthly listener time series. Use whenever the user asks what's happening with an artist, what's new, what's trending, what to watch out for, recent activity, or growth trajectory.",
+ inputSchema: insightsAgentInputSchema,
+ outputSchema: insightsAgentOutputSchema,
+ execute: async ({ artist }) => {
+ return getMockInsights(artist);
+ },
+});
diff --git a/lib/agent/tools/playlist-agent.ts b/lib/agent/tools/playlist-agent.ts
new file mode 100644
index 0000000..80a81c9
--- /dev/null
+++ b/lib/agent/tools/playlist-agent.ts
@@ -0,0 +1,125 @@
+import { tool } from "ai";
+import { z } from "zod";
+
+const playlistAgentInputSchema = z.object({
+ artist: z
+ .string()
+ .min(1)
+ .describe("Artist name to find playlist-fit matches for"),
+});
+
+const playlistItemSchema = z.object({
+ name: z.string(),
+ curator: z.string(),
+ followers: z.number().int().nonnegative(),
+ fitScore: z
+ .number()
+ .min(0)
+ .max(1)
+ .describe("0-1 estimated playlist fit based on genre + audience + sonic match"),
+ recentlyAdded: z
+ .boolean()
+ .describe("True if this playlist added artists in the last 30 days"),
+ submissionUrl: z.string().url().nullable(),
+});
+
+const playlistAgentOutputSchema = z.object({
+ reference: z.string(),
+ playlists: z.array(playlistItemSchema).min(1).max(10),
+});
+
+export type PlaylistAgentOutput = z.infer;
+export type PlaylistMatch = PlaylistAgentOutput["playlists"][number];
+
+const MOCK_PLAYLISTS: Record = {
+ sza: [
+ {
+ name: "RapCaviar",
+ curator: "Spotify",
+ followers: 14_200_000,
+ fitScore: 0.97,
+ recentlyAdded: true,
+ submissionUrl: null,
+ },
+ {
+ name: "Are & Be",
+ curator: "Spotify",
+ followers: 5_100_000,
+ fitScore: 0.96,
+ recentlyAdded: true,
+ submissionUrl: null,
+ },
+ {
+ name: "Mood Booster",
+ curator: "Spotify",
+ followers: 8_700_000,
+ fitScore: 0.84,
+ recentlyAdded: false,
+ submissionUrl: null,
+ },
+ {
+ name: "Chilled R&B",
+ curator: "Apple Music",
+ followers: 1_400_000,
+ fitScore: 0.95,
+ recentlyAdded: true,
+ submissionUrl: null,
+ },
+ {
+ name: "Slow R&B",
+ curator: "Indie · Cookie",
+ followers: 420_000,
+ fitScore: 0.93,
+ recentlyAdded: true,
+ submissionUrl: "https://submithub.com/playlist/slow-rnb",
+ },
+ {
+ name: "Velvet Dreams",
+ curator: "Indie · Lush Sounds",
+ followers: 86_000,
+ fitScore: 0.91,
+ recentlyAdded: true,
+ submissionUrl: "https://submithub.com/playlist/velvet-dreams",
+ },
+ {
+ name: "Late Night Soul",
+ curator: "Indie · After Hours",
+ followers: 64_000,
+ fitScore: 0.88,
+ recentlyAdded: false,
+ submissionUrl: "https://submithub.com/playlist/late-night-soul",
+ },
+ {
+ name: "Yoga & Meditation",
+ curator: "Spotify",
+ followers: 6_300_000,
+ fitScore: 0.42,
+ recentlyAdded: false,
+ submissionUrl: null,
+ },
+ ],
+};
+
+function getMockPlaylists(reference: string): PlaylistAgentOutput {
+ const key = reference.toLowerCase().split(/\s+/)[0];
+ const matches = MOCK_PLAYLISTS[key] ?? MOCK_PLAYLISTS["sza"];
+ return { reference, playlists: matches };
+}
+
+/**
+ * Playlist Agent — finds editorial + indie playlists that fit an artist's
+ * genre/audience profile, with submission paths for the indie ones.
+ *
+ * Day 1 ships with mock data so we can build the parallel-fan-out UI
+ * without juggling endpoint quirks. Day 4 swaps to
+ * `/research/playlists?artist=...` against the live Recoup API.
+ */
+export const playlistAgentTool = tool({
+ description:
+ "Scan editorial and indie playlists for fit with a given artist's genre, audience, and sonic profile. Returns up to 10 ranked playlist matches with curator, follower count, a 0-1 fit score, whether they've added new artists recently, and submission URLs where available. Use when the user asks about playlists, pitching, placement opportunities, or launching a song/single.",
+ inputSchema: playlistAgentInputSchema,
+ outputSchema: playlistAgentOutputSchema,
+ execute: async ({ artist }) => {
+ return getMockPlaylists(artist);
+ },
+});