Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions web/analytics/agent-signup-funnel.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions web/app/WebsitePostHogProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 <PostHogProvider apiKey={apiKey} clientOptions={{
api_host: host, autocapture: true, capture_exceptions: true, capture_heatmaps: true,
capture_pageview: false, capture_pageleave: true,
session_recording: { maskAllInputs: true, blockSelector: '.ph-sensitive', maskCapturedNetworkRequestFn: maskSignupNetworkRequest },
before_send: sanitizeSignupAnalytics,
}}>{children}</PostHogProvider>;
}
5 changes: 2 additions & 3 deletions web/app/flows/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -94,9 +95,7 @@ export default function FlowsPage() {
Try Free with Cloud
<ArrowRight aria-hidden="true" size={17} strokeWidth={2} />
</a>
<a href="/khaliq" className={s.ctaSecondary}>
Chat with founders
</a>
<AgentSignup product="flows" />
</div>
</div>
</section>
Expand Down
15 changes: 3 additions & 12 deletions web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -118,19 +118,10 @@ const siteStructuredData = {
export default function RootLayout({ children }: { children: ReactNode }) {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const content = postHogKey ? (
<PostHogProvider
clientOptions={{
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? POSTHOG_HOST,
autocapture: true,
capture_exceptions: true,
capture_heatmaps: true,
session_recording: { maskAllInputs: true, blockSelector: '.ph-sensitive' },
capture_pageleave: true,
}}
>
<WebsitePostHogProvider apiKey={postHogKey} host={process.env.NEXT_PUBLIC_POSTHOG_HOST ?? POSTHOG_HOST}>
<WebsitePostHogPageView />
{children}
</PostHogProvider>
</WebsitePostHogProvider>
) : (
children
);
Expand Down
17 changes: 17 additions & 0 deletions web/app/signup/[product]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <AgentSignupJourney product={product} />;
}
21 changes: 21 additions & 0 deletions web/app/signup/agent/[product]/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
}
2 changes: 2 additions & 0 deletions web/app/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function TeamsPage() {
<a className={home.ctaPrimary} href={signupHref}>
{signupLabel}
</a>
<AgentSignup product="teams" />
<p className={s.signupNote}>No credit card required</p>
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions web/app/teams/teams-landing.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
23 changes: 23 additions & 0 deletions web/components/AgentSignup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link onClick={() => analytics.track('entry_clicked')} className={s.button} href={`/signup/${product}`}>
<span className={s.icons} aria-hidden="true">
<span><AgentToolLogo provider="codex" className={s.logo} /></span>
<span><AgentToolLogo provider="claude" className={s.logo} /></span>
<span><Grok size={24} /></span>
<span><AgentToolLogo provider="opencode" className={s.logo} /></span>
</span>
Let your agent do it
</Link>
);
}
Loading
Loading