Show self-hosted and hosted Hub plans (#4213)

* feat(website): publish Hub pricing options

Present self-hosted and hosted Hub choices together while reading the hosted offer from Hub's public catalog.

* fix(website): require a valid hosted offer

* fix(website): preserve Hub page on catalog failure
This commit is contained in:
Mohamed Boudra
2026-09-02 15:05:28 +02:00
committed by GitHub
parent ff0171cf7b
commit 7838b534a8
4 changed files with 294 additions and 65 deletions
+123
View File
@@ -0,0 +1,123 @@
import { createServerFn } from "@tanstack/react-start";
const HUB_PLANS_URL =
import.meta.env.VITE_HUB_PLANS_URL ?? "https://hub.paseo.sh/api/billing/plans";
export interface HubHostedOffer {
name: string;
billing: {
model: "per_unit";
unit: { key: string; label: string };
};
features: Array<{ key: string; label: string; tooltip: string | null }>;
price: HubBillingPrice & { interval: "monthly" };
}
interface HubBillingPrice {
interval: "monthly" | "annual";
intervalCount: number;
unitAmount: number;
currency: string;
tooltip: string | null;
}
interface HubBillingPlan extends Omit<HubHostedOffer, "price"> {
slug: string;
prices: HubBillingPrice[];
}
export function parseHostedOfferResponse(value: unknown): HubHostedOffer {
if (!isRecord(value) || !Array.isArray(value["plans"])) throw new Error("Invalid Hub plans");
const hosted = value["plans"].map(parsePlan).find((plan) => plan.slug === "hosted");
const price = hosted?.prices.find(isMonthlyPrice);
if (hosted === undefined || price === undefined) throw new Error("Hosted Hub is unavailable");
return {
name: hosted.name,
billing: hosted.billing,
features: hosted.features,
price,
};
}
export const getHostedOffer = createServerFn({ method: "GET" }).handler(async () => {
const response = await fetch(HUB_PLANS_URL, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) throw new Error(`Hub plans request failed (${response.status})`);
return parseHostedOfferResponse(await response.json());
});
function parsePlan(value: unknown): HubBillingPlan {
if (!isRecord(value) || typeof value["slug"] !== "string" || typeof value["name"] !== "string")
throw new Error("Invalid Hub plan");
const billing = parseBilling(value["billing"]);
if (!Array.isArray(value["features"]) || !Array.isArray(value["prices"]))
throw new Error("Invalid Hub plan presentation");
return {
slug: value["slug"],
name: value["name"],
billing,
features: value["features"].map(parseFeature),
prices: value["prices"].map(parsePrice),
};
}
function parseBilling(value: unknown): HubHostedOffer["billing"] {
if (!isRecord(value) || value["model"] !== "per_unit" || !isRecord(value["unit"]))
throw new Error("Invalid Hub plan billing model");
const unit = value["unit"];
if (typeof unit["key"] !== "string" || typeof unit["label"] !== "string")
throw new Error("Invalid Hub plan billing unit");
return {
model: "per_unit",
unit: { key: unit["key"], label: unit["label"] },
};
}
function parseFeature(value: unknown): HubHostedOffer["features"][number] {
if (
!isRecord(value) ||
typeof value["key"] !== "string" ||
typeof value["label"] !== "string" ||
!isNullableString(value["tooltip"])
)
throw new Error("Invalid Hub plan feature");
return { key: value["key"], label: value["label"], tooltip: value["tooltip"] };
}
function parsePrice(value: unknown): HubBillingPrice {
const intervalCount = isRecord(value) ? value["intervalCount"] : undefined;
if (
!isRecord(value) ||
(value["interval"] !== "monthly" && value["interval"] !== "annual") ||
typeof intervalCount !== "number" ||
!Number.isInteger(intervalCount) ||
intervalCount < 1 ||
typeof value["unitAmount"] !== "number" ||
typeof value["currency"] !== "string" ||
!isNullableString(value["tooltip"])
)
throw new Error("Invalid Hub plan price");
return {
interval: value["interval"],
intervalCount,
unitAmount: value["unitAmount"],
currency: value["currency"],
tooltip: value["tooltip"],
};
}
function isMonthlyPrice(
price: HubBillingPrice,
): price is HubBillingPrice & { interval: "monthly" } {
return price.interval === "monthly";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
+168 -62
View File
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check, CircleHelp } from "lucide-react";
import type { ReactNode } from "react";
import {
ClaudeCodeIcon,
@@ -11,6 +12,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { AGENT_PAGES } from "~/data/agent-pages";
import { FAQItem } from "~/components/faq-item";
import { SiteShell } from "~/components/site-shell";
import { getHostedOffer, type HubHostedOffer } from "~/hub-plans";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/hub")({
@@ -20,65 +22,199 @@ export const Route = createFileRoute("/hub")({
"Run Paseo Hub yourself and start agents on your own machines from GitHub, Slack, and Discord.",
"/hub",
),
loader: async () => {
try {
return { hosted: await getHostedOffer() };
} catch {
return { hosted: null };
}
},
component: Hub,
});
const DISCORD_INVITE_URL = "https://discord.gg/jz8T2uahpH";
const HOSTED_HUB_URL = "https://hub.paseo.sh";
const LINK_CLASS = "underline hover:text-white/80";
function Hub() {
const { hosted } = Route.useLoaderData();
return (
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-4">Paseo Hub</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
An optional service that sits above your daemons and gives them extra capabilities.
</p>
<p className="text-white/40 text-sm mt-3">Self-host now. Hosted version coming later.</p>
<p className="text-white/40 text-sm mt-3">Run it yourself or use the hosted service.</p>
<div className="space-y-20 mt-16">
<Triggers />
<Agents />
<Shape />
<QuickStart />
<Access />
<Pricing hosted={hosted} />
<FaqSection />
</div>
</SiteShell>
);
}
function QuickStart() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Get started</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
Run one command, then finish setup in the browser.
</p>
<pre className="w-fit max-w-full overflow-x-auto rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 font-mono text-sm text-white/90">
<code>npx @getpaseo/hub</code>
</pre>
<div className="flex flex-wrap items-center gap-4 pt-2">
interface PlanFeature {
key: string;
label: string;
tooltip: string | null;
}
const SELF_HOSTED_FEATURES: readonly PlanFeature[] = [
{
key: "operate-hub",
label: "You operate Hub",
tooltip: null,
},
{
key: "own-apps",
label: "Provider apps you create and control",
tooltip: null,
},
{
key: "open-source",
label: "Open source",
tooltip: null,
},
];
function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
if (hosted === null) {
return (
<section className="space-y-6" aria-labelledby="pricing-heading">
<div className="space-y-2">
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<p className="max-w-2xl leading-relaxed text-white/70">
The plan comparison is temporarily unavailable.
</p>
</div>
<a
href="/docs/hub/quickstart"
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
className="inline-block rounded-md border border-white/15 px-4 py-2 text-sm font-medium text-white/80 transition-colors hover:border-white/25 hover:text-white"
>
Read the quickstart
</a>
<a
href="https://github.com/getpaseo/hub"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-white/15 px-4 py-2 text-sm font-medium text-white/80 hover:border-white/25 hover:text-white transition-colors"
>
<GitHubIcon className="h-4 w-4" />
View the source
Self-host Hub
</a>
</section>
);
}
return (
<section className="space-y-6" aria-labelledby="pricing-heading">
<div className="space-y-2">
<h2 id="pricing-heading" className="text-xl font-medium">
Choose how to run Hub
</h2>
<p className="max-w-2xl leading-relaxed text-white/70">
Both options use Paseo daemons to run agents on your machines.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<PlanCard
name="Self-hosted"
price="Free"
priceQualifier="/ open source"
priceTooltip={null}
features={SELF_HOSTED_FEATURES}
actionHref="/docs/hub/quickstart"
actionLabel="Self-host Hub"
/>
<PlanCard
name={hosted.name}
price={formatPrice(hosted.price)}
priceQualifier={`per ${hosted.billing.unit.label} / ${formatBillingPeriod(hosted.price)}`}
priceTooltip={hosted.price.tooltip}
features={hosted.features}
actionHref={HOSTED_HUB_URL}
actionLabel="Start free trial"
featured
/>
</div>
</section>
);
}
function PlanCard({
name,
price,
priceQualifier,
priceTooltip,
features,
actionHref,
actionLabel,
featured = false,
}: {
name: string;
price: string;
priceQualifier: string;
priceTooltip: string | null;
features: readonly PlanFeature[];
actionHref: string;
actionLabel: string;
featured?: boolean;
}) {
return (
<article
className={`flex flex-col rounded-2xl border p-6 ${featured ? "border-white/25 bg-white/[0.06]" : "border-white/10 bg-white/[0.02]"}`}
>
<h3 className="text-xl font-medium">{name}</h3>
<div className="mt-6 flex items-end gap-2">
<span className="text-4xl font-medium tracking-tight">{price}</span>
<span className="mb-1 inline-flex items-center gap-1 text-sm text-white/45">
{priceQualifier}
{priceTooltip !== null && <InfoTip text={priceTooltip} />}
</span>
</div>
{features.length > 0 && (
<ul className="mt-6 grid flex-1 content-start gap-3 text-sm text-white/70">
{features.map((feature) => (
<li key={feature.key} className="flex items-start gap-2.5">
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-emerald-300" />
<span>{feature.label}</span>
{feature.tooltip !== null && <InfoTip text={feature.tooltip} />}
</li>
))}
</ul>
)}
<a
href={actionHref}
className={`mt-8 rounded-md px-4 py-2 text-center text-sm font-medium transition-colors ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
>
{actionLabel}
</a>
</article>
);
}
function InfoTip({ text }: { text: string }) {
return (
<span className="group relative inline-flex shrink-0" tabIndex={0}>
<CircleHelp aria-label={text} className="size-3.5 text-white/35" />
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-56 -translate-x-1/2 rounded-md border border-white/10 bg-[#17201f] px-3 py-2 text-xs leading-relaxed text-white/75 shadow-xl group-hover:block group-focus:block"
>
{text}
</span>
</span>
);
}
function formatPrice(price: HubHostedOffer["price"]): string {
return new Intl.NumberFormat("en", {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(price.unitAmount / 100);
}
function formatBillingPeriod(price: HubHostedOffer["price"]): string {
return price.intervalCount === 1 ? "month" : `${price.intervalCount} months`;
}
const TRIGGER_SURFACES = [
{ name: "GitHub", icon: <GitHubIcon className="h-6 w-6" />, body: "Issues, PRs, and reviews." },
{ name: "Slack", icon: <SlackIcon className="h-6 w-6" />, body: "Channels and threads." },
@@ -386,29 +522,6 @@ function MachineIcon() {
);
}
function Access() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Hosted Hub</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
The hosted version is not generally available yet. Join the Paseo Discord and watch the
<span className="font-mono text-white/80"> #paseo-hub</span> channel for the release
announcement.
</p>
<div className="flex items-center gap-4 pt-2">
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
>
Join the Discord
</a>
</div>
</section>
);
}
function FaqSection() {
return (
<section className="space-y-6">
@@ -445,9 +558,8 @@ function FaqSection() {
Node.js and a running Paseo daemon. Hub guides you through connecting the provider apps
you want.
</FAQItem>
<FAQItem question="Will there be a hosted version?">
Yes. Join the Paseo Discord and watch the <code>#paseo-hub</code> channel. The hosted
release will be announced there.
<FAQItem question="Is there a hosted version?">
Yes. The hosted service runs the same Hub software you can self-host.
</FAQItem>
<FAQItem question="What else is planned?">
<p>Not built yet, listed so you know where this is going.</p>
@@ -470,17 +582,11 @@ function FaqSection() {
triggers, and it&apos;s why access to the beta stays small and trusted.
</FAQItem>
<FAQItem question="How do I get access?">
You can self-host today. For the hosted version, join the{" "}
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
Paseo Discord
You can self-host today or{" "}
<a href={HOSTED_HUB_URL} className={LINK_CLASS}>
sign in to Hosted Hub
</a>{" "}
and wait for the release announcement in <code>#paseo-hub</code>. You do not need to
message anyone directly.
to start a free trial.
</FAQItem>
</div>
</section>
+2 -2
View File
@@ -8,8 +8,8 @@ category: Hub
# Hosted Hub
[Paseo Hub](https://hub.paseo.sh) is the managed service. It is not generally available yet: existing accounts can sign in, but new account registration is closed.
[Paseo Hub](https://hub.paseo.sh) is the managed service. Sign in or create an account to start a free trial.
To start now, [run Hub yourself](/docs/hub/self-hosting). Projects, configuration, triggers, daemons, and activity use the same model in both forms. The managed service owns its GitHub App, Slack app, and Discord application; a self-hosted Hub uses apps you create and control.
For the hosted release, [join the Paseo Discord](https://discord.gg/jz8T2uahpH) and watch the `#paseo-hub` channel. The release will be announced there; you do not need to message anyone directly.
Pricing and included features are also shown on the [Hub page](/hub).
+1 -1
View File
@@ -56,4 +56,4 @@ If a workflow accepts requests from GitHub, Slack, Discord, or the API, read [Hu
Start on your machine with the embedded database, then add PostgreSQL or a public deployment only when you need them. [Self-hosting](/docs/hub/self-hosting) covers each step.
[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. New account registration is currently closed.
[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh).