diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e505bbb2a..593ba0d338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/packages/website/src/hub-plans.fixture.json b/packages/website/src/hub-plans.fixture.json new file mode 100644 index 0000000000..ba11f1e04b --- /dev/null +++ b/packages/website/src/hub-plans.fixture.json @@ -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." + } + ] + } + ] +} diff --git a/packages/website/src/hub-plans.test.ts b/packages/website/src/hub-plans.test.ts new file mode 100644 index 0000000000..ef19b5c665 --- /dev/null +++ b/packages/website/src/hub-plans.test.ts @@ -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', + ); + }); +}); diff --git a/packages/website/src/hub-plans.ts b/packages/website/src/hub-plans.ts index 27521b2338..a94d697654 100644 --- a/packages/website/src/hub-plans.ts +++ b/packages/website/src/hub-plans.ts @@ -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 { - slug: string; +interface HubBillingPlan extends Omit { 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 { 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); +} diff --git a/packages/website/src/routes/hub.tsx b/packages/website/src/routes/hub.tsx index ffc61d8d55..e691ecc393 100644 --- a/packages/website/src/routes/hub.tsx +++ b/packages/website/src/routes/hub.tsx @@ -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 (

Paseo Hub

@@ -50,7 +50,7 @@ function Hub() { - +
@@ -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 (
@@ -109,10 +109,11 @@ function Pricing({ hosted }: { hosted: HubHostedOffer | null }) { Choose how to run Hub

- 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.

-
+
+
@@ -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({ ))} )} + {actionNote !== undefined && ( +

{actionNote}

+ )} {actionLabel} @@ -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() { sign in to Hosted Hub {" "} - 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.
diff --git a/public-docs/cli.md b/public-docs/cli.md index 6d816acc48..71e54474e3 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -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 ` 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/-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/-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. diff --git a/public-docs/hub/api.md b/public-docs/hub/api.md index 36de361062..303a3ffa2d 100644 --- a/public-docs/hub/api.md +++ b/public-docs/hub/api.md @@ -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`: diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index d243b1a08c..91b71782d9 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -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 diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index a208838266..94496f0822 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -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. diff --git a/public-docs/hub/hosted.md b/public-docs/hub/hosted.md index dfe37d1582..b2aba6b2b6 100644 --- a/public-docs/hub/hosted.md +++ b/public-docs/hub/hosted.md @@ -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. diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md index 595bffaee5..2165f067c7 100644 --- a/public-docs/hub/index.md +++ b/public-docs/hub/index.md @@ -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. diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md index a0d1f4c991..55e1746a88 100644 --- a/public-docs/hub/quickstart.md +++ b/public-docs/hub/quickstart.md @@ -22,6 +22,8 @@ Open the address it prints, normally , 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: diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index 5fc4367934..164d1739eb 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -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.