diff --git a/web/app/pricing/page.tsx b/web/app/pricing/page.tsx
new file mode 100644
index 00000000..b3101015
--- /dev/null
+++ b/web/app/pricing/page.tsx
@@ -0,0 +1,191 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { ArrowRight, Bot, History, Monitor, Users } from 'lucide-react';
+
+import { FadeIn } from '../../components/FadeIn';
+import { SiteFooter } from '../../components/SiteFooter';
+import { SiteNav } from '../../components/SiteNav';
+import { defaultOgImage } from '../../lib/og-meta';
+import {
+ BETA_PRICING_ACTIVE,
+ BETA_PRICING_NOTE,
+ displayedPrice,
+ limitLines,
+ pricingTiers,
+ showsBetaPrice,
+ type PricingTier,
+} from '../../lib/pricing';
+import { absoluteUrl, SITE_NAME } from '../../lib/site';
+import s from './pricing.module.css';
+
+const pricingDescription =
+ 'Agent Relay plans: one flat monthly price per organization for devices, agents, humans, and session history. Intro, Pro, and Enterprise.';
+
+export const metadata: Metadata = {
+ title: 'Pricing',
+ description: pricingDescription,
+ alternates: {
+ canonical: absoluteUrl('/pricing'),
+ },
+ openGraph: {
+ siteName: SITE_NAME,
+ title: 'Pricing | Agent Relay',
+ description: pricingDescription,
+ url: absoluteUrl('/pricing'),
+ type: 'website',
+ images: [defaultOgImage()],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Pricing | Agent Relay',
+ description: pricingDescription,
+ images: [defaultOgImage().url],
+ },
+};
+
+const limitIcons = [Monitor, Bot, Users, History] as const;
+
+const faq = [
+ {
+ question: 'What counts as a device?',
+ answer:
+ 'A device is a node connected to your organization: a laptop, a CI runner, or a cloud VM running agent relay. One device can host many agents. Agents and humans are counted separately from devices.',
+ },
+ {
+ question: 'What happens when I reach a limit?',
+ answer:
+ 'The next device, agent, or human past your plan’s cap is refused with a clear message. Nothing already connected is disconnected. The desktop app tells you which limit you reached and links to upgrade.',
+ },
+ {
+ question: 'Can I change plans?',
+ answer:
+ 'Yes. Upgrade from Intro to Pro, or cancel, from your organization’s billing settings. Plans are monthly and billed per organization, not per seat. Enterprise is set up with our team.',
+ },
+ {
+ question: 'How do limits apply to existing customers?',
+ answer:
+ 'Existing customers keep their current access. Organizations created before plans launched are not held to the Intro or Pro caps and are not asked to pick a plan.',
+ },
+];
+
+function Price({ tier }: { tier: PricingTier }) {
+ const price = displayedPrice(tier);
+
+ if (price === null) {
+ return (
+
Docs
@@ -171,6 +176,11 @@ export function SiteNav({
Log in
)}
+ {!hideLinks && (
+ setMenuOpen(false)}>
+ Pricing
+
+ )}
{!hideLinks && !hideMobileDocsLink && (
setMenuOpen(false)}>
Docs
diff --git a/web/content/docs/plans-and-limits.mdx b/web/content/docs/plans-and-limits.mdx
new file mode 100644
index 00000000..8f27224f
--- /dev/null
+++ b/web/content/docs/plans-and-limits.mdx
@@ -0,0 +1,72 @@
+---
+title: 'Plans and limits'
+description: 'Agent Relay plans are flat monthly prices per organization. Each plan caps devices, agents, and humans, and sets how long session history is kept.'
+---
+
+An organization subscribes to one plan. The plan is a flat monthly price for the whole organization, not a per-seat charge, and it sets four limits: devices, agents, humans, and session history retention.
+
+| | Intro | Pro | Enterprise |
+|---|---|---|---|
+| Devices | 5 | 15 | Unlimited |
+| Agents | 15 | 60 | Unlimited |
+| Humans | 2 | 10 | Unlimited |
+| Session history | 7 days | 14 days | 365 days |
+| Price | $99 / month | $199 / month | Sales-only |
+
+Current prices, including beta pricing, are on the [pricing page](/pricing).
+
+## What the limits count
+
+- **Devices** are nodes connected to the organization: a laptop, a CI runner, or a cloud VM running agent relay. A device hosts any number of agents.
+- **Agents** are the agent identities registered in the organization's workspaces, across every device.
+- **Humans** are the human members of the organization.
+- **Session history** is how long session records stay readable. Older sessions are no longer available.
+
+## Reaching a limit
+
+Limits are hard caps. The next device, agent, or human past the cap is refused; everything already connected keeps working. The API responds with HTTP `429` and a machine-readable body:
+
+```json
+{
+ "error_code": "plan_limit_exceeded",
+ "limit": "agents",
+ "max": 15,
+ "current": 15,
+ "plan": "intro"
+}
+```
+
+`limit` is one of `devices`, `agents`, or `humans`. The desktop app recognizes `plan_limit_exceeded`, names the limit, and links to the organization's billing settings to upgrade.
+
+## Subscribing and changing plans
+
+- A new organization has no plan until it subscribes. Signing up from the pricing page takes you straight to plan selection and checkout; the card is charged immediately and the plan is active right away.
+- Intro and Pro are self-serve. Upgrade or cancel from the organization's billing settings in the Cloud dashboard.
+- Enterprise is assigned by the Agent Relay team for invoiced customers. [Talk to us](https://agentrelay.com/will) to set it up.
+
+## Beta pricing
+
+While the beta is on, Intro and Pro checkouts receive the beta price automatically. A subscription that starts at the beta price keeps it for as long as the subscription stays active. When the beta ends, new subscriptions pay the list price; existing beta subscriptions are unchanged.
+
+## Existing customers
+
+Existing customers keep their current access. Organizations created before plans launched are not held to the Intro or Pro caps, are not asked to choose a plan, and can keep using Agent Relay as before.
+
+## Reading the current plan
+
+`GET /api/v1/auth/whoami` returns the current workspace's plan alongside its limits, so clients can show usage against the cap before a request is refused:
+
+```json
+{
+ "current_workspace": {
+ "plan": {
+ "key": "intro",
+ "name": "Intro",
+ "limits": { "devices": 5, "agents": 15, "humans": 2, "session_retention_days": 7 },
+ "upgrade_url": "https://agentrelay.com/cloud/dashboard/settings#billing"
+ }
+ }
+}
+```
+
+`plan` is `null` when the organization has no active plan. `null` inside `limits` means unlimited.
diff --git a/web/lib/docs-nav.ts b/web/lib/docs-nav.ts
index 3befba05..c3ef7d83 100644
--- a/web/lib/docs-nav.ts
+++ b/web/lib/docs-nav.ts
@@ -15,6 +15,7 @@ export const docsNav: NavGroup[] = [
items: [
{ title: 'Introduction', slug: 'introduction' },
{ title: 'Quickstart', slug: 'quickstart' },
+ { title: 'Plans and limits', slug: 'plans-and-limits' },
],
},
{
diff --git a/web/lib/pricing.ts b/web/lib/pricing.ts
new file mode 100644
index 00000000..3be0b48b
--- /dev/null
+++ b/web/lib/pricing.ts
@@ -0,0 +1,108 @@
+import { googleSignInHref } from './login';
+
+/**
+ * Agent Relay plans as the pricing page and docs present them. Limits mirror
+ * the canonical `PLAN_LIMITS` table Cloud enforces; `null` means unlimited.
+ */
+export type PlanKey = 'intro' | 'pro' | 'enterprise';
+
+export type PlanLimits = {
+ devices: number | null;
+ agents: number | null;
+ humans: number | null;
+ session_retention_days: number | null;
+};
+
+export const PLAN_LIMITS: Record = {
+ intro: { devices: 5, agents: 15, humans: 2, session_retention_days: 7 },
+ pro: { devices: 15, agents: 60, humans: 10, session_retention_days: 14 },
+ enterprise: { devices: null, agents: null, humans: null, session_retention_days: 365 },
+};
+
+/**
+ * Beta pricing switch. While `true`, Intro and Pro show the list price struck
+ * through, the beta price, and the beta note. Set it to `false` when the beta
+ * ends; nothing else on the site needs to change.
+ */
+export const BETA_PRICING_ACTIVE = true;
+
+export const BETA_PRICING_NOTE = 'Beta pricing — locked in for as long as you keep your subscription.';
+
+/** Enterprise is sales-led: the CTA books a call instead of starting checkout. */
+export const SALES_HREF = 'https://agentrelay.com/will';
+
+export type PricingTier = {
+ key: PlanKey;
+ name: string;
+ tagline: string;
+ /** Monthly list price in USD; `null` for sales-only tiers. */
+ listPrice: number | null;
+ /** Monthly beta price in USD; `null` when the tier has no beta discount. */
+ betaPrice: number | null;
+ limits: PlanLimits;
+ highlighted: boolean;
+ cta: { label: string; href: string };
+};
+
+export const pricingTiers: readonly PricingTier[] = [
+ {
+ key: 'intro',
+ name: 'Intro',
+ tagline: 'One builder and a small crew of agents across a few machines.',
+ listPrice: 99,
+ betaPrice: 49,
+ limits: PLAN_LIMITS.intro,
+ highlighted: false,
+ cta: { label: 'Start with Intro', href: googleSignInHref({ next: '/onboarding/plan?plan=intro' }) },
+ },
+ {
+ key: 'pro',
+ name: 'Pro',
+ tagline: 'A team of people running a fleet of agents on shared infrastructure.',
+ listPrice: 199,
+ betaPrice: 99,
+ limits: PLAN_LIMITS.pro,
+ highlighted: true,
+ cta: { label: 'Start with Pro', href: googleSignInHref({ next: '/onboarding/plan?plan=pro' }) },
+ },
+ {
+ key: 'enterprise',
+ name: 'Enterprise',
+ tagline: 'Unlimited devices, agents, humans; 1 year history. Invoiced, with deployment options.',
+ listPrice: null,
+ betaPrice: null,
+ limits: PLAN_LIMITS.enterprise,
+ highlighted: false,
+ cta: { label: 'Talk to us', href: SALES_HREF },
+ },
+];
+
+/** Price shown on the card: the beta price while the beta is on, else the list price. */
+export function displayedPrice(tier: PricingTier, betaActive: boolean = BETA_PRICING_ACTIVE): number | null {
+ return betaActive && tier.betaPrice !== null ? tier.betaPrice : tier.listPrice;
+}
+
+/** True when the card shows the list price struck through next to the beta price. */
+export function showsBetaPrice(tier: PricingTier, betaActive: boolean = BETA_PRICING_ACTIVE): boolean {
+ return betaActive && tier.betaPrice !== null && tier.listPrice !== null;
+}
+
+export function formatCount(value: number | null, noun: { one: string; many: string }): string {
+ if (value === null) return `Unlimited ${noun.many}`;
+ return `${value} ${value === 1 ? noun.one : noun.many}`;
+}
+
+export function formatRetention(days: number | null): string {
+ if (days === null) return 'Unlimited session history';
+ if (days === 365) return '1 year of session history';
+ return `${days}-day session history`;
+}
+
+export function limitLines(limits: PlanLimits): readonly string[] {
+ return [
+ formatCount(limits.devices, { one: 'device', many: 'devices' }),
+ formatCount(limits.agents, { one: 'agent', many: 'agents' }),
+ formatCount(limits.humans, { one: 'human', many: 'humans' }),
+ formatRetention(limits.session_retention_days),
+ ];
+}
diff --git a/web/lib/test/pricing-links.test.tsx b/web/lib/test/pricing-links.test.tsx
new file mode 100644
index 00000000..7b1bcd76
--- /dev/null
+++ b/web/lib/test/pricing-links.test.tsx
@@ -0,0 +1,21 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
+
+import { SiteFooter } from '../../components/SiteFooter';
+import { SiteNav } from '../../components/SiteNav';
+
+describe('pricing links', () => {
+ it('appear in the site nav', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('href="/pricing"');
+ expect(html).toContain('>Pricing<');
+ });
+
+ it('appear in the site footer', () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('href="/pricing"');
+ expect(html).toContain('>Pricing<');
+ });
+});
diff --git a/web/lib/test/pricing-page.test.tsx b/web/lib/test/pricing-page.test.tsx
new file mode 100644
index 00000000..e8e57dcf
--- /dev/null
+++ b/web/lib/test/pricing-page.test.tsx
@@ -0,0 +1,127 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../components/SiteFooter', () => ({ SiteFooter: () => null }));
+vi.mock('../../components/SiteNav', () => ({ SiteNav: () => null }));
+
+import sitemap from '../../app/sitemap';
+import PricingPage, { metadata } from '../../app/pricing/page';
+import { docsNav } from '../docs-nav';
+import { getDoc } from '../docs';
+import { googleSignInHref } from '../login';
+import {
+ BETA_PRICING_ACTIVE,
+ BETA_PRICING_NOTE,
+ PLAN_LIMITS,
+ SALES_HREF,
+ displayedPrice,
+ limitLines,
+ pricingTiers,
+ showsBetaPrice,
+} from '../pricing';
+
+const html = renderToStaticMarkup();
+
+describe('pricing page', () => {
+ it('shows the three tiers with the contract limits', () => {
+ expect(html).toContain('>Intro<');
+ expect(html).toContain('>Pro<');
+ expect(html).toContain('>Enterprise<');
+ expect(html).toContain('5 devices');
+ expect(html).toContain('15 agents');
+ expect(html).toContain('2 humans');
+ expect(html).toContain('7-day session history');
+ expect(html).toContain('15 devices');
+ expect(html).toContain('60 agents');
+ expect(html).toContain('10 humans');
+ expect(html).toContain('14-day session history');
+ expect(html).toContain('Unlimited devices');
+ expect(html).toContain('Unlimited agents');
+ expect(html).toContain('Unlimited humans');
+ expect(html).toContain('1 year of session history');
+ });
+
+ it('shows beta pricing with the list price struck through while the beta is on', () => {
+ expect(BETA_PRICING_ACTIVE).toBe(true);
+ expect(html).toContain('$99');
+ expect(html).toContain('$49');
+ expect(html).toContain('$199');
+ expect(html).toContain('List price $99');
+ expect(html).toContain('List price $199');
+ expect(html).toContain(BETA_PRICING_NOTE);
+ });
+
+ it('sends Intro and Pro through Google sign-in to plan selection, and Enterprise to sales', () => {
+ const intro = googleSignInHref({ next: '/onboarding/plan?plan=intro' });
+ const pro = googleSignInHref({ next: '/onboarding/plan?plan=pro' });
+ expect(intro).toBe('/cloud/api/auth/google/start?next=%2Fonboarding%2Fplan%3Fplan%3Dintro');
+ expect(html).toContain(`href="${intro.replace(/&/g, '&')}"`);
+ expect(html).toContain(`href="${pro.replace(/&/g, '&')}"`);
+ expect(html).toContain(`href="${SALES_HREF}"`);
+ expect(html).toContain('Talk to us');
+ });
+
+ it('answers the four FAQ questions, with existing customers keeping their access', () => {
+ expect(html).toContain('What counts as a device?');
+ expect(html).toContain('What happens when I reach a limit?');
+ expect(html).toContain('Can I change plans?');
+ expect(html).toContain('How do limits apply to existing customers?');
+ expect(html).toContain('Existing customers keep their current access.');
+ });
+
+ it('uses the customer-facing product name only', () => {
+ expect(html.toLowerCase()).not.toContain('relaycast');
+ expect(html.toLowerCase()).not.toContain('broker');
+ });
+
+ it('has a canonical URL and appears in the sitemap', () => {
+ expect(metadata.title).toBe('Pricing');
+ expect(metadata.alternates?.canonical).toBe('https://agentrelay.com/pricing');
+ expect(sitemap().map((entry) => entry.url)).toContain('https://agentrelay.com/pricing');
+ });
+});
+
+describe('pricing data', () => {
+ it('matches the canonical plan limits', () => {
+ expect(PLAN_LIMITS).toEqual({
+ intro: { devices: 5, agents: 15, humans: 2, session_retention_days: 7 },
+ pro: { devices: 15, agents: 60, humans: 10, session_retention_days: 14 },
+ enterprise: { devices: null, agents: null, humans: null, session_retention_days: 365 },
+ });
+ expect(pricingTiers.map((tier) => tier.key)).toEqual(['intro', 'pro', 'enterprise']);
+ expect(pricingTiers.filter((tier) => tier.highlighted).map((tier) => tier.key)).toEqual(['pro']);
+ });
+
+ it('is a single switch: beta off shows list prices with no strikethrough', () => {
+ const [intro, pro, enterprise] = pricingTiers;
+ expect(displayedPrice(intro, true)).toBe(49);
+ expect(displayedPrice(pro, true)).toBe(99);
+ expect(displayedPrice(intro, false)).toBe(99);
+ expect(displayedPrice(pro, false)).toBe(199);
+ expect(displayedPrice(enterprise, true)).toBeNull();
+ expect(showsBetaPrice(intro, true)).toBe(true);
+ expect(showsBetaPrice(intro, false)).toBe(false);
+ expect(showsBetaPrice(enterprise, true)).toBe(false);
+ });
+
+ it('renders enterprise limits as unlimited with one year of history', () => {
+ expect(limitLines(PLAN_LIMITS.enterprise)).toEqual([
+ 'Unlimited devices',
+ 'Unlimited agents',
+ 'Unlimited humans',
+ '1 year of session history',
+ ]);
+ });
+});
+
+describe('plans and limits docs page', () => {
+ it('is in the docs nav and loads', () => {
+ const slugs = docsNav.flatMap((group) => group.items.map((item) => item.slug));
+ expect(slugs).toContain('plans-and-limits');
+ const doc = getDoc('plans-and-limits');
+ expect(doc?.frontmatter.title).toBe('Plans and limits');
+ expect(doc?.content).toContain('plan_limit_exceeded');
+ expect(doc?.content).toContain('Existing customers keep their current access');
+ expect(sitemap().map((entry) => entry.url)).toContain('https://agentrelay.com/docs/plans-and-limits');
+ });
+});