diff --git a/web/analytics/agent-signup-funnel.md b/web/analytics/agent-signup-funnel.md
new file mode 100644
index 00000000..f78bc446
--- /dev/null
+++ b/web/analytics/agent-signup-funnel.md
@@ -0,0 +1,121 @@
+# Agent signup funnels (Teams and Flows)
+
+This funnel measures the agent-led journey reached through “Let your agent do it.”
+It is separate from the existing manual Flows onboarding funnel (version 2).
+
+## Common event properties
+
+All events use `funnel=agent_signup`, `funnel_version=1`, `onboarding_method=agent`,
+`product=teams|flows`, `journey_id`, `step`, and `source=browser|server`.
+The journey ID is a random analytics identifier, **not** the progress session ID
+(the latter grants read access). The browser's existing PostHog distinct ID is
+passed to Cloud once at session creation so both sides refer to the same visitor.
+Use the same PostHog project for the marketing and Cloud clients.
+
+Server events additionally include `step_name`, `state`, `revision`, `elapsed_ms`
+(since session creation), `agent_type`, and `agent_attribution`.
+
+`agent_type` is self-reported by the agent's first progress PATCH and retained:
+`codex`, `claude_code`, `grok`, `opencode`, `cursor`, `gemini_cli`, `other`, or
+`unknown`. This describes the tool performing signup, not the models selected
+for the eventual Flows workflow. Rotating logos are decorative and never count
+as a user selection. Do not infer a known agent for visitors who never start it.
+
+## Event catalog
+
+| Event | Source | When |
+| --- | --- | --- |
+| `agent_signup_entry_clicked` | Browser | Landing-page secondary CTA |
+| `agent_signup_page_viewed` | Browser | Owner opens the signup page (including direct entry) |
+| `agent_signup_session_created` | Server | A new progress session is persisted |
+| `agent_signup_prompt_copied` | Browser | Clipboard write succeeds |
+| `agent_signup_manual_copy_shown` | Browser | Clipboard unavailable; full prompt revealed for manual copy |
+| `agent_signup_step_started` | Server | Agent advances into a numbered step |
+| `agent_signup_step_completed` | Server | Agent advances past a step, or completes verification |
+| `agent_signup_waiting` | Server | Agent reports waiting for approval or a choice |
+| `agent_signup_failed` | Server | Agent reports a blocked/failed step |
+| `agent_signup_resumed` | Server | Waiting/failed step resumes working |
+| `agent_signup_completed` | Server | Agent reports verified step 5 complete |
+| `agent_signup_details_opened` | Browser | Owner expands setup details |
+| `agent_signup_session_error` | Browser | Starting/restoring session fails (no error text sent) |
+| `agent_signup_expired` | Browser | Owner's open page observes expiry |
+| `agent_signup_restarted` | Browser | Owner explicitly starts a new session |
+| `agent_signup_page_left` | Browser | Owner's pagehide or SPA exit, with last observed step |
+| `agent_signup_dashboard_opened` | Browser | Completion CTA clicked |
+
+| Step | Teams `step_name` | Flows `step_name` |
+| --- | --- | --- |
+| 1 | `sign_in` | `sign_in` |
+| 2 | `install_app` | `choose_flow` |
+| 3 | `connect_workspace` | `connect_tools` |
+| 4 | `choose_sharing` | `activate_flow` |
+| 5 | `verify` | `verify` |
+
+## Build the two funnels
+
+Create a sequential funnel filtered to `funnel=agent_signup`, `funnel_version=1`,
+and `product=teams`; duplicate it with `product=flows`. Use a two-hour conversion
+window (session TTL), holding `journey_id` constant so separate attempts do not
+complete one another. The standard funnel counts people; for attempt counts,
+use a SQL insight grouped by `journey_id`, since a person can make multiple attempts.
+
+1. `agent_signup_page_viewed`
+2. `agent_signup_session_created`
+3. `agent_signup_step_started`, filter `step=1`
+4. `agent_signup_step_completed`, filter `step=1`
+5. `agent_signup_step_completed`, filter `step=2`
+6. `agent_signup_step_completed`, filter `step=3`
+7. `agent_signup_step_completed`, filter `step=4`
+8. `agent_signup_completed`
+
+Do not require clipboard success or the landing CTA in this main funnel: users
+can copy manually or arrive directly. Make separate entry→page and
+page→prompt-copied/manual-copy-shown→agent-started funnels to diagnose handoff.
+
+For an agent comparison, start at `agent_signup_step_started` with `step=1`,
+then add the completion steps. Break down by event property `agent_type`, using
+first-touch attribution on this agent-started funnel. This keeps non-starters
+in the overall funnel rather than silently excluding them for lacking an agent.
+To split the full funnel by agent, use last-touch attribution: pre-agent dropoffs
+remain `unknown`. See [PostHog funnel breakdown attribution](https://posthog.com/docs/product-analytics/funnels#attribution-types).
+
+For friction, trend `agent_signup_waiting` and `agent_signup_failed` by `product`,
+`step_name`, and `agent_type`; compare elapsed time at each completed step. Inspect
+the last server milestone per `journey_id` to see how far an attempt got. A
+`page_left` event is diagnostic, **not abandonment**: the agent can finish while
+the page is closed. Incomplete journeys after the two-hour window are dropoffs;
+there is no fabricated server "abandoned" event or fake completion.
+
+## Reliability, privacy, and rollout checks
+
+- Browser events and the server handoff are disabled when PostHog is absent,
+ uninitialized, or opted out at session creation. Server events continue for
+ that opted-in journey independently of the browser. Later browser opt-out
+ stops browser events; the existing server journey retains its original
+ capture context until its two-hour expiry. Do not claim per-request consent
+ revocation is implemented by this flow.
+- Server events follow committed writes. GET polling, watcher tabs, duplicate
+ PATCHes and rejected updates never emit milestones. Stable event UUIDs prevent
+ duplicate transport delivery. Tracking is best effort, bounded to 1.5 seconds
+ on Node and kept alive with Worker `waitUntil`; there is no durable retry outbox.
+- Events never include tokens, raw progress IDs, prompts, device codes, local
+ paths, user text, or approval URLs. SDK-enriched URLs redact the session query
+ and progress route IDs. Replay network capture drops the entire progress API
+ request/response, including serialized bodies and headers. The signup page is blocked from replay/autocapture;
+ intentional funnel events remain enabled.
+- Marketing uses its existing public project key at build time; Cloud uses its
+ existing shared server client and runtime ingest key. No new project or secret
+ is created. Ensure keys target the same project, and Cloud's stage is allowed
+ to emit analytics. The marketing GitHub build variable was compared with Cloud’s canonical production
+ ingest key and matches. No live PostHog project/dashboard was modified by this PR.
+- Before production signoff, perform one consented Teams signup and one Flows
+ signup, then find each `journey_id` in PostHog Live Events. Verify the visitor
+ joins browser and server events, the first PATCH has the actual agent type,
+ all five completion steps appear once, and no capabilities appear in event
+ JSON/replay. Repeat with analytics disabled: signup must still work.
+
+Local coverage: migrated database/real-handler tests exercise both products,
+concurrent retries, attribution, failures, and completion without browser polling;
+browser-tracker tests cover opt-out, reload, storage failures, product separation,
+restart, and sanitization. These prove instrumentation behavior, not production
+PostHog ingestion or real Google/provider/desktop onboarding.
diff --git a/web/app/WebsitePostHogProvider.tsx b/web/app/WebsitePostHogProvider.tsx
new file mode 100644
index 00000000..91f3ba50
--- /dev/null
+++ b/web/app/WebsitePostHogProvider.tsx
@@ -0,0 +1,16 @@
+'use client';
+// The public client provider shares @posthog/next's hook context. Keeping
+// configuration here lets before_send run without serializing a function
+// through the server-component boundary, and before the first SDK capture.
+import { PostHogProvider } from '@posthog/next/pages';
+import type { ReactNode } from 'react';
+import { maskSignupNetworkRequest, sanitizeSignupAnalytics } from '../lib/signup-analytics-privacy';
+
+export function WebsitePostHogProvider({ apiKey, host, children }: { apiKey: string; host: string; children: ReactNode }) {
+ return {children} ;
+}
diff --git a/web/app/flows/page.tsx b/web/app/flows/page.tsx
index 11e3e02c..c0032bec 100644
--- a/web/app/flows/page.tsx
+++ b/web/app/flows/page.tsx
@@ -17,6 +17,7 @@ import home from '../landing.module.css';
import s from './flows.module.css';
import { FlowExamples } from './FlowExamples';
import { IntegrationMarquee } from './IntegrationMarquee';
+import { AgentSignup } from '../../components/AgentSignup';
export const metadata: Metadata = {
title: 'Flows: Script your agents',
@@ -94,9 +95,7 @@ export default function FlowsPage() {
Try Free with Cloud
-
- Chat with founders
-
+
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 3e86c2a7..d3ad036b 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
-import { PostHogProvider } from '@posthog/next';
+import { WebsitePostHogProvider } from './WebsitePostHogProvider';
import { Geist_Mono, Inter, Sora } from 'next/font/google';
import type { ReactNode } from 'react';
@@ -118,19 +118,10 @@ const siteStructuredData = {
export default function RootLayout({ children }: { children: ReactNode }) {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const content = postHogKey ? (
-
+
{children}
-
+
) : (
children
);
diff --git a/web/app/signup/[product]/page.tsx b/web/app/signup/[product]/page.tsx
new file mode 100644
index 00000000..7d4da2a0
--- /dev/null
+++ b/web/app/signup/[product]/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { isAgentSignupProduct } from '../../../lib/agent-signup';
+import { AgentSignupJourney } from '../../../components/AgentSignupJourney';
+
+export const metadata: Metadata = {
+ title: 'Let your agent do it',
+ description: 'Give your agent a prompt and watch it set up Agent Relay.',
+ robots: { index: false, follow: false },
+ referrer: 'no-referrer',
+};
+
+export default async function AgentSignupPage({ params }: { params: Promise<{ product: string }> }) {
+ const { product } = await params;
+ if (!isAgentSignupProduct(product)) notFound();
+ return ;
+}
diff --git a/web/app/signup/agent/[product]/route.ts b/web/app/signup/agent/[product]/route.ts
new file mode 100644
index 00000000..d6623925
--- /dev/null
+++ b/web/app/signup/agent/[product]/route.ts
@@ -0,0 +1,21 @@
+import { agentSignupInstructions, isAgentSignupProduct } from '../../../../lib/agent-signup';
+import { teamsCloudUrl } from '../../../../lib/teams-cloud';
+import { SITE_URL } from '../../../../lib/site';
+
+export async function GET(request: Request, { params }: { params: Promise<{ product: string }> }) {
+ const { product } = await params;
+ if (!isAgentSignupProduct(product)) return new Response('Not found', { status: 404 });
+ const url = new URL(request.url);
+ // The apex router's HTTP fallback rewrites the URL to the marketing origin.
+ // Sign-in and /cloud remain on the public apex, never that upstream host.
+ const site = url.hostname === 'origin-web.agentrelay.com' ? SITE_URL : url.origin;
+ const cloud = new URL(teamsCloudUrl(''), site).href.replace(/\/$/, '');
+ return new Response(agentSignupInstructions(product, site, cloud), {
+ headers: {
+ 'Content-Type': 'text/markdown; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ 'Access-Control-Allow-Origin': '*',
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ });
+}
diff --git a/web/app/teams/page.tsx b/web/app/teams/page.tsx
index fd62fe41..382dc095 100644
--- a/web/app/teams/page.tsx
+++ b/web/app/teams/page.tsx
@@ -14,6 +14,7 @@ import { SiteFooter } from '../../components/SiteFooter';
import { TEAMS_OG_ALT, TEAMS_OG_IMAGE_PATH, ogImage } from '../../lib/og-meta';
import { absoluteUrl } from '../../lib/site';
import { teamsCloudUrl } from '../../lib/teams-cloud';
+import { AgentSignup } from '../../components/AgentSignup';
import home from '../landing.module.css';
import flows from '../flows/flows.module.css';
import { IntegrationMarquee } from '../flows/IntegrationMarquee';
@@ -66,6 +67,7 @@ export default function TeamsPage() {
{signupLabel}
+
No credit card required
diff --git a/web/app/teams/teams-landing.module.css b/web/app/teams/teams-landing.module.css
index eea4ebe9..c748e2fd 100644
--- a/web/app/teams/teams-landing.module.css
+++ b/web/app/teams/teams-landing.module.css
@@ -101,5 +101,5 @@
.sessionScreenshot a { border-radius: 8px; }
}
-.page .signupCtas { flex-direction: column; align-items: center; gap: 12px; }
-.signupNote { margin: 0; color: var(--flows-muted); font-size: 13px; line-height: 1.5; }
+.page .signupCtas { flex-direction: row; align-items: center; gap: 12px; }
+.signupNote { flex-basis: 100%; margin: 0; color: var(--flows-muted); font-size: 13px; line-height: 1.5; }
diff --git a/web/components/AgentSignup.tsx b/web/components/AgentSignup.tsx
new file mode 100644
index 00000000..6cabc9ef
--- /dev/null
+++ b/web/components/AgentSignup.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { useSignupAnalytics } from './useSignupAnalytics';
+import Link from 'next/link';
+import Grok from '@lobehub/icons/es/Grok';
+import { AgentToolLogo } from './AgentToolLogos';
+import type { AgentSignupProduct } from '../lib/agent-signup';
+import s from './agent-signup.module.css';
+
+export function AgentSignup({ product }: { product: AgentSignupProduct }) {
+ const analytics = useSignupAnalytics(product);
+ return (
+ analytics.track('entry_clicked')} className={s.button} href={`/signup/${product}`}>
+
+
+
+
+
+
+ Let your agent do it
+
+ );
+}
diff --git a/web/components/AgentSignupJourney.tsx b/web/components/AgentSignupJourney.tsx
new file mode 100644
index 00000000..89aaf70b
--- /dev/null
+++ b/web/components/AgentSignupJourney.tsx
@@ -0,0 +1,238 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useSignupAnalytics } from './useSignupAnalytics';
+import type { SignupAnalyticsContext } from '../lib/agent-signup-analytics';
+import Link from 'next/link';
+import Grok from '@lobehub/icons/es/Grok';
+import { ArrowLeft, ArrowUpRight, Check, Copy, RefreshCw } from 'lucide-react';
+import { AgentToolLogo } from './AgentToolLogos';
+import { SignupAtmosphere } from './SignupAtmosphere';
+import type { AgentSignupProduct } from '../lib/agent-signup';
+import { isSignupProgress, signupSteps, trackedSignupPrompt, type SignupProgress, type SignupSession } from '../lib/agent-signup-progress';
+import { teamsCloudUrl } from '../lib/teams-cloud';
+import s from './agent-signup-journey.module.css';
+
+const apiPath = teamsCloudUrl('/api/v1/signup/agent/sessions');
+const storageKey = (product: string) => `agent-relay-signup:${product}`;
+
+class ProgressError extends Error {
+ constructor(message: string, readonly retryAfterMs = 6000, readonly expired = false) { super(message); }
+}
+
+async function readProgress(id: string, signal?: AbortSignal): Promise {
+ const response = await fetch(`${apiPath}/${encodeURIComponent(id)}`, { cache: 'no-store', signal });
+ if (response.status === 404) throw new ProgressError('This setup session has expired. Start a new session to continue.', 0, true);
+ if (!response.ok) {
+ const retry = response.headers.get('retry-after');
+ const seconds = Number(retry);
+ const delay = retry ? (Number.isFinite(seconds) ? seconds * 1000 : Date.parse(retry) - Date.now()) : 6000;
+ throw new ProgressError('Updates are temporarily unavailable. Your agent can keep working.', Number.isFinite(delay) ? Math.max(3000, delay) : 6000);
+ }
+ const data: unknown = await response.json();
+ if (!isSignupProgress(data)) throw new Error('Could not read setup progress. Retrying shortly.');
+ return data;
+}
+
+async function startSession(product: AgentSignupProduct, analytics?: SignupAnalyticsContext): Promise<{ progress: SignupProgress; token?: string }> {
+ const requestedId = new URL(window.location.href).searchParams.get('session');
+ let saved: SignupSession | undefined;
+ try {
+ const value: unknown = JSON.parse(sessionStorage.getItem(storageKey(product)) || 'null');
+ if (isSignupProgress(value) && value.product === product && 'writeToken' in value &&
+ typeof value.writeToken === 'string' && /^[a-f0-9]{64}$/.test(value.writeToken)) saved = value as SignupSession;
+ } catch { /* Storage may be disabled; the current tab can still run setup. */ }
+ const id = requestedId || saved?.id;
+ if (id) {
+ const progress = await readProgress(id, AbortSignal.timeout(10_000));
+ if (progress.product !== product) throw new Error('This session belongs to a different product. Start a new session below.');
+ return { progress, token: saved?.id === id ? saved.writeToken : undefined };
+ }
+ const response = await fetch(apiPath, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ product, ...(analytics ? { analytics } : {}) }), signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) throw new Error('Could not start a setup session. Please try again.');
+ const session: unknown = await response.json();
+ if (!isSignupProgress(session) || session.product !== product || !('writeToken' in session) ||
+ typeof session.writeToken !== 'string' || !/^[a-f0-9]{64}$/.test(session.writeToken)) throw new Error('Could not start a setup session. Please try again.');
+ try { sessionStorage.setItem(storageKey(product), JSON.stringify(session)); } catch { /* Best effort reload recovery. */ }
+ return { progress: session, token: session.writeToken };
+}
+
+export function AgentSignupJourney({ product }: { product: AgentSignupProduct }) {
+ const analytics = useSignupAnalytics(product);
+ const viewed = useRef(false);
+ const lifecycle = useRef(0);
+ const trackedExpired = useRef(false);
+ const latest = useRef({ step: 0, owner: false });
+ const [progress, setProgress] = useState();
+ const [token, setToken] = useState();
+ const [origin, setOrigin] = useState('');
+ const [error, setError] = useState('');
+ const [copyMessage, setCopyMessage] = useState('');
+ const [expired, setExpired] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ const [showPrompt, setShowPrompt] = useState(false);
+ const textarea = useRef(null);
+ const boot = useRef | null>(null);
+ const steps = signupSteps[product];
+ const complete = progress?.state === 'complete';
+ const active = progress?.step || 0;
+ const paused = progress?.state === 'waiting' && active > 0;
+ const failed = progress?.state === 'failed';
+ const endpoint = origin ? new URL(apiPath, origin).href : '';
+ const prompt = progress && token ? trackedSignupPrompt(product, origin, endpoint, { id: progress.id, writeToken: token }) : '';
+
+ latest.current = { step: active, owner: Boolean(token) };
+ useEffect(() => {
+ const generation = ++lifecycle.current;
+ const leave = () => { if (latest.current.owner) analytics.leave(latest.current.step); };
+ const show = () => analytics.resume();
+ window.addEventListener('pagehide', leave);
+ window.addEventListener('pageshow', show);
+ return () => {
+ window.removeEventListener('pagehide', leave);
+ window.removeEventListener('pageshow', show);
+ // Ignore StrictMode's cleanup/setup probe, but include SPA Back exits.
+ queueMicrotask(() => { if (lifecycle.current === generation) leave(); });
+ };
+ }, [analytics]);
+ useEffect(() => {
+ if (expired && token && !trackedExpired.current) { trackedExpired.current = true; analytics.track('expired', active); }
+ }, [expired, token, active, analytics]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setOrigin(window.location.origin);
+ // Keep a single creation request through React StrictMode's effect replay.
+ // Watcher links must not create a second funnel or inherit the owner's identity.
+ const requested = new URL(window.location.href).searchParams.get('session');
+ let owner = !requested;
+ try { owner ||= JSON.parse(sessionStorage.getItem(storageKey(product)) || 'null')?.id === requested; } catch { /* Read-only until proven otherwise. */ }
+ if (owner && !viewed.current) viewed.current = true;
+ boot.current ||= (async () => {
+ // Wait briefly for PostHog so page_viewed, the session POST, and later
+ // events share one journey_id; never block setup on telemetry.
+ const context = owner ? await analytics.contextWhenReady() : undefined;
+ if (owner) analytics.track('page_viewed');
+ return startSession(product, context);
+ })();
+ void boot.current.then(({ progress: initial, token: writeToken }) => {
+ if (cancelled) return;
+ setProgress(initial); setToken(writeToken); setError('');
+ const url = new URL(window.location.href);
+ url.searchParams.set('session', initial.id);
+ window.history.replaceState(window.history.state, '', url);
+ }).catch((cause) => {
+ if (!cancelled) {
+ if (owner) analytics.track('session_error');
+ setError(cause instanceof Error && cause.name !== 'TimeoutError' ? cause.message : 'We couldn’t start live setup. Check your connection and try again.');
+ if (cause instanceof ProgressError && cause.expired) setExpired(true);
+ }
+ });
+ return () => { cancelled = true; };
+ }, [product, attempt, analytics]);
+
+ useEffect(() => {
+ if (!progress || complete) return;
+ let cancelled = false;
+ let timer: ReturnType;
+ const controller = new AbortController();
+ const poll = async () => {
+ let delay = 3000;
+ if (Date.now() >= Date.parse(progress.expiresAt)) { setExpired(true); return; }
+ try {
+ const next = await readProgress(progress.id, AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]));
+ if (cancelled) return;
+ if (next.product !== product) throw new Error('This session belongs to another product.');
+ setProgress(previous => !previous || next.revision >= previous.revision ? next : previous);
+ setError('');
+ if (next.state === 'complete') return;
+ } catch (cause) {
+ if (cancelled) return;
+ if (cause instanceof ProgressError && cause.expired) { setExpired(true); return; }
+ delay = cause instanceof ProgressError ? cause.retryAfterMs : 6000;
+ setError(cause instanceof Error && cause.name !== 'TimeoutError' ? cause.message : 'Updates are temporarily unavailable. Retrying shortly.');
+ }
+ if (!cancelled) timer = setTimeout(() => void poll(), Math.min(delay, Math.max(0, Date.parse(progress.expiresAt) - Date.now())));
+ };
+ timer = setTimeout(() => void poll(), 1500);
+ return () => { cancelled = true; clearTimeout(timer); controller.abort(); };
+ }, [progress?.id, progress?.expiresAt, complete, product]);
+
+ async function copy() {
+ try { await navigator.clipboard.writeText(prompt); setCopyMessage('Copied. Paste it into your agent.'); analytics.track('prompt_copied'); }
+ catch { analytics.track('manual_copy_shown'); setShowPrompt(true); setCopyMessage('Select and copy the prompt below.'); requestAnimationFrame(() => { textarea.current?.focus(); textarea.current?.select(); }); }
+ }
+ function restart() {
+ analytics.restart(Boolean(token));
+ try { sessionStorage.removeItem(storageKey(product)); } catch { /* Best effort. */ }
+ window.location.assign(window.location.pathname);
+ }
+ const title = expired ? 'Session expired' : complete ? 'You’re all set.' : failed ? 'Your agent needs a hand.' : paused ? 'A quick approval from you.' : active ? steps[active - 1].title : 'Waiting for your agent';
+ const detail = expired ? 'Start a new session to keep watching setup.' : complete ? 'Your agent has verified setup. You’re ready to go.' : failed ? 'Check your agent’s conversation to resolve the issue. Progress will resume here.' : paused ? 'Follow the approval request in your agent’s conversation. We’ll pick up right here.' : active ? steps[active - 1].detail : 'The show starts when you paste the prompt into your agent.';
+ const mode = expired || error ? 'offline' : complete ? 'complete' : failed ? 'failed' : paused ? 'paused' : active ? 'working' : 'waiting';
+
+ const heading = complete ? 'All yours.' : expired || failed || active ? title : 'Leave it to your agent.';
+
+ return (
+
+
+
+
+
+
{heading}
+
{complete ? 'Your agent has verified setup. You’re ready to go.' : expired || failed || active ? detail : 'Give this prompt to your coding agent and hang out here to watch it sign you up.'}
+ {!complete && !active && !expired && !failed && (
+
+ )}
+ {complete ?
{ if (token) analytics.track('dashboard_opened', active); }} className={s.primary} href={teamsCloudUrl(product === 'teams' ? '/dashboard/sessions' : '/dashboard')}>Open {product === 'teams' ? 'your workspace' : 'dashboard'}
+ : !active && !expired ?
void copy()}>{copyMessage.startsWith('Copied') ? : }{copyMessage.startsWith('Copied') ? 'Prompt copied' : 'Copy setup prompt'} : null}
+
{complete ? '' : active ? (paused ? 'Your agent will continue after you approve.' : 'You can leave this page open.') : copyMessage || (expired ? '' : progress && !token ? 'Watching this session. The prompt is in the original browser tab.' : product === 'teams' ? 'Paste into a coding agent on your Mac.' : 'Paste into a coding agent with terminal access.')}
+
+
{steps.map((step, index) => index + 1} data-current={!complete && active === index + 1} />)}
+
{expired ? 'Session expired' : error ? 'Waiting for a connection' : complete ? 'Setup complete' : active ? `${active} of 5 · ${paused ? 'Waiting for your approval' : failed ? 'Needs your attention' : steps[active - 1].title}` : progress ? 'Ready when your agent is' : 'Preparing your session…'}
+
+ {error &&
{error}
{!progress &&
{
+ boot.current = null; setError('');
+ if (expired) {
+ // Only a dead session gets replaced; transient failures retry the same one.
+ setExpired(false); trackedExpired.current = false;
+ try { sessionStorage.removeItem(storageKey(product)); } catch { /* Storage is optional. */ }
+ const url = new URL(window.location.href);
+ url.searchParams.delete('session');
+ window.history.replaceState(window.history.state, '', url);
+ }
+ setAttempt(value => value + 1);
+ }}>Try again }
}
+
{ if (event.currentTarget.open && token) analytics.track('details_opened', active); }}>
+ Setup details
+
+
{[...steps, { title: 'Ready to go' }].map((step, index) => {
+ const done = complete || active > index + 1;
+ const current = complete ? index === 5 : active === index + 1;
+ return
+ {done ? : index + 1}
+ {step.title}
+ ;
+ })}
+ {progress &&
Session {progress.id}
}
+
Live updates are reported by your agent.
+
Agent instructions
+
setShowPrompt(value => !value)} disabled={!prompt}>{showPrompt ? 'Hide prompt' : 'View prompt'}
+
Start a new session
+
+
+ {showPrompt && prompt &&
}
+ {expired &&
Start a new session }
+
+
+
+ );
+}
diff --git a/web/components/SignupAtmosphere.tsx b/web/components/SignupAtmosphere.tsx
new file mode 100644
index 00000000..49c2b291
--- /dev/null
+++ b/web/components/SignupAtmosphere.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { RELAY_MARK_PATHS } from '../lib/relay-mark';
+
+type Props = { step: number; mode: string; className: string };
+const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
+
+/** The actual Relay mark, woven from responsive ribbons of light. */
+export function SignupAtmosphere({ step, mode, className }: Props) {
+ const canvas = useRef(null);
+ const state = useRef({ step, mode });
+ state.current = { step, mode };
+
+ useEffect(() => {
+ const element = canvas.current;
+ const ctx = element?.getContext('2d');
+ if (!element || !ctx) return;
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const finePointer = window.matchMedia('(pointer: fine)');
+ // Sample the canonical paths once. No approximated logo or per-frame DOM work.
+ const shapes = RELAY_MARK_PATHS.map((d, index) => {
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', d);
+ const length = path.getTotalLength();
+ return {
+ fill: new Path2D(d), centerX: index === 0 ? 37 : 89,
+ points: Array.from({ length: 193 }, (_, i) => {
+ const point = path.getPointAtLength(i / 192 * length);
+ return { x: point.x, y: point.y };
+ }),
+ };
+ });
+ let frame = 0; let width = 0; let height = 0;
+ let phase = 0; let last = 0; let staticFrame = '';
+ let shownStep = state.current.step;
+ let assembled = state.current.mode === 'complete' ? 1 : 0;
+ let hover = 0; let driftX = 0; let driftY = 0; let kickX = 0; let kickY = 0;
+ const pointer = { x: 0, y: 0, inside: false, lastX: 0, lastY: 0, lastTime: 0 };
+ const centerY = () => Math.min(height * .29, 260);
+ const baseSize = () => Math.min(width * .7, height * .44, 440);
+ const resize = () => {
+ width = element.clientWidth; height = element.clientHeight;
+ const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
+ element.width = Math.round(width * ratio); element.height = Math.round(height * ratio);
+ staticFrame = '';
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
+ };
+ const move = (event: PointerEvent) => {
+ if (reduced.matches || !finePointer.matches || event.pointerType === 'touch') return;
+ const rect = element.getBoundingClientRect();
+ const x = event.clientX - rect.left; const y = event.clientY - rect.top;
+ const size = baseSize();
+ pointer.inside = size > 0 && Math.hypot((x - width / 2) / (size * .65), (y - centerY()) / (size * .55)) < 1;
+ if (pointer.inside) {
+ pointer.x = clamp((x - width / 2) / size, -.6, .6);
+ pointer.y = clamp((y - centerY()) / size, -.6, .6);
+ const elapsed = event.timeStamp - pointer.lastTime;
+ if (elapsed > 0 && elapsed < 120) {
+ kickX = clamp(kickX + (x - pointer.lastX) / elapsed * 3, -18, 18);
+ kickY = clamp(kickY + (y - pointer.lastY) / elapsed * 3, -18, 18);
+ }
+ }
+ pointer.lastX = x; pointer.lastY = y; pointer.lastTime = event.timeStamp;
+ };
+ const leave = () => { pointer.inside = false; pointer.lastTime = 0; };
+ const observer = new ResizeObserver(resize);
+ observer.observe(element); resize();
+ window.addEventListener('pointermove', move, { passive: true });
+ document.documentElement.addEventListener('pointerleave', leave);
+ window.addEventListener('blur', leave);
+
+ function draw(time: number) {
+ frame = requestAnimationFrame(draw);
+ if (document.hidden || time - last < 32) return;
+ const delta = Math.min(time - last, 50); last = time;
+ const current = state.current;
+ const motion = !reduced.matches && current.mode !== 'offline';
+ const complete = current.mode === 'complete';
+ const warm = current.mode === 'paused' || current.mode === 'failed';
+ const smooth = motion ? 1 - Math.exp(-delta / 650) : 1;
+ shownStep += (current.step - shownStep) * smooth;
+ assembled += ((complete ? 1 : 0) - assembled) * (motion ? 1 - Math.exp(-delta / 950) : 1);
+ if (assembled > .999) assembled = 1;
+ const progress = clamp(shownStep / 5, 0, 1);
+ if (motion) phase += delta * (warm ? .00008 : .00012 + progress * .00032) * (1 - assembled);
+ hover += ((pointer.inside && motion ? 1 : 0) - hover) * .09;
+ driftX += ((motion ? pointer.x * hover : 0) - driftX) * .075;
+ driftY += ((motion ? pointer.y * hover : 0) - driftY) * .075;
+ kickX *= .91; kickY *= .91;
+ if (!motion) { hover = 0; driftX = 0; driftY = 0; kickX = 0; kickY = 0; }
+ const frameKey = `${width}:${height}:${current.step}:${current.mode}`;
+ if (!motion && frameKey === staticFrame) return;
+ staticFrame = motion ? '' : frameKey;
+ const size = baseSize() * (.86 + progress * .14);
+ const scale = size / 112;
+ const cx = width / 2; const cy = centerY();
+ ctx!.clearRect(0, 0, width, height);
+ const glow = ctx!.createRadialGradient(cx, cy, 0, cx, cy, size * 1.15);
+ glow.addColorStop(0, `rgba(67,145,192,${.1 + progress * .1 + hover * .035})`);
+ glow.addColorStop(.5, 'rgba(30,85,126,.065)'); glow.addColorStop(1, 'rgba(10,27,40,0)');
+ ctx!.fillStyle = glow; ctx!.fillRect(0, 0, width, height);
+ ctx!.save();
+ ctx!.translate(cx + driftX * 16, cy + driftY * 12);
+ ctx!.rotate(driftX * .07 * (1 - assembled * .7));
+ ctx!.globalCompositeOperation = 'screen';
+
+ shapes.forEach((shape, shapeIndex) => {
+ const tone = shapeIndex === 0 ? 1 : .55;
+ for (let line = 0; line < 48; line++) {
+ const strand = line / 47;
+ const emphasis = Math.pow(Math.sin(strand * Math.PI), 2);
+ // Loose silk outlines gradually knit inward into the two solid facets.
+ const contour = (1 - assembled) * (.77 + strand * .32) + assembled * (.08 + strand * .92);
+ ctx!.beginPath();
+ shape.points.forEach((point, index) => {
+ const t = index / 192 * Math.PI * 2;
+ const wave = (1 - assembled) * (1 + progress * .35);
+ const flowX = Math.sin(t * 2 + phase + strand * 2.4) * 3.5 + Math.cos(t * 3 - phase * .7) * 1.7;
+ const flowY = Math.sin(t * 3 + phase * .8 + strand * 3) * 3;
+ const x = (point.x - shape.centerX) * contour + shape.centerX - 56;
+ const y = (point.y - 45.5) * contour;
+ // Pointer velocity excites a traveling ripple, then damps naturally.
+ const ripple = Math.sin(t * 2 - phase * 1.5 + strand * 4) * hover;
+ const px = (x + flowX * wave) * scale + ripple * kickX * (1 - assembled * .8);
+ const py = (y + flowY * wave) * scale + ripple * kickY * (1 - assembled * .8);
+ if (index === 0) ctx!.moveTo(px, py); else ctx!.lineTo(px, py);
+ });
+ const hue = warm ? 36 + strand * 16 : 195 + strand * 19;
+ ctx!.strokeStyle = `hsla(${hue}, 64%, ${58 + emphasis * 22}%, ${( .035 + emphasis * (.17 + progress * .1)) * tone * (1 - assembled * .65)})`;
+ ctx!.lineWidth = .7 + progress * .18;
+ ctx!.stroke();
+ }
+ // Resolve into the exact brand silhouette only on reported completion.
+ if (assembled > .01) {
+ ctx!.save();
+ ctx!.scale(scale, scale); ctx!.translate(-56, -45.5);
+ const fill = ctx!.createLinearGradient(25, 0, 75, 91);
+ fill.addColorStop(0, '#b4e5ff'); fill.addColorStop(.5, '#79bde5'); fill.addColorStop(1, '#4485b0');
+ ctx!.globalAlpha = assembled * .9 * tone;
+ ctx!.fillStyle = fill; ctx!.fill(shape.fill);
+ ctx!.restore();
+ }
+ });
+ ctx!.restore();
+ }
+ frame = requestAnimationFrame(draw);
+ return () => {
+ cancelAnimationFrame(frame); observer.disconnect();
+ window.removeEventListener('pointermove', move);
+ document.documentElement.removeEventListener('pointerleave', leave);
+ window.removeEventListener('blur', leave);
+ };
+ }, []);
+
+ return ;
+}
diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx
index baab760f..70841bac 100644
--- a/web/components/SiteNav.tsx
+++ b/web/components/SiteNav.tsx
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import s from './site-nav.module.css';
+import { RELAY_MARK_PATHS } from '../lib/relay-mark';
export function LogoIcon() {
return (
@@ -20,11 +21,11 @@ export function LogoIcon() {
diff --git a/web/components/agent-signup-journey.module.css b/web/components/agent-signup-journey.module.css
new file mode 100644
index 00000000..4cfe6984
--- /dev/null
+++ b/web/components/agent-signup-journey.module.css
@@ -0,0 +1,55 @@
+.page { position: relative; isolation: isolate; min-height: 100svh; overflow-x: clip; background: #091b28; color: #e8f2fa; }
+.atmosphere { position: absolute; inset: 0; width: 100%; height: max(780px, 100svh); pointer-events: none; z-index: -1; }
+.back { position: absolute; top: 30px; z-index: 1; display: grid; place-items: center; color: #98b6ca; }
+.back { left: 32px; width: 40px; height: 40px; border-radius: 50%; text-decoration: none; }
+.back:hover { color: #fff; background: #ffffff06; opacity: 1; }
+.main { display: flex; justify-content: center; min-height: 100svh; padding: min(52svh, 460px) 24px 44px; box-sizing: border-box; }
+.content { width: min(100%, 600px); text-align: center; }
+.content h1 { margin: 0; font-family: var(--font-heading), sans-serif; font-size: clamp(32px, 4.5vw, 48px); font-weight: 400; line-height: 1.2; letter-spacing: -.055em; text-wrap: balance; }
+.subtitle { max-width: 410px; margin: 18px auto 28px; min-height: 48px; color: #a0b9ca; font-size: 15px; line-height: 1.7; text-wrap: pretty; }
+.agents { display: flex; justify-content: center; align-items: center; gap: 26px; min-height: 48px; margin: 18px auto 28px; color: #c5dbe9; }
+.agents > span { display: grid; place-items: center; }
+.agentLogo { width: 28px; height: 28px; }
+.primary { composes: btn btn-primary from global; }
+.primary:disabled { opacity: .45; cursor: default; transform: none; }
+.copyStatus { min-height: 20px; margin: 14px 0 0; color: #7899af; font-size: 11px; line-height: 1.7; }
+.progress { margin: 32px 0 0; color: #9eb9cb; font-size: 11px; line-height: 1.7; }
+.progressDots { display: flex; gap: 6px; justify-content: center; margin-bottom: 11px; }
+.progressDots i { width: 23px; height: 2px; border-radius: 3px; background: #294355; transition: background 1s; }
+.progressDots i[data-current='true'] { background: #8dc7e9; box-shadow: 0 0 10px #8dc7e94d; }
+.progressDots i[data-done='true'] { background: #9fceca; }
+.page[data-mode='paused'] .progressDots i[data-current='true'], .page[data-mode='failed'] .progressDots i[data-current='true'] { background: #d9b985; }
+.details { margin: 26px auto 0; max-width: 600px; text-align: center; color: #8aabbf; font-size: 11px; }
+.details summary { display: inline-block; list-style: none; cursor: pointer; padding: 8px 14px; color: #7897ab; }
+.details summary::-webkit-details-marker { display: none; }
+.details summary::after { content: '+'; display: inline-block; margin-left: 7px; font-size: 13px; }
+.details[open] summary::after { content: '−'; }
+.detailBody { padding: 16px 0; border-top: 1px solid #ffffff0d; margin-top: 8px; }
+.steps { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); list-style: none; padding: 0; margin: 4px 0 26px; }
+.steps li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; min-width: 0; }
+.steps li:not(:last-child)::after { content: ''; position: absolute; top: 15px; left: calc(50% + 20px); width: calc(100% - 40px); height: 1px; background: #294355; }
+.stepCircle { display: grid; place-items: center; box-sizing: border-box; width: 31px; height: 31px; border: 1px solid #355266; border-radius: 50%; color: #8aabbf; font-size: 11px; }
+.stepLabel { max-width: 92px; padding: 0 4px; font-size: 10px; line-height: 1.5; text-wrap: balance; }
+.steps li[aria-current='step'] { color: #e5f2fa; }
+.steps li[aria-current='step'] .stepCircle { border-color: #8dc7e9; color: #e5f2fa; background: #8dc7e911; box-shadow: 0 0 18px #8dc7e914; }
+.steps li[data-done='true'] .stepCircle { border-color: #729d9a; color: #9fceca; background: #9fceca0d; }
+.steps li[data-done='true']::after { background: #729d9a; }
+.detailBody p { font-size: 10px; line-height: 1.7; }
+.session code { overflow-wrap: anywhere; font-size: 9px; }
+.detailBody a, .detailBody button { display: inline-flex; align-items: center; gap: 3px; border: 0; padding: 7px; background: transparent; color: #9cbace; font: inherit; cursor: pointer; text-underline-offset: 3px; }
+.detailBody button:disabled { opacity: .4; cursor: default; }
+.prompt { box-sizing: border-box; width: 100%; padding: 20px; margin-top: 20px; border: 1px solid #ffffff1c; border-radius: 10px; background: #071722; color: #bdd5e4; font: 11px/1.8 var(--font-geist-mono), monospace; resize: vertical; field-sizing: content; }
+.error { max-width: 380px; margin: 20px auto; color: #d5b78d; font-size: 12px; line-height: 1.7; }
+.error button, .restart { display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 5px; background: transparent; color: #c6dce9; font: inherit; font-size: 12px; cursor: pointer; }
+@media (max-width: 600px) {
+ .back { top: 18px; }.back { left: 15px; }
+ .main { padding-top: 43svh; padding-bottom: 30px; }.atmosphere { height: 720px; }
+ .content h1 { font-size: 34px; }.subtitle { font-size: 14px; margin-bottom: 24px; }
+ .progress { margin-top: 26px; }.copyStatus { font-size: 10px; }
+}
+@media (max-height: 700px) and (min-width: 601px) {
+ .main { padding-top: 360px; }.atmosphere { height: 720px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .page * { transition: none !important; }.primary:hover { transform: none; }
+}
diff --git a/web/components/agent-signup.module.css b/web/components/agent-signup.module.css
new file mode 100644
index 00000000..80392620
--- /dev/null
+++ b/web/components/agent-signup.module.css
@@ -0,0 +1,16 @@
+.button { composes: btn btn-secondary from global; gap: 12px; white-space: nowrap; }
+.icons { position: relative; display: inline-block; width: 24px; height: 24px; flex-shrink: 0; }
+.icons > span { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0; animation: agentIcon 16s infinite; }
+.icons > span:nth-child(2) { animation-delay: 4s; }
+.icons > span:nth-child(3) { animation-delay: 8s; }
+.icons > span:nth-child(4) { animation-delay: 12s; }
+.logo { width: 24px; height: 24px; }
+@keyframes agentIcon {
+ 0%, 20% { opacity: 1; transform: translateY(0) scale(1); }
+ 25%, 95% { opacity: 0; transform: translateY(-4px) scale(.85); }
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .icons > span { animation: none; }
+ .icons > span:first-child { opacity: 1; }
+}
diff --git a/web/components/useSignupAnalytics.ts b/web/components/useSignupAnalytics.ts
new file mode 100644
index 00000000..6f8ea2d0
--- /dev/null
+++ b/web/components/useSignupAnalytics.ts
@@ -0,0 +1,14 @@
+'use client';
+import { useMemo } from 'react';
+import { usePostHog } from '@posthog/next';
+import type { AgentSignupProduct } from '../lib/agent-signup';
+import { SignupTracker } from '../lib/agent-signup-analytics';
+
+export function useSignupAnalytics(product: AgentSignupProduct) {
+ const ph = usePostHog();
+ return useMemo(() => new SignupTracker(ph, product, {
+ getItem: key => sessionStorage.getItem(key),
+ setItem: (key, value) => sessionStorage.setItem(key, value),
+ removeItem: key => sessionStorage.removeItem(key),
+ }), [ph, product]);
+}
diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx
index 01e813d3..0a3a883b 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. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own.
+Software Garden currently supports GitHub repositories. Deploy one listener per repository using the direct-source API below. 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
@@ -30,21 +30,22 @@ The catalog does not copy or generate the flow body. `source` names the canonica
https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts
```
-CI and the production release workflow resolve the release tag, fetch the pinned raw file with a size bound, and verify the digest. A broken, moved, mutable, or drifted source therefore fails before the catalog can ship. Cloud fetches it through the existing trusted source loader and stores the fetched body only as that deployment's immutable snapshot and provenance.
+CI and the production release workflow resolve the release tag, fetch the pinned raw file with a size bound, and verify the digest. A broken, moved, mutable, or drifted source therefore fails before the catalog can ship. For the direct-source deploy API, the client downloads `source.rawUrl`, verifies the bytes against `source.sha256`, and submits that source text; Cloud stores it as the deployment's source snapshot.
## Activation contract
-Cloud accepts the catalog ID and activation settings, then fetches the matching canonical source itself:
+The current `POST /cloud/api/v1/flows/deploy` endpoint accepts the same direct-source listener body as `flows deploy`. Authenticate with a Cloud bearer session, download and verify the catalog source, then submit:
```json
{
"workspaceId": "workspace-id",
- "flowId": "software-factory",
- "label": "Platform Garden",
- "repositories": [
- { "owner": "acme", "name": "api" },
- { "owner": "acme", "name": "web" }
- ],
+ "name": "Platform Garden",
+ "workflow": "software-factory",
+ "source": "",
+ "handoffId": "",
+ "mode": "activate",
+ "repository": { "owner": "acme", "name": "api" },
+ "sources": [{ "provider": "github", "settings": { "repository": "acme/api" } }],
"inputs": {
"approver": "github:@octocat",
"agents": ["claude"]
@@ -52,4 +53,8 @@ Cloud accepts the catalog ID and activation settings, then fetches the matching
}
```
-`approver` is required. `agents` may be omitted to use the catalog default. The custom label names the visible activation group; each repository still gets its own scoped listener and run workspace.
+Replace the sample repository and approver with the user's choices. `approver` is required; for GitHub use `github:@handle`, not a Google email. Fill `agents` from the catalog defaults and supported harnesses. `workflow` labels the flow; it does not cause Cloud to fetch the source. The endpoint requires inline source text and one `repository`, not a `flowId`/`repositories` catalog request or the browser onboarding handoff.
+
+Set the GitHub trigger's `settings.repository` to the selected `owner/name`; Cloud does not derive that filter from `repository`, and an empty filter can wake the listener for other repositories in the workspace.
+
+For several repositories, submit one body per repository with its own trigger filter, distinct name, and `handoffId`; reuse that ID when retrying the same deployment. Connect required tools and coding agents before activation. Success is HTTP 201 with `agentId` and `status: "listening"`; verify the saved listener through `GET /cloud/api/v1/flows/listeners/`. See the [complete agent signup instructions](/signup/agent/flows) for authentication, connections, and retry behavior.
diff --git a/web/e2e/agent-signup.spec.ts b/web/e2e/agent-signup.spec.ts
new file mode 100644
index 00000000..69d5a89e
--- /dev/null
+++ b/web/e2e/agent-signup.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test } from '@playwright/test';
+
+const id = '537e4857-5590-42e8-8731-66441b466542';
+const token = 'a'.repeat(64);
+
+test('agent signup stays still until reported progress, restores its session, and supports reduced motion', async ({ page }) => {
+ let creates = 0;
+ let progress = { id, product: 'teams', step: 0, state: 'waiting', revision: 0, updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7200000).toISOString() };
+ await page.route('**/cloud/api/v1/signup/agent/sessions**', route => {
+ const create = route.request().method() === 'POST';
+ if (create) creates++;
+ return route.fulfill({ status: create ? 201 : 200, json: create ? {...progress, writeToken: token} : progress });
+ });
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto('/signup/teams');
+ await expect(page.getByRole('button', { name: 'Copy setup prompt' })).toBeEnabled();
+ await expect(page.getByText('Ready when your agent is')).toBeVisible();
+ await expect(page).toHaveURL(new RegExp(`session=${id}`));
+ await page.reload();
+ await expect(page.getByRole('button', { name: 'Copy setup prompt' })).toBeEnabled();
+ expect(creates).toBe(1);
+ const canvas = page.locator('canvas');
+ await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBeGreaterThan(0);
+ // Frames must remain identical when the OS requests reduced motion.
+ const before = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ expect(await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).toBe(before);
+ progress = {...progress, step: 1, state: 'waiting', revision: 1};
+ await expect(page.getByRole('heading', { name: 'A quick approval from you.' })).toBeVisible();
+ await expect(page.getByText('1 of 5 · Waiting for your approval')).toBeVisible();
+ progress = {...progress, step: 2, state: 'working', revision: 2};
+ await expect(page.getByRole('heading', { name: 'Install the app', exact: true })).toBeVisible();
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ const reducedFrame = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ expect(await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).toBe(reducedFrame);
+ progress = {...progress, step: 5, state: 'complete', revision: 6};
+ await expect(page.getByRole('heading', { name: 'All yours.' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Open your workspace' })).toHaveAttribute('href', /\/cloud\/dashboard\/sessions$/);
+});
+
+test('clipboard failure reveals and selects the entire prompt', async ({ page }) => {
+ const progress = { id, product: 'flows', step: 0, state: 'waiting', revision: 0, updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7200000).toISOString() };
+ await page.route('**/cloud/api/v1/signup/agent/sessions**', route => route.fulfill({status: route.request().method() === 'POST' ? 201 : 200, json: {...progress, writeToken: token}}));
+ await page.goto('/signup/flows');
+ await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true }));
+ await page.getByRole('button', { name: 'Copy setup prompt' }).click();
+ const prompt = page.getByRole('textbox', { name: 'Agent signup prompt' });
+ await expect(prompt).toBeVisible();
+ await expect.poll(() => prompt.evaluate(el => (el as HTMLTextAreaElement).selectionEnd - (el as HTMLTextAreaElement).selectionStart)).toBe((await prompt.inputValue()).length);
+ expect(await prompt.inputValue()).toContain('/signup/agent/flows');
+});
+
+test('logo ribbons respond to pointer movement and settle into the completed mark', async ({ page }, testInfo) => {
+ let progress = { id, product: 'teams', step: 0, state: 'waiting', revision: 0, updatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7200000).toISOString() };
+ await page.route('**/cloud/api/v1/signup/agent/sessions**', route => route.fulfill({ status: route.request().method() === 'POST' ? 201 : 200, json: route.request().method() === 'POST' ? {...progress, writeToken: token} : progress }));
+ await page.setViewportSize({ width: 1000, height: 900 });
+ await page.goto('/signup/teams');
+ await expect(page.getByRole('button', { name: 'Copy setup prompt' })).toBeEnabled();
+ const canvas = page.locator('canvas');
+ await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).width)).toBeGreaterThan(0);
+ await page.screenshot({ path: testInfo.outputPath('signup-logo-waiting.png') });
+ await page.mouse.move(340, 220);
+ await page.mouse.move(580, 300, { steps: 10 });
+ await page.screenshot({ path: testInfo.outputPath('signup-logo-swish.png') });
+ await page.mouse.move(20, 850);
+ progress = {...progress, step: 5, state: 'working', revision: 5};
+ await expect(page.getByRole('heading', { name: 'Check everything works' })).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath('signup-logo-verifying.png') });
+ progress = {...progress, state: 'complete', revision: 6};
+ await expect(page.getByRole('heading', { name: 'All yours.' })).toBeVisible();
+ // Completion should reveal a filled left facet, not leave a hollow ribbon.
+ await expect.poll(() => canvas.evaluate(el => {
+ const c = el as HTMLCanvasElement;
+ const context = c.getContext('2d')!;
+ const ratio = c.width / c.clientWidth;
+ return context.getImageData(Math.round(400 * ratio), Math.round(260 * ratio), 1, 1).data[3];
+ }), { timeout: 15000 }).toBeGreaterThan(180);
+ await page.screenshot({ path: testInfo.outputPath('signup-logo-complete.png') });
+});
diff --git a/web/lib/agent-signup-analytics.ts b/web/lib/agent-signup-analytics.ts
new file mode 100644
index 00000000..dfdc3967
--- /dev/null
+++ b/web/lib/agent-signup-analytics.ts
@@ -0,0 +1,75 @@
+import type { AgentSignupProduct } from './agent-signup';
+
+export type SignupAnalyticsContext = { distinctId: string; journeyId: string };
+export type SignupAnalyticsClient = {
+ __loaded?: boolean;
+ has_opted_out_capturing(): boolean;
+ get_distinct_id(): string;
+ capture(event: string, properties: Record, options?: { transport: 'sendBeacon' }): unknown;
+};
+export type SignupBrowserEvent = 'entry_clicked' | 'page_viewed' | 'prompt_copied' | 'manual_copy_shown' | 'details_opened' | 'restarted' | 'page_left' | 'expired' | 'session_error' | 'dashboard_opened';
+const key = (product: string) => `agent-relay-signup-analytics:${product}`;
+const ttl = 2 * 60 * 60_000;
+
+/** Distinct from the read-capability session ID. Never put that ID in analytics. */
+export class SignupTracker {
+ private departed = false;
+ private cached?: { journeyId: string; startedAt: number };
+ constructor(private ph: SignupAnalyticsClient | null | undefined, private product: AgentSignupProduct,
+ private storage: Pick, private uuid = () => crypto.randomUUID(), private now = Date.now) {}
+
+ context(): SignupAnalyticsContext | undefined {
+ try {
+ if (!this.ph?.__loaded || this.ph.has_opted_out_capturing()) return undefined;
+ const distinctId = this.ph.get_distinct_id();
+ if (!/^[a-zA-Z0-9._:-]{1,200}$/.test(distinctId)) return undefined;
+ if (!this.cached) {
+ try {
+ const saved = JSON.parse(this.storage.getItem(key(this.product)) || 'null');
+ if (saved && typeof saved.journeyId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(saved.journeyId) &&
+ typeof saved.startedAt === 'number' && this.now() - saved.startedAt < ttl && saved.startedAt <= this.now()) this.cached = saved;
+ } catch { /* Storage is optional. */ }
+ this.cached ||= { journeyId: this.uuid(), startedAt: this.now() };
+ try { this.storage.setItem(key(this.product), JSON.stringify(this.cached)); } catch { /* In-memory fallback. */ }
+ }
+ return { distinctId, journeyId: this.cached.journeyId };
+ } catch { return undefined; }
+ }
+
+ /** Wait briefly for PostHog so early funnel events and the session POST share one journeyId. Telemetry never blocks setup longer than `timeoutMs`. */
+ async contextWhenReady(timeoutMs = 2000): Promise {
+ const attempts = Math.max(1, Math.ceil(timeoutMs / 150));
+ for (let i = 0; i <= attempts; i++) {
+ const context = this.context();
+ if (context) return context;
+ if (i < attempts) await new Promise(resolve => setTimeout(resolve, 150));
+ }
+ return undefined;
+ }
+
+ track(event: SignupBrowserEvent, step = 0) {
+ try {
+ const context = this.context();
+ if (!context) return;
+ this.ph!.capture(`agent_signup_${event}`, {
+ funnel: 'agent_signup', funnel_version: 1, onboarding_method: 'agent', source: 'browser',
+ product: this.product, journey_id: context.journeyId,
+ step: Number.isInteger(step) && step >= 0 && step <= 5 ? step : 0,
+ }, event === 'page_left' || event === 'entry_clicked' || event === 'dashboard_opened' ? { transport: 'sendBeacon' } : undefined);
+ } catch { /* Optional telemetry cannot interrupt setup. */ }
+ }
+
+ leave(step: number) {
+ if (this.departed) return;
+ this.track('page_left', step);
+ this.departed = true;
+ }
+ resume() { this.departed = false; }
+
+ restart(track = true) {
+ if (track) this.track('restarted');
+ this.departed = true;
+ this.cached = undefined;
+ try { this.storage.removeItem(key(this.product)); } catch { /* Optional. */ }
+ }
+}
diff --git a/web/lib/agent-signup-progress.ts b/web/lib/agent-signup-progress.ts
new file mode 100644
index 00000000..e2ac7c75
--- /dev/null
+++ b/web/lib/agent-signup-progress.ts
@@ -0,0 +1,38 @@
+import { agentSignupPrompt, type AgentSignupProduct } from './agent-signup';
+
+export type SignupProgress = {
+ id: string; product: AgentSignupProduct; step: number;
+ state: 'working' | 'waiting' | 'failed' | 'complete';
+ revision: number; updatedAt: string; expiresAt: string;
+};
+export type SignupSession = SignupProgress & { writeToken: string };
+
+export function isSignupProgress(value: unknown): value is SignupProgress {
+ if (!value || typeof value !== 'object') return false;
+ const p = value as SignupProgress;
+ return typeof p.id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(p.id) &&
+ ['teams', 'flows'].includes(p.product) && Number.isInteger(p.step) && p.step >= 0 && p.step <= 5 &&
+ ['working', 'waiting', 'failed', 'complete'].includes(p.state) && (p.state !== 'complete' || p.step === 5) &&
+ Number.isSafeInteger(p.revision) && p.revision >= 0 && typeof p.updatedAt === 'string' && typeof p.expiresAt === 'string' && Number.isFinite(Date.parse(p.updatedAt)) && Number.isFinite(Date.parse(p.expiresAt));
+}
+
+export function trackedSignupPrompt(product: AgentSignupProduct, origin: string, endpoint: string, session: Pick) {
+ return `${agentSignupPrompt(product, origin)}\n\nReport real progress using the guide’s protocol so I can watch the signup page.\n\nProgress session: ${session.id}\nProgress API: ${endpoint}/${session.id}\nProgress token: ${session.writeToken}\n\nKeep this token private; it authorizes progress updates only.`;
+}
+
+export const signupSteps = {
+ teams: [
+ { title: 'Sign in', detail: 'Your agent opens Google. You approve access.' },
+ { title: 'Install the app', detail: 'Download, verify, and install Agent Relay on your Mac.' },
+ { title: 'Connect your workspace', detail: 'Link the app to your account and workspace.' },
+ { title: 'Choose what to share', detail: 'Your agent asks which sessions you want to share.' },
+ { title: 'Check everything works', detail: 'Verify the connection and open your workspace.' },
+ ],
+ flows: [
+ { title: 'Sign in', detail: 'Your agent opens Google. You approve access.' },
+ { title: 'Choose your flow', detail: 'Pick a workflow and the repository it should work on.' },
+ { title: 'Connect your tools', detail: 'Approve the services and coding agents your flow needs.' },
+ { title: 'Activate your flow', detail: 'Configure the workflow and its repository triggers.' },
+ { title: 'Check everything works', detail: 'Verify activation and open your dashboard.' },
+ ],
+} as const;
diff --git a/web/lib/agent-signup.ts b/web/lib/agent-signup.ts
new file mode 100644
index 00000000..dc697090
--- /dev/null
+++ b/web/lib/agent-signup.ts
@@ -0,0 +1,422 @@
+export type AgentSignupProduct = 'teams' | 'flows';
+
+export function isAgentSignupProduct(value: string): value is AgentSignupProduct {
+ return value === 'teams' || value === 'flows';
+}
+
+const agentSignupInteractionPolicy = 'Do NOT use computer use, browser automation, screenshots, DOM inspection, or mouse/keyboard control. Fetch the guide over HTTP and perform setup through its documented APIs. Use terminal commands only for HTTP requests and the documented installation/CLI operations. The user owns the screen: open approval URLs with the OS URL opener (or give the user the link), then wait and poll the documented API. Never operate the approval page or the desktop app UI.';
+
+export function agentSignupPrompt(product: AgentSignupProduct, origin: string): string {
+ return `Set up Agent Relay ${product === 'teams' ? 'Teams' : 'Flows'} for me. Fetch and follow ${origin}/signup/agent/${product} over HTTP for the exact APIs and setup steps. Do NOT use computer use, browser automation, or screen control. Use the documented APIs and CLI commands; leave sign-in and approval pages to me. For desktop installation, download the prebuilt binary from the guide—never clone, build, or compile the app.`;
+}
+
+/** Bundled strings: these instructions must also work on Workers without a filesystem. */
+export function agentSignupInstructions(product: AgentSignupProduct, site: string, cloud: string): string {
+ const title = product === 'teams' ? 'Teams' : 'Flows';
+ const shared = `# Agent Relay ${title}: agent-driven signup
+
+You are setting up Agent Relay on behalf of the user who gave you this URL.
+${agentSignupInteractionPolicy}
+Do the API calls, installation, configuration, and verification yourself. Keep
+working across user approvals; do not hand the user a checklist to execute.
+The signup page is a spectator view for the user. Do not read or control it;
+report progress only with GET/PATCH on the supplied Progress API URL.
+For an approval URL, use the OS URL opener (for example macOS open with the
+URL passed as a separate subprocess argument, never interpolated into shell
+code). If no opener is available, give the link to the user. Do not launch a
+browser-control tool. Verify approval by polling the API, never by inspecting
+the browser. If an operation has no documented API or CLI, report the blocker
+and ask the user for that specific action; never fall back to computer use.
+For desktop installation, download the prebuilt binary from this guide. Never
+clone the desktop repository, install build dependencies, run a build, compile
+from source, or generate a DMG. A missing binary is a blocker, not a build task.
+The user handles Google sign-in, device approval, and any provider or operating
+system consent. Never approve access on their behalf or ask for their password.
+
+Site: ${site}
+Cloud API base: ${cloud}
+All API paths below are relative to that Cloud base, including its /cloud prefix.
+Use this exact environment throughout; never fall back from local development
+to production. The Teams desktop app currently requires macOS 13 or later.
+Flows can be configured from any machine with HTTPS and Node.js 22+ for the CLI.
+
+## API map — use these interfaces, not the UI
+
+Use an HTTP client such as fetch or curl. Send JSON request bodies with
+Content-Type: application/json. The sections below specify exact bodies,
+response fields, authentication, polling and error handling.
+
+- Sign-in: POST ${cloud}/api/v1/auth/device/start, then poll POST
+ ${cloud}/api/v1/auth/device/token. Only the user approves the returned URL.
+- Identity/workspace: GET ${cloud}/api/v1/auth/whoami with the access token.
+- Refresh: POST ${cloud}/api/v1/auth/token/refresh before token expiry.
+- Progress: GET/PATCH the exact Progress API URL in the user's prompt;
+ PATCH uses the separate Progress token, not the account access token.
+${product === 'teams' ? `- Desktop install/connect/share/status: the bundled agent-relay-probe CLI
+ in sections 2–4. These are local machine operations, not dashboard clicks;
+ there is no public HTTP endpoint that installs an app on the user's Mac.` : `- Flow catalog: GET ${site}/api/v1/flows/catalog and /.
+- Tool consent links: POST ${cloud}/api/v1/integrations/connect-link;
+ poll GET ${cloud}/api/v1/workspaces//integrations//status.
+- Coding-agent credentials: the official cloud connect CLI in section 3;
+ GET ${cloud}/api/v1/cloud-agents inspects existing connections.
+- Activation: POST ${cloud}/api/v1/flows/deploy with the body in section 4.
+- Verification: GET ${cloud}/api/v1/flows/listeners/.`}
+
+## Live progress (when the user's prompt includes a progress session)
+
+The user is watching a setup page. Report real milestones using the Progress API
+and Progress token supplied in their prompt. The token authorizes progress only;
+it is NOT a Cloud access token. Never send account tokens, OAuth codes, passwords,
+logs, approval URLs, or personal information to the progress endpoint. Do not put
+the progress token in URLs or output it in your final reply. Use the same site
+and /cloud origin shown above; never forward it to another environment.
+
+GET the supplied Progress API URL to obtain the current revision and product.
+Check that the product matches this guide. Start by PATCHing that URL with
+Authorization: Bearer and Content-Type: application/json:
+
+~~~json
+{"step":1,"state":"working","revision":0,"agent":""}
+~~~
+
+Replace agent with your actual coding agent: codex, claude_code, grok, opencode,
+cursor, gemini_cli, other, or unknown. Report the tool running this setup, not
+its underlying model or the agents the user will run later. Use unknown if you
+cannot determine it; never guess from the logos on the page. Include agent in
+your first PATCH. It is stored once for signup funnel attribution; later PATCHes
+may omit it. A different known agent returns 409 agent_conflict: preserve the
+original attribution and omit agent when resuming from a different tool.
+
+Use the revision returned by the latest GET/PATCH, not the example's literal 0.
+PATCH before each numbered progress step below. A move to the next step marks
+the previous step done; do not skip steps or report success before checking it.
+Set state: waiting when you need the user to approve access or choose an option.
+Set state: working at the same step when you resume, and state: failed if work
+cannot continue. After completing step 5's verification, PATCH step: 5,
+state: complete. Only report complete after the actual checks succeed.
+
+${product === 'teams' ? `Progress steps for Teams:
+1. Sign in: before device authorization (section 1).
+2. Install the app: before download and installation (section 2).
+3. Connect the workspace: before cloud install (section 3).
+4. Choose what to share: before asking for/selecting sessions in section 3.
+ Report waiting while the user chooses. Respect an explicit choice to share none.
+5. Check everything works: before status, upload, and app verification (section 4).` : `Progress steps for Flows correspond to sections 1 through 5 below:
+1. Sign in. 2. Choose the flow/repository. 3. Connect tools.
+4. Activate the flow. 5. Verify the listening state.`}
+
+On HTTP 409, GET current progress and reconcile; never overwrite newer progress
+or regress a step. If a PATCH response is lost, GET before retrying. If a step is
+already complete, verify the actual account/app/flow state before continuing;
+progress reports alone are not proof that setup succeeded. On 429, honor
+Retry-After. Retry transient network/5xx failures with bounded backoff. On 404,
+stop reporting (the session expired or the token is invalid) and tell the user;
+do not recreate or switch their session silently. A progress service outage
+must not roll back working setup or cause duplicate installation/activation.
+
+## 1. Sign up and obtain an API session
+
+Use the existing OAuth device flow. No API key, invitation, dashboard wizard,
+or pre-existing Agent Relay account is required.
+
+POST /api/v1/auth/device/start with Content-Type: application/json:
+
+~~~json
+{"client_name":"My agent — ${title} setup","signup_source":"${product}"}
+~~~
+
+Expect HTTP 201 with device_code, user_code, verification_uri_complete,
+verification_uri, interval (seconds), and expires_in (seconds). Keep device_code
+private. Open verification_uri_complete in the user's browser and show the
+user_code so they can compare it. The page lets them sign in with Google,
+review the requesting device, and Approve or Deny. The signup marker in the
+returned URL creates the right account type; preserve it through sign-in.
+Do not call /auth/device/approve yourself.
+
+For a fresh signup, you can open ${cloud}/api/auth/google/start?next=
+first, where encoded-return-path is the URL-encoded pathname plus query of
+verification_uri_complete. This opens Google immediately and returns to the
+same device approval with its code and signup marker intact.
+
+While the browser is open, wait interval seconds between POSTs to
+/api/v1/auth/device/token with this JSON (substitute the private device_code):
+
+~~~json
+{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":""}
+~~~
+
+- authorization_pending: keep waiting; respect a returned interval.
+- slow_down: increase the interval by at least 5 seconds.
+- HTTP 429: respect Retry-After and increase the interval.
+- HTTP 5xx or request timeout: retry with backoff, bounded by expires_in.
+- access_denied: stop. expired_token or invalid_grant: explain and start a new
+ grant only if the user still wants to continue. Never poll past expiry.
+
+HTTP 200 returns access_token, refresh_token, access_token_expires_at,
+refresh_token_expires_at, api_url and token_type. Keep credentials in memory
+or a private file (directory 0700, file 0600) outside repositories. Never echo
+tokens, put them in chat or URLs, or dump full authentication responses.
+Use Authorization: Bearer for subsequent Cloud requests.
+Reject an api_url pointing at another origin; keep using the Cloud base above.
+Set a 30-second request timeout and check every response status before proceeding.
+
+Before expiry, POST /api/v1/auth/token/refresh with {"refreshToken":""}.
+The response uses camelCase: accessToken, refreshToken, accessTokenExpiresAt,
+refreshTokenExpiresAt, apiUrl. Replace both stored tokens atomically. Serialize
+refreshes: the refresh token rotates and must not be shared between machines.
+An invalid/expired refresh requires a new device login, not an endless retry.
+
+GET /api/v1/auth/whoami. Require authenticated: true and read user.id,
+user.email, currentWorkspace.id, and currentOrganization.id. New signups create
+a workspace automatically. Reuse it; do not create duplicate accounts/workspaces.
+If currentWorkspace is missing, or an existing account is in the wrong workspace,
+resolve that with the user before connecting or activating anything. Never
+silently replace an existing connection to another account.
+
+## Credential handoff to the supported CLI
+
+When running a child process, pass these through its environment from your
+private session object (never interpolate their values into logged commands):
+
+~~~text
+CLOUD_API_URL=${cloud}
+CLOUD_API_ACCESS_TOKEN=
+CLOUD_API_REFRESH_TOKEN=
+CLOUD_API_ACCESS_TOKEN_EXPIRES_AT=
+CLOUD_API_REFRESH_TOKEN_EXPIRES_AT=
+~~~
+
+The official CLI consumes this session. The bundled desktop probe uses
+CLOUD_API_ACCESS_TOKEN to exchange for its own scoped History session, so it
+does not need a second Google login. Refresh the parent session before starting
+a long command; do not concurrently refresh it from parent and child processes.
+Do not overwrite an existing CLI auth file or copy a session to another machine.
+`;
+ return shared + (product === 'teams' ? teamsInstructions(site) : flowsInstructions(site, cloud));
+}
+
+function teamsInstructions(site: string): string {
+ const local = new URL(site).protocol === 'http:';
+ const name = local ? 'Agent Relay Dev' : 'Agent Relay';
+ const download = local
+ ? `${site}/cloud/desktop-downloads/AgentRelay-Dev-macOS-.dmg`
+ : 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-macOS-.dmg';
+ return `
+## 2. Download and install the prebuilt desktop binary
+
+Run sw_vers -productVersion and uname -m on the user's Mac. arm64 means Apple
+silicon; x86_64 means the x64 download. If this agent runs in a remote sandbox,
+it needs authorized terminal access to the user's Mac before installation; installing into
+the sandbox does not connect their computer. Report an unsupported OS honestly.
+
+Download ${download} and the same URL plus .sha256. Replace with arm64
+or x64. Use a private temporary directory, follow HTTPS release redirects, and
+fail on HTTP errors. Verify SHA-256 before mounting. For local development,
+use only the prebuilt DMG already served by the local stack. If the DMG or its
+checksum is unavailable for this architecture, stop and report the missing
+prebuilt artifact. Do not build it, run the development launcher to produce it,
+or switch to a production download.
+
+Use hdiutil attach -nobrowse with a private mount point, then ditto the mounted
+${name}.app into ~/Applications/${name}.app (create ~/Applications if needed).
+Detach the image and clean up the temporary download after copying. Check for
+an already installed app in /Applications and ~/Applications first: reuse the
+correct app instead of overwriting a running or newer installation. Preserve
+macOS signing/quarantine checks. If macOS requires consent, let the user approve
+the normal Open / Privacy & Security prompt; do not strip quarantine or disable
+Gatekeeper. A checksum mismatch is a hard stop.
+The desktop app supports https://agentrelay.com and the local development stack;
+other preview hosts require a separately configured app. Do not connect a
+production app to a preview URL.
+
+## 3. Connect the app using its bundled CLI
+
+Set PROBE to the absolute path of the installed app's
+Contents/Helpers/agent-relay-probe. Use the bundled executable, not an unrelated
+binary on PATH. Run it with --help and verify support for cloud install --json
+and --selected-sessions-only. Read installs --json first, keeping local paths
+and account details private. Reuse a matching account/workspace install.
+
+Run this with the private credential environment from step 1. Substitute the
+actual user.id and currentWorkspace.id returned by whoami:
+
+~~~sh
+"$PROBE" cloud install --site-url '${site}' --account '' --workspace '' --selected-sessions-only --json
+~~~
+
+This exchanges credentials, catalogs local sessions, and starts the background
+collector. Consume the NDJSON events until the process exits successfully;
+do not declare success when the process merely starts. If it requests another
+approval, verify the environment and credential expiry before retrying.
+
+Selected sessions is the default: signing up is not consent to upload all past
+conversations. When the user has selected sessions to share, list the catalog
+and pass their exact session keys to sessions include. Each JSON row has source
+and session_id; construct the key as SOURCE:SESSION_ID (for example codex:abc123).
+The list response does not contain a precomputed key. Never pass a bare session_id:
+
+~~~sh
+"$PROBE" sessions list --site-url '${site}' --account '' --workspace '' --limit 500 --json
+"$PROBE" sessions include --site-url '${site}' --account '' --workspace '' --session '' --json
+~~~
+
+Repeat --session for multiple keys. Do not choose sessions for the user.
+Only when explicitly requested, use --new-sessions-only or --include-existing
+instead of --selected-sessions-only during installation. Do not start duplicate
+collectors or silently stop an existing uploader for another environment.
+
+## 4. Verify and open the app
+
+~~~sh
+"$PROBE" status --site-url '${site}' --account '' --workspace '' --json
+~~~
+
+Require running: true, paused: false, and the expected site_url, account_id, and
+workspace_id. These are the CLI's JSON field names. Check delivery and last_cycle
+for failures. If sessions were selected,
+poll sessions list with a bounded wait until those sessions report uploaded;
+do not equate a running collector with uploaded content. If none were selected,
+report that the app is connected and no sessions have been shared yet.
+
+Use this as the initial handoff to the installed ${name}.app:
+${local ? 'agentrelay-dev' : 'agentrelay'}://connect?site=&account=&workspace=
+URL-encode the three values; the link contains identifiers only, never tokens.
+The app discovers the probe's saved connection. If it is already attached to a
+different account/workspace, the link will report a conflict, not switch accounts.
+Ask the user to explicitly log out/switch in Account before retrying; do not
+disconnect an existing workspace automatically. The probe's JSON status verifies
+the collector, not whether the app accepted the handoff. Do not inspect the app's
+Account view yourself. Ask the user to confirm that the visible app account and
+workspace match the target after handoff. Until confirmed, report the app
+attachment as unverified and keep progress waiting at step 5; do not mark setup
+complete. Open ${site}/cloud/dashboard/sessions for the team history. Report
+the installed app, account/workspace, sharing choice, and verification outcome.
+Delete temporary authentication files after use; retain the app-managed scoped
+credentials so background collection continues. The user can pause, select
+sessions, or disconnect in the app.
+`;
+}
+
+function flowsInstructions(site: string, cloud: string): string {
+ return `
+## 2. Choose the flow and repository
+
+Ask only for missing product choices: repository, desired workflow/trigger, and
+approver. For GitHub use the approver's GitHub login as github:@handle (for
+example github:@octocat), not their Google email. Human-gate replies are matched
+to the provider identity. Infer choices from the user's request and current
+repository where clear.
+Do not invent a repository or enable automation on an unrelated project.
+
+GET ${site}/api/v1/flows/catalog and select a matching entry from flows.
+GET ${site}/api/v1/flows/catalog/ for its full contract. Use the catalog's
+supportedRepositoryHosts, defaultTrigger, inputs.required, inputs.defaults, and
+inputs.allowedAgents. Name a model per harness in inputs.models when the house
+default is wrong (for example {"claude": "claude-sonnet-4"}); omit it to fund
+the house default for each declared agent. Download source.rawUrl, verify its bytes against
+source.sha256, and use that source text unchanged for a recommended flow.
+The source is TypeScript, not the source URL. Do not guess a template or hash.
+For custom flows use ${site}/docs/relayflows/markdown/build.md and
+${site}/docs/relayflows/markdown/cloud.md for the authoring contract.
+
+## 3. Connect the required tools and coding agents
+
+Use bearer-authenticated POST /api/v1/integrations/connect-link:
+
+~~~json
+{"provider":"github","workspaceId":""}
+~~~
+
+Open the returned connectUrl for the user to approve. Keep token/sessionToken
+private. Connect only the repository and tools the chosen flow requires.
+For GitHub the user must grant repository access. Repeat with the chosen trigger
+provider if different. Reuse existing ready connections rather than relinking.
+Check GET /api/v1/workspaces//integrations//status
+until ready is true (poll with backoff and a bounded timeout); a returned connect
+link or a closed popup alone does not prove the integration is ready.
+
+Do not connect a Claude or Codex subscription yet. The first three runs use
+Cloud's own model key, so no provider login is needed to activate. If the chosen
+flow declares more than one coding agent in inputs.agents, included Cloud runs
+can fund only one of them per run — connect your own subscription for at least
+one declared agent before activating a multi-agent flow. After the included
+runs, activation and launches will ask for your own subscription; only then use
+the official Relay CLI with the private credential environment from step 1 and
+a PTY:
+
+~~~sh
+npx --yes agent-relay@latest cloud connect anthropic --api-url '${cloud}'
+~~~
+
+Use anthropic for Claude or openai for Codex, according to the selected flow.
+The command drives provider login; open its authorization URL for the user,
+and keep the process alive until it confirms the credential is connected.
+Google approval does not grant GitHub or model-provider access: those services
+may require their own consent. Never fabricate credentials or claim consent
+happened. GET /api/v1/cloud-agents lets you inspect the account's credential
+state without reconnecting.
+
+## 4. Activate through the same API as web onboarding
+
+POST /api/v1/flows/deploy with Content-Type: application/json and the bearer
+session. This is the direct-source listener API used by flows deploy, not the
+browser onboarding handoff: source is TypeScript text, repository is singular,
+and sources contains provider/settings objects. The catalog supplies the source
+reference and defaults; it is not itself a deploy request. The current endpoint
+does not accept a flowId/repositories-only catalog activation request or fetch
+the source for you. For multiple repositories, submit one deployment per
+repository with a distinct name and handoffId.
+
+For the catalog's Software Garden entry, construct this body, substituting the
+workspace, verified source, repository, GitHub approver and a new UUID:
+
+~~~json
+{
+ "workspaceId": "",
+ "name": "Platform Garden",
+ "workflow": "software-factory",
+ "source": "",
+ "handoffId": "",
+ "inputs": {"approver": "github:@octocat", "agents": ["claude"]},
+ "mode": "activate",
+ "repository": {"owner": "acme", "name": "api"},
+ "sources": [{"provider": "github", "settings": {"repository": "acme/api"}}]
+}
+~~~
+
+For another catalog entry use its id as workflow, allowed agents, and
+defaultTrigger for sources, then apply the user's trigger settings. Scope a
+GitHub issue trigger with settings.repository set to the chosen owner/name;
+for GitLab use settings.project. Cloud does not derive this filter from the
+deployment repository. An empty filter can trigger on other repositories in
+the workspace. Only use a different trigger scope when explicitly requested.
+Give each repository its own trigger filter for multi-repository setup. Do not send
+the example acme repository or octocat approver unchanged. The workflow field
+is a label, not a source lookup; keep the verified source in the request.
+For GitLab set repository.host to gitlab and use the namespace path
+as owner. Reuse the handoffId on retry. Before retrying an ambiguous network
+failure, GET /api/v1/flows/listeners and check whether the flow already exists;
+do not create a new ID/name on every retry. Activation subscribes to matching
+future events and can run work; confirm the intended repository and trigger
+with the user if they have not specified them.
+
+HTTP 201 must contain agentId and status: listening. A draft is not completion.
+For a 409 workspace_mismatch, verify the active workspace; for connection
+preflight failures, fix the indicated connection before retrying. If the user
+wants to save incomplete work, use mode: draft explicitly and report that it
+is inactive. Never mask activation failures by silently falling back to draft.
+
+## 5. Verify
+
+GET /api/v1/flows/listeners/. Require listener.status: listening and
+verify its repository and sources match the request. Open
+${site}/cloud/dashboard/workflows/listeners/ for the user. Report the flow name,
+workspace, repository, trigger, and verified listening state. This proves
+activation; only an actual completed run proves execution. Do not create a
+real issue or launch paid work merely to make the onboarding check turn green.
+Delete temporary authentication files after the work is complete.
+
+The desktop app is optional for Flows. If the user also wants local session
+sharing, follow ${site}/signup/agent/teams using the same signed-in account.
+`;
+}
diff --git a/web/lib/docs-markdown.ts b/web/lib/docs-markdown.ts
index 89b8925a..788e6d57 100644
--- a/web/lib/docs-markdown.ts
+++ b/web/lib/docs-markdown.ts
@@ -216,6 +216,8 @@ export function getLlmsText(): string {
'',
'## Documentation',
'',
+ formatLink({ title: 'Sign up for Teams with an agent', url: absoluteUrl('/signup/agent/teams'), description: 'Google authorization, desktop installation, session sharing, and verification.' }),
+ formatLink({ title: 'Sign up for Flows with an agent', url: absoluteUrl('/signup/agent/flows'), description: 'Google authorization, repository and provider connections, flow activation, and verification.' }),
formatLink({
title: 'Full documentation content',
url: absoluteUrl('/llms-full.txt'),
diff --git a/web/lib/relay-mark.ts b/web/lib/relay-mark.ts
new file mode 100644
index 00000000..acef3c43
--- /dev/null
+++ b/web/lib/relay-mark.ts
@@ -0,0 +1,5 @@
+// Canonical Relay mark, shared by the navigation and signup animation.
+export const RELAY_MARK_PATHS = [
+ 'M71.3682 21.7098L54.042 39.036C50.6567 42.4213 50.6568 47.9099 54.042 51.2952L71.3727 68.6259L52.8321 87.1665C48.6005 91.3981 41.7397 91.3981 37.5081 87.1665L3.17369 52.8321C-1.05789 48.6005 -1.0579 41.7397 3.17369 37.5081L37.5081 3.17369C41.7397 -1.0579 48.6005 -1.05789 52.8321 3.17369L71.3682 21.7098Z',
+ 'M75.5711 72.8243C78.9563 76.2096 84.445 76.2096 87.8302 72.8243L109.359 51.2952C112.745 47.9099 112.745 42.4213 109.359 39.036L87.8302 17.507C84.445 14.1218 78.9563 14.1218 75.5711 17.507L71.3682 21.7098L88.6989 39.0405C92.0842 42.4258 92.0842 47.9144 88.6989 51.2997L71.3727 68.6259L75.5711 72.8243Z',
+] as const;
diff --git a/web/lib/signup-analytics-privacy.ts b/web/lib/signup-analytics-privacy.ts
new file mode 100644
index 00000000..7d866d76
--- /dev/null
+++ b/web/lib/signup-analytics-privacy.ts
@@ -0,0 +1,30 @@
+/** Scrub signup read capabilities from SDK-enriched URLs and replay metadata. */
+export function sanitizeSignupAnalytics(event: T | null): T | null {
+ const seen = new WeakMap();
+ function clean(value: unknown, key = ''): unknown {
+ if (/^(writeToken|write_token|progress_token|device_code|access_token|refresh_token|prompt)$/i.test(key)) return '[redacted]';
+ if (typeof value === 'string') return value
+ .replace(/([?&]session=)[^\s"']+/gi, '$1[redacted]')
+ .replace(/(\/api\/v1\/signup\/agent\/sessions\/)[0-9a-f-]{36}/gi, '$1[redacted]');
+ if (!value || typeof value !== 'object') return value;
+ if (seen.has(value)) return seen.get(value);
+ if (Array.isArray(value)) {
+ const result: unknown[] = []; seen.set(value, result);
+ value.forEach(item => result.push(clean(item))); return result;
+ }
+ if (Object.getPrototypeOf(value) !== Object.prototype) return value;
+ const result: Record = {}; seen.set(value, result);
+ for (const [k, v] of Object.entries(value)) result[k] = clean(v, k);
+ return result;
+ }
+ return clean(event) as T | null;
+}
+
+/** Drop request AND response bodies/headers before the replay recorder sees them. */
+export function maskSignupNetworkRequest(request: T): T | null {
+ try {
+ const path = new URL(request.name, 'https://agentrelay.com').pathname;
+ if (/\/(?:cloud\/)?api\/v1\/signup\/agent\/sessions(?:\/|$)/.test(path)) return null;
+ } catch { /* A non-URL performance entry has no signup payload. */ }
+ return request;
+}
diff --git a/web/lib/test/agent-signup-analytics.test.ts b/web/lib/test/agent-signup-analytics.test.ts
new file mode 100644
index 00000000..054d9d54
--- /dev/null
+++ b/web/lib/test/agent-signup-analytics.test.ts
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { SignupTracker } from '../agent-signup-analytics';
+import { maskSignupNetworkRequest, sanitizeSignupAnalytics } from '../signup-analytics-privacy';
+
+const journeyId = '537e4857-5590-42e8-8731-66441b466542';
+const storage = () => {
+ const values = new Map();
+ return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
+};
+const ph = { __loaded: true, has_opted_out_capturing: vi.fn(() => false), get_distinct_id: vi.fn(() => 'anon-visitor'), capture: vi.fn() };
+beforeEach(() => { ph.__loaded = true; ph.has_opted_out_capturing.mockReturnValue(false); ph.capture.mockReset(); });
+
+describe('agent signup funnel', () => {
+ it.each(['teams', 'flows'] as const)('joins %s entry, copy, and server handoff across reloads', product => {
+ const store = storage();
+ const first = new SignupTracker(ph, product, store, () => journeyId, () => 1000);
+ first.track('entry_clicked');
+ expect(first.context()).toEqual({ journeyId, distinctId: 'anon-visitor' });
+ const reload = new SignupTracker(ph, product, store, () => { throw new Error('must reuse journey'); }, () => 2000);
+ reload.track('page_viewed'); reload.track('prompt_copied'); reload.track('page_left', 3);
+ const events = ph.capture.mock.calls;
+ expect(events.map(([event]) => event)).toEqual(['agent_signup_entry_clicked', 'agent_signup_page_viewed', 'agent_signup_prompt_copied', 'agent_signup_page_left']);
+ for (const [, properties] of events) expect(properties).toMatchObject({ journey_id: journeyId, product, funnel: 'agent_signup', funnel_version: 1 });
+ expect(events.at(-1)?.[1]).toMatchObject({ step: 3 });
+ expect(events.at(-1)?.[2]).toEqual({ transport: 'sendBeacon' });
+ });
+ it('creates distinct journeys by product and explicit restart', () => {
+ const uuid = vi.fn().mockReturnValueOnce(journeyId).mockReturnValueOnce('637e4857-5590-42e8-8731-66441b466542').mockReturnValueOnce('737e4857-5590-42e8-8731-66441b466542');
+ const store = storage();
+ const teams = new SignupTracker(ph, 'teams', store, uuid);
+ const flows = new SignupTracker(ph, 'flows', store, uuid);
+ expect(teams.context()?.journeyId).not.toBe(flows.context()?.journeyId);
+ teams.restart();
+ expect(teams.context()?.journeyId).not.toBe(journeyId);
+ });
+ it('deduplicates pagehide plus SPA unmount and does not seed a new journey when restarting', () => {
+ const uuid = vi.fn(() => journeyId);
+ const tracker = new SignupTracker(ph, 'teams', storage(), uuid);
+ tracker.context(); tracker.leave(3); tracker.leave(3);
+ expect(ph.capture.mock.calls.map(([event]) => event)).toEqual(['agent_signup_page_left']);
+ tracker.resume(); tracker.leave(4);
+ expect(ph.capture).toHaveBeenCalledTimes(2);
+ tracker.restart(); tracker.leave(4);
+ expect(ph.capture.mock.calls.map(([event]) => event)).toEqual(['agent_signup_page_left', 'agent_signup_page_left', 'agent_signup_restarted']);
+ expect(uuid).toHaveBeenCalledTimes(1);
+ });
+ it('waits briefly for the SDK so early events and the session POST share one journey', async () => {
+ ph.__loaded = false;
+ const tracker = new SignupTracker(ph, 'flows', storage(), () => journeyId);
+ const pending = tracker.contextWhenReady(500);
+ ph.__loaded = true;
+ await expect(pending).resolves.toEqual({ journeyId, distinctId: 'anon-visitor' });
+ });
+ it('returns undefined without blocking setup when the SDK never loads', async () => {
+ ph.__loaded = false;
+ const tracker = new SignupTracker(ph, 'flows', storage(), () => journeyId);
+ await expect(tracker.contextWhenReady(50)).resolves.toBeUndefined();
+ expect(ph.capture).not.toHaveBeenCalled();
+ });
+ it('does not send browser or server context when unconfigured or opted out', () => {
+ const tracker = new SignupTracker(ph, 'teams', storage(), () => journeyId);
+ ph.__loaded = false;
+ expect(tracker.context()).toBeUndefined(); tracker.track('page_viewed');
+ ph.__loaded = true; ph.has_opted_out_capturing.mockReturnValue(true);
+ expect(tracker.context()).toBeUndefined(); tracker.track('prompt_copied');
+ expect(ph.capture).not.toHaveBeenCalled();
+ });
+ it('keeps working with unavailable storage, a broken SDK, or malformed stored data', () => {
+ const broken = { getItem: () => { throw new Error(); }, setItem: () => { throw new Error(); }, removeItem: () => { throw new Error(); } };
+ const tracker = new SignupTracker(ph, 'teams', broken, () => journeyId);
+ expect(tracker.context()?.journeyId).toBe(journeyId);
+ ph.capture.mockImplementation(() => { throw new Error(); });
+ expect(() => tracker.track('page_viewed')).not.toThrow();
+ expect(() => tracker.restart()).not.toThrow();
+ });
+ it('drops replay network entries including serialized capability-bearing bodies and headers', () => {
+ for (const name of ['https://agentrelay.com/cloud/api/v1/signup/agent/sessions', '/cloud/api/v1/signup/agent/sessions/' + journeyId, 'https://cloud.example/api/v1/signup/agent/sessions/' + journeyId]) {
+ const entry = { name, requestBody: JSON.stringify({ product: 'teams' }),
+ responseBody: JSON.stringify({ id: journeyId, writeToken: 'a'.repeat(64) }),
+ requestHeaders: { Authorization: 'Bearer private' } };
+ expect(maskSignupNetworkRequest(entry)).toBeNull();
+ }
+ const safe = { name: 'https://agentrelay.com/api/v1/flows/catalog' };
+ expect(maskSignupNetworkRequest(safe)).toBe(safe);
+ });
+ it('scrubs SDK URL enrichment and nested replay metadata without mutating the original event', () => {
+ const id = '637e4857-5590-42e8-8731-66441b466542';
+ const event = { event: '$pageleave', properties: { $current_url: `https://agentrelay.com/signup/teams?session=${id}&utm_source=docs`, $referrer: `http://localhost:3100/signup/teams?session=${id}`, prompt: 'private prompt with token', writeToken: 'secret', journey_id: journeyId, nested: [{ url: `https://agentrelay.com/cloud/api/v1/signup/agent/sessions/${id}` }] } };
+ const clean = sanitizeSignupAnalytics(event)!;
+ expect(JSON.stringify(clean)).not.toContain(id);
+ expect(JSON.stringify(clean)).not.toContain('private prompt');
+ expect(JSON.stringify(clean)).not.toContain('secret');
+ expect(clean.properties.journey_id).toBe(journeyId);
+ expect(clean.properties.$current_url).toContain('utm_source=docs');
+ expect(event.properties.writeToken).toBe('secret');
+ });
+});
diff --git a/web/lib/test/agent-signup-progress.test.ts b/web/lib/test/agent-signup-progress.test.ts
new file mode 100644
index 00000000..ea318e29
--- /dev/null
+++ b/web/lib/test/agent-signup-progress.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { isSignupProgress, trackedSignupPrompt } from '../agent-signup-progress';
+import { agentSignupInstructions } from '../agent-signup';
+const id = '537e4857-5590-42e8-8731-66441b466542';
+const progress = { id, product: 'teams', step: 0, state: 'waiting', revision: 0, updatedAt: '2026-09-21T00:00:00.000Z', expiresAt: '2026-09-21T02:00:00.000Z' };
+
+describe('signup progress handoff', () => {
+ it('accepts valid progress and rejects malformed or premature completion', () => {
+ expect(isSignupProgress(progress)).toBe(true);
+ for (const invalid of [null, {}, {...progress, step: 6}, {...progress, state: 'complete'}, {...progress, id: '../'.repeat(12)}, {...progress, updatedAt: 0}, {...progress, revision: -1}]) expect(isSignupProgress(invalid)).toBe(false);
+ expect(isSignupProgress({...progress, step: 5, state: 'complete'})).toBe(true);
+ });
+ it.each(['teams', 'flows'] as const)('carries the %s progress capability separately from URLs and preserves the local environment', (product) => {
+ const token = 'a'.repeat(64);
+ const prompt = trackedSignupPrompt(product, 'http://localhost:3100', 'http://localhost:3100/cloud/api/v1/signup/agent/sessions', { id, writeToken: token });
+ expect(prompt).toContain(`http://localhost:3100/signup/agent/${product}`);
+ expect(prompt).toContain(`Progress session: ${id}`);
+ expect(prompt).toContain('Do NOT use computer use, browser automation');
+ expect(prompt).toContain('download the prebuilt binary');
+ expect(prompt).toContain('never clone, build, or compile the app');
+ expect(prompt).toContain(`Progress API: http://localhost:3100/cloud/api/v1/signup/agent/sessions/${id}`);
+ expect(prompt).not.toContain('/api/v1/auth/device/start');
+ expect(prompt).toContain(`Progress token: ${token}`);
+ expect(prompt.match(/https?:\/\/\S+/g)?.every(url => !url.includes(token))).toBe(true);
+ const guide = agentSignupInstructions(product, 'http://localhost:3100', 'http://localhost:3100/cloud');
+ expect(guide).toContain('Authorization: Bearer ');
+ expect(guide).toContain('revision returned by the latest GET/PATCH');
+ expect(guide).toContain('Only report complete after the actual checks succeed');
+ expect(guide).toContain('NOT a Cloud access token');
+ expect(guide).toContain('Do NOT use computer use, browser automation');
+ expect(guide).toContain('Verify approval by polling the API');
+ expect(guide).toContain('A missing binary is a blocker, not a build task.');
+ expect(guide).toContain('POST http://localhost:3100/cloud/api/v1/auth/device/start');
+ if (product === 'teams') {
+ expect(guide).toContain('there is no public HTTP endpoint that installs an app');
+ expect(guide).toContain('Do not build it, run the development launcher to produce it');
+ expect(guide).not.toContain('the local stack must be built');
+ expect(guide).toContain("Do not inspect the app's");
+ expect(guide).toContain("attachment as unverified and keep progress waiting at step 5");
+ } else {
+ expect(guide).toContain('POST http://localhost:3100/cloud/api/v1/flows/deploy');
+ expect(guide).toContain('GET http://localhost:3100/api/v1/flows/catalog');
+ }
+ });
+});
diff --git a/web/lib/test/agent-signup.test.ts b/web/lib/test/agent-signup.test.ts
new file mode 100644
index 00000000..7ef401f4
--- /dev/null
+++ b/web/lib/test/agent-signup.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { GET } from '../../app/signup/agent/[product]/route';
+import { agentSignupPrompt } from '../agent-signup';
+import { getRecommendedFlow } from '../recommended-flow-catalog';
+
+afterEach(() => vi.unstubAllEnvs());
+
+describe('agent signup instructions', () => {
+ it.each(['teams', 'flows'])('serves complete %s instructions without cookies or JavaScript', async (product) => {
+ vi.stubEnv('NEXT_PUBLIC_CLOUD_URL', '/cloud');
+ const response = await GET(new Request(`https://agentrelay.com/signup/agent/${product}`), {
+ params: Promise.resolve({ product }),
+ });
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8');
+ expect(response.headers.get('access-control-allow-origin')).toBe('*');
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ const content = await response.text();
+ expect(content).toContain(`"signup_source":"${product}"`);
+ expect(content).toContain('Cloud API base: https://agentrelay.com/cloud');
+ expect(content).toContain('/api/v1/auth/device/token');
+ expect(content).toContain('/api/v1/auth/whoami');
+ expect(content).toContain('/api/v1/auth/token/refresh');
+ expect(agentSignupPrompt(product as 'teams' | 'flows', 'https://agentrelay.com')).toContain('Do NOT use computer use, browser automation');
+ if (product === 'teams') {
+ expect(content).toContain('--selected-sessions-only --json');
+ expect(content).toContain('releases/latest/download/AgentRelay-macOS-.dmg');
+ expect(content).toContain('site_url, account_id, and\nworkspace_id');
+ expect(content).toContain('last_cycle');
+ expect(content).toContain('SOURCE:SESSION_ID');
+ expect(content).toContain('will report a conflict, not switch accounts');
+ } else {
+ expect(content).toContain('"mode": "activate"');
+ expect(content).toContain('/api/v1/flows/listeners/');
+ }
+ });
+
+ it('keeps the local hostname and port for OAuth, APIs, and the dev desktop download', async () => {
+ vi.stubEnv('NEXT_PUBLIC_CLOUD_URL', '/cloud');
+ const content = await (await GET(new Request('http://127.0.0.1:3199/signup/agent/teams'), {
+ params: Promise.resolve({ product: 'teams' }),
+ })).text();
+ expect(content).toContain('Cloud API base: http://127.0.0.1:3199/cloud');
+ expect(content).toContain('http://127.0.0.1:3199/cloud/desktop-downloads/AgentRelay-Dev-macOS-.dmg');
+ expect(content).not.toContain('releases/latest/download');
+ expect(agentSignupPrompt('teams', 'http://127.0.0.1:3199')).toContain('http://127.0.0.1:3199/signup/agent/teams');
+ });
+
+ it.each(['unknown', 'Teams', 'flows/extra'])('returns 404 for unsupported product %s', async (product) => {
+ const response = await GET(new Request('https://agentrelay.com/signup/agent/unknown'), {
+ params: Promise.resolve({ product }),
+ });
+ expect(response.status).toBe(404);
+ });
+
+ it('keeps the public apex when served through the router HTTP fallback', async () => {
+ vi.stubEnv('NEXT_PUBLIC_CLOUD_URL', '/cloud');
+ const content = await (await GET(new Request('https://origin-web.agentrelay.com/signup/agent/teams'), {
+ params: Promise.resolve({ product: 'teams' }),
+ })).text();
+ expect(content).toContain('Cloud API base: https://agentrelay.com/cloud');
+ expect(content).not.toContain('origin-web.agentrelay.com');
+ });
+
+ it('uses the direct-source listener body and a provider-addressable approver', async () => {
+ const content = await (await GET(new Request('https://agentrelay.com/signup/agent/flows'), {
+ params: Promise.resolve({ product: 'flows' }),
+ })).text();
+ const examples = [...content.matchAll(/~~~json\n([\s\S]*?)\n~~~/g)].map(match => JSON.parse(match[1]));
+ const deploy = examples.find(body => body.mode === 'activate');
+ const flow = getRecommendedFlow('software-factory')!;
+ expect(deploy).toMatchObject({
+ workflow: flow.id,
+ source: expect.any(String),
+ handoffId: expect.any(String),
+ repository: { owner: 'acme', name: 'api' },
+ sources: [{ ...flow.defaultTrigger, settings: { ...flow.defaultTrigger.settings, repository: 'acme/api' } }],
+ inputs: { approver: 'github:@octocat', agents: flow.inputs.defaults.agents },
+ });
+ expect(deploy).not.toHaveProperty('flowId');
+ expect(deploy).not.toHaveProperty('repositories');
+ expect(content).toContain('one deployment per');
+ expect(content).not.toContain('');
+ });
+});