mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
feat(website): show Hub's Free and Pro plans, and drop trial wording (#5138)
Hosted Hub replaces its automatic trial with a real Free plan, so the pricing section becomes three columns: Self-hosted, Free, and Pro. Both hosted columns render entirely from `GET /api/billing/plans` — name, price, the `included` figures, and the catalog's feature prose — because those figures are derived from the entitlement template the instance enforces, so nothing on this page can drift from the limit a customer actually gets. The parser requires both slugs and the new `included` object and throws on anything else. The page already degrades to "temporarily unavailable" when the loader throws, so a strict boundary costs a fallback we have rather than a wrong number we would ship. This is also why the change must not merge before the Hub side deploys: against today's API the section renders that fallback. Public docs follow the two Hub changes: Free replaces the trial and points at /hub for the figures Stripe metadata now owns; sign-in lands on Home with its four-step checklist; `hub.execute` gets the grant command it needs; and trigger authoring documents that the agent is validated against the target daemon at save, which needs that daemon connected and accepts Claude, Codex, and OpenCode only.
This commit is contained in:
@@ -120,6 +120,9 @@ jobs:
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run website tests
|
||||
run: npx vitest run packages/website/src --bail=1
|
||||
|
||||
- name: Install website test browsers
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"plans": [
|
||||
{
|
||||
"slug": "free",
|
||||
"name": "Free",
|
||||
"billing": {
|
||||
"model": "per_unit",
|
||||
"unit": {
|
||||
"key": "seat",
|
||||
"label": "seat"
|
||||
}
|
||||
},
|
||||
"included": {
|
||||
"seats": 1,
|
||||
"executionsPerMonth": 50
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"key": "daemon-location",
|
||||
"label": "Daemons run on your machines",
|
||||
"tooltip": null
|
||||
}
|
||||
],
|
||||
"prices": [
|
||||
{
|
||||
"interval": "monthly",
|
||||
"intervalCount": 1,
|
||||
"unitAmount": 0,
|
||||
"currency": "eur",
|
||||
"tooltip": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "hosted",
|
||||
"name": "Pro",
|
||||
"billing": {
|
||||
"model": "per_unit",
|
||||
"unit": {
|
||||
"key": "seat",
|
||||
"label": "seat"
|
||||
}
|
||||
},
|
||||
"included": {
|
||||
"seats": null,
|
||||
"executionsPerMonth": null
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"key": "hub-operation",
|
||||
"label": "Paseo operates Hub",
|
||||
"tooltip": null
|
||||
}
|
||||
],
|
||||
"prices": [
|
||||
{
|
||||
"interval": "monthly",
|
||||
"intervalCount": 1,
|
||||
"unitAmount": 1500,
|
||||
"currency": "eur",
|
||||
"tooltip": "Seats are Hub members and pending invitations. People who only trigger agents through GitHub, Slack, or Discord do not count as seats."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// The documented `GET /api/billing/plans` response, copied from getpaseo/hub docs/public-api.md.
|
||||
import documentedResponse from "./hub-plans.fixture.json" with { type: "json" };
|
||||
import { parseHubPlansResponse } from "./hub-plans";
|
||||
|
||||
describe("parseHubPlansResponse", () => {
|
||||
it("reads Free and Pro, their monthly prices, and the figures each includes", () => {
|
||||
const plans = parseHubPlansResponse(documentedResponse);
|
||||
|
||||
expect(plans.free.name).toBe("Free");
|
||||
expect(plans.free.included).toEqual({ seats: 1, executionsPerMonth: 50 });
|
||||
expect(plans.free.price.unitAmount).toBe(0);
|
||||
expect(plans.free.features).toEqual([
|
||||
{ key: "daemon-location", label: "Daemons run on your machines", tooltip: null },
|
||||
]);
|
||||
|
||||
expect(plans.paid.slug).toBe("hosted");
|
||||
expect(plans.paid.name).toBe("Pro");
|
||||
expect(plans.paid.included).toEqual({ seats: null, executionsPerMonth: null });
|
||||
expect(plans.paid.price.unitAmount).toBe(1500);
|
||||
expect(plans.paid.price.currency).toBe("eur");
|
||||
expect(plans.paid.billing.unit.label).toBe("seat");
|
||||
});
|
||||
|
||||
it("rejects a catalog that omits the free plan", () => {
|
||||
const withoutFree = {
|
||||
plans: documentedResponse.plans.filter((plan) => plan.slug !== "free"),
|
||||
};
|
||||
|
||||
expect(() => parseHubPlansResponse(withoutFree)).toThrow('Hub plan "free" is unavailable');
|
||||
});
|
||||
|
||||
it("rejects the pre-included plan shape instead of rendering a plan with no figures", () => {
|
||||
const withoutIncluded = {
|
||||
plans: documentedResponse.plans.map(({ included: _included, ...plan }) => plan),
|
||||
};
|
||||
|
||||
expect(() => parseHubPlansResponse(withoutIncluded)).toThrow(
|
||||
"Invalid Hub plan included figures",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a plan that has no monthly price", () => {
|
||||
const annualOnly = structuredClone(documentedResponse);
|
||||
for (const plan of annualOnly.plans) {
|
||||
if (plan.slug !== "hosted") continue;
|
||||
for (const price of plan.prices) price.interval = "annual";
|
||||
}
|
||||
|
||||
expect(() => parseHubPlansResponse(annualOnly)).toThrow(
|
||||
'Hub plan "hosted" has no monthly price',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,28 @@ 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 {
|
||||
/** The two catalog slugs the page renders. `hosted` is displayed as whatever `name` says. */
|
||||
const FREE_SLUG = "free";
|
||||
const PAID_SLUG = "hosted";
|
||||
|
||||
export interface HubPlanOffer {
|
||||
slug: string;
|
||||
name: string;
|
||||
billing: {
|
||||
model: "per_unit";
|
||||
unit: { key: string; label: string };
|
||||
};
|
||||
/** What the plan gives, as figures. `null` is unlimited. */
|
||||
included: { seats: number | null; executionsPerMonth: number | null };
|
||||
features: Array<{ key: string; label: string; tooltip: string | null }>;
|
||||
price: HubBillingPrice & { interval: "monthly" };
|
||||
}
|
||||
|
||||
export interface HubPlans {
|
||||
free: HubPlanOffer;
|
||||
paid: HubPlanOffer;
|
||||
}
|
||||
|
||||
interface HubBillingPrice {
|
||||
interval: "monthly" | "annual";
|
||||
intervalCount: number;
|
||||
@@ -21,49 +33,61 @@ interface HubBillingPrice {
|
||||
tooltip: string | null;
|
||||
}
|
||||
|
||||
interface HubBillingPlan extends Omit<HubHostedOffer, "price"> {
|
||||
slug: string;
|
||||
interface HubBillingPlan extends Omit<HubPlanOffer, "price"> {
|
||||
prices: HubBillingPrice[];
|
||||
}
|
||||
|
||||
export function parseHostedOfferResponse(value: unknown): HubHostedOffer {
|
||||
export function parseHubPlansResponse(value: unknown): HubPlans {
|
||||
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");
|
||||
const plans = value["plans"].map(parsePlan);
|
||||
return {
|
||||
name: hosted.name,
|
||||
billing: hosted.billing,
|
||||
features: hosted.features,
|
||||
price,
|
||||
free: selectOffer(plans, FREE_SLUG),
|
||||
paid: selectOffer(plans, PAID_SLUG),
|
||||
};
|
||||
}
|
||||
|
||||
export const getHostedOffer = createServerFn({ method: "GET" }).handler(async () => {
|
||||
export const getHubPlans = 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());
|
||||
return parseHubPlansResponse(await response.json());
|
||||
});
|
||||
|
||||
function selectOffer(plans: readonly HubBillingPlan[], slug: string): HubPlanOffer {
|
||||
const plan = plans.find((candidate) => candidate.slug === slug);
|
||||
if (plan === undefined) throw new Error(`Hub plan "${slug}" is unavailable`);
|
||||
const price = plan.prices.find(isMonthlyPrice);
|
||||
if (price === undefined) throw new Error(`Hub plan "${slug}" has no monthly price`);
|
||||
return {
|
||||
slug: plan.slug,
|
||||
name: plan.name,
|
||||
billing: plan.billing,
|
||||
included: plan.included,
|
||||
features: plan.features,
|
||||
price,
|
||||
};
|
||||
}
|
||||
|
||||
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"]);
|
||||
const included = parseIncluded(value["included"]);
|
||||
if (!Array.isArray(value["features"]) || !Array.isArray(value["prices"]))
|
||||
throw new Error("Invalid Hub plan presentation");
|
||||
return {
|
||||
slug: value["slug"],
|
||||
name: value["name"],
|
||||
billing,
|
||||
included,
|
||||
features: value["features"].map(parseFeature),
|
||||
prices: value["prices"].map(parsePrice),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBilling(value: unknown): HubHostedOffer["billing"] {
|
||||
function parseBilling(value: unknown): HubPlanOffer["billing"] {
|
||||
if (!isRecord(value) || value["model"] !== "per_unit" || !isRecord(value["unit"]))
|
||||
throw new Error("Invalid Hub plan billing model");
|
||||
const unit = value["unit"];
|
||||
@@ -75,7 +99,17 @@ function parseBilling(value: unknown): HubHostedOffer["billing"] {
|
||||
};
|
||||
}
|
||||
|
||||
function parseFeature(value: unknown): HubHostedOffer["features"][number] {
|
||||
function parseIncluded(value: unknown): HubPlanOffer["included"] {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!isNullableCount(value["seats"]) ||
|
||||
!isNullableCount(value["executionsPerMonth"])
|
||||
)
|
||||
throw new Error("Invalid Hub plan included figures");
|
||||
return { seats: value["seats"], executionsPerMonth: value["executionsPerMonth"] };
|
||||
}
|
||||
|
||||
function parseFeature(value: unknown): HubPlanOffer["features"][number] {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value["key"] !== "string" ||
|
||||
@@ -121,3 +155,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isNullableCount(value: unknown): value is number | null {
|
||||
return value === null || (typeof value === "number" && Number.isInteger(value) && value >= 0);
|
||||
}
|
||||
|
||||
@@ -12,7 +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 { getHubPlans, type HubPlanOffer, type HubPlans } from "~/hub-plans";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export const Route = createFileRoute("/hub")({
|
||||
@@ -24,9 +24,9 @@ export const Route = createFileRoute("/hub")({
|
||||
),
|
||||
loader: async () => {
|
||||
try {
|
||||
return { hosted: await getHostedOffer() };
|
||||
return { plans: await getHubPlans() };
|
||||
} catch {
|
||||
return { hosted: null };
|
||||
return { plans: null };
|
||||
}
|
||||
},
|
||||
component: Hub,
|
||||
@@ -37,7 +37,7 @@ const HOSTED_HUB_URL = "https://hub.paseo.sh";
|
||||
const LINK_CLASS = "underline hover:text-white/80";
|
||||
|
||||
function Hub() {
|
||||
const { hosted } = Route.useLoaderData();
|
||||
const { plans } = Route.useLoaderData();
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-4">Paseo Hub</h1>
|
||||
@@ -50,7 +50,7 @@ function Hub() {
|
||||
<Triggers />
|
||||
<Agents />
|
||||
<Shape />
|
||||
<Pricing hosted={hosted} />
|
||||
<Pricing plans={plans} />
|
||||
<FaqSection />
|
||||
</div>
|
||||
</SiteShell>
|
||||
@@ -80,8 +80,8 @@ const SELF_HOSTED_FEATURES: readonly PlanFeature[] = [
|
||||
tooltip: null,
|
||||
},
|
||||
];
|
||||
function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
|
||||
if (hosted === null) {
|
||||
function Pricing({ plans }: { plans: HubPlans | null }) {
|
||||
if (plans === null) {
|
||||
return (
|
||||
<section className="space-y-6" aria-labelledby="pricing-heading">
|
||||
<div className="space-y-2">
|
||||
@@ -109,10 +109,11 @@ function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
|
||||
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.
|
||||
Every option runs agents on the daemons you already have. {plans.free.name} needs no card,
|
||||
and {plans.paid.name} is one click away under Billing once you are signed in.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<PlanCard
|
||||
name="Self-hosted"
|
||||
price="Free"
|
||||
@@ -123,13 +124,24 @@ function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
|
||||
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}
|
||||
name={plans.free.name}
|
||||
price={formatPrice(plans.free.price)}
|
||||
priceQualifier={planQualifier(plans.free)}
|
||||
priceTooltip={plans.free.price.tooltip}
|
||||
features={planFeatures(plans.free)}
|
||||
actionHref={HOSTED_HUB_URL}
|
||||
actionLabel="Start free trial"
|
||||
actionLabel="Create a free account"
|
||||
actionNote="No card, never expires."
|
||||
/>
|
||||
<PlanCard
|
||||
name={plans.paid.name}
|
||||
price={formatPrice(plans.paid.price)}
|
||||
priceQualifier={planQualifier(plans.paid)}
|
||||
priceTooltip={plans.paid.price.tooltip}
|
||||
features={planFeatures(plans.paid)}
|
||||
actionHref={HOSTED_HUB_URL}
|
||||
actionLabel={`Go to Hub for ${plans.paid.name}`}
|
||||
actionNote={`Upgrade to ${plans.paid.name} from Billing, whether you work alone or with a team.`}
|
||||
featured
|
||||
/>
|
||||
</div>
|
||||
@@ -137,6 +149,33 @@ function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The figures the plan includes, ahead of the prose the catalog wrote for it. */
|
||||
function planFeatures(plan: HubPlanOffer): readonly PlanFeature[] {
|
||||
return [
|
||||
{
|
||||
key: "included-executions",
|
||||
label: formatAllowance(plan.included.executionsPerMonth),
|
||||
tooltip: null,
|
||||
},
|
||||
{
|
||||
key: "included-seats",
|
||||
label: formatSeats(plan.included.seats, plan.billing.unit.label),
|
||||
tooltip: null,
|
||||
},
|
||||
...plan.features,
|
||||
];
|
||||
}
|
||||
|
||||
function formatAllowance(executionsPerMonth: number | null): string {
|
||||
if (executionsPerMonth === null) return "Unlimited agent runs";
|
||||
return `${executionsPerMonth.toLocaleString("en")} agent runs a month`;
|
||||
}
|
||||
|
||||
function formatSeats(seats: number | null, unitLabel: string): string {
|
||||
if (seats === null) return `Unlimited ${unitLabel}s`;
|
||||
return `${seats.toLocaleString("en")} ${seats === 1 ? unitLabel : `${unitLabel}s`}`;
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
name,
|
||||
price,
|
||||
@@ -145,6 +184,7 @@ function PlanCard({
|
||||
features,
|
||||
actionHref,
|
||||
actionLabel,
|
||||
actionNote,
|
||||
featured = false,
|
||||
}: {
|
||||
name: string;
|
||||
@@ -154,6 +194,7 @@ function PlanCard({
|
||||
features: readonly PlanFeature[];
|
||||
actionHref: string;
|
||||
actionLabel: string;
|
||||
actionNote?: string;
|
||||
featured?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -179,9 +220,12 @@ function PlanCard({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{actionNote !== undefined && (
|
||||
<p className="mt-8 text-center text-xs leading-relaxed text-white/35">{actionNote}</p>
|
||||
)}
|
||||
<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"}`}
|
||||
className={`rounded-md px-4 py-2 text-center text-sm font-medium transition-colors ${actionNote === undefined ? "mt-8" : "mt-3"} ${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>
|
||||
@@ -203,7 +247,11 @@ function InfoTip({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(price: HubHostedOffer["price"]): string {
|
||||
function planQualifier(plan: HubPlanOffer): string {
|
||||
return `per ${plan.billing.unit.label} / ${formatBillingPeriod(plan.price)}`;
|
||||
}
|
||||
|
||||
function formatPrice(price: HubPlanOffer["price"]): string {
|
||||
return new Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: price.currency.toUpperCase(),
|
||||
@@ -211,7 +259,7 @@ function formatPrice(price: HubHostedOffer["price"]): string {
|
||||
}).format(price.unitAmount / 100);
|
||||
}
|
||||
|
||||
function formatBillingPeriod(price: HubHostedOffer["price"]): string {
|
||||
function formatBillingPeriod(price: HubPlanOffer["price"]): string {
|
||||
return price.intervalCount === 1 ? "month" : `${price.intervalCount} months`;
|
||||
}
|
||||
|
||||
@@ -586,7 +634,8 @@ function FaqSection() {
|
||||
<a href={HOSTED_HUB_URL} className={LINK_CLASS}>
|
||||
sign in to Hosted Hub
|
||||
</a>{" "}
|
||||
to start a free trial.
|
||||
for a free account. It needs no card and does not expire. When you want more agent runs or
|
||||
more seats, upgrade from Billing inside Hub.
|
||||
</FAQItem>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+4
-1
@@ -334,6 +334,9 @@ paseo hub init # Create and optionally deploy a starter trigger
|
||||
paseo hub connect [url] # Enroll this daemon using CLI access
|
||||
paseo hub projects # List legacy projects in the authenticated organization
|
||||
paseo hub status # Show the current Hub relationship
|
||||
paseo hub permissions list # Show what this Hub may do on this daemon
|
||||
paseo hub permissions grant hub.execute # Let Hub automations run agents here
|
||||
paseo hub permissions revoke hub.execute # Take it back
|
||||
paseo hub disconnect # End it
|
||||
paseo hub deploy # Validate and install .paseo/triggers/*.yml
|
||||
paseo hub deploy --dry-run # Validate without installing
|
||||
@@ -349,7 +352,7 @@ Pass `-p, --project <slug>` for an existing legacy bundle: `.paseo/hub.yml`, dir
|
||||
|
||||
`init` requires a TTY. It signs in and connects the daemon as needed, then lists the organization's app connections that can back a starter trigger. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
|
||||
|
||||
Setup asks which agent provider, model, and mode to run. Providers must be enabled and expose both a selectable model and an execution mode. Suggested model and mode entries are the daemon's defaults; a mode is still selected explicitly when there is no default. Setup then asks for the identity allowed to trigger the bot: a GitHub username, Slack member ID, or Discord user ID. It validates the trigger, writes `.paseo/triggers/<provider>-help.yml`, and asks whether to deploy. Replacing that file requires confirmation; existing legacy bundles and other trigger files are preserved. See the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger).
|
||||
Setup asks which agent provider, model, and mode to run. Providers must be enabled and expose both a selectable model and an execution mode. Suggested model and mode entries are the daemon's defaults; a mode is still selected explicitly when there is no default. Hub validates the choice against the daemon before deploying, and accepts only Claude, Codex, and OpenCode for its unattended runs. `deploy` applies the same checks, so it needs the named daemon connected. Setup then asks for the identity allowed to trigger the bot: a GitHub username, Slack member ID, or Discord user ID. It validates the trigger, writes `.paseo/triggers/<provider>-help.yml`, and asks whether to deploy. Replacing that file requires confirmation; existing legacy bundles and other trigger files are preserved. See the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger).
|
||||
|
||||
Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Both endpoints accept one self-contained document:
|
||||
}
|
||||
```
|
||||
|
||||
Use a daemon slug and agent runtime available in your organization. Validation requires `configuration:validate` and returns `200` with `{ "name": "manual-task", "valid": true }`.
|
||||
Use a daemon slug and agent runtime available in your organization. Both operations ask the named daemon to check the agent, so it has to be connected and hold `hub.execute`; an agent it cannot run is a field issue rather than a failed first run. See [Saving checks the agent against the daemon](/docs/hub/triggers#saving-checks-the-agent-against-the-daemon). Validation requires `configuration:validate` and returns `200` with `{ "name": "manual-task", "valid": true }`.
|
||||
|
||||
Installation requires `configuration:install`. It creates or updates the organization's trigger by the YAML `name` and returns `201`:
|
||||
|
||||
|
||||
@@ -90,17 +90,17 @@ An environment is a complete named object. A step selects its name; objects are
|
||||
|
||||
Each agent is one complete provider configuration:
|
||||
|
||||
| Field | Required | Notes |
|
||||
| ------------------ | -------- | ---------------------------------------------------------------- |
|
||||
| `provider` | yes | Provider ID. |
|
||||
| `model` | no | Provider model ID. |
|
||||
| `mode` | no | Paseo mode ID. |
|
||||
| `thinkingOptionId` | no | Provider thinking option. |
|
||||
| `options` | no | JSON-safe provider-native options, preserving names and nesting. |
|
||||
| Field | Required | Notes |
|
||||
| ------------------ | -------- | ---------------------------------------------------------------------- |
|
||||
| `provider` | yes | Provider ID. Hub runs unattended, so `claude`, `codex`, or `opencode`. |
|
||||
| `model` | no | Provider model ID. |
|
||||
| `mode` | no | Paseo mode ID. |
|
||||
| `thinkingOptionId` | no | Provider thinking option. |
|
||||
| `options` | no | JSON-safe provider-native options, preserving names and nesting. |
|
||||
|
||||
A named selection preserves the complete object, including structured options. Named agents have no parent, patch, or per-step override.
|
||||
|
||||
Hub passes `model`, `mode`, `thinkingOptionId`, and `options` to the Paseo daemon without renaming or flattening provider fields. The selected daemon validates them against its current provider schema; Hub does not translate provider-native options.
|
||||
Hub passes `model`, `mode`, `thinkingOptionId`, and `options` to the Paseo daemon without renaming or flattening provider fields. The selected daemon validates them against its current provider schema when the trigger is saved or deployed, so a value it does not offer is refused then rather than at the first run; Hub does not translate provider-native options. See [Saving checks the agent against the daemon](/docs/hub/triggers#saving-checks-the-agent-against-the-daemon).
|
||||
|
||||
## Workflow files
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ paseo hub logout --disconnect-daemon # remove both identities
|
||||
paseo hub logout --disconnect-daemon --force # drop local authority when Hub is unreachable
|
||||
```
|
||||
|
||||
## Let Hub run agents on it
|
||||
|
||||
Enrolling a daemon does not let Hub start agents on it. That is the separate `hub.execute` permission, which login asks about and defaults to no. Grant it from the machine at any time:
|
||||
|
||||
```sh
|
||||
paseo hub permissions list
|
||||
paseo hub permissions grant hub.execute
|
||||
paseo hub permissions revoke hub.execute
|
||||
```
|
||||
|
||||
Until it is granted, Hub keeps the daemon for identity and presence only. The Daemons page shows it as **Connected only** with the grant command, and Home's checklist reads **Cannot run agents**. [Hub security](/docs/hub/security#choose-daemon-authority) covers what the permission allows once granted.
|
||||
|
||||
## Reference it from configuration
|
||||
|
||||
```yaml
|
||||
@@ -107,6 +119,6 @@ If Hub loses the create response, or the daemon restarts mid-execution, Hub rese
|
||||
| Offline | Enrolled but not currently connected |
|
||||
| Revoked | Access removed from Hub |
|
||||
|
||||
An event that arrives while a daemon is offline fails dispatch with `daemon_not_connected`. Nothing is queued for later. The event is in the project's Activity, and the trigger has to fire again.
|
||||
An event that arrives while a daemon is offline fails dispatch with `daemon_not_connected`. Nothing is queued for later. The event is in the project's Activity, and the trigger has to fire again. An offline daemon also blocks saving a trigger that changes its target or agent, because Hub checks that agent against it; see [Saving checks the agent against the daemon](/docs/hub/triggers#saving-checks-the-agent-against-the-daemon).
|
||||
|
||||
Revoking from **Daemons → Revoke daemon** ends the relationship from Hub's side. The daemon keeps running your local agents.
|
||||
|
||||
@@ -8,8 +8,8 @@ category: Hub
|
||||
|
||||
# Hosted Hub
|
||||
|
||||
[Paseo Hub](https://hub.paseo.sh) is the managed service. Sign in or create an account to start a free trial.
|
||||
[Paseo Hub](https://hub.paseo.sh) is the managed service. Create an account and your organization starts on the Free plan: one seat, a monthly allowance of agent runs, no card, and no expiry. Upgrade to Pro from **Billing** in Hub when you want unlimited runs, more seats, or invitations. The [Hub page](/hub) carries the current price and the figures each plan includes.
|
||||
|
||||
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.
|
||||
Signing in opens **Home**, the same four-step checklist the [quickstart](/docs/hub/quickstart) walks through: connect an app, connect a daemon, create a trigger, run it.
|
||||
|
||||
Pricing and included features are also shown on the [Hub page](/hub).
|
||||
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, and has no plans or billing at all.
|
||||
|
||||
@@ -55,4 +55,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 triggers, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh).
|
||||
[Hosted Hub](/docs/hub/hosted) uses the same triggers, daemons, and activity model. [Create a free account](https://hub.paseo.sh); it needs no card and does not expire.
|
||||
|
||||
@@ -22,6 +22,8 @@ Open the address it prints, normally <http://localhost:3000>, and create the ope
|
||||
|
||||
The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database and your organization.
|
||||
|
||||
Hub opens **Home**, a checklist of the four things that have to be true before a mention starts an agent: connect an app, connect a daemon, create a trigger, run it. The steps below are those four. Home derives each one from what your organization actually has, prints the command the next step needs, and collapses once all four are done.
|
||||
|
||||
## 2. Connect Slack
|
||||
|
||||
**Set up your apps** explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and needs no public address or HTTPS.
|
||||
@@ -48,7 +50,7 @@ Approve the login in the browser tab that opens. Leave the Hub tab open: it watc
|
||||
|
||||
## 4. Create the starter trigger
|
||||
|
||||
After approving login, answer **Yes** to **Connect this daemon to Paseo Hub?** and **Allow Hub automations to run agents on this daemon?**. Execution permission defaults to no, so enable it explicitly for this setup.
|
||||
After approving login, answer **Yes** to **Connect this daemon to Paseo Hub?** and **Allow Hub automations to run agents on this daemon?**. Execution permission defaults to no, so enable it explicitly for this setup. If you answered no, Home shows the daemon as connected but unable to run agents, with the command that fixes it: `paseo hub permissions grant hub.execute`.
|
||||
|
||||
Then run:
|
||||
|
||||
@@ -58,13 +60,15 @@ paseo hub init
|
||||
|
||||
Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses your login and daemon connection, then lists the app connections ready for this trigger. With one Slack workspace connected, it selects that connection automatically. With several usable connections, choose the **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
|
||||
|
||||
| Question | What it wants |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Starter agent provider, model, and mode | A runtime available on your daemon. Suggested model and mode entries are its defaults. |
|
||||
| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. |
|
||||
| Deploy now? | Yes, to activate this trigger in Hub. |
|
||||
| Question | What it wants |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Starter agent provider, model, and mode | Claude, Codex, or OpenCode on your daemon. Suggested model and mode entries are its defaults. |
|
||||
| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. |
|
||||
| Deploy now? | Yes, to activate this trigger in Hub. |
|
||||
|
||||
Providers must expose a selectable model and execution mode. If there is no default mode, choose the one the agent should use. [Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected app connection.
|
||||
Providers must expose a selectable model and execution mode. If there is no default mode, choose the one the agent should use. Hub runs agents unattended, so it accepts only Claude, Codex, and OpenCode, and it checks the model, mode, and options against your daemon before deploying. Another provider, or a value the daemon does not offer, is refused here rather than at the first run.
|
||||
|
||||
[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected app connection.
|
||||
|
||||
Setup validates the trigger and writes:
|
||||
|
||||
|
||||
@@ -34,12 +34,26 @@ Field-by-field detail is in the [configuration reference](/docs/hub/configuratio
|
||||
|
||||
## Choose the agent in Hub
|
||||
|
||||
When you create or edit a trigger in the Hub dashboard, choose the daemon and enter its working directory first. Hub then asks that daemon for its available providers, models, execution modes, and thinking options. The suggested model and mode are the daemon's defaults.
|
||||
When you create or edit a trigger in the Hub dashboard, choose the daemon and enter its working directory first. Hub does not prefill the directory: it is a path on that machine, it has to exist, and only you know which repository the agent should work in. Hub then asks that daemon for its available providers, models, execution modes, and thinking options. The suggested model and mode are the daemon's defaults.
|
||||
|
||||
Only daemons that were granted `hub.execute` are offered as targets, and only Claude, Codex, and OpenCode are offered as providers. Hub preapproves its own tools on every run because nobody is at the keyboard to approve them, and those are the providers whose daemon contract accepts that.
|
||||
|
||||
Changing the daemon or working directory reloads the choices. If an existing trigger names a model, mode, or thinking option that the daemon no longer reports, Hub marks that value unavailable without replacing it. You can keep the authored value, choose a current value, or switch to YAML editing.
|
||||
|
||||
If the daemon is offline or needs a newer Paseo version, the agent selectors show an error and a retry action. The rest of the trigger and its YAML remain editable.
|
||||
|
||||
## Saving checks the agent against the daemon
|
||||
|
||||
Saving a trigger asks the target daemon to validate the agent it would launch: the provider, model, mode, thinking option, and provider-native options, and every named agent a choice can select. A value the daemon does not offer is refused at save, naming the field, instead of failing on the first run.
|
||||
|
||||
```text
|
||||
run.agent.mode: Mode 'default' is not available for provider 'opencode'
|
||||
```
|
||||
|
||||
The check needs the daemon connected. A new trigger, or a change of daemon, working directory, or agent, cannot be saved while its daemon is offline; the refusal names the daemon. Editing the prompt, filters, or limits of an existing trigger saves either way.
|
||||
|
||||
The same checks run wherever a trigger is authored: the dashboard form, its YAML mode, `POST /api/v1/triggers/install`, `paseo hub init`, and `paseo hub deploy`.
|
||||
|
||||
## Continue the same agent
|
||||
|
||||
Dashboard triggers default to **Same conversation**. Messages in the same Slack or Discord thread, events on the same GitHub issue or pull request, and events on the same Linear issue continue the existing agent in that project. An event without a conversation starts a new agent.
|
||||
|
||||
Reference in New Issue
Block a user