From de19cf9bae7492c79e124ddac5726797209f2452 Mon Sep 17 00:00:00 2001
From: Molecule AI Marketing Lead
Date: Fri, 24 Apr 2026 03:11:43 +0000
Subject: [PATCH 1/2] fix(canvas): apply flat-rate pricing copy for Phase 34
launch (Issue #1833)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rename "Starter" → "Team", update tagline + pricing page hero copy to
lead with flat-rate per-org positioning — deliberate wedge against
Cursor/Windsurf per-seat pricing ($40/seat vs $29/org).
PMM decision: Issue #1833. Approved by Marketing Lead 2026-04-24.
Co-Authored-By: Claude Sonnet 4.6
---
canvas/src/app/pricing/page.tsx | 14 +++++++++-----
.../components/__tests__/PricingTable.test.tsx | 10 +++++-----
canvas/src/lib/billing.ts | 18 ++++++++++++------
3 files changed, 26 insertions(+), 16 deletions(-)
diff --git a/canvas/src/app/pricing/page.tsx b/canvas/src/app/pricing/page.tsx
index 061a7e60..a7327793 100644
--- a/canvas/src/app/pricing/page.tsx
+++ b/canvas/src/app/pricing/page.tsx
@@ -14,7 +14,7 @@ import { PricingTable } from "@/components/PricingTable";
export const metadata = {
title: "Pricing — Molecule AI",
description:
- "Free while you tinker, paid tiers for shipping production multi-agent organizations. Transparent usage-based overage pricing on Pro.",
+ "Flat-rate team and org pricing — no per-seat fees. Free to start, $29/month for teams, $99/month for production orgs. Full runtime stack included on every paid tier.",
};
export default function PricingPage() {
@@ -25,9 +25,12 @@ export default function PricingPage() {
Pricing
- Free while you tinker. Pay when you ship real agents to production.
- Every tier includes the full runtime stack — you upgrade for scale,
- support, and dedicated infrastructure.
+ One flat price per org — not per seat. Every paid tier includes the
+ full runtime stack. You upgrade for scale, support, and dedicated
+ infrastructure.
+
+
+ 5-person team? You pay $29/month — not $200. No seat math, ever.
@@ -53,7 +56,8 @@ export default function PricingPage() {
.
- Prices shown in USD. Enterprise / self-hosted licensing available — contact us.
+ Prices shown in USD. Flat-rate per org — no per-seat fees on any paid tier.
+ Enterprise / self-hosted licensing available — contact us.
diff --git a/canvas/src/components/__tests__/PricingTable.test.tsx b/canvas/src/components/__tests__/PricingTable.test.tsx
index af5faec0..919dc788 100644
--- a/canvas/src/components/__tests__/PricingTable.test.tsx
+++ b/canvas/src/components/__tests__/PricingTable.test.tsx
@@ -50,14 +50,14 @@ describe("PricingTable", () => {
it("renders all three plans with their CTAs", () => {
render();
expect(screen.getByRole("heading", { name: "Free" })).toBeTruthy();
- expect(screen.getByRole("heading", { name: "Starter" })).toBeTruthy();
- expect(screen.getByRole("heading", { name: "Pro" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Team" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Growth" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Get started" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "Upgrade to Starter" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Upgrade to Team" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Upgrade to Growth" })).toBeTruthy();
});
- it("shows the 'Most popular' badge only on the starter card", () => {
+ it("shows the 'Most popular' badge only on the Team card", () => {
render();
const badges = screen.getAllByText("Most popular");
expect(badges.length).toBe(1);
diff --git a/canvas/src/lib/billing.ts b/canvas/src/lib/billing.ts
index c9260e61..b258a56a 100644
--- a/canvas/src/lib/billing.ts
+++ b/canvas/src/lib/billing.ts
@@ -32,6 +32,10 @@ export interface Plan {
// plans is the canonical order shown on the pricing page: free → starter
// → pro. Change the order here + the rendered columns follow. Keeping
// this as a module-level const so tests can assert against a known list.
+//
+// Flat-rate positioning (Issue #1833): "starter" and "pro" are flat-rate
+// per-org, not per-seat. This is a deliberate wedge against Cursor/Windsurf
+// ($40/seat) — at 5 engineers the Team tier is 28% cheaper.
export const plans: Plan[] = [
{
id: "free",
@@ -48,8 +52,8 @@ export const plans: Plan[] = [
},
{
id: "starter",
- name: "Starter",
- tagline: "For small teams shipping real agents",
+ name: "Team",
+ tagline: "Flat-rate for teams — one price, no per-seat fees",
price: "$29/month",
features: [
"10 workspaces",
@@ -57,14 +61,15 @@ export const plans: Plan[] = [
"Private Upstash Redis namespace",
"Email support (48h)",
"5M LLM tokens / month included",
+ "No per-seat pricing",
],
- ctaLabel: "Upgrade to Starter",
+ ctaLabel: "Upgrade to Team",
highlighted: true,
},
{
id: "pro",
- name: "Pro",
- tagline: "For production multi-agent orgs",
+ name: "Growth",
+ tagline: "Flat-rate for production multi-agent orgs",
price: "$99/month",
features: [
"Unlimited workspaces",
@@ -72,9 +77,10 @@ export const plans: Plan[] = [
"Cross-workspace A2A audit log",
"Priority support (24h)",
"25M LLM tokens / month included",
+ "No per-seat pricing",
"Usage-based overage billing",
],
- ctaLabel: "Upgrade to Pro",
+ ctaLabel: "Upgrade to Growth",
},
];
From 62217250ed9a5689fe543e78ee95037d82a310ed Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Fri, 24 Apr 2026 13:01:40 -0700
Subject: [PATCH 2/2] =?UTF-8?q?test(pricing):=20finish=20Starter=E2=86=92T?=
=?UTF-8?q?eam,=20Pro=E2=86=92Growth=20rename=20in=206=20stale=20assertion?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Marketing-lead agent's rename pass updated the "renders all three plans"
test (lines 56-57) but missed lines 77, 94, 114, 132, 143, 158 which still
referenced the pre-rename "Upgrade to Starter" / "Upgrade to Pro" button
names. Canvas (Next.js) build failed with getByRole timeout because the
component now says "Upgrade to Team" / "Upgrade to Growth".
Internal PlanId tuple ("free" | "starter" | "pro") and startCheckout(planId)
call are unchanged — only the user-facing button labels shifted, so
assertions like startCheckout("pro", "acme") still match the server-side API.
Verified locally: 9/9 PricingTable tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../src/components/__tests__/PricingTable.test.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/canvas/src/components/__tests__/PricingTable.test.tsx b/canvas/src/components/__tests__/PricingTable.test.tsx
index 919dc788..535daeb7 100644
--- a/canvas/src/components/__tests__/PricingTable.test.tsx
+++ b/canvas/src/components/__tests__/PricingTable.test.tsx
@@ -74,7 +74,7 @@ describe("PricingTable", () => {
it("Paid CTA + anonymous → bounces to signup (no checkout call)", async () => {
mockedFetchSession.mockResolvedValue(null);
render();
- fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
+ fireEvent.click(screen.getByRole("button", { name: "Upgrade to Team" }));
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
@@ -91,7 +91,7 @@ describe("PricingTable", () => {
});
render();
- fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
+ fireEvent.click(screen.getByRole("button", { name: "Upgrade to Growth" }));
await waitFor(() =>
expect(mockedStartCheckout).toHaveBeenCalledWith("pro", "acme"),
@@ -111,7 +111,7 @@ describe("PricingTable", () => {
mockedGetTenantSlug.mockReturnValue("");
render();
- fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
+ fireEvent.click(screen.getByRole("button", { name: "Upgrade to Team" }));
await waitFor(() => {
const alert = screen.getByRole("alert");
@@ -129,7 +129,7 @@ describe("PricingTable", () => {
mockedStartCheckout.mockRejectedValue(new Error("checkout: 500 boom"));
render();
- fireEvent.click(screen.getByRole("button", { name: "Upgrade to Pro" }));
+ fireEvent.click(screen.getByRole("button", { name: "Upgrade to Growth" }));
await waitFor(() => {
const alert = screen.getByRole("alert");
@@ -140,7 +140,7 @@ describe("PricingTable", () => {
it("treats fetchSession network errors as anonymous (fail-closed to signup)", async () => {
mockedFetchSession.mockRejectedValue(new Error("network down"));
render();
- fireEvent.click(screen.getByRole("button", { name: "Upgrade to Starter" }));
+ fireEvent.click(screen.getByRole("button", { name: "Upgrade to Team" }));
await waitFor(() => expect(mockedRedirectToLogin).toHaveBeenCalledWith("sign-up"));
expect(mockedStartCheckout).not.toHaveBeenCalled();
});
@@ -155,7 +155,7 @@ describe("PricingTable", () => {
mockedStartCheckout.mockReturnValue(new Promise(() => {}));
render();
- const button = screen.getByRole("button", { name: "Upgrade to Pro" });
+ const button = screen.getByRole("button", { name: "Upgrade to Growth" });
fireEvent.click(button);
await waitFor(() => {