diff --git a/COMMERCIAL.md b/COMMERCIAL.md
index 963690f8..421a62ff 100644
--- a/COMMERCIAL.md
+++ b/COMMERCIAL.md
@@ -4,12 +4,10 @@ Most libraries in this repository — `@ngaf/render`, `@ngaf/agent`, `@ngaf/lang
## `@ngaf/chat`
-Starting with the next published version, `@ngaf/chat` is dual-licensed:
+`@ngaf/chat` is dual-licensed:
-- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30-day commercial evaluation).
-- **Threadplane commercial license** for commercial production use.
-
-Historical MIT releases of `@ngaf/chat` remain under their original terms.
+- **PolyForm Noncommercial 1.0.0** for free noncommercial use (personal, hobby, student, academic, nonprofit, public demos, OSI-licensed open source, 30 calendar days of commercial evaluation from first commercial use).
+- **ThreadPlane Commercial license** for commercial production use. Sold via [threadplane.ai/pricing](https://threadplane.ai/pricing); see [/docs/licensing](https://threadplane.ai/docs/licensing) for installation.
See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/LICENSE-COMMERCIAL.md`](./libs/chat/LICENSE-COMMERCIAL.md), and [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md) for the full terms.
diff --git a/README.md b/README.md
index d818dc90..0e4c078c 100644
--- a/README.md
+++ b/README.md
@@ -131,4 +131,4 @@ That's it. `chat.messages()` and `chat.status()` are Angular Signals. Bind them
Most libraries in this repository (`@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/licensing`, `@ngaf/telemetry`, `@ngaf/design-tokens`) are released under the **MIT License** — free for any use, including commercial, with attribution.
-**`@ngaf/chat`** is the exception. Future versions are licensed under **PolyForm Noncommercial 1.0.0 OR a Threadplane commercial license**. Historical npm releases remain MIT. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), and [`COMMERCIAL.md`](./COMMERCIAL.md) for details.
+**`@ngaf/chat`** is the exception. It is dual-licensed under **PolyForm Noncommercial 1.0.0** for free noncommercial use, or a **ThreadPlane Commercial license** for production use inside a for-profit context. See [`libs/chat/LICENSE.md`](./libs/chat/LICENSE.md), [`libs/chat/COMMERCIAL-USE.md`](./libs/chat/COMMERCIAL-USE.md), [`COMMERCIAL.md`](./COMMERCIAL.md), and [threadplane.ai/docs/licensing](https://threadplane.ai/docs/licensing) for details.
diff --git a/apps/minting-service/scripts/remint.ts b/apps/minting-service/scripts/remint.ts
index f98f6a4b..d580fba0 100644
--- a/apps/minting-service/scripts/remint.ts
+++ b/apps/minting-service/scripts/remint.ts
@@ -57,7 +57,7 @@ export async function runRemint(args: RemintArgs, deps: RemintDeps): Promise {
expiresAt: new Date('2027-04-20T00:00:00Z'),
});
- expect(out.text).toContain('-----BEGIN CACHEPLANE LICENSE-----');
+ expect(out.text).toContain('-----BEGIN THREADPLANE LICENSE-----');
expect(out.text).toContain('PAYLOAD.SIG');
- expect(out.text).toContain('-----END CACHEPLANE LICENSE-----');
+ expect(out.text).toContain('-----END THREADPLANE LICENSE-----');
});
it('subject includes tier and seat count with plural s for seats > 1', () => {
@@ -27,12 +27,12 @@ describe('renderLicenseEmail', () => {
it('subject uses singular seat for seats === 1', () => {
const out = renderLicenseEmail({
- tier: 'app_deployment',
+ tier: 'team',
seats: 1,
token: 't.s',
expiresAt: new Date('2027-04-20T00:00:00Z'),
});
- expect(out.subject).toBe('Your ThreadPlane license — app_deployment (1 seat)');
+ expect(out.subject).toBe('Your ThreadPlane license — team (1 seat)');
});
it('includes ISO 8601 UTC expiry in text body', () => {
@@ -54,6 +54,6 @@ describe('renderLicenseEmail', () => {
});
expect(out.html).toContain('
+ // .env
+ THREADPLANE_LICENSE=
Docs: https://threadplane.ai/docs/licensing
Questions: reply to this email.
@@ -48,16 +51,21 @@ Questions: reply to this email.
-- The ThreadPlane team
`;
- const html = `Thanks for subscribing to ThreadPlane.
-Your license token is below. Set it as the CACHEPLANE_LICENSE environment variable in your application:
------BEGIN CACHEPLANE LICENSE-----
+ const html = `Thanks for your ThreadPlane license purchase.
+Your license is valid for 12 months from today. Paste the token below into your @ngaf/chat configuration:
+-----BEGIN THREADPLANE LICENSE-----
${escapeHtml(vars.token)}
------END CACHEPLANE LICENSE-----
+-----END THREADPLANE LICENSE-----
Tier: ${escapeHtml(vars.tier)}
Seats: ${vars.seats}
Expires: ${escapeHtml(expiresIso)}
Installation:
-export CACHEPLANE_LICENSE="<paste token above>"
+provideChat({
+ license: process.env['THREADPLANE_LICENSE'],
+});
+
+// .env
+THREADPLANE_LICENSE=<paste token above>
Docs: threadplane.ai/docs/licensing
Questions: reply to this email.
-- The ThreadPlane team
diff --git a/apps/minting-service/src/lib/handlers.spec.ts b/apps/minting-service/src/lib/handlers.spec.ts
index 357a7d0c..e9d11d43 100644
--- a/apps/minting-service/src/lib/handlers.spec.ts
+++ b/apps/minting-service/src/lib/handlers.spec.ts
@@ -39,7 +39,7 @@ function paymentSession(overrides: Partial = {}): Strip
{
quantity: 1,
price: {
- metadata: { ngaf_tier_slug: 'indie' },
+ metadata: { ngaf_tier_slug: 'developer_seat' },
} as Stripe.Price,
} as Stripe.LineItem,
],
@@ -110,7 +110,7 @@ describe('handleCheckoutCompleted', () => {
expect(deps.mintToken).toHaveBeenCalledWith(
expect.objectContaining({
stripeCustomerId: 'cus_test_123',
- tier: 'indie',
+ tier: 'developer_seat',
seats: 1,
expiresAt: expect.any(Date),
}),
@@ -122,7 +122,7 @@ describe('handleCheckoutCompleted', () => {
stripeCustomerId: 'cus_test_123',
stripePaymentId: 'pi_test_123',
customerEmail: 'buyer@example.com',
- tier: 'indie',
+ tier: 'developer_seat',
seats: 1,
lastToken: 'mock.token',
}),
@@ -131,7 +131,7 @@ describe('handleCheckoutCompleted', () => {
expect.objectContaining({
from: 'noreply@example.com',
to: 'buyer@example.com',
- vars: expect.objectContaining({ tier: 'indie', seats: 1, token: 'mock.token' }),
+ vars: expect.objectContaining({ tier: 'developer_seat', seats: 1, token: 'mock.token' }),
}),
);
});
@@ -177,7 +177,7 @@ describe('handleCheckoutCompleted', () => {
const deps = makeDeps({
getLicense: vi.fn().mockResolvedValue({
customerEmail: 'buyer@example.com',
- tier: 'indie',
+ tier: 'developer_seat',
}),
revokeLicense: vi.fn().mockResolvedValue({}),
});
@@ -186,7 +186,7 @@ describe('handleCheckoutCompleted', () => {
expect(deps.sendRevocationEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'buyer@example.com',
- vars: { tier: 'indie' },
+ vars: { tier: 'developer_seat' },
}),
);
});
diff --git a/apps/minting-service/src/lib/handlers.ts b/apps/minting-service/src/lib/handlers.ts
index a85109ea..384a48d5 100644
--- a/apps/minting-service/src/lib/handlers.ts
+++ b/apps/minting-service/src/lib/handlers.ts
@@ -168,6 +168,6 @@ export async function handleChargeRefunded(
resendApiKey: deps.resendApiKey,
from: deps.emailFrom,
to: existing.customerEmail,
- vars: { tier: existing.tier as 'indie' | 'developer_seat' | 'app_deployment' },
+ vars: { tier: existing.tier as 'developer_seat' | 'team' },
});
}
diff --git a/apps/minting-service/src/lib/sign.spec.ts b/apps/minting-service/src/lib/sign.spec.ts
index 433f4d86..21085f56 100644
--- a/apps/minting-service/src/lib/sign.spec.ts
+++ b/apps/minting-service/src/lib/sign.spec.ts
@@ -42,7 +42,7 @@ describe('mintToken', () => {
mintToken(
{
stripeCustomerId: 'cus_x',
- tier: 'app_deployment',
+ tier: 'team',
seats: 1,
expiresAt: new Date('2027-01-01T00:00:00Z'),
},
diff --git a/apps/minting-service/src/lib/tier.spec.ts b/apps/minting-service/src/lib/tier.spec.ts
index 274ecbf6..b0de2a2a 100644
--- a/apps/minting-service/src/lib/tier.spec.ts
+++ b/apps/minting-service/src/lib/tier.spec.ts
@@ -2,16 +2,12 @@
import { extractTier, computeSeats } from './tier.js';
describe('extractTier', () => {
- it('returns indie from price metadata', () => {
- expect(extractTier({ ngaf_tier_slug: 'indie' })).toBe('indie');
- });
-
it('returns developer_seat from price metadata', () => {
expect(extractTier({ ngaf_tier_slug: 'developer_seat' })).toBe('developer_seat');
});
- it('returns app_deployment from price metadata', () => {
- expect(extractTier({ ngaf_tier_slug: 'app_deployment' })).toBe('app_deployment');
+ it('returns team from price metadata', () => {
+ expect(extractTier({ ngaf_tier_slug: 'team' })).toBe('team');
});
it('throws when ngaf_tier_slug is missing', () => {
@@ -32,12 +28,9 @@ describe('computeSeats', () => {
expect(computeSeats('developer_seat', 5)).toBe(5);
});
- it('returns 1 for app_deployment regardless of quantity', () => {
- expect(computeSeats('app_deployment', 10)).toBe(1);
- });
-
- it('returns 1 for indie regardless of quantity', () => {
- expect(computeSeats('indie', 10)).toBe(1);
+ it('returns 5 for team regardless of quantity', () => {
+ expect(computeSeats('team', 1)).toBe(5);
+ expect(computeSeats('team', 10)).toBe(5);
});
it('defaults developer_seat to 1 when quantity is null', () => {
diff --git a/apps/minting-service/src/lib/tier.ts b/apps/minting-service/src/lib/tier.ts
index ae593248..c99a3e0c 100644
--- a/apps/minting-service/src/lib/tier.ts
+++ b/apps/minting-service/src/lib/tier.ts
@@ -1,11 +1,13 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import type { LicenseTier } from '@ngaf/licensing';
-export type MintableTier = Extract;
+export type MintableTier = Extract;
-const VALID_TIERS: readonly MintableTier[] = ['indie', 'developer_seat', 'app_deployment'] as const;
+const VALID_TIERS: readonly MintableTier[] = ['developer_seat', 'team'] as const;
const METADATA_KEY = 'ngaf_tier_slug';
+const TEAM_SEAT_COUNT = 5;
+
/**
* Extract the tier slug from a Stripe price metadata bag.
* Throws if the field is missing or holds an unknown value.
@@ -27,12 +29,14 @@ export function extractTier(metadata: Record | null | undefined)
/**
* Compute the `seats` claim from the Stripe line-item quantity.
* - developer_seat: tracks Stripe quantity (minimum 1).
- * - indie: always 1.
- * - app_deployment: always 1.
+ * - team: always 5 (the bundle size baked into the SKU).
*/
export function computeSeats(tier: MintableTier, quantity: number | null | undefined): number {
if (tier === 'developer_seat') {
return quantity && quantity > 0 ? quantity : 1;
}
+ if (tier === 'team') {
+ return TEAM_SEAT_COUNT;
+ }
return 1;
}
diff --git a/apps/website/content/docs/licensing/api/api-docs.json b/apps/website/content/docs/licensing/api/api-docs.json
index 9f450918..5d8e42be 100644
--- a/apps/website/content/docs/licensing/api/api-docs.json
+++ b/apps/website/content/docs/licensing/api/api-docs.json
@@ -158,7 +158,7 @@
"name": "LicenseTier",
"kind": "type",
"description": "The tier a license grants.",
- "signature": "\"indie\" | \"developer_seat\" | \"app_deployment\" | \"enterprise\"",
+ "signature": "\"developer_seat\" | \"team\" | \"enterprise\"",
"examples": []
},
{
diff --git a/apps/website/src/app/api/checkout/session/route.spec.ts b/apps/website/src/app/api/checkout/session/route.spec.ts
index cc61e1fa..510f90de 100644
--- a/apps/website/src/app/api/checkout/session/route.spec.ts
+++ b/apps/website/src/app/api/checkout/session/route.spec.ts
@@ -10,9 +10,8 @@ vi.mock('../../../../lib/stripe', () => ({
vi.mock('../../../../../../../pricing/tiers.generated', () => ({
STRIPE_PRICE_IDS: {
- indie: 'price_test_indie',
- developer_seat: 'price_test_seat',
- app_deployment: 'price_test_app',
+ developer_seat: { monthly: 'price_seat_monthly', annual: 'price_seat_annual' },
+ team: { monthly: 'price_team_monthly', annual: 'price_team_annual' },
},
}));
@@ -47,17 +46,31 @@ describe('POST /api/checkout/session', () => {
expect(res.status).toBe(400);
});
- it('returns 303 redirect to Stripe for indie', async () => {
- const res = await POST(makeReq({ tier: 'indie' }));
- expect(res.status).toBe(303);
- expect(res.headers.get('location')).toBe('https://checkout.stripe.com/c/pay/cs_test_abc');
- expect(stripeCreate).toHaveBeenCalledTimes(1);
+ it('returns 400 for invalid billing_cycle', async () => {
+ const res = await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'weekly' }));
+ expect(res.status).toBe(400);
+ });
+
+ it('defaults to annual billing cycle when omitted', async () => {
+ await POST(makeReq({ tier: 'developer_seat' }));
+ const args = stripeCreate.mock.calls[0]?.[0];
+ expect(args.mode).toBe('subscription');
+ expect(args.line_items[0].price).toBe('price_seat_annual');
+ expect(args.metadata.ngaf_billing_cycle).toBe('annual');
+ });
+
+ it('routes to the monthly price when billing_cycle=monthly', async () => {
+ await POST(makeReq({ tier: 'developer_seat', billing_cycle: 'monthly' }));
+ const args = stripeCreate.mock.calls[0]?.[0];
+ expect(args.line_items[0].price).toBe('price_seat_monthly');
+ expect(args.metadata.ngaf_billing_cycle).toBe('monthly');
+ });
+
+ it('routes Team to the team annual price by default', async () => {
+ await POST(makeReq({ tier: 'team' }));
const args = stripeCreate.mock.calls[0]?.[0];
- expect(args.mode).toBe('payment');
- expect(args.customer_creation).toBe('always');
- expect(args.line_items[0].price).toBe('price_test_indie');
- expect(args.line_items[0].quantity).toBe(1);
- expect(args.metadata.ngaf_tier_slug).toBe('indie');
+ expect(args.line_items[0].price).toBe('price_team_annual');
+ expect(args.metadata.ngaf_tier_slug).toBe('team');
});
it('enables adjustable_quantity only for developer_seat', async () => {
@@ -78,7 +91,7 @@ describe('POST /api/checkout/session', () => {
it('returns 502 if Stripe returns no URL', async () => {
stripeCreate.mockResolvedValueOnce({ url: null });
- const res = await POST(makeReq({ tier: 'indie' }));
+ const res = await POST(makeReq({ tier: 'developer_seat' }));
expect(res.status).toBe(502);
});
});
diff --git a/apps/website/src/app/api/checkout/session/route.ts b/apps/website/src/app/api/checkout/session/route.ts
index 7362a862..e75d5657 100644
--- a/apps/website/src/app/api/checkout/session/route.ts
+++ b/apps/website/src/app/api/checkout/session/route.ts
@@ -1,13 +1,19 @@
// SPDX-License-Identifier: MIT
import { NextRequest, NextResponse } from 'next/server';
import { getStripe } from '../../../../lib/stripe';
-import { TIERS, type TierSlug } from '../../../../../../../pricing/tiers.config';
+import {
+ TIERS,
+ type TierSlug,
+ type BillingCycle,
+} from '../../../../../../../pricing/tiers.config';
import { STRIPE_PRICE_IDS } from '../../../../../../../pricing/tiers.generated';
-const BUYABLE_SLUGS = new Set(['indie', 'developer_seat', 'app_deployment']);
+const BUYABLE_SLUGS = new Set(['developer_seat', 'team']);
+const VALID_CYCLES = new Set(['monthly', 'annual']);
interface RequestBody {
tier?: string;
+ billing_cycle?: string;
quantity?: number;
}
@@ -31,6 +37,8 @@ export async function POST(req: NextRequest) {
const form = await req.formData();
body = {
tier: typeof form.get('tier') === 'string' ? (form.get('tier') as string) : undefined,
+ billing_cycle:
+ typeof form.get('billing_cycle') === 'string' ? (form.get('billing_cycle') as string) : undefined,
quantity: form.get('quantity') ? Number(form.get('quantity')) : undefined,
};
}
@@ -41,10 +49,16 @@ export async function POST(req: NextRequest) {
}
const tierSlug = tier as Exclude;
- const priceId = STRIPE_PRICE_IDS[tierSlug];
+ const cycle = (body.billing_cycle ?? 'annual') as BillingCycle;
+ if (!VALID_CYCLES.has(cycle)) {
+ return NextResponse.json({ error: 'Invalid billing_cycle (expected monthly or annual)' }, { status: 400 });
+ }
+
+ const tierPrices = STRIPE_PRICE_IDS[tierSlug];
+ const priceId = tierPrices?.[cycle];
if (!priceId) {
return NextResponse.json(
- { error: 'Checkout not yet configured for this tier. Run scripts/stripe/sync-products.ts.' },
+ { error: `Checkout not yet configured for tier=${tierSlug} cycle=${cycle}. Run scripts/stripe/sync-products.ts.` },
{ status: 503 },
);
}
@@ -61,8 +75,7 @@ export async function POST(req: NextRequest) {
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
- mode: 'payment',
- customer_creation: 'always',
+ mode: 'subscription',
line_items: [
{
price: priceId,
@@ -74,8 +87,10 @@ export async function POST(req: NextRequest) {
],
success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
- metadata: { ngaf_tier_slug: tierSlug },
- payment_intent_data: { metadata: { ngaf_tier_slug: tierSlug } },
+ metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle },
+ subscription_data: {
+ metadata: { ngaf_tier_slug: tierSlug, ngaf_billing_cycle: cycle },
+ },
});
if (!session.url) {
diff --git a/apps/website/src/app/docs/licensing/page.tsx b/apps/website/src/app/docs/licensing/page.tsx
new file mode 100644
index 00000000..5d67167e
--- /dev/null
+++ b/apps/website/src/app/docs/licensing/page.tsx
@@ -0,0 +1,292 @@
+import Link from 'next/link';
+import { tokens } from '@ngaf/design-tokens';
+import { Container } from '../../../components/ui/Container';
+import { Section } from '../../../components/ui/Section';
+import { Eyebrow } from '../../../components/ui/Eyebrow';
+import { Button } from '../../../components/ui/Button';
+import { createPageMetadata } from '../../../lib/site-metadata';
+
+export const metadata = createPageMetadata({
+ title: 'Licensing — ThreadPlane',
+ description:
+ 'How the ThreadPlane Commercial license works, who needs one, and how to install your license token in @ngaf/chat.',
+ pathname: '/docs/licensing',
+ type: 'website',
+});
+
+const headingStyle = {
+ fontFamily: tokens.typography.h2.family,
+ fontSize: tokens.typography.h2.size,
+ lineHeight: tokens.typography.h2.line,
+ fontWeight: 700,
+ color: tokens.colors.textPrimary,
+ margin: 0,
+ marginBottom: 16,
+ letterSpacing: '-0.01em',
+} as const;
+
+const h3Style = {
+ fontFamily: tokens.typography.h3.family,
+ fontSize: tokens.typography.h3.size,
+ lineHeight: tokens.typography.h3.line,
+ fontWeight: 600,
+ color: tokens.colors.textPrimary,
+ margin: 0,
+ marginTop: 24,
+ marginBottom: 8,
+} as const;
+
+const bodyStyle = {
+ fontFamily: tokens.typography.body.family,
+ fontSize: tokens.typography.body.size,
+ lineHeight: tokens.typography.body.line,
+ color: tokens.colors.textSecondary,
+ margin: '0 0 16px',
+ maxWidth: '64ch',
+} as const;
+
+const codeBlockStyle = {
+ fontFamily: tokens.typography.fontMono,
+ fontSize: 13,
+ lineHeight: 1.6,
+ background: tokens.surfaces.surfaceTinted,
+ border: `1px solid ${tokens.surfaces.border}`,
+ borderRadius: 8,
+ padding: 16,
+ overflow: 'auto',
+ color: tokens.colors.textPrimary,
+ margin: '0 0 16px',
+ whiteSpace: 'pre' as const,
+} as const;
+
+const tableStyle = {
+ width: '100%',
+ borderCollapse: 'collapse' as const,
+ fontFamily: tokens.typography.body.family,
+ fontSize: 14,
+ color: tokens.colors.textSecondary,
+ margin: '0 0 24px',
+} as const;
+
+const cellStyle = {
+ padding: '10px 12px',
+ borderBottom: `1px solid ${tokens.surfaces.border}`,
+ verticalAlign: 'top' as const,
+} as const;
+
+const headerCellStyle = {
+ ...cellStyle,
+ color: tokens.colors.textPrimary,
+ fontWeight: 600,
+ background: tokens.surfaces.surfaceTinted,
+} as const;
+
+export default function LicensingPage() {
+ return (
+ <>
+
+
+
+
Documentation
+
+ Licensing
+
+
+ How the ThreadPlane licensing model works, who needs a paid license, and how to install your license token.
+
+
+
+
+
+
+
+
+
The model
+
+ Agent UI for Angular is a suite of libraries. Most are{' '}
+ MIT-licensed and free for any use,
+ commercial or not. Only @ngaf/chat is
+ dual-licensed.
+
+
+ @ngaf/chat is source-available under{' '}
+ PolyForm Noncommercial 1.0.0 for free
+ noncommercial use, or a ThreadPlane Commercial license {' '}
+ for production use inside a for-profit context. The same source ships under both — you don't get a
+ different build.
+
+
+
Do you need a paid license?
+
+ You need a ThreadPlane Commercial license if you use @ngaf/chat{' '}
+ in any of:
+
+
+ A commercial product or SaaS
+ An internal business tool inside a for-profit company
+ An agency deliverable or paid client project
+ Any application operated by or for a for-profit entity
+
+
You do not need a paid license for:
+
+ Personal, hobby, student, academic, or nonprofit projects
+ Public demos and tutorials
+ Open-source applications released under an OSI-approved license
+ Commercial evaluation, up to 30 calendar days from your first commercial use
+
+
+
+
+
+
+
+
+
Install your license
+
+ After purchase, ThreadPlane emails a signed license token to the address on your receipt. Paste it
+ into your app's provideChat(){' '}
+ configuration:
+
+
{`// app.config.ts
+import { ApplicationConfig } from '@angular/core';
+import { provideChat } from '@ngaf/chat';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideChat({
+ license: process.env['THREADPLANE_LICENSE'],
+ }),
+ ],
+};`}
+
+ The library verifies the token's Ed25519 signature on boot. The check is{' '}
+ advisory-only : a missing, expired, or
+ tampered token logs a console.warn but
+ never blocks rendering. Verification is fully offline; no calls leave your app at runtime.
+
+
+ The token is safe to commit to a private repository, or to read from a build-time environment variable
+ for public repos. Public-repo demos are exempt from the commercial-use definition, but if your
+ public repo backs a commercial product, the deployed bundle does need a license.
+
+
+
+
+
+
+
+
+
Tier scoping
+
+ Pick the tier that matches how you'll deploy. All paid tiers grant the same{' '}
+ ThreadPlane Commercial license; the difference is the scope of use and the number of seats.
+
+
+
+
+
+ Tier
+ Developers
+ Best for
+
+
+
+
+ Developer Seat — $29/dev/mo or $299/dev/yr
+ Per seat
+ Solo devs, growing teams
+
+
+ Team — $149/mo or $1,495/yr
+ 5 seats included
+ Small teams that want a single SKU and renewal
+
+
+ Enterprise — from $4,000/mo
+ Custom
+ SLA, security review, Pilot-to-Prod engagement, Slack Connect
+
+
+
+
+
+ Paid tiers are recurring subscriptions. Annual saves ~15% vs monthly. Cancel anytime — the license
+ stays valid through the end of the current paid period.
+
+
+
+
+
+
+
+
+
Evaluation
+
+ You may use @ngaf/chat commercially
+ for 30 calendar days from your first
+ commercial use as a good-faith evaluation. There is no telemetry, no registration, no email check —
+ we trust you to count the days. After 30 days you must either purchase a license or stop the
+ commercial use.
+
+
+
Refunds
+
+ If you refund a license through Stripe, the token is revoked automatically and we email a confirmation.
+ The verification check warns on boot. There's no clawback of the source code you already have —
+ everything is source-available under PolyForm Noncommercial by default.
+
+
+
Questions
+
+ Volume pricing, multi-app licensing, audit clauses, custom terms — any of those, reach out and we'll
+ work it out.
+
+
+
+ See pricing
+
+
+ Contact us
+
+
+ Pricing FAQ →
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/website/src/app/pricing/page.tsx b/apps/website/src/app/pricing/page.tsx
index de07919f..823c7ddd 100644
--- a/apps/website/src/app/pricing/page.tsx
+++ b/apps/website/src/app/pricing/page.tsx
@@ -2,7 +2,6 @@ import { tokens } from '@ngaf/design-tokens';
import { Container } from '../../components/ui/Container';
import { Section } from '../../components/ui/Section';
import { Eyebrow } from '../../components/ui/Eyebrow';
-import { PricingGrid } from '../../components/pricing/PricingGrid';
import { CompareTable } from '../../components/pricing/CompareTable';
import { CompatibilityMatrix } from '../../components/pricing/CompatibilityMatrix';
import { PricingFAQ } from '../../components/pricing/PricingFAQ';
@@ -13,33 +12,15 @@ import { createPageMetadata } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
title: 'Pricing — Agent UI for Angular',
description:
- '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a Threadplane license. Other libraries remain MIT.',
+ '@ngaf/chat is free for noncommercial use under PolyForm Noncommercial 1.0.0. Commercial production use requires a ThreadPlane Commercial license. Other libraries remain MIT.',
pathname: '/pricing',
type: 'website',
});
-function SmallNote({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
export default function PricingPage() {
return (
<>
-
+
Pricing
@@ -52,47 +33,17 @@ export default function PricingPage() {
lineHeight: tokens.typography.h1.line,
color: tokens.colors.textPrimary,
margin: 0,
- marginBottom: 16,
letterSpacing: '-0.02em',
}}
>
- Pricing for production AI chat interfaces
+ Simple, transparent pricing
-
- @ngaf/chat is free for noncommercial use. Commercial production use requires a Threadplane license. Other libraries in the framework remain MIT.
-
-
-
-
-
-
- A license is required when @ngaf/chat is used in a commercial product, SaaS app, internal business tool, paid client project, or production application operated by or for a for-profit entity.
-
-
-
-
-
-
-
- Commercial evaluation is free for 30 days. A paid license is required before production deployment.
-
-
-
-
Compatibility
@@ -123,14 +74,6 @@ export default function PricingPage() {
-
-
-
- Because commercial use requires a license, @ngaf/chat is source-available rather than OSI open source. Threadplane keeps ecosystem packages (@ngaf/render, @ngaf/agent, @ngaf/langgraph, @ngaf/ag-ui, @ngaf/a2ui, @ngaf/licensing, @ngaf/telemetry, @ngaf/design-tokens) permissively MIT-licensed.
-
-
-
-
>
diff --git a/apps/website/src/app/thanks/page.tsx b/apps/website/src/app/thanks/page.tsx
index 1dd98470..cf2f5cb9 100644
--- a/apps/website/src/app/thanks/page.tsx
+++ b/apps/website/src/app/thanks/page.tsx
@@ -7,7 +7,7 @@ import { Button } from '../../components/ui/Button';
import { createPageMetadata } from '../../lib/site-metadata';
export const metadata = createPageMetadata({
- title: 'Payment received — Threadplane',
+ title: 'Payment received — ThreadPlane',
description: 'Your @ngaf/chat license token will be emailed shortly.',
pathname: '/thanks',
type: 'website',
@@ -57,8 +57,8 @@ export default function ThanksPage() {
If you don't see the email within 10 minutes, check spam or contact us.
-
- Installation docs
+
+ Installation & licensing
Contact support
diff --git a/apps/website/src/components/pricing/CompareTable.tsx b/apps/website/src/components/pricing/CompareTable.tsx
index a12417cc..d7861dc0 100644
--- a/apps/website/src/components/pricing/CompareTable.tsx
+++ b/apps/website/src/components/pricing/CompareTable.tsx
@@ -1,119 +1,434 @@
'use client';
-import { tokens } from '@ngaf/design-tokens';
-type TierKey = 'community' | 'indie' | 'seat' | 'app' | 'enterprise';
+import { useState } from 'react';
+import { tokens } from '@ngaf/design-tokens';
+import { Button } from '../ui/Button';
+import { trackCtaClick } from '../../lib/analytics/client';
+import type { CtaId } from '../../lib/analytics/events';
+import {
+ TIERS,
+ type TierConfig,
+ type BillingCycle,
+ annualDiscountPercent,
+} from '../../../../../pricing/tiers.config';
-interface Row {
- feature: string;
- cells: Record;
+interface PlanCta {
+ readonly cta: string;
+ readonly ctaId: CtaId;
+ readonly stripeBuyable?: boolean;
+ readonly ctaHref?: string;
+ readonly ctaExternal?: boolean;
}
-const TIERS: { key: TierKey; label: string }[] = [
- { key: 'community', label: 'Community' },
- { key: 'indie', label: 'Indie' },
- { key: 'seat', label: 'Developer Seat' },
- { key: 'app', label: 'App Deployment' },
- { key: 'enterprise', label: 'Enterprise' },
-];
-
-const ROWS: Row[] = [
- {
- feature: 'License model',
- cells: {
- community: 'PolyForm NC 1.0.0',
- indie: 'Commercial',
- seat: 'Commercial',
- app: 'Commercial',
- enterprise: 'Commercial + custom',
- },
+const CTAS: Record = {
+ community: {
+ cta: 'Start free',
+ ctaId: 'pricing_tier_community',
+ ctaHref: 'https://www.npmjs.com/package/@ngaf/chat',
+ ctaExternal: true,
},
- {
- feature: 'Commercial production use',
- cells: { community: false, indie: true, seat: true, app: true, enterprise: true },
+ developer_seat: {
+ cta: 'Get Developer Seat',
+ ctaId: 'pricing_tier_developer_seat',
+ stripeBuyable: true,
},
- {
- feature: 'Developers',
- cells: { community: 'Unlimited (noncommercial)', indie: '1', seat: 'Per seat', app: 'Unlimited', enterprise: 'Unlimited' },
+ team: {
+ cta: 'Get Team',
+ ctaId: 'pricing_tier_team',
+ stripeBuyable: true,
+ },
+ enterprise: {
+ cta: 'Talk to Sales',
+ ctaId: 'pricing_tier_enterprise',
+ ctaHref: '/contact?source=pricing_tier_enterprise',
},
+};
+
+type CellValue = boolean | string;
+interface FeatureRow {
+ feature: string;
+ cells: Record;
+}
+
+const LICENSING_ROWS: FeatureRow[] = [
{
- feature: 'Apps covered',
- cells: { community: 'Unlimited (noncommercial)', indie: '1', seat: 'All apps owned by your org', app: '1', enterprise: 'Multi-app' },
+ feature: 'Commercial',
+ cells: { community: false, developer_seat: true, team: true, enterprise: true },
},
{
- feature: 'End users',
- cells: { community: 'Unlimited', indie: 'Unlimited', seat: 'Unlimited', app: 'Unlimited', enterprise: 'Unlimited' },
+ feature: 'Developers',
+ cells: {
+ community: 'Unlimited (noncommercial)',
+ developer_seat: 'Per seat',
+ team: '5 included',
+ enterprise: 'Unlimited',
+ },
},
{
- feature: 'Environments (dev / staging / prod)',
- cells: { community: false, indie: true, seat: true, app: true, enterprise: true },
+ feature: '30-day commercial eval',
+ cells: { community: true, developer_seat: false, team: false, enterprise: false },
},
{
feature: 'Support',
- cells: { community: 'Community', indie: 'Email', seat: 'Email', app: 'Email', enterprise: 'Priority + private channel' },
+ cells: { community: 'GitHub', developer_seat: 'GitHub', team: 'Email', enterprise: 'Slack Connect' },
},
{
feature: 'SLA',
- cells: { community: false, indie: false, seat: false, app: false, enterprise: true },
+ cells: { community: false, developer_seat: false, team: false, enterprise: true },
},
{
- feature: 'Security review',
- cells: { community: false, indie: false, seat: false, app: false, enterprise: true },
+ feature: 'Pilot-to-Prod',
+ cells: { community: false, developer_seat: false, team: false, enterprise: 'Weekly 30-min check-in' },
},
];
-const Check = () => ✓ ;
-const X = () => — ;
+const FEATURE_ROWS: FeatureRow[] = [
+ { feature: 'Headless chat primitives', cells: allInclusive() },
+ { feature: 'Durable threads', cells: allInclusive() },
+ { feature: 'Interrupts (human-in-the-loop)', cells: allInclusive() },
+ { feature: 'Subagents + delegation', cells: allInclusive() },
+ { feature: 'Planning + memory', cells: allInclusive() },
+ { feature: 'Generative UI (json-render + A2UI)', cells: allInclusive() },
+ { feature: 'Signal-based streaming', cells: allInclusive() },
+ { feature: 'Citations + sources panel', cells: allInclusive() },
+ { feature: 'LangGraph + AG-UI adapters', cells: allInclusive() },
+ { feature: 'Theme presets (light/dark, Material 3)', cells: allInclusive() },
+];
+
+function allInclusive(): Record {
+ return { community: true, developer_seat: true, team: true, enterprise: true };
+}
+
+const Check = () => (
+ ✓
+);
+const Dash = () => (
+ —
+);
-function renderCell(value: boolean | string): React.ReactNode {
- if (typeof value === 'boolean') return value ? : ;
- return {value} ;
+function renderCell(value: CellValue): React.ReactNode {
+ if (typeof value === 'boolean') return value ? : ;
+ if (value === '—') return ;
+ return {value} ;
}
-export function CompareTable() {
+function PlanButton({ tier, cycle }: { tier: TierConfig; cycle: BillingCycle }) {
+ const cta = CTAS[tier.slug];
+ const variant = tier.highlight ? 'primary' : 'secondary';
+ const common = {
+ variant,
+ size: 'md' as const,
+ style: { width: '100%' },
+ };
+ if (cta.stripeBuyable) {
+ return (
+
+ );
+ }
+ return (
+
+ trackCtaClick({
+ surface: 'pricing',
+ destination_url: cta.ctaHref!,
+ cta_id: cta.ctaId,
+ cta_text: cta.cta,
+ })
+ }
+ >
+ {cta.cta}
+
+ );
+}
+
+const LABEL_COL_WIDTH = '22%';
+
+function BillingToggle({
+ cycle,
+ setCycle,
+ discountPct,
+}: {
+ cycle: BillingCycle;
+ setCycle: (c: BillingCycle) => void;
+ discountPct: number;
+}) {
+ const baseBtn = {
+ fontFamily: tokens.typography.fontSans,
+ fontSize: 13,
+ fontWeight: 600,
+ padding: '8px 16px',
+ border: 'none',
+ cursor: 'pointer',
+ transition: 'background 150ms ease, color 150ms ease',
+ background: 'transparent',
+ color: tokens.colors.textSecondary,
+ borderRadius: 999,
+ } as const;
+ const activeBtn = {
+ ...baseBtn,
+ background: tokens.colors.accent,
+ color: '#fff',
+ } as const;
+ return (
+
+
+ setCycle('monthly')}
+ style={cycle === 'monthly' ? activeBtn : baseBtn}
+ >
+ Monthly
+
+ setCycle('annual')}
+ style={cycle === 'annual' ? activeBtn : baseBtn}
+ >
+ Annual{discountPct > 0 ? ` — save ${discountPct}%` : ''}
+
+
+
+ );
+}
+
+function SectionTable({
+ title,
+ rows,
+ cycle,
+ showPrice,
+}: {
+ title: string;
+ rows: FeatureRow[];
+ cycle: BillingCycle;
+ showPrice: boolean;
+}) {
return (
-
+
-
+
-
+
- Feature
+ {title}
- {TIERS.map((t) => (
+ {TIERS.map((tier) => (
- {t.label}
+ {tier.highlight && (
+
+ MOST POPULAR
+
+ )}
+
+ {tier.name}
+
))}
+ {showPrice ? (
+
+
+ Price
+
+ {TIERS.map((tier) => {
+ const p = tier.prices[cycle];
+ return (
+
+
+
+ {p.display}
+
+ {p.period && (
+
+ {p.period}
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+
+ {TIERS.map((tier) => (
+
+ ))}
+
+ )}
- {ROWS.map((row) => (
+ {rows.map((row, i) => (
(e.currentTarget.style.background = tokens.colors.accentSurface)}
- onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+ style={{
+ borderBottom: i === rows.length - 1 ? 'none' : `1px solid ${tokens.surfaces.border}`,
+ }}
>
-
+
{row.feature}
- {TIERS.map((t) => (
-
- {renderCell(row.cells[t.key])}
+ {TIERS.map((tier) => (
+
+ {renderCell(row.cells[tier.slug])}
))}
@@ -121,6 +436,67 @@ export function CompareTable() {
+
+ );
+}
+
+function CtaStrip({ cycle }: { cycle: BillingCycle }) {
+ return (
+
+
+ {TIERS.map((tier) => (
+
+ ))}
+
+ );
+}
+
+export function CompareTable() {
+ const [cycle, setCycle] = useState('annual');
+ const discountPct = annualDiscountPercent();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/website/src/components/pricing/LeadForm.tsx b/apps/website/src/components/pricing/LeadForm.tsx
index ea604aae..1e23d715 100644
--- a/apps/website/src/components/pricing/LeadForm.tsx
+++ b/apps/website/src/components/pricing/LeadForm.tsx
@@ -9,8 +9,30 @@ import { Eyebrow } from '../ui/Eyebrow';
import { Button } from '../ui/Button';
import { Card } from '../ui/Card';
+const VALUE_PROPS = [
+ {
+ title: 'ThreadPlane Commercial license',
+ body: 'Multi-app coverage, unlimited developers, custom contract — built for procurement.',
+ },
+ {
+ title: 'SLA + security review',
+ body: 'Response SLAs, security questionnaires, and a private support channel.',
+ },
+ {
+ title: 'Pilot-to-Prod engagement',
+ body: '8-week concierge delivery. We ship your first Angular agent on your real data, in your real app — and your engineers own it at the end.',
+ highlight: true,
+ link: { href: '/pilot-to-prod', label: 'See how Pilot-to-Prod works →' },
+ },
+ {
+ title: 'Procurement support',
+ body: 'Master services agreement, security review, custom indemnification — handled by humans, not portals.',
+ },
+];
+
export function LeadForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
+ const [pilotInterest, setPilotInterest] = useState<'yes' | 'maybe' | 'no'>('maybe');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -25,7 +47,7 @@ export function LeadForm() {
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data),
+ body: JSON.stringify({ ...data, pilot_interest: pilotInterest }),
});
if (res.ok) {
track(analyticsEvents.marketingLeadFormSuccess, {
@@ -63,11 +85,11 @@ export function LeadForm() {
outline: 'none',
};
- const handleFocus = (e: React.FocusEvent) => {
+ const handleFocus = (e: React.FocusEvent) => {
e.target.style.borderColor = tokens.colors.accent;
e.target.style.boxShadow = tokens.shadows.focus;
};
- const handleBlur = (e: React.FocusEvent) => {
+ const handleBlur = (e: React.FocusEvent) => {
e.target.style.borderColor = tokens.surfaces.border;
e.target.style.boxShadow = 'none';
};
@@ -75,91 +97,302 @@ export function LeadForm() {
return (
diff --git a/apps/website/src/components/pricing/PricingFAQ.spec.tsx b/apps/website/src/components/pricing/PricingFAQ.spec.tsx
index cd4ba263..b630f89d 100644
--- a/apps/website/src/components/pricing/PricingFAQ.spec.tsx
+++ b/apps/website/src/components/pricing/PricingFAQ.spec.tsx
@@ -21,22 +21,21 @@ const EXPECTED_QUESTIONS = [
'Do my end users need licenses?',
'Can I modify the source?',
'Can I redistribute it?',
- 'What happens to older MIT versions?',
];
describe('PricingFAQ', () => {
it('renders the FAQ heading', () => {
render( );
expect(
- screen.getByRole('heading', { level: 2, name: 'Licensing FAQ' }),
+ screen.getByRole('heading', { level: 2, name: /Licensing FAQ/ }),
).toBeTruthy();
});
- it('renders all 7 questions as elements inside ', () => {
+ it('renders all questions as elements inside ', () => {
const { container } = render( );
const summaries = container.querySelectorAll('details > summary');
- expect(summaries.length).toBe(7);
- const texts = Array.from(summaries, (s) => s.textContent);
+ expect(summaries.length).toBe(EXPECTED_QUESTIONS.length);
+ const texts = Array.from(summaries, (s) => s.querySelector('span')?.textContent?.trim());
expect(texts).toEqual(EXPECTED_QUESTIONS);
});
diff --git a/apps/website/src/components/pricing/PricingFAQ.tsx b/apps/website/src/components/pricing/PricingFAQ.tsx
index 6febda73..f76f3286 100644
--- a/apps/website/src/components/pricing/PricingFAQ.tsx
+++ b/apps/website/src/components/pricing/PricingFAQ.tsx
@@ -2,13 +2,9 @@ import { tokens } from '@ngaf/design-tokens';
import { Container } from '../ui/Container';
import { Section } from '../ui/Section';
import { Eyebrow } from '../ui/Eyebrow';
+import { FAQ, type FAQItem } from '../ui/FAQ';
-interface QA {
- q: string;
- a: string;
-}
-
-const ITEMS: readonly QA[] = [
+const ITEMS: FAQItem[] = [
{
q: 'Is @ngaf/chat open source?',
a: '@ngaf/chat is source-available under the PolyForm Noncommercial License 1.0.0. Because commercial use requires a license, it is not OSI open source.',
@@ -19,7 +15,7 @@ const ITEMS: readonly QA[] = [
},
{
q: 'Can I use it at work?',
- a: 'You can evaluate it at work for 30 days. Production use in a commercial product, internal tool, SaaS app, or client deliverable requires a commercial license.',
+ a: 'You can evaluate it at work for 30 calendar days from your first commercial use. After that, production use in a commercial product, internal tool, SaaS app, or client deliverable requires a ThreadPlane Commercial license. The eval window is good-faith — no telemetry, no registration.',
},
{
q: 'Do my end users need licenses?',
@@ -27,24 +23,22 @@ const ITEMS: readonly QA[] = [
},
{
q: 'Can I modify the source?',
- a: 'Yes, for permitted noncommercial use under the PolyForm Noncommercial license, or for commercial production use under a paid Threadplane license.',
+ a: 'Yes, for permitted noncommercial use under the PolyForm Noncommercial license, or for commercial production use under a paid ThreadPlane Commercial license.',
},
{
q: 'Can I redistribute it?',
a: 'You may bundle it inside a larger licensed application. You may not redistribute it as a standalone package or as part of a competing component library, SDK, template kit, app builder, or design system.',
},
- {
- q: 'What happens to older MIT versions?',
- a: 'Versions previously released under MIT remain available under their original license terms. The new license applies only to future versions where the license change is introduced.',
- },
];
export function PricingFAQ() {
return (
-
+
-
-
FAQ
+
+
+ Questions
+
- Licensing FAQ
+ Licensing FAQ.
-
- {ITEMS.map((item) => (
-
-
- {item.q}
-
-
- {item.a}
-
-
- ))}
-
+
+
+
diff --git a/apps/website/src/components/pricing/PricingGrid.tsx b/apps/website/src/components/pricing/PricingGrid.tsx
deleted file mode 100644
index 823a04c3..00000000
--- a/apps/website/src/components/pricing/PricingGrid.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-'use client';
-
-import { tokens } from '@ngaf/design-tokens';
-import { Container } from '../ui/Container';
-import { Section } from '../ui/Section';
-import { Card } from '../ui/Card';
-import { Button } from '../ui/Button';
-import { Eyebrow } from '../ui/Eyebrow';
-import { trackCtaClick } from '../../lib/analytics/client';
-import type { CtaId } from '../../lib/analytics/events';
-import { TIERS, type TierConfig } from '../../../../../pricing/tiers.config';
-
-interface PlanCta {
- readonly cta: string;
- readonly ctaId: CtaId;
- /** Set for tiers that route to Stripe via a POST form. */
- readonly stripeBuyable?: boolean;
- /** Set for tiers that link directly (community = npm, enterprise = /contact). */
- readonly ctaHref?: string;
- readonly ctaExternal?: boolean;
-}
-
-const CTAS: Record
= {
- community: {
- cta: 'Start free',
- ctaId: 'pricing_tier_community',
- ctaHref: 'https://www.npmjs.com/package/@ngaf/chat',
- ctaExternal: true,
- },
- indie: {
- cta: 'Buy indie license',
- ctaId: 'pricing_tier_indie',
- stripeBuyable: true,
- },
- developer_seat: {
- cta: 'Buy developer seat',
- ctaId: 'pricing_tier_developer_seat',
- stripeBuyable: true,
- },
- app_deployment: {
- cta: 'License an app',
- ctaId: 'pricing_tier_app_deployment',
- stripeBuyable: true,
- },
- enterprise: {
- cta: 'Contact sales',
- ctaId: 'pricing_tier_enterprise',
- ctaHref: '/contact?source=pricing_tier_enterprise',
- },
-};
-
-export function PricingGrid() {
- return (
-
-
-
- {TIERS.map((tier) => {
- const cta = CTAS[tier.slug];
- return (
-
- {tier.name}
-
- {tier.displayPrice}
-
-
- {tier.displayPeriod}
-
-
- {tier.features.map((feature) => (
-
-
- ✓
-
- {feature}
-
- ))}
-
- {cta.stripeBuyable ? (
-
- ) : (
-
- trackCtaClick({
- surface: 'pricing',
- destination_url: cta.ctaHref!,
- cta_id: cta.ctaId,
- cta_text: cta.cta,
- })
- }
- >
- {cta.cta}
-
- )}
-
- );
- })}
-
-
-
- );
-}
diff --git a/apps/website/src/components/shared/Footer.tsx b/apps/website/src/components/shared/Footer.tsx
index 2cb665bd..459341b9 100644
--- a/apps/website/src/components/shared/Footer.tsx
+++ b/apps/website/src/components/shared/Footer.tsx
@@ -295,8 +295,8 @@ export function Footer() {
onMouseLeave={(e) => (e.currentTarget.style.color = tokens.colors.textSecondary)}>
npm Package
- trackFooterCta('Licensing', '/pricing#faq')}
+ trackFooterCta('Licensing', '/docs/licensing')}
onMouseEnter={(e) => (e.currentTarget.style.color = tokens.colors.accent)}
onMouseLeave={(e) => (e.currentTarget.style.color = tokens.colors.textSecondary)}>
Licensing
@@ -310,9 +310,9 @@ export function Footer() {
© {new Date().getFullYear()} Agent UI for Angular. All rights reserved.
trackFooterCta('Licensing Bottom', '/pricing#faq')}
+ onClick={() => trackFooterCta('Licensing Bottom', '/docs/licensing')}
onMouseEnter={(e) => (e.currentTarget.style.color = tokens.colors.accent)}
onMouseLeave={(e) => (e.currentTarget.style.color = tokens.colors.textMuted)}
>
diff --git a/apps/website/src/components/shared/Nav.tsx b/apps/website/src/components/shared/Nav.tsx
index f2fdbc81..cc2f6c03 100644
--- a/apps/website/src/components/shared/Nav.tsx
+++ b/apps/website/src/components/shared/Nav.tsx
@@ -11,6 +11,7 @@ import { Button } from '../ui/Button';
const links = [
{ label: 'Pilot to Prod', href: '/pilot-to-prod', external: false },
{ label: 'Docs', href: '/docs', external: false },
+ { label: 'Pricing', href: '/pricing', external: false },
{ label: 'Demo', href: 'https://demo.threadplane.ai', external: true },
{ label: 'Examples', href: 'https://cockpit.threadplane.ai', external: true },
];
diff --git a/apps/website/src/lib/analytics/events.ts b/apps/website/src/lib/analytics/events.ts
index dbaa31aa..d1738d1a 100644
--- a/apps/website/src/lib/analytics/events.ts
+++ b/apps/website/src/lib/analytics/events.ts
@@ -62,9 +62,8 @@ export type CtaId =
| 'home_why_pilot_to_prod'
// Pricing tier CTAs
| 'pricing_tier_community'
- | 'pricing_tier_indie'
| 'pricing_tier_developer_seat'
- | 'pricing_tier_app_deployment'
+ | 'pricing_tier_team'
| 'pricing_tier_enterprise'
// Footer licensing links
| 'footer_licensing'
diff --git a/libs/chat/CHANGELOG.md b/libs/chat/CHANGELOG.md
index de575956..c54d779a 100644
--- a/libs/chat/CHANGELOG.md
+++ b/libs/chat/CHANGELOG.md
@@ -4,8 +4,8 @@
### Changed
-- **License:** Changed the license for `@ngaf/chat` from MIT to PolyForm Noncommercial 1.0.0 plus commercial licensing. This change applies to future versions only. Historical MIT releases remain under their original license terms.
+- **License:** `@ngaf/chat` is dual-licensed under PolyForm Noncommercial 1.0.0 (free noncommercial use) or a ThreadPlane Commercial license (production use inside a for-profit context).
### Migration
-Commercial users upgrading to this version or later need a Threadplane commercial license before production deployment. See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial use and the 30-day evaluation window, and for plans.
+Commercial users need a ThreadPlane Commercial license before production deployment. See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial use and the 30-day evaluation window, and for plans.
diff --git a/libs/chat/COMMERCIAL-USE.md b/libs/chat/COMMERCIAL-USE.md
index 6e6ed871..2c64e6eb 100644
--- a/libs/chat/COMMERCIAL-USE.md
+++ b/libs/chat/COMMERCIAL-USE.md
@@ -19,7 +19,7 @@ Free under the PolyForm Noncommercial 1.0.0 license:
## Evaluation
-Commercial evaluation is free for 30 days. Commercial production deployment requires a paid Threadplane license.
+You may use `@ngaf/chat` commercially for 30 calendar days from your first commercial use as a good-faith evaluation. There is no telemetry or tracking — we trust you to count the days. After 30 days you must either purchase a ThreadPlane Commercial license or stop the commercial use. Continued commercial deployment beyond the evaluation window requires a paid ThreadPlane Commercial license.
## Learn more
diff --git a/libs/chat/NOTICE.md b/libs/chat/NOTICE.md
index 07892c1c..f256af8e 100644
--- a/libs/chat/NOTICE.md
+++ b/libs/chat/NOTICE.md
@@ -1,7 +1,7 @@
# `@ngaf/chat` Notice
-Copyright © 2026 Threadplane. All rights reserved.
+Copyright © 2026 ThreadPlane. All rights reserved.
-`@ngaf/chat` is dual-licensed: PolyForm Noncommercial 1.0.0 for free noncommercial use, or a paid Threadplane commercial license. See [LICENSE.md](./LICENSE.md), [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md), and [COMMERCIAL-USE.md](./COMMERCIAL-USE.md).
+`@ngaf/chat` is dual-licensed: PolyForm Noncommercial 1.0.0 for free noncommercial use, or a paid ThreadPlane Commercial license. See [LICENSE.md](./LICENSE.md), [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md), and [COMMERCIAL-USE.md](./COMMERCIAL-USE.md).
Built on top of the wider Angular Agent UI ecosystem. The other libraries (`@ngaf/render`, `@ngaf/agent`, `@ngaf/langgraph`, `@ngaf/ag-ui`, `@ngaf/a2ui`, `@ngaf/licensing`, `@ngaf/telemetry`, `@ngaf/design-tokens`) remain MIT-licensed.
diff --git a/libs/chat/README.md b/libs/chat/README.md
index b6b0c29d..8ce6ffff 100644
--- a/libs/chat/README.md
+++ b/libs/chat/README.md
@@ -4,25 +4,25 @@ Drop-in agent chat UI for Angular 20+. Headless primitives that read from a runt
Part of [Agent UI for Angular](https://github.com/cacheplane/angular-agent-framework).
-`@ngaf/chat` is source-available and free for noncommercial use under the PolyForm Noncommercial License 1.0.0. Commercial production use requires a Threadplane commercial license.
+`@ngaf/chat` is source-available and free for noncommercial use under PolyForm Noncommercial License 1.0.0. Commercial production use requires a ThreadPlane Commercial license.
-This package is not licensed as OSI open source because commercial use requires a license. Threadplane uses a source-available model for `@ngaf/chat` while keeping protocol and ecosystem packages permissively licensed where appropriate.
+This package is not licensed as OSI open source because commercial use requires a license. ThreadPlane uses a source-available model for `@ngaf/chat` while keeping protocol and ecosystem packages permissively licensed where appropriate.
## Commercial use
-Building a commercial product, SaaS application, internal business tool, agency deliverable, or paid client project with `@ngaf/chat` requires a commercial license.
+Building a commercial product, SaaS application, internal business tool, agency deliverable, or paid client project with `@ngaf/chat` requires a ThreadPlane Commercial license.
Free under PolyForm Noncommercial:
- Personal, hobby, student, academic, nonprofit, public-demo use
- Open-source applications released under an OSI-approved license
-- Evaluation and prototyping (commercial evaluation is free for 30 days)
+- 30 calendar days of commercial evaluation from your first commercial use (good-faith — no tracking, no email required)
-See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial use, [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md) for the commercial license summary, and the [Threadplane pricing page](https://threadplane.ai/pricing) for plans.
+See [COMMERCIAL-USE.md](./COMMERCIAL-USE.md) for the definition of commercial use, [LICENSE-COMMERCIAL.md](./LICENSE-COMMERCIAL.md) for the commercial license summary, and the [ThreadPlane pricing page](https://threadplane.ai/pricing) for plans.
## Using a commercial license
-After purchase, Threadplane emails a signed license token to the address on your receipt. Paste it into your app's `provideChat()` configuration:
+After purchase, ThreadPlane emails a signed license token to the address on your receipt. The license is valid for 12 months and the same email contains the token to paste into your app's `provideChat()` configuration:
```typescript
// app.config.ts
@@ -38,16 +38,16 @@ export const appConfig: ApplicationConfig = {
};
```
-The library verifies the token's signature on boot. A missing, expired, or tampered token logs a `console.warn` advisory but does not block rendering — chat continues to work either way. Tokens are validated offline; no calls to Threadplane are made at runtime.
+The library verifies the token's signature on boot. A missing, expired, or tampered token logs a `console.warn` advisory but does not block rendering — chat continues to work either way. Tokens are validated offline; no calls to ThreadPlane are made at runtime.
The license string is safe to commit to source control if your repository is private, or to read from a build-time env var for public repositories:
```typescript
-declare const NGAF_LICENSE_TOKEN: string | undefined;
+declare const THREADPLANE_LICENSE: string | undefined;
providers: [
provideChat({
- license: typeof NGAF_LICENSE_TOKEN === 'string' ? NGAF_LICENSE_TOKEN : undefined,
+ license: typeof THREADPLANE_LICENSE === 'string' ? THREADPLANE_LICENSE : undefined,
}),
],
```
diff --git a/libs/licensing/src/lib/license-token.ts b/libs/licensing/src/lib/license-token.ts
index 7d457576..176b348e 100644
--- a/libs/licensing/src/lib/license-token.ts
+++ b/libs/licensing/src/lib/license-token.ts
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
/** The tier a license grants. */
-export type LicenseTier = 'indie' | 'developer_seat' | 'app_deployment' | 'enterprise';
+export type LicenseTier = 'developer_seat' | 'team' | 'enterprise';
/** Claims carried inside a signed license token. */
export interface LicenseClaims {
@@ -52,9 +52,8 @@ function isLicenseClaims(value: unknown): value is LicenseClaims {
const seats = v['seats'];
return (
typeof v['sub'] === 'string' &&
- (tier === 'indie' ||
- tier === 'developer_seat' ||
- tier === 'app_deployment' ||
+ (tier === 'developer_seat' ||
+ tier === 'team' ||
tier === 'enterprise') &&
typeof v['iat'] === 'number' &&
typeof v['exp'] === 'number' &&
diff --git a/libs/licensing/src/lib/sign-license.spec.ts b/libs/licensing/src/lib/sign-license.spec.ts
index d017085f..6087703f 100644
--- a/libs/licensing/src/lib/sign-license.spec.ts
+++ b/libs/licensing/src/lib/sign-license.spec.ts
@@ -29,7 +29,7 @@ describe('signLicense', () => {
const privateKey = ed.utils.randomPrivateKey();
const claims: LicenseClaims = {
sub: 'cus_abc',
- tier: 'app_deployment',
+ tier: 'team',
iat: 1_700_000_000,
exp: 1_800_000_000,
seats: 1,
diff --git a/pricing/tiers.config.ts b/pricing/tiers.config.ts
index 5d9055a0..1018a77d 100644
--- a/pricing/tiers.config.ts
+++ b/pricing/tiers.config.ts
@@ -2,30 +2,45 @@
/**
* Single source of truth for /pricing tier display and Stripe product sync.
* Read by:
- * - apps/website/src/components/pricing/PricingGrid.tsx (display)
+ * - apps/website/src/components/pricing/CompareTable.tsx (display)
* - scripts/stripe/sync-products.ts (Stripe-side products + prices)
*
* Stripe products are identified by `metadata.ngaf_tier_slug = slug`. Never
* rely on product name to match — names are display copy and may change.
+ *
+ * Pricing model: every paid tier has BOTH a monthly and an annual recurring
+ * price. The annual price is a discount over 12 × monthly. The pricing page
+ * exposes a Monthly | Annual toggle; default is Annual.
*/
export type TierSlug =
| 'community'
- | 'indie'
| 'developer_seat'
- | 'app_deployment'
+ | 'team'
| 'enterprise';
+export type BillingCycle = 'monthly' | 'annual';
+
+export interface TierPrice {
+ /** USD cents for this billing cycle. null for free / custom. */
+ readonly cents: number | null;
+ /** Display value, e.g. "$29" or "$299". */
+ readonly display: string;
+ /** Period suffix shown inline after the price, e.g. "/dev/mo" or "/dev/yr". */
+ readonly period: string;
+}
+
export interface TierConfig {
readonly slug: TierSlug;
readonly name: string;
- /** USD cents. null for free / custom. */
- readonly priceCents: number | null;
- readonly displayPrice: string;
- readonly displayPeriod: string;
+ readonly prices: Record;
+ /** Subtitle under the price; replaces the standalone period gray subline. */
+ readonly subtitle: string;
readonly features: readonly string[];
+ /** Short one-liner shown in its own row below the features. */
+ readonly bestFor: string;
/** false → community (npm), enterprise (sales). true → real Stripe product + price. */
readonly stripeBuyable: boolean;
- /** Highlighted card in the PricingGrid. */
+ /** Highlighted card / column in the pricing table. */
readonly highlight: boolean;
/** Checkout `adjustable_quantity` enabled. Only Developer Seat today. */
readonly adjustableQuantity?: boolean;
@@ -33,86 +48,74 @@ export interface TierConfig {
readonly defaultQuantity?: number;
}
+const FREE: TierPrice = { cents: null, display: 'Free', period: '' };
+
export const TIERS: readonly TierConfig[] = [
{
slug: 'community',
- name: 'Community / Noncommercial',
- priceCents: null,
- displayPrice: 'Free',
- displayPeriod: 'forever',
+ name: 'Community',
+ prices: { monthly: FREE, annual: FREE },
+ subtitle: 'forever',
features: [
- 'Personal, student, academic, nonprofit, demo',
+ 'Personal, OSS, demos',
'Source access',
- 'Noncommercial use',
- 'Commercial evaluation (30 days)',
- 'License: PolyForm Noncommercial 1.0.0',
+ '30-day commercial eval',
],
+ bestFor: 'Tinkering, OSS projects, students',
stripeBuyable: false,
highlight: false,
},
- {
- slug: 'indie',
- name: 'Indie Commercial',
- priceCents: 14900,
- displayPrice: '$149',
- displayPeriod: '/year',
- features: [
- '1 developer',
- '1 commercial app',
- 'Unlimited end users',
- 'Commercial license',
- 'Best for: solo devs, indie products, consultants with one app',
- ],
- stripeBuyable: true,
- highlight: false,
- },
{
slug: 'developer_seat',
name: 'Developer Seat',
- priceCents: 29900,
- displayPrice: '$299',
- displayPeriod: '/developer/year',
+ prices: {
+ monthly: { cents: 2900, display: '$29', period: '/dev/mo' },
+ annual: { cents: 29900, display: '$299', period: '/dev/yr' },
+ },
+ subtitle: 'per developer',
features: [
- 'Commercial use',
- 'Unlimited end users',
- 'Dev / staging / production',
- 'Apps owned by your org',
- 'Best for: startups & growing teams',
+ 'Per developer seat',
+ 'Unlimited apps',
+ 'GitHub support',
],
+ bestFor: 'Solo devs, growing teams',
stripeBuyable: true,
- highlight: true,
+ highlight: false,
adjustableQuantity: true,
defaultQuantity: 1,
},
{
- slug: 'app_deployment',
- name: 'App Deployment',
- priceCents: 149900,
- displayPrice: '$1,499',
- displayPeriod: '/app/year',
+ slug: 'team',
+ name: 'Team',
+ prices: {
+ monthly: { cents: 14900, display: '$149', period: '/mo' },
+ annual: { cents: 149500, display: '$1,495', period: '/yr' },
+ },
+ subtitle: '5 developer seats',
features: [
- 'Unlimited developers',
- '1 production app',
- 'Unlimited end users',
- 'Procurement-friendly',
- 'Best for: agencies, CI/CD-heavy teams',
+ '5 developer seats included',
+ 'Unlimited apps',
+ 'Email support',
],
+ bestFor: 'Procurement-friendly small teams',
stripeBuyable: true,
- highlight: false,
+ highlight: true,
},
{
slug: 'enterprise',
name: 'Enterprise',
- priceCents: null,
- displayPrice: 'Custom',
- displayPeriod: 'starting at $10k/year',
+ // Enterprise is sales-led — same "From $4,000/mo" label regardless of cycle.
+ prices: {
+ monthly: { cents: null, display: 'From $4,000', period: '/mo' },
+ annual: { cents: null, display: 'From $4,000', period: '/mo' },
+ },
+ subtitle: 'annual contract',
features: [
- 'Custom contract & SLA',
- 'Procurement support',
- 'Security review',
- 'Multi-app licensing',
- 'Priority + private support channel',
+ 'Pilot-to-Prod engagement',
+ 'Slack Connect support',
+ 'SLA + private channel',
],
+ bestFor: 'Procurement-led orgs',
stripeBuyable: false,
highlight: false,
},
@@ -125,3 +128,30 @@ export function getTier(slug: TierSlug): TierConfig {
if (!t) throw new Error(`Unknown tier slug: ${slug}`);
return t;
}
+
+/**
+ * Annual savings for a tier, in dollars (rounded). 0 if either price is null
+ * or annual is not actually a discount.
+ */
+export function annualSavingsDollars(tier: TierConfig): number {
+ const m = tier.prices.monthly.cents;
+ const a = tier.prices.annual.cents;
+ if (m == null || a == null) return 0;
+ const annualizedMonthly = m * 12;
+ const savings = annualizedMonthly - a;
+ return savings > 0 ? Math.round(savings / 100) : 0;
+}
+
+/**
+ * Compute the global "save N%" badge shown on the Annual toggle. We use the
+ * Team tier as the canonical example since it's the highlighted plan.
+ */
+export function annualDiscountPercent(): number {
+ const team = TIERS.find((t) => t.slug === 'team');
+ if (!team) return 0;
+ const m = team.prices.monthly.cents;
+ const a = team.prices.annual.cents;
+ if (m == null || a == null) return 0;
+ const annualizedMonthly = m * 12;
+ return Math.round((1 - a / annualizedMonthly) * 100);
+}
diff --git a/pricing/tiers.generated.ts b/pricing/tiers.generated.ts
index d278d2aa..aedd6752 100644
--- a/pricing/tiers.generated.ts
+++ b/pricing/tiers.generated.ts
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MIT
// Generated by scripts/stripe/sync-products.ts. Do not edit by hand.
-import type { TierSlug } from './tiers.config';
+import type { TierSlug, BillingCycle } from './tiers.config';
-export const STRIPE_PRICE_IDS: Partial, string>> = {
- app_deployment: "price_1TZbHyGYRsLErhxb57O1CSEG",
- developer_seat: "price_1TZbHxGYRsLErhxb2u82hupX",
- indie: "price_1TZbHxGYRsLErhxbQfo00q9Q",
-};
+type BuyableSlug = Exclude;
+
+// Empty stub — run `STRIPE_SECRET_KEY=sk_... pnpm tsx scripts/stripe/sync-products.ts`
+// to populate. Until that runs, the checkout API returns a 503 with a helpful
+// message.
+export const STRIPE_PRICE_IDS: Partial>> = {};
diff --git a/scripts/stripe/sync-products.ts b/scripts/stripe/sync-products.ts
index 67d60e74..a726928b 100644
--- a/scripts/stripe/sync-products.ts
+++ b/scripts/stripe/sync-products.ts
@@ -3,23 +3,33 @@
* Idempotent Stripe products + prices sync.
*
* Reads pricing/tiers.config.ts and ensures each `stripeBuyable: true` tier
- * has a Stripe product (matched by metadata.ngaf_tier_slug) and exactly one
- * active one-time price. Writes the resulting price IDs to
- * pricing/tiers.generated.ts.
+ * has a Stripe product (matched by metadata.ngaf_tier_slug) and two active
+ * recurring prices: one monthly and one annual. Writes the resulting price
+ * IDs to pricing/tiers.generated.ts.
*
* Usage:
* STRIPE_SECRET_KEY=sk_test_... pnpm tsx scripts/stripe/sync-products.ts
*
* Re-running is safe: products are matched by metadata, prices are reused if
- * the unit_amount matches, otherwise the old price is archived and a new one
- * created.
+ * the unit_amount and interval match, otherwise stale prices are archived
+ * and new ones created.
*/
import Stripe from 'stripe';
import fs from 'node:fs';
import path from 'node:path';
-import { BUYABLE_TIERS, type TierConfig } from '../../pricing/tiers.config';
+import {
+ BUYABLE_TIERS,
+ type TierConfig,
+ type BillingCycle,
+} from '../../pricing/tiers.config';
const METADATA_KEY = 'ngaf_tier_slug';
+const CYCLE_KEY = 'ngaf_billing_cycle';
+
+interface SyncedPriceIds {
+ monthly: string;
+ annual: string;
+}
async function findOrCreateProduct(stripe: Stripe, tier: TierConfig): Promise {
const search = await stripe.products.search({
@@ -43,51 +53,71 @@ async function findOrCreatePrice(
stripe: Stripe,
product: Stripe.Product,
tier: TierConfig,
+ cycle: BillingCycle,
): Promise {
- if (tier.priceCents === null) {
- throw new Error(`Tier ${tier.slug} has null priceCents but is marked stripeBuyable`);
+ const cents = tier.prices[cycle].cents;
+ if (cents === null) {
+ throw new Error(`Tier ${tier.slug} cycle ${cycle} has null cents but is marked stripeBuyable`);
}
- const prices = await stripe.prices.list({ product: product.id, active: true, limit: 10 });
+ const interval: Stripe.PriceCreateParams.Recurring.Interval = cycle === 'monthly' ? 'month' : 'year';
+
+ const prices = await stripe.prices.list({ product: product.id, active: true, limit: 20 });
const match = prices.data.find(
- (p) => p.unit_amount === tier.priceCents && p.currency === 'usd' && p.type === 'one_time',
+ (p) =>
+ p.unit_amount === cents &&
+ p.currency === 'usd' &&
+ p.type === 'recurring' &&
+ p.recurring?.interval === interval,
);
if (match) return match;
- // Archive any active prices that don't match (one active price per tier).
+ // Archive any active recurring prices for this cycle that don't match
+ // (one active price per (tier, cycle)).
for (const stale of prices.data) {
- await stripe.prices.update(stale.id, { active: false });
+ if (stale.type === 'recurring' && stale.recurring?.interval === interval) {
+ await stripe.prices.update(stale.id, { active: false });
+ }
}
return stripe.prices.create({
product: product.id,
currency: 'usd',
- unit_amount: tier.priceCents,
- metadata: { [METADATA_KEY]: tier.slug },
+ unit_amount: cents,
+ recurring: { interval },
+ metadata: { [METADATA_KEY]: tier.slug, [CYCLE_KEY]: cycle },
});
}
-function renderGeneratedFile(idsBySlug: Record): string {
+function renderGeneratedFile(idsBySlug: Record): string {
const entries = Object.entries(idsBySlug)
.sort(([a], [b]) => a.localeCompare(b))
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`)
+ .map(
+ ([slug, ids]) =>
+ ` ${slug}: { monthly: ${JSON.stringify(ids.monthly)}, annual: ${JSON.stringify(ids.annual)} },`,
+ )
.join('\n');
return `// SPDX-License-Identifier: MIT
// Generated by scripts/stripe/sync-products.ts. Do not edit by hand.
-import type { TierSlug } from './tiers.config';
+import type { TierSlug, BillingCycle } from './tiers.config';
+
+type BuyableSlug = Exclude;
-export const STRIPE_PRICE_IDS: Partial, string>> = {
+export const STRIPE_PRICE_IDS: Partial>> = {
${entries}
};
`;
}
-export async function syncProducts(stripe: Stripe): Promise> {
- const idsBySlug: Record = {};
+export async function syncProducts(stripe: Stripe): Promise> {
+ const idsBySlug: Record = {};
for (const tier of BUYABLE_TIERS) {
const product = await findOrCreateProduct(stripe, tier);
- const price = await findOrCreatePrice(stripe, product, tier);
- idsBySlug[tier.slug] = price.id;
- console.log(`✓ ${tier.slug}: product=${product.id} price=${price.id} (${tier.priceCents}¢)`);
+ const monthly = await findOrCreatePrice(stripe, product, tier, 'monthly');
+ const annual = await findOrCreatePrice(stripe, product, tier, 'annual');
+ idsBySlug[tier.slug] = { monthly: monthly.id, annual: annual.id };
+ console.log(
+ `✓ ${tier.slug}: product=${product.id} monthly=${monthly.id} (${tier.prices.monthly.cents}¢) annual=${annual.id} (${tier.prices.annual.cents}¢)`,
+ );
}
return idsBySlug;
}