Hunter outreach
diff --git a/components/preview/SocialPreview/SocialPreview.tsx b/components/preview/SocialPreview/SocialPreview.tsx
new file mode 100644
index 0000000..b29f9a9
--- /dev/null
+++ b/components/preview/SocialPreview/SocialPreview.tsx
@@ -0,0 +1,39 @@
+import type { PreviewProps } from '@/lib/preview/preview.types'
+import type { SocialContent } from '@/lib/types'
+
+export function SocialPreview({ content, productName }: PreviewProps) {
+ const social = content as SocialContent
+ const handle = productName.trim().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 15) || 'maker'
+
+ return (
+
+
+ {social.thread.tweets.map((tweet, i) => (
+
+
+ {handle.charAt(0).toUpperCase()}
+
+
+
+ {productName}
+ @{handle}
+ {i === 0 && thread}
+
+
{tweet}
+
+ 💬🔁♥↗
+
+
+
+ ))}
+
+ {social.postingTips.hashtags.length > 0 && (
+
+ {social.postingTips.hashtags.map((h) => (
+ {h}
+ ))}
+
+ )}
+
+ )
+}
diff --git a/components/preview/SocialPreview/index.ts b/components/preview/SocialPreview/index.ts
new file mode 100644
index 0000000..de79e36
--- /dev/null
+++ b/components/preview/SocialPreview/index.ts
@@ -0,0 +1 @@
+export { SocialPreview } from './SocialPreview'
diff --git a/lib/export/plan-to-markdown.test.ts b/lib/export/plan-to-markdown.test.ts
new file mode 100644
index 0000000..fe637ea
--- /dev/null
+++ b/lib/export/plan-to-markdown.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest'
+import { planToMarkdown } from './plan-to-markdown'
+import type { LaunchCore, LaunchPlan } from '@/lib/types'
+
+const core: LaunchCore = {
+ productName: 'Acme', essence: 'Ship faster', audience: 'developers', problem: 'Shipping is slow', features: ['Fast builds'], differentiators: ['Zero config'],
+}
+
+const plan: LaunchPlan = {
+ phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define ICP'] }],
+ countdown30: ['Build the landing page'],
+ countdown7: ['Confirm the hunter'],
+ countdown48h: ['Final QA'],
+ seoGeo: ['Configure IndexNow'],
+ momentum: ['Post a recap'],
+}
+
+describe('planToMarkdown', () => {
+ it('renders the timeline, countdowns, SEO/GEO and momentum', () => {
+ const md = planToMarkdown(core, plan)
+ expect(md).toContain('# Acme — Launch Plan')
+ expect(md).toContain('### L-6 weeks — Lock strategy')
+ expect(md).toContain('## 30-Day Countdown')
+ expect(md).toContain('## 7-Day Countdown')
+ expect(md).toContain('## Final 48 Hours')
+ expect(md).toContain('## SEO / GEO')
+ expect(md).toContain('## Post-Launch Momentum')
+ })
+})
diff --git a/lib/export/plan-to-markdown.ts b/lib/export/plan-to-markdown.ts
new file mode 100644
index 0000000..a0cde3d
--- /dev/null
+++ b/lib/export/plan-to-markdown.ts
@@ -0,0 +1,29 @@
+import type { LaunchCore, LaunchPlan } from '@/lib/types'
+
+const bullets = (items: string[]) => items.map((i) => `- ${i}`).join('\n')
+
+// Renders the cross-platform launch plan as Markdown for copy/export.
+export function planToMarkdown(core: LaunchCore, plan: LaunchPlan): string {
+ return `# ${core.productName} — Launch Plan
+
+## Timeline (L-6 weeks → L+4 weeks)
+${plan.phases
+ .map((p) => `### ${p.window} — ${p.goal}\n${bullets(p.tasks)}`)
+ .join('\n\n')}
+
+## 30-Day Countdown
+${bullets(plan.countdown30)}
+
+## 7-Day Countdown
+${bullets(plan.countdown7)}
+
+## Final 48 Hours
+${bullets(plan.countdown48h)}
+
+## SEO / GEO
+${bullets(plan.seoGeo)}
+
+## Post-Launch Momentum
+${bullets(plan.momentum)}
+`
+}
diff --git a/lib/export/to-markdown.test.ts b/lib/export/to-markdown.test.ts
index 86aabb2..27e66ad 100644
--- a/lib/export/to-markdown.test.ts
+++ b/lib/export/to-markdown.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { platformToMarkdown } from './to-markdown'
-import type { LaunchCore, ProductHuntContent, HackerNewsContent, RedditContent, AppSumoContent } from '@/lib/types'
+import type { LaunchCore, ProductHuntContent, HackerNewsContent, RedditContent, AppSumoContent, SocialContent } from '@/lib/types'
const core: LaunchCore = {
productName: 'Acme', essence: 'Ship faster', audience: 'developers', problem: 'Shipping is slow', features: ['Fast builds'], differentiators: ['Zero config'],
@@ -12,21 +12,28 @@ const ph: ProductHuntContent = {
firstComment: 'Hey hunters!',
gallery: { shots: [{ title: 'Hero', purpose: 'wow', caption: 'cap', layoutHint: 'full-bleed' }] },
video: { hook: 'Tired?', scenes: [{ timeRange: '0-3s', visual: 'logo', onScreenText: 'Acme' }], lengthSec: 30, cta: 'Try it' },
- launch: { recommendedDay: 'Tuesday', recommendedTimePT: '12:01 AM PT', prelaunchChecklist: ['a'], launchDayChecklist: ['b'], outreach: { hunter: 'hi', supporters: 'yo' } },
+ launch: {
+ recommendedDay: 'Tuesday', recommendedTimePT: '12:01 AM PT', prelaunchChecklist: ['a'], launchDayChecklist: ['b'],
+ hourByHour: [{ timePT: '12:01 AM PT', action: 'Go live' }],
+ momentumTactics: ['Ask for honest comments'],
+ commentModeration: ['Reply fast'],
+ outreach: { hunter: 'hi', supporters: 'yo' },
+ },
}
const hn: HackerNewsContent = {
title: 'Show HN: Acme – ship faster',
postBody: 'I built Acme to make builds fast.',
firstComment: 'Happy to answer questions.',
- postingTips: { bestTimeET: '8:00 AM ET', avoid: ['hype'] },
+ postingTips: { bestTimeET: '8:00 AM ET', avoid: ['hype'], etiquette: ['Answer critique directly'] },
}
const reddit: RedditContent = {
- subreddits: [{ name: 'r/SideProject', why: 'makers share early projects' }],
+ subreddits: [{ name: 'r/SideProject', why: 'makers share early projects', rulesNote: 'Lead with value' }],
title: 'I built Acme',
body: 'Story here.',
replyEtiquette: ['Reply to everyone'],
+ postingTiming: 'Weekday mornings ET',
}
const appSumo: AppSumoContent = {
@@ -37,6 +44,13 @@ const appSumo: AppSumoContent = {
faq: [{ q: 'Lifetime?', a: 'Yes.' }],
}
+const social: SocialContent = {
+ thread: { tweets: ['Devs: shipping is slow.', 'I built Acme.'] },
+ kolOutreach: { twitter: 'Hey {name}', linkedin: 'Hi {name}', telegram: 'Hi {name}!' },
+ ugcAsk: 'Share how you use Acme?',
+ postingTips: { bestTimeET: '9 AM ET', hashtags: ['#buildinpublic'] },
+}
+
describe('platformToMarkdown', () => {
it('renders Product Hunt content with gallery and video', () => {
const md = platformToMarkdown('product-hunt', core, ph)
@@ -45,6 +59,9 @@ describe('platformToMarkdown', () => {
expect(md).toContain('## Gallery')
expect(md).toContain('## Demo Video')
expect(md).toContain('Tuesday')
+ expect(md).toContain('### Hour-by-hour (PT)')
+ expect(md).toContain('### Upvote momentum')
+ expect(md).toContain('### Comment moderation')
})
it('renders Hacker News content without a gallery section', () => {
@@ -52,12 +69,15 @@ describe('platformToMarkdown', () => {
expect(md).toContain('Show HN: Acme – ship faster')
expect(md).toContain('I built Acme to make builds fast.')
expect(md).not.toContain('## Gallery')
+ expect(md).toContain('Etiquette:')
})
it('renders Reddit content with subreddits', () => {
const md = platformToMarkdown('reddit', core, reddit)
expect(md).toContain('r/SideProject')
expect(md).toContain('Story here.')
+ expect(md).toContain('Lead with value')
+ expect(md).toContain('Best timing:')
})
it('renders AppSumo content with the deal headline', () => {
@@ -65,4 +85,12 @@ describe('platformToMarkdown', () => {
expect(md).toContain('Lifetime access to Acme')
expect(md).toContain('Pay once, own forever.')
})
+
+ it('renders Social content with thread, outreach, and UGC ask', () => {
+ const md = platformToMarkdown('social', core, social)
+ expect(md).toContain('## X Launch Thread')
+ expect(md).toContain('## KOL Outreach')
+ expect(md).toContain('## UGC Ask')
+ expect(md).toContain('#buildinpublic')
+ })
})
diff --git a/lib/export/to-markdown.ts b/lib/export/to-markdown.ts
index afb98ce..e508fc0 100644
--- a/lib/export/to-markdown.ts
+++ b/lib/export/to-markdown.ts
@@ -5,6 +5,7 @@ import type {
PlatformContent,
ProductHuntContent,
RedditContent,
+ SocialContent,
} from '@/lib/types'
import type { PlatformId } from '@/lib/platforms'
@@ -46,7 +47,7 @@ ${bullets(launch.prelaunchChecklist)}
### Launch day
${bullets(launch.launchDayChecklist)}
-
+${launch.hourByHour?.length ? `\n### Hour-by-hour (PT)\n${launch.hourByHour.map((s) => `- \`${s.timePT}\` — ${s.action}`).join('\n')}\n` : ''}${launch.momentumTactics?.length ? `\n### Upvote momentum\n${bullets(launch.momentumTactics)}\n` : ''}${launch.commentModeration?.length ? `\n### Comment moderation\n${bullets(launch.commentModeration)}\n` : ''}
### Outreach — Hunter
${launch.outreach.hunter}
@@ -72,15 +73,15 @@ ${hn.firstComment}
Avoid:
${bullets(hn.postingTips.avoid)}
-`
+${hn.postingTips.etiquette?.length ? `\nEtiquette:\n${bullets(hn.postingTips.etiquette)}\n` : ''}`
}
function redditMarkdown(core: LaunchCore, reddit: RedditContent): string {
return `# ${core.productName} — Reddit Launch Post
## Subreddits
-${reddit.subreddits.map((s) => `- **${s.name}** — ${s.why}`).join('\n')}
-
+${reddit.subreddits.map((s) => `- **${s.name}** — ${s.why}${s.rulesNote ? `\n - ⚑ ${s.rulesNote}` : ''}`).join('\n')}
+${reddit.postingTiming ? `\n**Best timing:** ${reddit.postingTiming}\n` : ''}
## Title
${reddit.title}
@@ -112,6 +113,32 @@ ${deal.faq.map((f) => `**${f.q}**\n${f.a}`).join('\n\n')}
`
}
+function socialMarkdown(core: LaunchCore, social: SocialContent): string {
+ return `# ${core.productName} — Social / KOL Launch Kit
+
+## X Launch Thread
+${social.thread.tweets.map((t, i) => `${i + 1}. ${t}`).join('\n\n')}
+
+## KOL Outreach
+**X / Twitter:**
+${social.kolOutreach.twitter}
+
+**LinkedIn:**
+${social.kolOutreach.linkedin}
+
+**Telegram:**
+${social.kolOutreach.telegram}
+
+## UGC Ask
+${social.ugcAsk}
+
+## Posting Tips
+**Best time:** ${social.postingTips.bestTimeET}
+
+Hashtags: ${social.postingTips.hashtags.join(' ')}
+`
+}
+
export function platformToMarkdown(platform: PlatformId, core: LaunchCore, content: PlatformContent): string {
switch (platform) {
case 'product-hunt':
@@ -122,5 +149,7 @@ export function platformToMarkdown(platform: PlatformId, core: LaunchCore, conte
return redditMarkdown(core, content as RedditContent)
case 'appsumo':
return appSumoMarkdown(core, content as AppSumoContent)
+ case 'social':
+ return socialMarkdown(core, content as SocialContent)
}
}
diff --git a/lib/generation/core/core.prompt.ts b/lib/generation/core/core.prompt.ts
index 1344274..98517b5 100644
--- a/lib/generation/core/core.prompt.ts
+++ b/lib/generation/core/core.prompt.ts
@@ -1,11 +1,14 @@
import type { GenerateInput } from '@/lib/types'
-import { renderFacts } from '../prompt-shared'
+import { PLAYBOOK_PRINCIPLES, renderFacts } from '../prompt-shared'
export const CORE_SYSTEM_PROMPT = [
+ PLAYBOOK_PRINCIPLES,
+ '',
'You are a product positioning strategist.',
'You distill a product into its platform-neutral essence so launch copy can later be written for any audience.',
+ 'The essence and value proposition you extract must be defensible and benefit-led — the foundation every channel amplifies.',
'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
-].join(' ')
+].join('\n')
const CORE_SHAPE = `{
"productName": "the product's name",
@@ -13,7 +16,10 @@ const CORE_SHAPE = `{
"audience": "who it is for",
"problem": "the concrete problem it solves",
"features": ["3-6 concrete capabilities"],
- "differentiators": ["2-4 reasons it is different or better"]
+ "differentiators": ["2-4 reasons it is different or better"],
+ "valueProp": "the single-sentence promise to the user that every channel will amplify",
+ "icp": "the ideal customer profile — sharper and more specific than the broad audience",
+ "keywords": ["5-8 search/topic keywords for SEO, Product Hunt topics and community targeting"]
}`
export function buildCorePrompt(input: GenerateInput): string {
diff --git a/lib/generation/core/core.schema.ts b/lib/generation/core/core.schema.ts
index 4177623..134a3a7 100644
--- a/lib/generation/core/core.schema.ts
+++ b/lib/generation/core/core.schema.ts
@@ -1,6 +1,8 @@
import { z } from 'zod'
// The shared, platform-agnostic essence every platform's content is written from.
+// keywords/icp/valueProp are optional so cores persisted before this field existed
+// still validate; new generations always populate them.
export const launchCoreSchema = z.object({
productName: z.string(),
essence: z.string(),
@@ -8,6 +10,9 @@ export const launchCoreSchema = z.object({
problem: z.string(),
features: z.array(z.string()),
differentiators: z.array(z.string()),
+ keywords: z.array(z.string()).optional(),
+ icp: z.string().optional(),
+ valueProp: z.string().optional(),
})
export type LaunchCoreSchema = z.infer
diff --git a/lib/generation/mock-provider.test.ts b/lib/generation/mock-provider.test.ts
index c005e21..11a901f 100644
--- a/lib/generation/mock-provider.test.ts
+++ b/lib/generation/mock-provider.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { MockProvider } from './mock-provider'
import { launchCoreSchema } from './core/core.schema'
+import { launchPlanSchema } from './plan/plan.schema'
import { PLATFORM_GENERATORS } from './platforms/registry'
import type { GenerateInput } from '@/lib/types'
import type { PlatformId } from '@/lib/platforms'
@@ -14,7 +15,7 @@ const input: GenerateInput = {
refinements: { tone: 'bold', audience: 'developers' },
}
-const PLATFORMS: PlatformId[] = ['product-hunt', 'appsumo', 'hacker-news', 'reddit']
+const PLATFORMS: PlatformId[] = ['product-hunt', 'appsumo', 'hacker-news', 'reddit', 'social']
describe('MockProvider', () => {
it('produces a schema-valid core that references the product', async () => {
@@ -37,4 +38,13 @@ describe('MockProvider', () => {
const content = await provider.generatePlatform('product-hunt', core, input)
expect((content as { copy: { tagline: string } }).copy.tagline.length).toBeLessThanOrEqual(60)
})
+
+ it('produces a schema-valid launch plan spanning the timeline', async () => {
+ const provider = new MockProvider()
+ const core = await provider.generateCore(input)
+ const plan = await provider.generatePlan(core, input)
+ expect(() => launchPlanSchema.parse(plan)).not.toThrow()
+ expect(plan.phases.length).toBeGreaterThan(0)
+ expect(plan.seoGeo.length).toBeGreaterThan(0)
+ })
})
diff --git a/lib/generation/mock-provider.ts b/lib/generation/mock-provider.ts
index aaf7877..fe26b78 100644
--- a/lib/generation/mock-provider.ts
+++ b/lib/generation/mock-provider.ts
@@ -3,9 +3,11 @@ import type {
GenerateInput,
HackerNewsContent,
LaunchCore,
+ LaunchPlan,
PlatformContent,
ProductHuntContent,
RedditContent,
+ SocialContent,
} from '@/lib/types'
import type { PlatformId } from '@/lib/platforms'
import type { GenerationProvider } from './provider.types'
@@ -16,13 +18,20 @@ function clamp(s: string, n: number): string {
function buildCore({ context, refinements }: GenerateInput): LaunchCore {
const audience = refinements.audience || context.audience || 'builders'
+ const essence = context.oneLiner || context.summary || `${context.name} gets the job done`
+ const keywords = Array.from(
+ new Set([...context.techStack, ...context.features.map((f) => f.toLowerCase())]),
+ ).slice(0, 8)
return {
productName: context.name,
- essence: context.oneLiner || context.summary || `${context.name} gets the job done`,
+ essence,
audience,
problem: context.problem || `${audience} waste time on a slow, manual process`,
features: context.features.length ? context.features : ['Does the core job well', 'Simple to start', 'Built for speed'],
differentiators: context.differentiators.length ? context.differentiators : ['Faster than the manual way', 'No setup required'],
+ valueProp: essence,
+ icp: refinements.audience || context.audience || `${audience} who want results without the busywork`,
+ keywords: keywords.length ? keywords : [context.name.toLowerCase(), 'productivity', 'tool'],
}
}
@@ -77,6 +86,28 @@ function buildProductHunt(core: LaunchCore): ProductHuntContent {
'Share to your channels (X, LinkedIn, Slack/Discord)',
'Post a mid-day update with traction',
],
+ hourByHour: [
+ { timePT: '12:01 AM PT', action: 'Go live and pin your first comment' },
+ { timePT: '12:05–2:00 AM PT', action: 'Message your first cohort of genuine supporters to take a look' },
+ { timePT: '6:00–9:00 AM PT', action: 'US wakes up — reply to every comment within minutes' },
+ { timePT: '9:00 AM–12:00 PM PT', action: 'Share to X, LinkedIn and your communities with the live link' },
+ { timePT: '12:00–2:00 PM PT', action: 'Post a mid-day traction update to re-engage early supporters' },
+ { timePT: '2:00–6:00 PM PT', action: 'Keep answering comments; thank everyone who upvoted or shared' },
+ { timePT: '6:00–9:00 PM PT', action: 'Final push to fence-sitters; ask for honest comments, not just upvotes' },
+ { timePT: '9:00–11:59 PM PT', action: 'Last-call message and a thank-you to the community' },
+ ],
+ momentumTactics: [
+ 'Stagger outreach to real supporters across the day so engagement stays steady, not front-loaded',
+ 'Ask for an honest comment plus an upvote — comments weigh on ranking and credibility',
+ 'Reply fast and personally; an active maker thread pulls more visitors in',
+ 'Never buy or trade votes — Product Hunt detects it and it tanks ranking',
+ ],
+ commentModeration: [
+ 'Triage every comment within minutes for the first 6 hours',
+ 'Answer hard questions directly and publicly — defensiveness reads worse than a flaw',
+ 'Thank supporters by name; turn praise into a follow-up question to keep the thread alive',
+ 'Pin the most useful exchange so newcomers see the product in use',
+ ],
outreach: {
hunter: `Hi {name}, I'm launching ${name} (${benefit}) on Product Hunt and admire your eye for ${audience} tools. Would you be open to hunting it? Assets ready to go.`,
supporters: `Hey! ${name} is live on Product Hunt today 🚀 If it looks useful, an upvote and an honest comment would mean a lot: {link}`,
@@ -100,6 +131,12 @@ function buildHackerNews(core: LaunchCore): HackerNewsContent {
postingTips: {
bestTimeET: '8:00–10:00 AM ET, Tuesday–Thursday',
avoid: ['Marketing language or superlatives', 'Emoji and exclamation marks', 'Vague claims without specifics', 'Asking directly for upvotes'],
+ etiquette: [
+ 'Answer technical critique directly and without defensiveness',
+ 'Be candid about trade-offs and what is still rough',
+ 'Reply in the first person as the maker, with specifics',
+ 'Let the work speak — never ask for upvotes',
+ ],
},
}
}
@@ -108,9 +145,9 @@ function buildReddit(core: LaunchCore): RedditContent {
const { productName: name, audience, essence, problem, features: feat } = core
return {
subreddits: [
- { name: 'r/SideProject', why: 'makers share and get feedback on early projects' },
- { name: 'r/startups', why: 'founders discuss building and launching' },
- { name: 'r/Entrepreneur', why: 'a broad audience open to useful tools, if you lead with value' },
+ { name: 'r/SideProject', why: 'makers share and get feedback on early projects', rulesNote: 'Sharing is welcome; still lead with what you learned, not a pitch' },
+ { name: 'r/startups', why: 'founders discuss building and launching', rulesNote: 'Self-promo is restricted to specific threads — check the rules before posting' },
+ { name: 'r/Entrepreneur', why: 'a broad audience open to useful tools, if you lead with value', rulesNote: 'Heavy self-promo filter; comment and contribute before linking' },
],
title: `I built ${name} to help ${audience} — ${essence}`,
body:
@@ -123,6 +160,7 @@ function buildReddit(core: LaunchCore): RedditContent {
"Don't drop links until asked, or only where the sub's rules allow",
'Give value in comments before promoting',
],
+ postingTiming: 'Weekday mornings (8–10 AM ET) when these subs are most active; avoid weekends',
}
}
@@ -143,6 +181,79 @@ function buildAppSumo(core: LaunchCore): AppSumoContent {
}
}
+function buildSocial(core: LaunchCore): SocialContent {
+ const { productName: name, audience, essence, problem, features: feat, keywords } = core
+ const tags = (keywords?.length ? keywords : ['buildinpublic', 'indiehackers']).slice(0, 3).map((k) => `#${k.replace(/[^a-zA-Z0-9]/g, '')}`)
+ return {
+ thread: {
+ tweets: [
+ `${audience}: ${problem} I got tired of it, so I built ${name}. 🧵`,
+ `${name} ${essence.charAt(0).toLowerCase()}${essence.slice(1)}`,
+ `What it does:\n${feat.slice(0, 3).map((f) => `→ ${f}`).join('\n')}`,
+ `I built it because the existing tools didn't fit how ${audience} actually work.`,
+ `It's live today and free to try. If you've felt this pain, I'd love your honest take. 👇`,
+ ],
+ },
+ kolOutreach: {
+ twitter: `Hey {name} — love how you cover tools for ${audience}. I just launched ${name} (${essence}). Thought it might resonate with your audience — happy to send free access if you want to take a look, no strings.`,
+ linkedin: `Hi {name}, I follow your posts on ${audience} workflows. I just shipped ${name} — ${essence}. Would you be open to a quick look? I'd value your perspective and can share full access.`,
+ telegram: `Hi {name}! Big fan of your community for ${audience}. I just launched ${name} (${essence}). Would it be okay to share it with the group, or get your take first?`,
+ },
+ ugcAsk: `Hey! Thanks for being an early ${name} user. If it's saved you time, would you be up for a short post or 30-sec clip on how you use it? Happy to feature you and send some perks — real stories from ${audience} help far more than any ad.`,
+ postingTips: {
+ bestTimeET: '9:00–11:00 AM ET, Tuesday–Thursday',
+ hashtags: tags,
+ },
+ }
+}
+
+function buildPlan(core: LaunchCore): LaunchPlan {
+ const { productName: name, audience, keywords } = core
+ const kw = (keywords?.length ? keywords : [name.toLowerCase()]).slice(0, 3).join(', ')
+ return {
+ phases: [
+ { window: 'L-6 weeks', goal: 'Lock strategy', tasks: [`Define the ICP and value prop for ${audience}`, `Pick target keywords (${kw}) and a launch goal`, 'Set the budget for the few high-value channels'] },
+ { window: 'L-5 to L-4 weeks', goal: 'Build assets', tasks: ['Polish the landing page and demo video', `Write the core story for ${name}`, 'Prepare gallery shots and the feature clip'] },
+ { window: 'L-4 to L-3 weeks', goal: 'Lock partnerships', tasks: ['Shortlist and reach out to relevant KOLs', 'Recruit a handful of early UGC creators', 'Line up a credible Product Hunt hunter'] },
+ { window: 'L-3 to L-2 weeks', goal: 'Prepare content', tasks: ['Draft the PH, HN, Reddit and X copy', 'Brief KOLs with a content pack', 'Map which subreddits and communities to seed'] },
+ { window: 'L-2 to L-1 weeks', goal: 'Final confirmation', tasks: ['Confirm all drafts and the hunter', 'Lock the launch date and time', 'Warm up your network'] },
+ { window: 'Launch week (Day 1-5)', goal: 'Execute', tasks: ['Run the Product Hunt day plan', 'Publish the X thread and trigger KOL posts', 'Seed Reddit/HN where you are a real member'] },
+ { window: 'L+1 to L+4 weeks', goal: 'Build momentum', tasks: ['Convert early users into UGC', 'Capture backlinks and press', 'Publish follow-up content on traction'] },
+ ],
+ countdown30: [
+ 'Finalize positioning and the launch goal',
+ 'Build the landing page and demo video',
+ 'Start KOL and hunter outreach',
+ `Set up analytics and configure IndexNow for ${kw}`,
+ ],
+ countdown7: [
+ 'Confirm hunter and KOL commitments',
+ 'Schedule the launch-day comms',
+ 'Pre-write first comments and the X thread',
+ 'Warm up your network with a teaser',
+ ],
+ countdown48h: [
+ 'Final QA of the product and signup flow',
+ 'Pin assets and double-check links',
+ 'Brief supporters on timing (12:01 AM PT)',
+ 'Rest — launch day is long',
+ ],
+ seoGeo: [
+ `Publish a launch blog post targeting "${kw}"`,
+ 'Configure IndexNow so new pages hit Bing/AI search in seconds',
+ 'Add Product/SoftwareApplication schema markup to the site',
+ 'Capture backlinks from the launch (PH, directories, press)',
+ 'Structure pages with clear Q&A so AI search engines cite you',
+ ],
+ momentum: [
+ 'Day 1-7: thank everyone, reply to all feedback, ship a quick win',
+ 'Day 7: post a "what we learned" recap with metrics',
+ 'Day 14: publish UGC and case studies from early users',
+ 'Day 30: roundup post and outreach to newsletters/press',
+ ],
+ }
+}
+
export class MockProvider implements GenerationProvider {
async generateCore(input: GenerateInput): Promise {
return buildCore(input)
@@ -159,6 +270,13 @@ export class MockProvider implements GenerationProvider {
return buildReddit(core)
case 'appsumo':
return buildAppSumo(core)
+ case 'social':
+ return buildSocial(core)
}
}
+
+ async generatePlan(core: LaunchCore, _input?: GenerateInput): Promise {
+ void _input
+ return buildPlan(core)
+ }
}
diff --git a/lib/generation/openrouter-provider.ts b/lib/generation/openrouter-provider.ts
index 0d04455..6ad135f 100644
--- a/lib/generation/openrouter-provider.ts
+++ b/lib/generation/openrouter-provider.ts
@@ -1,9 +1,11 @@
import type { z } from 'zod'
-import type { GenerateInput, LaunchCore, PlatformContent } from '@/lib/types'
+import type { GenerateInput, LaunchCore, LaunchPlan, PlatformContent } from '@/lib/types'
import type { PlatformId } from '@/lib/platforms'
import type { GenerationProvider } from './provider.types'
import { launchCoreSchema } from './core/core.schema'
import { buildCorePrompt, CORE_SYSTEM_PROMPT } from './core/core.prompt'
+import { launchPlanSchema } from './plan/plan.schema'
+import { buildPlanPrompt, PLAN_SYSTEM } from './plan/plan.prompt'
import { PLATFORM_GENERATORS } from './platforms/registry'
import { extractJsonObject } from './json'
import { DEFAULT_MODEL } from './model'
@@ -26,6 +28,10 @@ export class OpenRouterProvider implements GenerationProvider {
return this.call(gen.system, gen.buildPrompt(core, input), gen.schema)
}
+ async generatePlan(core: LaunchCore, input: GenerateInput): Promise {
+ return this.call(PLAN_SYSTEM, buildPlanPrompt(core, input), launchPlanSchema)
+ }
+
private async call(system: string, user: string, schema: z.ZodType): Promise {
if (!this.apiKey) throw new Error('OPENROUTER_API_KEY is not set.')
diff --git a/lib/generation/plan/plan.prompt.ts b/lib/generation/plan/plan.prompt.ts
new file mode 100644
index 0000000..2d39786
--- /dev/null
+++ b/lib/generation/plan/plan.prompt.ts
@@ -0,0 +1,38 @@
+import type { GenerateInput, LaunchCore } from '@/lib/types'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../prompt-shared'
+
+export const PLAN_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
+ 'You are a launch operator who plans multi-channel product launches end to end.',
+ 'You sequence a launch over ~10 weeks (L-6 weeks to L+4 weeks) so channels amplify each other instead of cannibalizing: strategy, then assets, then partnerships, then content, then launch week, then momentum.',
+ 'Every task must be concrete and specific to THIS product, its audience, and its keywords — no generic filler.',
+ 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
+].join('\n')
+
+const SHAPE = `{
+ "phases": [
+ { "window": "e.g. L-6 weeks", "goal": "the milestone for this window", "tasks": ["2-4 concrete tasks for this product"] }
+ ],
+ "countdown30": ["4-6 tasks for the 30-day pre-launch countdown"],
+ "countdown7": ["4-6 tasks for the 7-day countdown"],
+ "countdown48h": ["4-6 tasks for the final 48 hours"],
+ "seoGeo": ["4-6 SEO/GEO tasks using this product's keywords: IndexNow, schema markup, a launch blog post, backlink capture, and optimizing to be cited by AI search"],
+ "momentum": ["4-6 post-launch tasks across the 7-day and 30-day follow-up cadence"]
+}`
+
+export function buildPlanPrompt(core: LaunchCore, input: GenerateInput): string {
+ return [
+ 'Write a complete, product-specific launch plan covering the full timeline and the pre-launch countdowns.',
+ '',
+ renderCore(core),
+ renderRefinements(input),
+ '',
+ 'Return ONLY a JSON object that matches this exact shape (same keys, no extras):',
+ SHAPE,
+ '',
+ 'Cover the phases from L-6 weeks through L+4 weeks. Make SEO/GEO tasks reference the keywords above. Keep every task actionable.',
+ ]
+ .filter(Boolean)
+ .join('\n')
+}
diff --git a/lib/generation/plan/plan.schema.test.ts b/lib/generation/plan/plan.schema.test.ts
new file mode 100644
index 0000000..46bfed6
--- /dev/null
+++ b/lib/generation/plan/plan.schema.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest'
+import { launchPlanSchema } from './plan.schema'
+
+const plan = {
+ phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define ICP'] }],
+ countdown30: ['Build the landing page'],
+ countdown7: ['Confirm the hunter'],
+ countdown48h: ['Final QA'],
+ seoGeo: ['Configure IndexNow'],
+ momentum: ['Post a recap'],
+}
+
+describe('launchPlanSchema', () => {
+ it('accepts a valid plan', () => {
+ expect(launchPlanSchema.parse(plan)).toEqual(plan)
+ })
+ it('rejects a plan missing phases', () => {
+ expect(() => launchPlanSchema.parse({ ...plan, phases: undefined })).toThrow()
+ })
+ it('rejects a phase missing its tasks', () => {
+ expect(() => launchPlanSchema.parse({ ...plan, phases: [{ window: 'L-6 weeks', goal: 'x' }] })).toThrow()
+ })
+})
diff --git a/lib/generation/plan/plan.schema.ts b/lib/generation/plan/plan.schema.ts
new file mode 100644
index 0000000..5d3702d
--- /dev/null
+++ b/lib/generation/plan/plan.schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+
+// A cross-platform, product-tailored launch plan: the L-6w→L+4w timeline, the
+// 30/7/48h countdowns, an SEO/GEO checklist, and the post-launch momentum cadence.
+export const launchPlanSchema = z.object({
+ phases: z.array(z.object({ window: z.string(), goal: z.string(), tasks: z.array(z.string()) })),
+ countdown30: z.array(z.string()),
+ countdown7: z.array(z.string()),
+ countdown48h: z.array(z.string()),
+ seoGeo: z.array(z.string()),
+ momentum: z.array(z.string()),
+})
diff --git a/lib/generation/platforms/appsumo/prompt.ts b/lib/generation/platforms/appsumo/prompt.ts
index e835b89..c1990d5 100644
--- a/lib/generation/platforms/appsumo/prompt.ts
+++ b/lib/generation/platforms/appsumo/prompt.ts
@@ -1,11 +1,13 @@
import type { GenerateInput, LaunchCore } from '@/lib/types'
-import { renderCore, renderRefinements } from '../../prompt-shared'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared'
export const APPSUMO_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
'You are a lifetime-deal copywriter for AppSumo, writing for a value-hungry marketplace audience.',
- 'Emphasize buying once and owning forever, ROI, who it is for, and refund confidence. Concrete benefits over hype.',
+ 'Emphasize buying once and owning forever, concrete ROI, who it is for, and refund confidence. Real user value over hype.',
'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
-].join(' ')
+].join('\n')
const SHAPE = `{
"dealHeadline": "the lifetime-deal headline",
diff --git a/lib/generation/platforms/hacker-news/prompt.ts b/lib/generation/platforms/hacker-news/prompt.ts
index 5b9d2eb..ed062c9 100644
--- a/lib/generation/platforms/hacker-news/prompt.ts
+++ b/lib/generation/platforms/hacker-news/prompt.ts
@@ -1,12 +1,15 @@
import type { GenerateInput, LaunchCore } from '@/lib/types'
-import { renderCore, renderRefinements } from '../../prompt-shared'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared'
export const HACKER_NEWS_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
'You are a seasoned Hacker News poster writing a Show HN.',
'HN despises marketing language, hype, superlatives, and emoji. Be technical, humble, specific, and honest about trade-offs.',
'Speak in the first person as the maker. No exclamation marks, no buzzwords.',
+ 'Engage authentically: answer technical critique head-on and treat tough questions as the point, not a threat.',
'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
-].join(' ')
+].join('\n')
const SHAPE = `{
"title": "a 'Show HN:' title — honest and specific, no hype, ideally <80 chars",
@@ -14,7 +17,8 @@ const SHAPE = `{
"firstComment": "your first comment: extra technical context + one specific question inviting feedback",
"postingTips": {
"bestTimeET": "best day and time to post, in US Eastern Time",
- "avoid": ["3-4 things to avoid when posting this on HN"]
+ "avoid": ["3-4 things to avoid when posting this on HN"],
+ "etiquette": ["3-4 rules for engaging authentically: answer critique directly, stay humble, no vote-asking, reply technically"]
}
}`
diff --git a/lib/generation/platforms/hacker-news/schema.ts b/lib/generation/platforms/hacker-news/schema.ts
index 4296f9e..0ec94a8 100644
--- a/lib/generation/platforms/hacker-news/schema.ts
+++ b/lib/generation/platforms/hacker-news/schema.ts
@@ -5,5 +5,10 @@ export const hackerNewsSchema = z.object({
title: z.string(),
postBody: z.string(),
firstComment: z.string(),
- postingTips: z.object({ bestTimeET: z.string(), avoid: z.array(z.string()) }),
+ postingTips: z.object({
+ bestTimeET: z.string(),
+ avoid: z.array(z.string()),
+ // Optional for back-compat with content persisted before this field existed.
+ etiquette: z.array(z.string()).optional(),
+ }),
})
diff --git a/lib/generation/platforms/product-hunt/prompt.ts b/lib/generation/platforms/product-hunt/prompt.ts
index 963554c..286750b 100644
--- a/lib/generation/platforms/product-hunt/prompt.ts
+++ b/lib/generation/platforms/product-hunt/prompt.ts
@@ -1,11 +1,14 @@
import type { GenerateInput, LaunchCore } from '@/lib/types'
-import { renderCore, renderRefinements } from '../../prompt-shared'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared'
export const PRODUCT_HUNT_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
'You are an expert Product Hunt launch strategist and copywriter.',
'Product Hunt rewards punchy, specific, maker-energy copy with a strong visual story.',
+ 'A top-5 finish comes from concentrated, genuine supporters posting at 12:01 AM PT and from comments that keep momentum — not from spread-thin, generic asks.',
'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
-].join(' ')
+].join('\n')
const SHAPE = `{
"copy": {
@@ -32,6 +35,9 @@ const SHAPE = `{
"recommendedTimePT": "e.g. 12:01 AM PT",
"prelaunchChecklist": ["4-5 concrete pre-launch tasks"],
"launchDayChecklist": ["4-5 concrete launch-day tasks"],
+ "hourByHour": [ { "timePT": "e.g. 12:01 AM PT", "action": "what to do in this window to build and hold momentum" } ],
+ "momentumTactics": ["3-4 upvote-momentum tactics: stagger genuine supporters, ask for an honest comment + upvote, never buy votes"],
+ "commentModeration": ["3-4 rules for triaging and replying to comments fast to sustain ranking"],
"outreach": {
"hunter": "a DM asking someone to hunt the product (use {name})",
"supporters": "a message rallying supporters (use {link})"
@@ -50,6 +56,7 @@ export function buildProductHuntPrompt(core: LaunchCore, input: GenerateInput):
SHAPE,
'',
'Provide 4 gallery shots and 4-5 video scenes. Keep the tagline at or under 60 characters.',
+ 'Provide a 6-8 step hour-by-hour launch-day timeline in Pacific Time, starting at 12:01 AM PT and covering the first-comment pin, an early reply blitz, a mid-day traction update, and a final push.',
]
.filter(Boolean)
.join('\n')
diff --git a/lib/generation/platforms/product-hunt/schema.ts b/lib/generation/platforms/product-hunt/schema.ts
index b44e0e5..5f7a692 100644
--- a/lib/generation/platforms/product-hunt/schema.ts
+++ b/lib/generation/platforms/product-hunt/schema.ts
@@ -20,6 +20,10 @@ export const productHuntSchema = z.object({
recommendedTimePT: z.string(),
prelaunchChecklist: z.array(z.string()),
launchDayChecklist: z.array(z.string()),
+ // Playbook additions — optional for back-compat with kits persisted before they existed.
+ hourByHour: z.array(z.object({ timePT: z.string(), action: z.string() })).optional(),
+ momentumTactics: z.array(z.string()).optional(),
+ commentModeration: z.array(z.string()).optional(),
outreach: z.object({ hunter: z.string(), supporters: z.string() }),
}),
})
diff --git a/lib/generation/platforms/reddit/prompt.ts b/lib/generation/platforms/reddit/prompt.ts
index 408221c..4fb3d08 100644
--- a/lib/generation/platforms/reddit/prompt.ts
+++ b/lib/generation/platforms/reddit/prompt.ts
@@ -1,17 +1,21 @@
import type { GenerateInput, LaunchCore } from '@/lib/types'
-import { renderCore, renderRefinements } from '../../prompt-shared'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared'
export const REDDIT_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
'You are a Reddit-native maker who knows each subreddit has its own culture and self-promotion rules.',
'Redditors punish salesy, promotional posts. Be transparent that you are the maker, lead with value or a story, and never sound like an ad.',
+ 'Concentrate on the one or two subreddits where you are a genuine member; depth of value beats breadth of posting.',
'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
-].join(' ')
+].join('\n')
const SHAPE = `{
- "subreddits": [ { "name": "r/...", "why": "why this subreddit fits and its self-promo norm" } ],
+ "subreddits": [ { "name": "r/...", "why": "why this subreddit fits and its self-promo norm", "rulesNote": "its specific rule to respect — self-promo ratio, required flair, or 'comment before you post'" } ],
"title": "a non-clickbait, value-first post title that respects subreddit norms",
"body": "the post body: tell a story or give value, be transparent you built it; \\n for line breaks",
- "replyEtiquette": ["4-5 rules for engaging the comments and respecting self-promo norms"]
+ "replyEtiquette": ["4-5 rules for engaging the comments and respecting self-promo norms"],
+ "postingTiming": "the best day/time to post given these subreddits' activity and norms"
}`
export function buildRedditPrompt(core: LaunchCore, input: GenerateInput): string {
diff --git a/lib/generation/platforms/reddit/schema.ts b/lib/generation/platforms/reddit/schema.ts
index 4910285..bd81efd 100644
--- a/lib/generation/platforms/reddit/schema.ts
+++ b/lib/generation/platforms/reddit/schema.ts
@@ -2,8 +2,10 @@ import { z } from 'zod'
// Reddit: which subreddits to post in, the post itself, and how to behave in the comments.
export const redditSchema = z.object({
- subreddits: z.array(z.object({ name: z.string(), why: z.string() })),
+ // rulesNote is optional for back-compat with content persisted before it existed.
+ subreddits: z.array(z.object({ name: z.string(), why: z.string(), rulesNote: z.string().optional() })),
title: z.string(),
body: z.string(),
replyEtiquette: z.array(z.string()),
+ postingTiming: z.string().optional(),
})
diff --git a/lib/generation/platforms/registry.ts b/lib/generation/platforms/registry.ts
index 66e36a0..b1a1450 100644
--- a/lib/generation/platforms/registry.ts
+++ b/lib/generation/platforms/registry.ts
@@ -10,6 +10,8 @@ import { redditSchema } from './reddit/schema'
import { buildRedditPrompt, REDDIT_SYSTEM } from './reddit/prompt'
import { appSumoSchema } from './appsumo/schema'
import { buildAppSumoPrompt, APPSUMO_SYSTEM } from './appsumo/prompt'
+import { socialSchema } from './social/schema'
+import { buildSocialPrompt, SOCIAL_SYSTEM } from './social/prompt'
// Everything needed to generate and validate one platform's content. Adding a
// platform is a single entry here plus its schema.ts + prompt.ts.
@@ -24,4 +26,5 @@ export const PLATFORM_GENERATORS: Record = {
appsumo: { schema: appSumoSchema, system: APPSUMO_SYSTEM, buildPrompt: buildAppSumoPrompt },
'hacker-news': { schema: hackerNewsSchema, system: HACKER_NEWS_SYSTEM, buildPrompt: buildHackerNewsPrompt },
reddit: { schema: redditSchema, system: REDDIT_SYSTEM, buildPrompt: buildRedditPrompt },
+ social: { schema: socialSchema, system: SOCIAL_SYSTEM, buildPrompt: buildSocialPrompt },
}
diff --git a/lib/generation/platforms/social/prompt.ts b/lib/generation/platforms/social/prompt.ts
new file mode 100644
index 0000000..0d0cade
--- /dev/null
+++ b/lib/generation/platforms/social/prompt.ts
@@ -0,0 +1,43 @@
+import type { GenerateInput, LaunchCore } from '@/lib/types'
+import { PLAYBOOK_PRINCIPLES, renderCore, renderRefinements } from '../../prompt-shared'
+
+export const SOCIAL_SYSTEM = [
+ PLAYBOOK_PRINCIPLES,
+ '',
+ 'You are a launch-savvy founder writing for X (Twitter) and running KOL/influencer outreach.',
+ 'Threads earn attention with a sharp hook, one idea per tweet, and a concrete payoff — not hype or thread-bait.',
+ 'Outreach DMs are short, specific, and personalized; they lead with why this matters to that creator and their audience, never a generic blast. A good cold DM earns roughly a 10-15% reply rate because it respects the reader.',
+ 'You ALWAYS reply with a single raw JSON object and nothing else — no markdown, no code fences, no commentary.',
+].join('\n')
+
+const SHAPE = `{
+ "thread": {
+ "tweets": ["5-8 tweets that form one launch thread; tweet 1 is the hook, the last is a clear CTA with the link; one idea per tweet, no hashtag spam"]
+ },
+ "kolOutreach": {
+ "twitter": "a short cold DM for an X creator (use {name}); lead with why it fits their audience",
+ "linkedin": "a short cold DM for a LinkedIn creator (use {name}); slightly more professional framing",
+ "telegram": "a short cold DM for a Telegram group owner or KOL (use {name}); direct and community-aware"
+ },
+ "ugcAsk": "a message inviting an early user to make a short post/video about the product (UGC), framed around what's in it for them",
+ "postingTips": {
+ "bestTimeET": "best day and time to post the thread, in US Eastern Time",
+ "hashtags": ["2-4 relevant, non-spammy hashtags"]
+ }
+}`
+
+export function buildSocialPrompt(core: LaunchCore, input: GenerateInput): string {
+ return [
+ 'Write a social launch kit for this product: an X (Twitter) launch thread, cold-outreach DMs for KOLs, and a UGC ask.',
+ '',
+ renderCore(core),
+ renderRefinements(input),
+ '',
+ 'Return ONLY a JSON object that matches this exact shape (same keys, no extras):',
+ SHAPE,
+ '',
+ 'Write 5-8 tweets. Keep each tweet tight enough to stand on its own. Make every DM feel personally written, not mass-sent.',
+ ]
+ .filter(Boolean)
+ .join('\n')
+}
diff --git a/lib/generation/platforms/social/schema.ts b/lib/generation/platforms/social/schema.ts
new file mode 100644
index 0000000..f00dd54
--- /dev/null
+++ b/lib/generation/platforms/social/schema.ts
@@ -0,0 +1,9 @@
+import { z } from 'zod'
+
+// Social / KOL: an X launch thread, cold-outreach DMs for each network, a UGC ask, and posting tips.
+export const socialSchema = z.object({
+ thread: z.object({ tweets: z.array(z.string()) }),
+ kolOutreach: z.object({ twitter: z.string(), linkedin: z.string(), telegram: z.string() }),
+ ugcAsk: z.string(),
+ postingTips: z.object({ bestTimeET: z.string(), hashtags: z.array(z.string()) }),
+})
diff --git a/lib/generation/prompt-shared.ts b/lib/generation/prompt-shared.ts
index 95b53ad..667cd05 100644
--- a/lib/generation/prompt-shared.ts
+++ b/lib/generation/prompt-shared.ts
@@ -1,5 +1,16 @@
import type { GenerateInput, LaunchCore } from '@/lib/types'
+// The Gingiris launch-playbook principles, prepended to every system prompt so all
+// generated copy is shaped by the same launch philosophy regardless of platform.
+export const PLAYBOOK_PRINCIPLES = [
+ 'Launch principles to honor in everything you write:',
+ '1. User value first — every line must earn attention by promising real value to the target user, never by hyping the maker.',
+ '2. Content is king, channel is queen — the message must be strong enough to spread on its own; the platform only amplifies it.',
+ '3. Global brand, local execution — keep one clear, consistent positioning while speaking natively to this specific platform and audience.',
+ '4. Concentrate on real people, not vanity — favor a few high-intent, genuine asks over broad, hollow ones.',
+ 'Anchor claims in concrete specifics; use credible numbers only when given, and never invent metrics or lean on empty superlatives.',
+].join('\n')
+
// Raw product facts, used for the core distillation call.
export function renderFacts(input: GenerateInput): string {
const { context, refinements } = input
@@ -20,16 +31,22 @@ export function renderFacts(input: GenerateInput): string {
.join('\n')
}
-// The distilled core, rendered for a platform-content call.
+// The distilled core, rendered for a platform-content call. The enrichment lines
+// are emitted only when present so cores persisted before they existed still render.
export function renderCore(core: LaunchCore): string {
return [
`Product: ${core.productName}`,
`Essence: ${core.essence}`,
+ core.valueProp && `Value proposition: ${core.valueProp}`,
`Audience: ${core.audience}`,
+ core.icp && `Ideal customer profile: ${core.icp}`,
`Problem solved: ${core.problem}`,
`Features: ${core.features.join('; ')}`,
`Differentiators: ${core.differentiators.join('; ')}`,
- ].join('\n')
+ core.keywords?.length && `Keywords: ${core.keywords.join(', ')}`,
+ ]
+ .filter(Boolean)
+ .join('\n')
}
// Author-chosen refinements that shape voice; appended to every platform prompt.
diff --git a/lib/generation/prompt.test.ts b/lib/generation/prompt.test.ts
index 506c565..c30e533 100644
--- a/lib/generation/prompt.test.ts
+++ b/lib/generation/prompt.test.ts
@@ -33,8 +33,8 @@ describe('buildCorePrompt', () => {
})
describe('PLATFORM_GENERATORS', () => {
- it('covers all four platforms with schema, system, and buildPrompt', () => {
- for (const id of ['product-hunt', 'appsumo', 'hacker-news', 'reddit'] as const) {
+ it('covers every platform with schema, system, and buildPrompt', () => {
+ for (const id of ['product-hunt', 'appsumo', 'hacker-news', 'reddit', 'social'] as const) {
const gen = PLATFORM_GENERATORS[id]
expect(gen.schema).toBeDefined()
expect(gen.system.length).toBeGreaterThan(0)
@@ -59,4 +59,8 @@ describe('PLATFORM_GENERATORS', () => {
it('builds an AppSumo prompt framed around a lifetime deal', () => {
expect(PLATFORM_GENERATORS['appsumo'].buildPrompt(core, input).toLowerCase()).toContain('lifetime')
})
+
+ it('builds a Social prompt that asks for a launch thread', () => {
+ expect(PLATFORM_GENERATORS['social'].buildPrompt(core, input).toLowerCase()).toContain('thread')
+ })
})
diff --git a/lib/generation/provider.types.ts b/lib/generation/provider.types.ts
index 155f33c..ac4dea3 100644
--- a/lib/generation/provider.types.ts
+++ b/lib/generation/provider.types.ts
@@ -1,4 +1,4 @@
-import type { GenerateInput, LaunchCore, PlatformContent } from '@/lib/types'
+import type { GenerateInput, LaunchCore, LaunchPlan, PlatformContent } from '@/lib/types'
import type { PlatformId } from '@/lib/platforms'
export interface GenerationProvider {
@@ -6,4 +6,6 @@ export interface GenerationProvider {
generateCore(input: GenerateInput): Promise
// Write one platform's native content from the shared core.
generatePlatform(platform: PlatformId, core: LaunchCore, input: GenerateInput): Promise
+ // Build the cross-platform launch plan from the shared core.
+ generatePlan(core: LaunchCore, input: GenerateInput): Promise
}
diff --git a/lib/generation/schema.test.ts b/lib/generation/schema.test.ts
index ca9084f..bbb325d 100644
--- a/lib/generation/schema.test.ts
+++ b/lib/generation/schema.test.ts
@@ -4,6 +4,7 @@ import { productHuntSchema } from './platforms/product-hunt/schema'
import { hackerNewsSchema } from './platforms/hacker-news/schema'
import { redditSchema } from './platforms/reddit/schema'
import { appSumoSchema } from './platforms/appsumo/schema'
+import { socialSchema } from './platforms/social/schema'
const core = {
productName: 'Acme',
@@ -12,6 +13,9 @@ const core = {
problem: 'Shipping is slow.',
features: ['Fast builds'],
differentiators: ['Zero config'],
+ valueProp: 'Ship in minutes, not days.',
+ icp: 'Seed-stage dev teams shipping weekly',
+ keywords: ['ci', 'builds', 'developer tools'],
}
const productHunt = {
@@ -45,6 +49,13 @@ const appSumo = {
faq: [{ q: 'Is it really lifetime?', a: 'Yes.' }],
}
+const social = {
+ thread: { tweets: ['Devs: shipping is slow. I built Acme. 🧵', 'It does X, Y, Z.', 'Live today — try it.'] },
+ kolOutreach: { twitter: 'Hey {name}...', linkedin: 'Hi {name}...', telegram: 'Hi {name}!' },
+ ugcAsk: 'Would you share how you use Acme?',
+ postingTips: { bestTimeET: '9:00 AM ET', hashtags: ['#buildinpublic'] },
+}
+
describe('launchCoreSchema', () => {
it('accepts a valid core', () => {
expect(launchCoreSchema.parse(core)).toEqual(core)
@@ -52,6 +63,11 @@ describe('launchCoreSchema', () => {
it('rejects a core missing essence', () => {
expect(() => launchCoreSchema.parse({ ...core, essence: undefined })).toThrow()
})
+ it('accepts a core without the optional enrichment fields (back-compat)', () => {
+ const { valueProp, icp, keywords, ...legacy } = core
+ void valueProp; void icp; void keywords
+ expect(() => launchCoreSchema.parse(legacy)).not.toThrow()
+ })
})
describe('productHuntSchema', () => {
@@ -89,3 +105,15 @@ describe('appSumoSchema', () => {
expect(() => appSumoSchema.parse({ ...appSumo, faq: [{ q: 'no answer' }] })).toThrow()
})
})
+
+describe('socialSchema', () => {
+ it('accepts valid Social content', () => {
+ expect(socialSchema.parse(social)).toEqual(social)
+ })
+ it('rejects content missing the thread', () => {
+ expect(() => socialSchema.parse({ ...social, thread: undefined })).toThrow()
+ })
+ it('rejects outreach missing a channel', () => {
+ expect(() => socialSchema.parse({ ...social, kolOutreach: { twitter: 'a', linkedin: 'b' } })).toThrow()
+ })
+})
diff --git a/lib/platforms.ts b/lib/platforms.ts
index 52a81ec..7976ece 100644
--- a/lib/platforms.ts
+++ b/lib/platforms.ts
@@ -1,6 +1,6 @@
// Launch platforms the kit can target. Each gets its own native, audience-tuned
// content; adding one is a new entry here plus its generator + preview + sections.
-export type PlatformId = 'product-hunt' | 'appsumo' | 'hacker-news' | 'reddit'
+export type PlatformId = 'product-hunt' | 'appsumo' | 'hacker-news' | 'reddit' | 'social'
export type Platform = {
id: PlatformId
@@ -13,4 +13,5 @@ export const PLATFORMS: Platform[] = [
{ id: 'appsumo', name: 'AppSumo', blurb: 'Lifetime-deal marketplace' },
{ id: 'hacker-news', name: 'Hacker News', blurb: 'Show HN' },
{ id: 'reddit', name: 'Reddit', blurb: 'Subreddit launch post' },
+ { id: 'social', name: 'Social / KOL', blurb: 'X thread + influencer outreach' },
]
diff --git a/lib/preview/registry.ts b/lib/preview/registry.ts
index 337901c..b5ea48d 100644
--- a/lib/preview/registry.ts
+++ b/lib/preview/registry.ts
@@ -5,6 +5,7 @@ import { ProductHuntPreview } from '@/components/preview/ProductHuntPreview'
import { AppSumoPreview } from '@/components/preview/AppSumoPreview'
import { HackerNewsPreview } from '@/components/preview/HackerNewsPreview'
import { RedditPreview } from '@/components/preview/RedditPreview'
+import { SocialPreview } from '@/components/preview/SocialPreview'
// Maps a platform to its preview renderer. Each platform's preview lives in its
// own component folder; adding one here is the only integration step.
@@ -13,6 +14,7 @@ export const PREVIEWS: Partial>>
appsumo: AppSumoPreview,
'hacker-news': HackerNewsPreview,
reddit: RedditPreview,
+ social: SocialPreview,
}
export function hasPreview(id: PlatformId): boolean {
diff --git a/lib/types.ts b/lib/types.ts
index 8ebf4f1..01e9031 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -31,6 +31,10 @@ export type LaunchCore = {
problem: string
features: string[]
differentiators: string[]
+ // Enrichments (optional for back-compat with cores persisted before they existed).
+ keywords?: string[] // SEO/GEO + PH topics + subreddit targeting
+ icp?: string // sharper than free-text `audience` — the ideal customer profile
+ valueProp?: string // the one-sentence promise channels amplify
}
// Product Hunt is the richest format: gallery, demo video, maker comment, hunter outreach.
@@ -50,6 +54,10 @@ export type ProductHuntContent = {
recommendedTimePT: string
prelaunchChecklist: string[]
launchDayChecklist: string[]
+ // Playbook additions — optional for back-compat with kits persisted before they existed.
+ hourByHour?: { timePT: string; action: string }[]
+ momentumTactics?: string[]
+ commentModeration?: string[]
outreach: { hunter: string; supporters: string }
}
}
@@ -62,15 +70,18 @@ export type HackerNewsContent = {
title: string
postBody: string
firstComment: string
- postingTips: { bestTimeET: string; avoid: string[] }
+ // etiquette is optional for back-compat with content persisted before it existed.
+ postingTips: { bestTimeET: string; avoid: string[]; etiquette?: string[] }
}
// Reddit lives in the right subreddit with a value-first, non-salesy post.
export type RedditContent = {
- subreddits: { name: string; why: string }[]
+ // rulesNote / postingTiming are optional for back-compat with content persisted before they existed.
+ subreddits: { name: string; why: string; rulesNote?: string }[]
title: string
body: string
replyEtiquette: string[]
+ postingTiming?: string
}
// AppSumo is a lifetime-deal marketplace listing.
@@ -82,20 +93,41 @@ export type AppSumoContent = {
faq: { q: string; a: string }[]
}
+// Social / KOL: a launch thread for X plus influencer cold-outreach and a UGC ask.
+export type SocialContent = {
+ thread: { tweets: string[] }
+ kolOutreach: { twitter: string; linkedin: string; telegram: string }
+ ugcAsk: string
+ postingTips: { bestTimeET: string; hashtags: string[] }
+}
+
// Maps each platform id to its own native content shape.
export type PlatformContentMap = {
'product-hunt': ProductHuntContent
appsumo: AppSumoContent
'hacker-news': HackerNewsContent
reddit: RedditContent
+ social: SocialContent
}
export type PlatformContent = PlatformContentMap[keyof PlatformContentMap]
-// The full generation result: a shared core plus whichever platforms succeeded.
+// A cross-platform, product-tailored launch plan: timeline, countdowns, SEO/GEO, momentum.
+export type LaunchPlan = {
+ phases: { window: string; goal: string; tasks: string[] }[]
+ countdown30: string[]
+ countdown7: string[]
+ countdown48h: string[]
+ seoGeo: string[]
+ momentum: string[]
+}
+
+// The full generation result: a shared core, whichever platforms succeeded, and an
+// optional cross-platform launch plan (optional for back-compat with persisted runs).
export type Generation = {
core: LaunchCore
platforms: Partial
+ plan?: LaunchPlan
}
export type GenerateInput = { context: ProjectContext; refinements: Refinements }