Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9330d7e
feat(pivot): GTM → content/blog/GEO multi-persona team
sebtsang May 10, 2026
73dc270
merge: bring in CompanyContext system, resolve in favor of content pivot
sebtsang May 10, 2026
a99028e
docs(pitch): lock the pitch — devtools docs → multi-channel content
sebtsang May 10, 2026
3e96866
feat(input-form): 3-input form (company URL + docs URL + destination)
sebtsang May 10, 2026
aaff736
fix(workflow): surface silent failures + collapse single-destination …
sebtsang May 10, 2026
2b4cf06
chore(auth-configs): bake FIRECRAWL auth config id into the shared map
sebtsang May 10, 2026
8b913eb
fix(runtime): bump single-task timeout 120s → 300s for long-form gene…
sebtsang May 10, 2026
095e032
chore(prompts): halve blog-html target length 2,000 → 1,000 words
sebtsang May 10, 2026
bd033bb
fix(runtime): bump single-task maxTurns 4 → 12 for long-form generation
sebtsang May 10, 2026
bdfd7a9
chore(writer-prompt): rewrite for blog translation, not summarization
sebtsang May 10, 2026
0a73e08
fix(firecrawl): pass waitFor + onlyMainContent for SPA-rendered docs
sebtsang May 10, 2026
c4dac7b
chore(speed): writer → haiku, sync prompt synthesis to 1,000 words
sebtsang May 10, 2026
ca59f5f
fix(content-manager): drop triggerRule:all_done cascade on writer/geo…
sebtsang May 10, 2026
b26265b
fix(firecrawl): bump waitFor 2.5s → 5s for slow-hydrating SPAs
sebtsang May 10, 2026
4ae4720
fix(firecrawl): unwrap Composio's double-data wrapper, add user-bindi…
sebtsang May 10, 2026
095b00c
feat(approvals): DM founder on Slack when an approval is raised
sebtsang May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 52 additions & 49 deletions CLAUDE.md

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions PITCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# GMaestro — pitch & purpose

> **Single source of truth for what we're building, who it's for, and why it wins.**
> Slide decks, demo scripts, marketing copy, and READMEs all derive from this file.
> If a teammate is unsure where to point a sentence, this is north.

---

## One-liner (project header)

> **Docs are for parsers. Blogs are for people. We turn one into the other — for devtools founders whose engineering velocity outruns their marketing.**

## Tagline (everywhere else)

> **One commit. Every channel. All your buyers.**

---

## The contrarian insight

Technical documentation is now written for AI: dense, structured, exhaustive — optimized for LLM parsers. Humans still read **blogs**. As your docs change every day, your buyers fall further behind your product. Your AI knows you. Your buyers don't — yet.

**GMaestro is the bridge.** A multi-persona AI content team that watches your docs, drafts the human-readable blog version of every meaningful change, optimizes it for AI search citation, and ships it across the channels your buyers actually read — with founder approval at every gate.

---

## Who it's for

**Series A devtools companies whose docs ship daily and blogs ship quarterly.** Companies in our heads: Resend, Linear, Vercel, Stripe, Stainless's customers (OpenAI / Anthropic), Mintlify's customers, Anvil-shape startups.

**NOT for:** every founder ever, enterprise marketing teams, agencies, content farms. The narrow ICP is the wedge — broader expansion is the vision slide, not the pitch.

## Why now

1. **AI search is real and growing.** ChatGPT, Perplexity, Claude, and Google AI Overviews handle ~12–18% of English informational queries (Q1 2026, up from <2% a year ago). Reddit drives ~40% of AI-search citations across major engines (Semrush 150K analysis).
2. **Docs platforms are publicly naming the marketing surface.** Mintlify ($45M Series B at $500M, 10× ARR in 2025) is calling docs "the new top-of-funnel." Stainless ($25M Series A) makes SDKs from docs for OpenAI/Anthropic. They're solving inside their own products — not extending out into multi-channel content.
3. **The translation layer is white space.** Notra and PersonaBox are early; Docsie does landing pages not blogs; nobody has multi-persona reasoning + founder-in-loop approval + multi-channel fanout. We're first.
4. **Composio's tool surface** makes multi-channel publishing trivial. One approval, fan out via deterministic dispatcher → GitHub PR, Reddit, LinkedIn, Notion, etc.

## Why we win

| | What we do | What incumbents do |
|---|---|---|
| **Multi-persona reasoning** | 10 specialists across 3 departments, each prompt-tuned | Single LLM call with a long prompt |
| **Founder-in-loop** | Approval gates at every irreversible step | "Generate and post" or fully manual |
| **One approval, N channels** | Founder ticks destinations once, dispatcher fans out | Copy/paste to each channel |
| **GEO-aware** | Dedicated `geo-editor` persona; fact density, citations, schema | SEO-only or generic AI writing |
| **Local-first** | Runs on the founder's laptop; privacy moat | Hosted SaaS |
| **Devtools-shaped** | Reads docs URLs via Firecrawl; commits MDX via GitHub PR | Generic content automation |

## What we're explicitly not

- **Not a docs platform.** We don't host docs. (Mintlify, GitBook own that.)
- **Not GEO measurement.** Profound just raised $96M Series C ($1B valuation) owning GEO observability. We're *generation*, not *measurement*. We get the GEO benefit for free by shipping where AI search crawls.
- **Not generic AI content.** Jasper / Copy.ai / Writer fight for the broad "AI content" square. We don't.
- **Not a GTM tool.** Pivoted off this on 2026-05-09. The architecture survived; the framing tightened.

---

## Hackathon theme: "One for All"

Three independent ways the architecture *is* One for All:

1. **One approval, all destinations.** The BlogDraft channels-checkbox is the central UX innovation: founder approves once, dispatcher fans out to N targets.
2. **One source, all audiences.** Docs → blog → Reddit thread → LinkedIn post → X thread → GitHub PR. One canonical truth, every reader's preferred format.
3. **One prompt, the whole team executes.** Conductor → 3 managers → 10 specialists.

The theme isn't a slogan — it's the architecture.

---

## Demo arc (target: 90 seconds)

**Setup:** founder is "Anvil," a YC W26 devtools startup whose docs change weekly.

**The prompt:**

> *"Anvil shipped v2.3 of our API last week. Read our docs at anvil.co/docs/v2.3, find the 3 most important changes for our buyers, write a blog post about them, and cross-post to r/programming, LinkedIn, and a PR to our static-site repo."*

**The beats:**

1. DAG renders the 10-persona org chart, channels lit up by department.
2. Researcher fires Firecrawl on the docs URL → surfaces 3 changes worth covering.
3. Strategist picks the angle: *"v2.3 has a backwards-incompatible auth change — lead with that."*
4. Writer drafts in the founder's voice (loaded from voice samples at setup).
5. GEO-Editor: direct-answer lead, fact density check, Reddit thread citation, schema markup recommendation.
6. **Approval gate** — the BlogDraft card pops with the draft + the channels checkbox. Founder ticks: GitHub PR, Reddit (r/programming), LinkedIn. Approves.
7. Formatter fans out 3 channel variants in parallel — markdown-with-frontmatter for GitHub, Reddit-native discussion shape, LinkedIn long-form.
8. Bulk-approve the per-channel previews.
9. Dispatcher publishes via Composio: GitHub PR opens (real PR URL!), Reddit post lands, LinkedIn post is live.
10. Toast: *"3 channels live in 47 seconds. Reddit thread should surface in Perplexity citations within 7 days."*

**Closing line:**

> *"Engineering ships docs every day. Now marketing does too. One commit, every channel, all your buyers — that's not just the theme, it's the architecture. One for All."*

---

## MVP scope (what must work for the live demo)

| Capability | Status | Owner |
|---|---|---|
| Real LLM persona pipeline (researcher → strategist → writer → geo-editor → formatter) | ✅ live | core |
| Real Firecrawl docs scrape | ✅ wired (needs auth config) | core + Foundation |
| BlogDraft approval card with channels checkbox | ✅ live | core |
| Real Composio publish for **GitHub PR** | wired in providers; needs end-to-end test | core |
| Real Composio publish for **Reddit** | needs auth config registration | Foundation |
| Real Composio publish for **LinkedIn** | wired (auth config exists); needs publish-flow test | core |
| Bulk-approve for per-channel previews | endpoint exists; needs UI wiring | core |
| Mock-mode fallback (full demo without live LLM) | ✅ live | core |
| CompanyContext system (one-time setup) | ✅ in main; persona slice-map TBD | parallel session |
| Voice samples (founder paste at setup) | wired | core |

**If anything above is flaky day-of:** mock-mode demo path is fully working as fallback.

## Explicitly out of scope for MVP

- WordPress / Ghost publish (Composio slugs unverified)
- X (Twitter) live publish (requires BYO Twitter dev creds)
- Cross-run voice learning
- Slack alt-chat surface (mention as "capability," don't demo)
- Analytics / citation tracking loop
- General GTM features (sales, CRM, scheduling) — deliberately killed in the pivot
- Multi-topic sprint demo (single-blog flow only)

## North-star metrics

- **Demo:** *"3+ channels live in <60 seconds from a docs URL."* If we hit that, we win.
- **Product:** *"Every doc commit auto-becomes the blog post you didn't write."*

---

## Decision log (why this framing won)

- **Locked in 2026-05-10** after debating GEO-only / blog-tool / GMF / docs-→-blogs.
- Research-driven: Anthropic-judged hackathons reward narrow + theatrical demos over broad pitches (the *lawyer* beat 500 devs at Cerebral Valley with permit-processing). Profound's $1B Series C owns the GEO narrative — we don't try to out-pitch a unicorn. Jasper/Copy.ai/Writer own the broad "AI content for founders" square — we don't fight there.
- White space: Notra/PersonaBox/Docsie are adjacent; nobody has multi-persona + multi-channel + founder-in-loop. Mintlify and GitBook publicly naming the gap = category being established.
- Devtools is the highest-WTP B2B niche. $4–12K/mo agency budgets at Series A devtools companies prove the buyer pays.
- "One for All" theme literally restates the product: one input, all the channels.

## Pivot history

- **2026-05-08** — initial pitch: AI GTM team for pre-Series A founders (sales / CS / RevOps).
- **2026-05-09** — pivoted to AI content team (blog / GEO / multi-channel). Architecture survived; domain types swapped.
- **2026-05-10** — narrowed to **devtools docs → multi-channel content**. Same architecture, sharper positioning. **This is locked.**

---

## Where each teammate goes from here

- **Pitch deck:** lift hero / tagline / theme tie-in / demo arc verbatim. Don't paraphrase.
- **Demo script:** the 10 beats above. Single founder prompt. 90 seconds.
- **Marketing copy / project page:** start from the one-liner. Use Mintlify/Stainless funding as social proof.
- **Engineering (this branch):** finish the real-LLM publish path for GitHub PR + Reddit + LinkedIn. Verify Firecrawl docs scrape end-to-end. Bulk-approve UI for per-channel previews.

If you change the pitch, change this file first. Everything else flows from here.
10 changes: 5 additions & 5 deletions app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { CompanyContextCard } from "@/lib/ui/components/company-context-card";
import { PromptInput } from "@/lib/ui/components/prompt-input";
import { RecentRunsList } from "@/lib/ui/components/recent-runs-list";
import { ResumePill } from "@/lib/ui/components/resume-pill";
import { RunInputForm } from "@/lib/ui/components/run-input-form";

const Hero = (
<div className="px-1 pb-1">
<h1 className="whitespace-nowrap text-5xl tracking-tight font-[family-name:var(--font-space-grotesk)]">
GMaestro{" "}
<span className="text-muted-foreground">- GStack for blogs</span>
<span className="text-muted-foreground">- your AI content team</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
You → Conductor → research, synthesis, write, design.{" "}
<em>Your B2B blog pipeline, on autopilot.</em>
You → Conductor → 3 managers → 10 specialists across blog, GEO, and
multi-channel distribution. <em>Founder-in-loop, multi-channel, GEO-aware.</em>
</p>
</div>
);
Expand All @@ -38,7 +38,7 @@ export default function DashboardPage() {
<ResumePill />
<CompanyContextCard />
<div className="w-full">
<PromptInput onRunStarted={handleRunStarted} />
<RunInputForm onRunStarted={handleRunStarted} />
</div>
<RecentRunsList />
</div>
Expand Down
62 changes: 48 additions & 14 deletions app/api/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,37 @@ export async function POST(request: Request) {

const founderId = process.env.GMAESTRO_USER_ID ?? "default";

// Materialize leads for any emails the founder named in the prompt BEFORE
// we build WorkContext (which the Conductor reads). Done in the request
// path, not the detached workflow, so a DB failure surfaces as a clean 500
// instead of producing a no-op run that hangs in "running" forever.
try {
await ensureLeadsForPromptEmails(parsed.data.prompt);
} catch (err) {
console.error("[api/runs] ensureLeadsForPromptEmails failed:", err);
return NextResponse.json(
{ error: "Failed to materialize leads from prompt" },
{ status: 500 },
);
// Build a prompt-shaped string for the run row (drives the recent-runs UI
// + the Conductor's prompt input). For the new 3-input form, we synthesize
// a structured prompt the Conductor can reason about. For legacy callers
// sending a freeform `prompt`, pass it through.
const { companyUrl, docsUrl, destination, prompt: legacyPrompt } = parsed.data;
const promptString = legacyPrompt ?? buildStructuredPrompt({
companyUrl: companyUrl!,
docsUrl: docsUrl!,
destination: destination!,
});

// Materialize leads for any emails in legacy prompts BEFORE WorkContext
// is built. New 3-input flow has no email parsing.
if (legacyPrompt) {
try {
await ensureLeadsForPromptEmails(legacyPrompt);
} catch (err) {
console.error("[api/runs] ensureLeadsForPromptEmails failed:", err);
return NextResponse.json(
{ error: "Failed to materialize leads from prompt" },
{ status: 500 },
);
}
}

const workflowRunId = await createRun(parsed.data.prompt);
const workflowRunId = await createRun(promptString);

// Fire-and-forget: the workflow runs for minutes; the route returns the id
// immediately. The .catch is non-negotiable — without it, an unhandled
// rejection in detached land kills the dev server with no DB trace.
void runWorkflow(workflowRunId, parsed.data.prompt, founderId).catch(
void runWorkflow(workflowRunId, promptString, founderId).catch(
async (err) => {
try {
await markRunFailed(workflowRunId, err);
Expand All @@ -119,3 +130,26 @@ export async function POST(request: Request) {

return NextResponse.json({ workflowRunId }, { status: 202 });
}

/**
* Synthesize a Conductor-readable prompt from the structured 3-input payload.
* The Conductor doesn't need to know the inputs are structured — it just sees
* a clear instruction with the URLs + destination baked in.
*/
function buildStructuredPrompt(input: {
companyUrl: string;
docsUrl: string;
destination: "blog-html" | "reddit" | "x-thread";
}): string {
const destinationLabel = {
"blog-html": "a blog post (~1,000 words)",
reddit: "a Reddit thread (~250 words)",
"x-thread": "an X thread (5–10 tweets)",
}[input.destination];
return [
`Write ${destinationLabel} for the company at ${input.companyUrl}.`,
`The blog is about the technical content at ${input.docsUrl}.`,
`Match the company's existing voice (extracted from their blog automatically).`,
`Destination: ${input.destination}.`,
].join(" ");
}
27 changes: 23 additions & 4 deletions app/api/test-persona/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,30 @@ export async function POST(request: Request) {
// Pattern B: researcher needs the Composio fetch bundle pre-baked into
// its input (the workflow dispatcher does this; we replicate here).
if (personaId === "researcher") {
const item = (input.item as Record<string, unknown> | undefined) ?? {};
const topic =
typeof input.topic === "string"
? input.topic
: typeof (input.item as { topic?: string } | undefined)?.topic === "string"
? ((input.item as { topic: string }).topic)
: "";
const companyProfileRaw = input.companyProfile ?? (input.item as { companyProfile?: unknown } | undefined)?.companyProfile;
const companyProfile =
companyProfileRaw && typeof companyProfileRaw === "object" && !Array.isArray(companyProfileRaw)
? (companyProfileRaw as Record<string, unknown>)
: {};
const companyName =
typeof companyProfile.companyName === "string"
? companyProfile.companyName
: undefined;
const competitorUrls = Array.isArray(companyProfile.competitors)
? (companyProfile.competitors as unknown[]).filter(
(u): u is string => typeof u === "string",
)
: undefined;
const bundle = await fetchResearcherBundle(userId, {
email: typeof item.email === "string" ? item.email : undefined,
name: typeof item.name === "string" ? item.name : undefined,
company: typeof item.company === "string" ? item.company : undefined,
topic,
companyName,
competitorUrls,
});
finalInput = { ...input, fetchBundle: bundle };
}
Expand Down
15 changes: 6 additions & 9 deletions lib/dispatch/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,12 @@ function mergeFounderEdits(
}

async function stampArtifactSent(approval: ApprovalRequest): Promise<void> {
if (approval.artifactType === "OutreachDraft") {
await db
.update(schema.outreachDrafts)
.set({ sentAt: new Date(), approvalStatus: "approved" })
.where(eq(schema.outreachDrafts.id, approval.artifactId));
return;
}
// Other artifact tables don't yet have a "sentAt" or analogue — extend as
// BookedMeeting/ActivationNudge dispatch lands.
// Content-domain artifacts (TopicResearchBrief, ContentOutline, BlogDraft,
// ChannelVariant, PublishedArtifact) don't have dedicated tables yet —
// the approval_requests row carries the full artifact in its proposed_action
// column. Sent-state is implicit (the dispatcher succeeded). Wire dedicated
// tables here when historical artifact pages need them.
void approval;
}

function isAuthFailure(message: string): boolean {
Expand Down
Loading
Loading