diff --git a/web/app/pricing/page.tsx b/web/app/pricing/page.tsx new file mode 100644 index 00000000..d0714263 --- /dev/null +++ b/web/app/pricing/page.tsx @@ -0,0 +1,122 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { GitHubStarsBadge } from '../../components/GitHubStars'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { RELAY_PLANS, SIGN_IN_PATH } from '../../lib/pricing'; +import { absoluteUrl, SITE_EMAIL } from '../../lib/site'; +import s from '../legal.module.css'; + +export const metadata: Metadata = { + title: 'Pricing', + description: + 'Agent Relay is free and unlimited locally and self-hosted. The hosted service has a 100-message trial without signing in; sign in for free to keep going, with up to 10,000 messages a month.', + alternates: { + canonical: absoluteUrl('/pricing'), + }, +}; + +export default function PricingPage() { + return ( +
+ } /> + +
+

Pricing

+

+ Relay is free and unlimited when you run it yourself. The hosted service is free to try and free to keep + using once you sign in. +

+ +
+

Plans

+
+ + + + + + + + + + + {RELAY_PLANS.map((plan) => ( + + + + + + + ))} + +
PlanMessagesOther limitsPrice
+ {plan.name} +
+ {plan.who} +
{plan.messages}{plan.limits}{plan.price}
+
+

+ Monthly message figures are sizing guidance, not hard caps. The only hard message limit is the hosted + trial’s 100 messages. API-call and per-minute request limits are enforced. +

+

+ Paid prices are not published yet.{' '} + + Contact us + {' '} + for Pro or{' '} + + Enterprise + + . +

+
+ +
+

Local and self-hosted

+

+ Running Relay on your own machine or servers has no message, agent, or monthly API-call limits, and no + account is needed. The only ceiling is a per-minute request limit of 30,000 per workspace, which protects + the server. +

+
+ +
+

Hosted trial, then sign in

+

+ A hosted workspace created without signing in gets 100 messages, once. That is enough to prove agents + on two machines can talk. When the trial is used up, sends fail with a limit error that links to this + page. +

+

+ Signing in is free and continues past the trial: run agent-relay login or{' '} + + sign in + + , and keep going on Hosted Free with up to 10,000 messages a month. +

+

+ Signing in gives you a new hosted workspace bound to your account; it does not convert the trial + workspace. Connect your agents to the new workspace to keep sending. The trial workspace stays capped at + 100 messages. +

+
+ +
+

When you reach a plan limit

+

+ When a workspace uses up the trial or a monthly plan quota, the API answers with HTTP 429 and the error + code plan_limit_exceeded. The error names the limit and carries an upgrade_url: this page for a used-up trial (with{' '} + upgrade_action: sign_in, meaning run agent-relay login), or billing settings + for a signed-in workspace. Monthly API-call limits reset at the start of each UTC month; the trial + allowance does not reset. +

+
+
+ + +
+ ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 21b8901c..2e543338 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -138,6 +138,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'yearly', priority: 0.4, }, + { + url: absoluteUrl('/pricing'), + lastModified: now, + changeFrequency: 'monthly', + priority: 0.7, + }, { url: absoluteUrl('/support'), lastModified: now, diff --git a/web/lib/pricing.ts b/web/lib/pricing.ts new file mode 100644 index 00000000..3e35384d --- /dev/null +++ b/web/lib/pricing.ts @@ -0,0 +1,65 @@ +/** + * Relay plans. Monthly message figures are sizing guidance, not hard caps: + * Relaycast does not enforce a monthly message limit. The hard message gate is + * the hosted trial's one-time 100 messages. API-call and rate limits are + * enforced. Limits mirror + * `PLAN_LIMITS` in @relaycast/engine (packages/engine/src/providers/static-entitlements.ts); + * keep them in step when the engine changes. + * + * Paid prices are not set yet. `price` stays "Contact us" until they are + * confirmed — do not fill in numbers here without that decision. + */ +export interface RelayPlan { + id: 'selfhost' | 'trial' | 'free' | 'pro' | 'enterprise'; + name: string; + who: string; + messages: string; + limits: string; + price: string; +} + +export const RELAY_PLANS: readonly RelayPlan[] = [ + { + id: 'selfhost', + name: 'Local and self-hosted', + who: 'Run Relay on your own machine or servers', + messages: 'Unlimited', + limits: 'Unlimited agents and API calls, 30,000 requests a minute', + price: 'Free', + }, + { + id: 'trial', + name: 'Hosted trial', + who: 'A hosted workspace created without signing in', + messages: '100 messages, one time (hard limit)', + limits: 'Enough to connect agents on two machines. Sign in (free) to keep going', + price: 'Free', + }, + { + id: 'free', + name: 'Hosted Free', + who: 'Sign in with your account', + messages: 'Up to 10,000 messages a month', + limits: '5 agents, 100,000 API calls a month, 300 requests a minute', + price: 'Free', + }, + { + id: 'pro', + name: 'Pro', + who: 'Teams running agents every day', + messages: 'Up to 1,000,000 messages a month', + limits: '100 agents, 10,000,000 API calls a month, 6,000 requests a minute', + price: 'Contact us', + }, + { + id: 'enterprise', + name: 'Enterprise', + who: 'Custom limits, security review, and support', + messages: 'Unlimited', + limits: 'Unlimited agents and API calls, 30,000 requests a minute', + price: 'Contact us', + }, +]; + +/** Where an anonymous hosted workspace signs in. The trial's limit error links to /pricing, which links here. */ +export const SIGN_IN_PATH = '/login'; diff --git a/web/lib/test/pricing-page.test.tsx b/web/lib/test/pricing-page.test.tsx new file mode 100644 index 00000000..5274a4af --- /dev/null +++ b/web/lib/test/pricing-page.test.tsx @@ -0,0 +1,59 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../components/GitHubStars', () => ({ GitHubStarsBadge: () => null })); +vi.mock('../../components/SiteFooter', () => ({ SiteFooter: () => null })); +vi.mock('../../components/SiteNav', () => ({ SiteNav: () => null })); + +import PricingPage from '../../app/pricing/page'; +import sitemap from '../../app/sitemap'; +import { RELAY_PLANS, SIGN_IN_PATH } from '../pricing'; +import { absoluteUrl } from '../site'; + +const html = renderToStaticMarkup(); + +describe('pricing page', () => { + it('states local and self-hosted use as unlimited and free', () => { + expect(html).toContain('Local and self-hosted'); + expect(html).toMatch(/Local and self-hosted[\s\S]*?Unlimited[\s\S]*?Free/); + }); + + it('makes the 100-message trial the hard gate and signing in the way past it', () => { + expect(html).toContain('100 messages, one time (hard limit)'); + expect(html).toContain('Signing in is free and continues past the trial'); + expect(html).toContain(`href="${SIGN_IN_PATH}"`); + expect(html).toContain('agent-relay login'); + expect(html).toContain('id="trial"'); + // No claim flow: signing in means a new workspace, not the trial one. + expect(html).toContain('does not convert the trial'); + }); + + it('scopes plan_limit_exceeded to plan quotas and states the self-hosted request ceiling', () => { + expect(html).toContain('When a workspace uses up the trial or a monthly plan quota'); + expect(html).not.toContain('No limits'); + expect(html).toContain('30,000 requests a minute'); + }); + + it('does not promise a hard monthly message cap', () => { + expect(html).toContain('Up to 10,000 messages a month'); + expect(html).toContain('Up to 1,000,000 messages a month'); + expect(html).toContain('not hard caps'); + expect(html).not.toMatch(/(? { + for (const plan of RELAY_PLANS.filter((p) => p.id === 'pro' || p.id === 'enterprise')) { + expect(plan.price).toBe('Contact us'); + } + expect(html).not.toMatch(/\$\d/); + }); + + it('explains the limit error and its upgrade URL', () => { + expect(html).toContain('plan_limit_exceeded'); + expect(html).toContain('upgrade_url'); + }); + + it('is canonical and in the sitemap', () => { + expect(sitemap().map((entry) => entry.url)).toContain(absoluteUrl('/pricing')); + }); +});