diff --git a/web/app/flows/plugins/PluginsGallery.tsx b/web/app/flows/plugins/PluginsGallery.tsx
new file mode 100644
index 0000000..265e262
--- /dev/null
+++ b/web/app/flows/plugins/PluginsGallery.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import { Lock, ShieldCheck, Unplug } from 'lucide-react';
+import Link from 'next/link';
+
+import { FadeIn } from '../../../components/FadeIn';
+import {
+ FLOW_PLUGIN_BADGE_IMAGE_PATH,
+ FLOW_PLUGIN_TRUST_TIER_COPY,
+ FLOW_PLUGIN_TRUST_TIER_DISCLAIMER,
+ flowPluginBadgeMarkdown,
+ flowPluginInstallHref,
+ flowPluginSourceUrl,
+ getFlowPluginCatalog,
+ pluginHasUnroutableTriggers,
+ pluginInstallFlowUrl,
+} from '../../../lib/flow-plugin-catalog';
+import s from './plugins.module.css';
+
+const CAPABILITIES = [
+ {
+ Icon: Lock,
+ title: 'Pinned source',
+ text: 'Every card is a sha and a content digest. Branches are never persisted; upgrades re-resolve.',
+ },
+ {
+ Icon: ShieldCheck,
+ title: 'Trust tiers, displayed only',
+ text: 'First-party, verified, or community. The label never skips digest, compat, or event checks.',
+ },
+ {
+ Icon: Unplug,
+ title: 'Fail closed',
+ text: 'A trigger the surface registry cannot route is plugin_event_unroutable, not a silent drop.',
+ },
+];
+
+export function PluginsGallery() {
+ const catalog = getFlowPluginCatalog();
+
+ return (
+
+
+
+
+
+
+ FLOW PLUGINS
+
+
+
+
+
+ Install a plugin
+
+ onto a base flow
+
+
+
+
+
+ A curated catalog of schema-2 flow extensions. Each card pins a public GitHub
+ directory and an install badge that opens Cloud with the base flow and the plugin
+ source.
+
+
+
+
+
+
+ Plugin contract
+
+
+ View on GitHub
+
+
+
+
+
+
+
+ {CAPABILITIES.map(({ Icon, ...cap }, i) => (
+
+
+
+
+
+
+
{cap.title}
+
{cap.text}
+
+
+
+ ))}
+
+
+
+ {catalog.plugins.map((plugin, i) => {
+ const installHref = flowPluginInstallHref(plugin);
+ const flowUrl = pluginInstallFlowUrl(plugin);
+ const pluginUrl = flowPluginSourceUrl(plugin);
+ const tier = FLOW_PLUGIN_TRUST_TIER_COPY[plugin.tier];
+ const badgeMarkdown =
+ flowUrl &&
+ flowPluginBadgeMarkdown({
+ flowUrl,
+ plugins: [pluginUrl],
+ });
+
+ return (
+
+
+
+ {plugin.name}
+
+ {tier.label}
+
+
+ {plugin.description}
+
+ {plugin.base.map((base) => (
+
+ base: {base}
+
+ ))}
+ sha {plugin.ref.slice(0, 12)}
+ sha256:{plugin.digest.slice(0, 12)}
+
+ {pluginHasUnroutableTriggers(plugin) ? (
+
+ Fail-closed: GitHub pull_request.ready_for_review,{' '}
+ labeled, and unlabeled are not in the surface
+ registry yet, so some Babysitter triggers remain{' '}
+ plugin_event_unroutable.
+
+ ) : null}
+
+ {badgeMarkdown ? {badgeMarkdown} : null}
+
+
+ );
+ })}
+
+
+
+ {FLOW_PLUGIN_TRUST_TIER_DISCLAIMER} permissions.writes is declared,
+ UNENFORCED until gate 8.
+
+
+ );
+}
diff --git a/web/app/flows/plugins/page.tsx b/web/app/flows/plugins/page.tsx
new file mode 100644
index 0000000..c58d9f0
--- /dev/null
+++ b/web/app/flows/plugins/page.tsx
@@ -0,0 +1,35 @@
+import type { Metadata } from 'next';
+
+import { GitHubStarsBadge } from '../../../components/GitHubStars';
+import { SiteFooter } from '../../../components/SiteFooter';
+import { SiteNav } from '../../../components/SiteNav';
+import { absoluteUrl, SITE_NAME } from '../../../lib/site';
+import { PluginsGallery } from './PluginsGallery';
+
+const title = 'Flow plugins — Install onto a base flow';
+const description =
+ 'A curated catalog of schema-2 flow extensions. Install Babysitter onto Software Garden with a pinned sha, digest, and trust tier.';
+
+export const metadata: Metadata = {
+ title,
+ description,
+ alternates: {
+ canonical: absoluteUrl('/flows/plugins'),
+ },
+ openGraph: {
+ siteName: SITE_NAME,
+ title,
+ description,
+ url: absoluteUrl('/flows/plugins'),
+ },
+};
+
+export default function FlowPluginsPage() {
+ return (
+ <>
+ } />
+
+
+ >
+ );
+}
diff --git a/web/app/flows/plugins/plugins.module.css b/web/app/flows/plugins/plugins.module.css
new file mode 100644
index 0000000..2aad8f8
--- /dev/null
+++ b/web/app/flows/plugins/plugins.module.css
@@ -0,0 +1,340 @@
+/* Plugin gallery. Same token language as /agents (Sora headings, radial
+ hero, pill CTAs) with cards that carry the install badge. */
+
+.page {
+ min-height: 100vh;
+ background:
+ radial-gradient(
+ ellipse 36% 28% at 10% 8%,
+ color-mix(in srgb, var(--primary) 16%, white 22%),
+ transparent 72%
+ ),
+ radial-gradient(
+ ellipse 34% 26% at 90% 10%,
+ color-mix(in srgb, var(--primary) 14%, white 20%),
+ transparent 70%
+ ),
+ var(--landing-hero-bg);
+ color: var(--fg);
+ font-family: var(--font-geist-sans), sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+:global(html[data-theme='dark']) .page {
+ background:
+ radial-gradient(circle at 14% 0%, color-mix(in srgb, var(--primary) 18%, transparent), transparent 36%),
+ radial-gradient(circle at 78% 14%, color-mix(in srgb, var(--primary) 14%, transparent), transparent 30%),
+ radial-gradient(circle at 50% 22%, color-mix(in srgb, var(--primary) 8%, transparent), transparent 44%),
+ var(--landing-hero-bg);
+}
+
+.heroSection {
+ position: relative;
+ margin-top: -60px;
+ padding-top: 60px;
+}
+
+.hero {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 72px 40px 64px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 26px;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ padding: 6px 16px;
+ border-radius: 100px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ color: var(--primary);
+}
+
+.badgeDot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--primary);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--primary) 50%, transparent);
+}
+
+.headline {
+ font-family: var(--font-heading), sans-serif;
+ font-size: clamp(2.6rem, 5.6vw, 4.1rem);
+ font-weight: 500;
+ line-height: 1.06;
+ letter-spacing: -0.03em;
+ color: var(--fg);
+ opacity: 0.85;
+ margin: 0;
+}
+
+:global(html[data-theme='dark']) .headline {
+ opacity: 1;
+}
+
+.subtitle {
+ font-size: 1.12rem;
+ line-height: 1.65;
+ color: var(--fg-muted);
+ max-width: 62ch;
+ margin: 0;
+}
+
+.pillNav {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 12px;
+ margin-top: 6px;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px 22px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: var(--card-bg);
+ color: var(--fg);
+ font-size: 0.92rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition:
+ border-color 0.2s,
+ color 0.2s,
+ transform 0.15s,
+ box-shadow 0.2s;
+}
+
+.pill:hover {
+ border-color: var(--primary);
+ color: var(--primary);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--primary) 10%, transparent);
+}
+
+.capabilities {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 8px 40px 4px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 18px;
+}
+
+.capCard {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ height: 100%;
+ padding: 18px 20px;
+ border-radius: 14px;
+ border: 1px solid var(--line);
+ background: var(--card-bg);
+}
+
+.capIcon {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
+ color: var(--primary);
+}
+
+.capIcon svg {
+ width: 19px;
+ height: 19px;
+}
+
+.capTitle {
+ font-family: var(--font-heading), sans-serif;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--fg);
+ margin: 0 0 3px;
+}
+
+.capText {
+ font-size: 0.86rem;
+ line-height: 1.5;
+ color: var(--fg-muted);
+ margin: 0;
+}
+
+.gallery {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 36px 40px 96px;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 20px;
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 26px;
+ border-radius: 18px;
+ border: 1px solid var(--line);
+ background: var(--card-bg);
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 4%, transparent);
+}
+
+.cardHead {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.cardName {
+ font-family: var(--font-heading), sans-serif;
+ font-size: 1.35rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ color: var(--fg);
+ margin: 0;
+}
+
+.cardDescription {
+ font-size: 0.98rem;
+ line-height: 1.6;
+ color: var(--fg-muted);
+ margin: 0;
+}
+
+.meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.chip,
+.tier {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 11px;
+ border-radius: 100px;
+ font-size: 0.74rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.chip {
+ border: 1px solid var(--line);
+ background: var(--section-bg, var(--card-bg));
+ color: var(--fg-muted);
+ font-family: var(--font-geist-mono), monospace;
+}
+
+.tier {
+ border: 1px solid color-mix(in srgb, var(--primary) 16%, transparent);
+ background: color-mix(in srgb, var(--primary) 7%, transparent);
+ color: var(--primary);
+}
+
+.note {
+ margin: 0;
+ padding: 12px 14px;
+ border-radius: 12px;
+ border: 1px solid color-mix(in srgb, var(--secondary-500, #c2410c) 28%, var(--line));
+ background: color-mix(in srgb, var(--secondary-500, #c2410c) 8%, transparent);
+ color: var(--fg);
+ font-size: 0.86rem;
+ line-height: 1.55;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 14px;
+ margin-top: 4px;
+}
+
+.install {
+ display: inline-flex;
+ align-items: center;
+ line-height: 0;
+}
+
+.install img {
+ display: block;
+ height: 32px;
+ width: auto;
+}
+
+.source {
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--fg-muted);
+ text-decoration: none;
+}
+
+.source:hover {
+ color: var(--primary);
+}
+
+.snippet {
+ margin: 0;
+ padding: 12px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--section-bg, var(--bg));
+ color: var(--fg-muted);
+ font-family: var(--font-geist-mono), monospace;
+ font-size: 0.72rem;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.disclaimer {
+ max-width: 72ch;
+ margin: 0 auto;
+ padding: 0 40px 80px;
+ font-size: 0.88rem;
+ line-height: 1.6;
+ color: var(--fg-muted);
+ text-align: center;
+}
+
+.note code,
+.disclaimer code {
+ font-family: var(--font-geist-mono), monospace;
+ font-size: 0.9em;
+}
+
+@media (max-width: 860px) {
+ .hero {
+ padding: 56px 24px 48px;
+ }
+
+ .capabilities,
+ .gallery,
+ .disclaimer {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+
+ .capabilities {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts
index 2273d83..fe56cf1 100644
--- a/web/app/sitemap.ts
+++ b/web/app/sitemap.ts
@@ -92,6 +92,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'weekly',
priority: 0.9,
},
+ {
+ url: absoluteUrl('/flows/plugins'),
+ lastModified: now,
+ changeFrequency: 'weekly',
+ priority: 0.8,
+ },
{
url: absoluteUrl('/agents/use-cases'),
lastModified: now,
diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx
new file mode 100644
index 0000000..a352c8c
--- /dev/null
+++ b/web/content/docs/relayflows/plugins.mdx
@@ -0,0 +1,137 @@
+---
+title: 'Plugins'
+description: 'Install a schema-2 flow extension onto a base flow — flows-plugin.json, flows add, flows plugin, and the trust tiers the gallery displays.'
+---
+
+A **flow plugin** is a directory in a public GitHub repository whose `flows-plugin.json` says what it contributes to a base flow. Helper plugins (`kind` absent or `"helper"`) still install from npm as `@flows/helper-*` and extend `Ctx` with verbs; that v1 path is unchanged. Schema 2 adds a second kind on the same file:
+
+```json
+{ "schema": 2, "kind": "flow-extension" }
+```
+
+The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). Each card's **Install plugin** badge opens Cloud with the base flow and the plugin source:
+
+```text
+/cloud/flows/deploy?flow= &plugin=
+```
+
+`plugin` may repeat. The badge helper appends each one so a second plugin does not overwrite the first.
+
+
+ See the plugin gallery — Babysitter on Software Garden, with the install badge.
+
+
+## `flows-plugin.json` schema 2
+
+`kind` absent or `"helper"` keeps today's v1 semantics byte-for-byte. `"schema": 2, "kind": "flow-extension"` is additive: it requires `entry`, `compat`, and `source` (filled by `flows add`); it forbids `verbs` (a flow extension that also wants verbs ships a helper plugin beside it). Unknown top-level keys are refused (`plugin_manifest_invalid`). `preflight` is mandatory.
+
+Worked example — Babysitter on Software Garden:
+
+```json
+{
+ "schema": 2,
+ "kind": "flow-extension",
+ "name": "babysitter",
+ "version": "0.1.0",
+ "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate.",
+ "compat": {
+ "surface": "^2.0.22",
+ "sdk": "^2.0.22",
+ "base": [{ "name": "software-factory", "version": "^2.0.0" }]
+ },
+ "entry": "babysitter.flow.ts",
+ "extends": { "handlers": true, "hooks": ["merge-gate"], "verbs": [], "gates": [] },
+ "triggers": [
+ { "provider": "github", "event": "pull_request", "actions": ["opened", "synchronize", "reopened", "ready_for_review"] },
+ { "provider": "github", "event": "pull_request_review", "actions": ["submitted"] },
+ { "provider": "github", "event": "check_run", "actions": ["completed"] },
+ { "provider": "github", "event": "issue_comment", "actions": ["created"] }
+ ],
+ "permissions": {
+ "integrations": ["github"],
+ "harnesses": ["claude"],
+ "mcp": [],
+ "writes": ["github:pull_request:comment"],
+ "budget": { "dollars": 8, "wallclock": "45m" }
+ },
+ "preflight": { "credentials": [], "servers": ["https://api.github.com"] }
+}
+```
+
+Rules that matter at review time:
+
+- `name` is kebab-case (`^[a-z0-9]+(-[a-z0-9]+)*$`).
+- `compat.surface` / `compat.sdk` are semver ranges checked against the pinned runtime. Mismatch is `plugin_incompatible`, never a warning.
+- `compat.base[]` names the base `flow()` and a version range. A base without `version` matches only `"*"`.
+- `permissions.writes` is a **declaration for review**, labelled UNENFORCED until gate 8 (#442) turns it into scope enforcement. The honest statement until then: the plugin can do anything the deployment can.
+- Manifest triggers are validated against the surface registry `providerEventTypes`. An event the registry cannot route is `plugin_event_unroutable`.
+
+
+ GitHub `pull_request.ready_for_review`, `pull_request.labeled`, and `pull_request.unlabeled` are not in the surface registry yet. A Babysitter manifest that declares them is refused `plugin_event_unroutable` until the relayfile adapter catalog grows. The gallery states this fail-closed; it does not paper over it.
+
+
+## `flows add`
+
+Input forms accepted (ref may be a tag, branch, or sha **at input only**):
+
+```text
+flows add github:/@[#]
+flows add https://github.com///tree/[/]
+flows add /@[#]
+flows add
+```
+
+The GitHub path is public, unauthenticated, https only. Private repositories are out of scope and answer 404 (`plugin_source_unresolved`). Resolution:
+
+1. Ref → 40-hex sha.
+2. Tree at that commit, refusing symlinks, submodules, traversal, files over 256 KB, or a plugin over 2 MB.
+3. Content digest = sha256 of the canonical `[{bytes,path,sha256}]` payload (the same routine sealed bundles use).
+4. Bytes land at `.flows/plugins/@sha256:/`, never in `node_modules`.
+5. `flows.json.plugins` records the canonical `github:/@#` — a branch or tag is never persisted.
+6. `flows.lock.json` (version 2) records name, version, source, digest, manifest hash, and declaration order. That order is composition order.
+
+A later re-fetch that yields a different digest for the same sha is `plugin_source_drift`.
+
+## `flows plugin`
+
+```text
+flows plugin list [--json]
+flows plugin verify [--json] [--offline]
+flows plugin remove [--json]
+flows plugin update [--json] [--yes] [--to [] []]
+```
+
+- **list** reads `flows.lock.json`.
+- **verify** re-hashes the store against the lock and, unless `--offline`, re-fetches the pinned commit. Drift is exit 2.
+- **remove** drops the name from `flows.json.plugins` and the lock (order is rebuilt) and deletes the store directory only if nothing else references it.
+- **update** re-resolves, shows the permissions / events / budget diff, and requires `--yes` (refuse otherwise, exit 2).
+
+`flows check` composes base + plugins, runs plugin preflight, compat, and event routability, and prints one `EXTENSION` line per composed extension. `flows deploy … [--plugin ]…` is send-only: it does not rewrite the working tree's `flows.json`.
+
+## Trust tiers
+
+The gallery and the deploy wizard display a tier. **The label is never used to skip a check.**
+
+| Tier | How it is assigned |
+| --- | --- |
+| **first-party** | `AgentWorkforce/*` at a sha reachable from `main` |
+| **verified** | bundle `identity.json` keyid matches a publisher key registered in Cloud |
+| **community** | anything else |
+
+Babysitter in catalog v1 is **community**: the owner is AgentWorkforce, but the pinned sha is not reachable from `main` until the babysitter branch merges. That is the honest label, not a downgrade of the code.
+
+
+ `permissions.writes` stays a reviewed declaration labelled UNENFORCED until gate 8. Displaying a first-party or verified badge does not enforce write scope, skip the digest, or route an event the registry does not carry.
+
+
+## Install badge
+
+`flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive:
+
+```markdown
+[](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fb4dd665eb433bd7f52d1045543aef5f14fb7891e%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F05c3dff138883322e80cb793b1f5a097ad510572%2Fexamples%2Fbabysitter)
+```
+
+The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge.
+
+The catalog itself is versioned JSON, `{version:1, plugins:[{name, description, source:{owner,repo,path}, ref, digest, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate.
diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx
index bf1a0fb..01e813d 100644
--- a/web/content/docs/relayflows/recommended.mdx
+++ b/web/content/docs/relayflows/recommended.mdx
@@ -11,7 +11,7 @@ Software Garden is the display name of the first recommended flow. Its stable ca
The released source uses Claude Code for implementation and review, so catalog version 1 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow.
-Software Garden currently supports GitHub repositories. Each activation supplies one or more repositories, and Cloud scopes a GitHub listener to each one. The catalog deliberately does not define Babysitter behavior.
+Software Garden currently supports GitHub repositories. Each activation supplies one or more repositories, and Cloud scopes a GitHub listener to each one. The catalog deliberately does not define Babysitter behavior. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own.
## Catalog API
diff --git a/web/data/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json
new file mode 100644
index 0000000..d7323b9
--- /dev/null
+++ b/web/data/flow-plugin-catalog.v1.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "plugins": [
+ {
+ "name": "babysitter",
+ "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate. Fail-closed: GitHub pull_request.ready_for_review, labeled, and unlabeled are not in the surface registry, so a manifest that declares them is refused plugin_event_unroutable until the relayfile adapter catalog grows.",
+ "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "examples/babysitter" },
+ "ref": "05c3dff138883322e80cb793b1f5a097ad510572",
+ "digest": "ae6af3335eb6d4e54559327acc1465419244b47911d8ff356850b61f6228d862",
+ "compat": { "surface": "^2.0.22", "sdk": "^2.0.22", "base": ["software-factory"] },
+ "tier": "community",
+ "base": ["software-factory"]
+ }
+ ]
+}
diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts
new file mode 100644
index 0000000..f60f0e5
--- /dev/null
+++ b/web/lib/flow-plugin-catalog.ts
@@ -0,0 +1,130 @@
+import catalogJson from '../data/flow-plugin-catalog.v1.json';
+import { SITE_URL } from './site';
+
+export const FLOW_PLUGIN_TRUST_TIERS = ['first-party', 'verified', 'community'] as const;
+export type FlowPluginTrustTier = (typeof FLOW_PLUGIN_TRUST_TIERS)[number];
+
+export type FlowPluginCatalogEntry = {
+ name: string;
+ description: string;
+ source: { owner: string; repo: string; path: string };
+ ref: string;
+ digest: string;
+ compat: { surface: string; sdk: string; base: string[] };
+ tier: FlowPluginTrustTier;
+ base: string[];
+};
+
+export type FlowPluginCatalog = {
+ version: 1;
+ plugins: FlowPluginCatalogEntry[];
+};
+
+export const FLOW_PLUGIN_TRUST_TIER_COPY: Record<
+ FlowPluginTrustTier,
+ { label: string; summary: string }
+> = {
+ 'first-party': {
+ label: 'First-party',
+ summary: 'AgentWorkforce/* at a sha reachable from main.',
+ },
+ verified: {
+ label: 'Verified',
+ summary: 'Publisher keyid registered in Cloud.',
+ },
+ community: {
+ label: 'Community',
+ summary: 'Any other public source.',
+ },
+};
+
+/** Display-only. Trust tiers never skip digest, compat, or event-routability checks. */
+export const FLOW_PLUGIN_TRUST_TIER_DISCLAIMER =
+ 'Tiers are displayed only. They never skip digest, compat, or event-routability checks.';
+
+export const SOFTWARE_FACTORY_FLOW_URL =
+ 'https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts';
+
+export const BASE_FLOW_URLS: Record = {
+ 'software-factory': SOFTWARE_FACTORY_FLOW_URL,
+};
+
+export const FLOW_PLUGIN_BADGE_IMAGE_PATH = '/deploy-flow_small.svg';
+
+const catalog = catalogJson as FlowPluginCatalog;
+
+/** Vendored copy of AgentWorkforce/flows catalog/plugins.json (D1). */
+export function getFlowPluginCatalog(): FlowPluginCatalog {
+ return catalog;
+}
+
+export function getFlowPlugin(name: string): FlowPluginCatalogEntry | null {
+ return catalog.plugins.find((plugin) => plugin.name === name) ?? null;
+}
+
+export function flowPluginSourceUrl(plugin: FlowPluginCatalogEntry): string {
+ const { owner, repo, path } = plugin.source;
+ return `https://github.com/${owner}/${repo}/tree/${plugin.ref}/${path}`;
+}
+
+export function flowPluginGithubRef(plugin: FlowPluginCatalogEntry): string {
+ const { owner, repo, path } = plugin.source;
+ return `github:${owner}/${repo}@${plugin.ref}#${path}`;
+}
+
+export function baseFlowUrl(name: string): string | null {
+ return BASE_FLOW_URLS[name] ?? null;
+}
+
+export function pluginInstallFlowUrl(plugin: FlowPluginCatalogEntry): string | null {
+ for (const name of plugin.base) {
+ const url = baseFlowUrl(name);
+ if (url) return url;
+ }
+ return null;
+}
+
+export function pluginHasUnroutableTriggers(plugin: FlowPluginCatalogEntry): boolean {
+ return plugin.description.includes('plugin_event_unroutable');
+}
+
+function originFrom(appOrigin: string): string {
+ return appOrigin.replace(/\/+$/, '');
+}
+
+/**
+ * Query string for `/cloud/flows/deploy`. `plugin` is appended so a second
+ * plugin does not overwrite the first (`URLSearchParams.set` would).
+ */
+export function flowPluginDeploySearch(input: { flowUrl: string; plugins: string[] }): string {
+ const params = new URLSearchParams();
+ params.set('flow', input.flowUrl);
+ for (const plugin of input.plugins) {
+ params.append('plugin', plugin);
+ }
+ return params.toString();
+}
+
+export function flowPluginInstallPath(input: { flowUrl: string; plugins: string[] }): string {
+ return `/cloud/flows/deploy?${flowPluginDeploySearch(input)}`;
+}
+
+export function flowPluginInstallHref(plugin: FlowPluginCatalogEntry): string | null {
+ const flowUrl = pluginInstallFlowUrl(plugin);
+ if (!flowUrl) return null;
+ return flowPluginInstallPath({ flowUrl, plugins: [flowPluginSourceUrl(plugin)] });
+}
+
+/**
+ * README badge for installing a plugin onto a base flow. Counterpart of
+ * Cloud's `flowDeployBadgeMarkdown()`, with `plugin` repeated via append.
+ */
+export function flowPluginBadgeMarkdown(input: {
+ appOrigin?: string;
+ flowUrl: string;
+ plugins: string[];
+}): string {
+ const origin = originFrom(input.appOrigin ?? SITE_URL);
+ const query = flowPluginDeploySearch(input);
+ return `[](${origin}/cloud/flows/deploy?${query})`;
+}
diff --git a/web/lib/product-docs-nav.ts b/web/lib/product-docs-nav.ts
index 0f683cc..b11d50e 100644
--- a/web/lib/product-docs-nav.ts
+++ b/web/lib/product-docs-nav.ts
@@ -238,6 +238,7 @@ export const relayflowsSection: ProductDocSection = {
{ title: 'Multi-agent flows', slug: 'multi-agent' },
{ title: 'Cloud', slug: 'cloud' },
{ title: 'Recommended flows', slug: 'recommended' },
+ { title: 'Plugins', slug: 'plugins' },
{ title: 'Memory & integrations', slug: 'memory-and-integrations' },
{ title: 'Reliability', slug: 'reliability' },
],
diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts
new file mode 100644
index 0000000..46ef017
--- /dev/null
+++ b/web/lib/test/flow-plugin-catalog.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ FLOW_PLUGIN_TRUST_TIERS,
+ SOFTWARE_FACTORY_FLOW_URL,
+ flowPluginBadgeMarkdown,
+ flowPluginGithubRef,
+ flowPluginInstallHref,
+ flowPluginInstallPath,
+ flowPluginSourceUrl,
+ getFlowPlugin,
+ getFlowPluginCatalog,
+ pluginHasUnroutableTriggers,
+} from '../flow-plugin-catalog';
+import { relayflowsSection, getProductSearchIndex } from '../product-docs';
+
+const SHA = /^[0-9a-f]{40}$/;
+const HEX64 = /^[0-9a-f]{64}$/;
+const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+describe('flow plugin catalog', () => {
+ const catalog = getFlowPluginCatalog();
+
+ it('is version 1 with unique kebab-case plugin names', () => {
+ expect(catalog.version).toBe(1);
+ expect(Array.isArray(catalog.plugins)).toBe(true);
+ expect(catalog.plugins.length).toBeGreaterThan(0);
+ const names = catalog.plugins.map((plugin) => plugin.name);
+ expect(names.every((name) => NAME.test(name))).toBe(true);
+ expect(new Set(names).size).toBe(names.length);
+ });
+
+ it('records a fail-closed babysitter entry with a pinned sha and digest', () => {
+ const babysitter = getFlowPlugin('babysitter');
+ expect(babysitter).toMatchObject({
+ source: { owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter' },
+ tier: 'community',
+ base: ['software-factory'],
+ });
+ expect(babysitter!.ref).toMatch(SHA);
+ expect(babysitter!.digest).toMatch(HEX64);
+ expect(babysitter!.description).toContain('plugin_event_unroutable');
+ expect(FLOW_PLUGIN_TRUST_TIERS.includes(babysitter!.tier)).toBe(true);
+ expect(pluginHasUnroutableTriggers(babysitter!)).toBe(true);
+ });
+
+ it('builds a GitHub tree URL at the pinned sha, not a branch', () => {
+ const babysitter = getFlowPlugin('babysitter')!;
+ expect(flowPluginSourceUrl(babysitter)).toBe(
+ `https://github.com/AgentWorkforce/flows/tree/${babysitter.ref}/examples/babysitter`,
+ );
+ expect(flowPluginGithubRef(babysitter)).toBe(
+ `github:AgentWorkforce/flows@${babysitter.ref}#examples/babysitter`,
+ );
+ });
+});
+
+describe('flowPluginBadgeMarkdown', () => {
+ const pluginA =
+ 'https://github.com/AgentWorkforce/flows/tree/05c3dff138883322e80cb793b1f5a097ad510572/examples/babysitter';
+ const pluginB = 'github:acme/plugins@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#extra';
+
+ it('points the Install plugin badge at /cloud/flows/deploy with flow and plugin', () => {
+ const markdown = flowPluginBadgeMarkdown({
+ appOrigin: 'https://agentrelay.com/',
+ flowUrl: SOFTWARE_FACTORY_FLOW_URL,
+ plugins: [pluginA],
+ });
+ const href = markdown.match(/\((https:\/\/agentrelay\.com\/cloud\/flows\/deploy\?[^)]+)\)/)?.[1];
+ expect(markdown.startsWith('[]')).toBe(
+ true,
+ );
+ expect(href).toBeDefined();
+ const url = new URL(href!);
+ expect(url.pathname).toBe('/cloud/flows/deploy');
+ expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL);
+ expect(SOFTWARE_FACTORY_FLOW_URL).toContain('/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/');
+ expect(SOFTWARE_FACTORY_FLOW_URL).not.toContain('/blob/main/');
+ expect(url.searchParams.getAll('plugin')).toEqual([pluginA]);
+ });
+
+ it('appends plugin so repeats survive', () => {
+ const markdown = flowPluginBadgeMarkdown({
+ appOrigin: 'https://agentrelay.com',
+ flowUrl: SOFTWARE_FACTORY_FLOW_URL,
+ plugins: [pluginA, pluginB],
+ });
+ const href = markdown.match(/\((https:\/\/agentrelay\.com\/cloud\/flows\/deploy\?[^)]+)\)/)?.[1];
+ const url = new URL(href!);
+ expect(url.searchParams.getAll('plugin')).toEqual([pluginA, pluginB]);
+ expect(url.searchParams.get('plugin')).toBe(pluginA);
+
+ const path = flowPluginInstallPath({
+ flowUrl: SOFTWARE_FACTORY_FLOW_URL,
+ plugins: [pluginA, pluginB],
+ });
+ expect(new URL(path, 'https://agentrelay.com').searchParams.getAll('plugin')).toEqual([
+ pluginA,
+ pluginB,
+ ]);
+ });
+
+ it('wires the babysitter gallery card to Software Garden plus the pinned plugin tree', () => {
+ const babysitter = getFlowPlugin('babysitter')!;
+ const href = flowPluginInstallHref(babysitter);
+ expect(href).toBeTruthy();
+ const url = new URL(href!, 'https://agentrelay.com');
+ expect(url.pathname).toBe('/cloud/flows/deploy');
+ expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL);
+ expect(url.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(babysitter)]);
+ });
+});
+
+describe('Flows plugin docs', () => {
+ it('publishes plugins in navigation and scoped search', () => {
+ const navItems = relayflowsSection.nav.flatMap((group) => group.items);
+
+ expect(navItems).toContainEqual({ title: 'Plugins', slug: 'plugins' });
+
+ const searchEntry = getProductSearchIndex(relayflowsSection).find(
+ (entry) => entry.slug === 'plugins',
+ );
+
+ expect(searchEntry).toMatchObject({ title: 'Plugins' });
+ expect(searchEntry?.headings).toEqual([
+ 'flows-plugin.json schema 2',
+ 'flows add',
+ 'flows plugin',
+ 'Trust tiers',
+ 'Install badge',
+ ]);
+ expect(searchEntry?.body).toContain('Schema 2');
+ expect(searchEntry?.body).toContain('flow-extension');
+ });
+});
diff --git a/web/lib/test/product-docs.test.ts b/web/lib/test/product-docs.test.ts
index ccaa63f..b8bc1bd 100644
--- a/web/lib/test/product-docs.test.ts
+++ b/web/lib/test/product-docs.test.ts
@@ -5,6 +5,7 @@ import {
fileSection,
getProductDocSlugs,
getProductSearchIndex,
+ relayflowsSection,
} from '../product-docs';
describe('Factory product docs', () => {
@@ -68,3 +69,12 @@ describe('Relayfile product docs', () => {
expect(searchEntry?.headings).toContain('Publish the review');
});
});
+
+describe('Flows product docs', () => {
+ it('lists plugins next to recommended flows', () => {
+ const goingFurther = relayflowsSection.nav.find((group) => group.title === 'Going further');
+
+ expect(goingFurther?.items).toContainEqual({ title: 'Plugins', slug: 'plugins' });
+ expect(getProductDocSlugs(relayflowsSection)).toContain('plugins');
+ });
+});