diff --git a/web/app/mcp-events-shim/api/page.tsx b/web/app/mcp-events-shim/api/page.tsx new file mode 100644 index 00000000..66b530df --- /dev/null +++ b/web/app/mcp-events-shim/api/page.tsx @@ -0,0 +1,197 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { CodeBlock } from '../../../components/events-shim/CodeBlock'; +import s from '../../../components/events-shim/events-shim.module.css'; +import { SiteFooter } from '../../../components/SiteFooter'; +import { SiteNav } from '../../../components/SiteNav'; +import { SiteNavActions } from '../../../components/SiteNavActions'; +import { defaultOgImage } from '../../../lib/og-meta'; +import { absoluteUrl, SITE_EMAIL, SITE_NAME } from '../../../lib/site'; + +const TITLE = 'Wake agents from your own MCP'; +const DESCRIPTION = + 'Your MCP keeps the data. Send Agent Relay a one-line nudge and it lands in the agent’s session, waking it even when idle. Hosted or self-hosted, priced per message.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: absoluteUrl('/mcp-events-shim/api') }, + openGraph: { + siteName: SITE_NAME, + title: TITLE, + description: DESCRIPTION, + url: absoluteUrl('/mcp-events-shim/api'), + type: 'website', + images: [defaultOgImage()], + }, + twitter: { card: 'summary_large_image', title: TITLE, description: DESCRIPTION, images: [defaultOgImage().url] }, +}; + +const BASE = 'https://agentrelay.com/cloud/api/v1/events-shim'; + +const CREATE = `curl -X POST ${BASE}/shims +# → { "mcp_url": ".../t/esm_…/mcp", +# "webhook_urls": { "bearer": ".../h/esh_…/bearer", … }, +# "manage_token": "esk_…" }`; + +const BEARER = `curl -X PUT ${BASE}/shims/current/secrets/bearer \\ + -H "Authorization: Bearer $MANAGE_TOKEN" \\ + -H 'Content-Type: application/json' -d '{}' +# → { "secret": "esb_…" } shown once; keep it with the user's record`; + +const NUDGE = `# WEBHOOK_URL is the inbox's /bearer URL +curl -X POST "$WEBHOOK_URL" \\ + -H "Authorization: Bearer $NUDGE_SECRET" \\ + -H 'Content-Type: text/plain' \\ + -H 'Idempotency-Key: task_42' \\ + --data 'New task t_42 is waiting. Read it with get_task on the acme MCP, then ack it.' +# → 202 { "ok": true, "seq": 7, "wake": "forwarding" }`; + +const STATUS = `curl ${BASE}/shims/current -H "Authorization: Bearer $MANAGE_TOKEN" +# → { "agent": { "connected": true, "wake": "linked" }, +# "events": [{ "seq": 7, "delivery": "woken" }, …] }`; + +const INSTALL = `claude mcp add --transport http webhooks "$MCP_URL" +# then: "Watch for webhooks and tell me when one arrives."`; + +const steps = [ + { + n: '1', + title: 'Create an inbox per agent', + body: 'One API call gives you an MCP URL for the user and a private webhook URL for your backend.', + }, + { + n: '2', + title: 'The agent connects once', + body: 'The user adds the MCP URL next to yours. Under Agent Relay, the agent links its own session, so later nudges arrive in that session.', + }, + { + n: '3', + title: 'Your backend sends the nudge', + body: 'POST the exact words the agent should see. It wakes, calls your MCP for the real content, and acks it there.', + }, +]; + +export default function McpEventsShimApiPage() { + return ( + <> + } /> +
+
+

Agent Relay · Wake API

+

+ Want to use our API for your own MCP? +

+

+ You run the MCP and the mailbox. We deliver the nudge: the exact string you send lands in the agent's session + and wakes it, even when idle. No polling, no burned tokens, and your data never passes through us. +

+
+ + Talk to us about volume + + + Try it in your browser + +
+
+ +
+

+ i How it works +

+
+ {steps.map((step) => ( +
+ {step.n} +

{step.title}

+

{step.body}

+
+ ))} +
+
+

+ Think of it as a SYN, not the payload. The nudge says there is something for you and names the id. The + agent fetches it, takes the lease, and confirms through your MCP, so delivery is acknowledged + where your data lives. All Agent Relay ever stores is the nudge text. +

+
+
+ +
+

+ ii The API +

+

Create an inbox (one per user or agent)

+ +

Give your backend a sender key

+ +

Hand the user one line

+ +

Send a nudge

+ +

Check delivery

+ +
+

+ Verbatim on authenticated URLs. A text/plain post to the bearer URL (or a GitHub, + Stripe or Linear signed URL) is delivered as exactly that text, trimmed and cut at 6,000 characters, so you + control the prompt. On signed provider URLs the body must be plain text, not a JSON event, since the signature + does not cover Content-Type. Posts to the open URL are always wrapped and marked as untrusted, + because anyone holding that URL can send them. +

+

+ Once per key. Retries with the same Idempotency-Key wake the agent once. +

+

+ Delivery states. Each nudge is woken (injected into the session), read{' '} + (picked up through the MCP) or waiting. If a session isn't running Agent Relay, the agent + falls back to a background wait_for_webhook call and still gets it within seconds. +

+
+
+ +
+

+ iii Where it runs +

+
+

+ Agent Relay Cloud. Use the endpoints above. You send a pointer, not customer data, which keeps + the security review short. +

+

+ Self-hosted. The same API ships in the open-source control plane. Turn on the{' '} + events_shim feature and run it in your own AWS, GCP or Kubernetes with our Helm charts. The wake + logic is the open-source @agent-relay/events-shim package, so you can also mount the tools inside + your own MCP server. See the self-hosting docs. +

+

+ Agents. It works with Claude Code, Codex, opencode and the other harnesses Agent Relay delivers + to, whether they run locally or in your sandboxes. We maintain the quirks of each one so you don't have to. +

+
+
+ +
+

+ iv Pricing +

+
+

+ Priced by message volume, not seats: agents are programmatic, so per-seat pricing doesn't fit. Self-hosted + installs add a platform fee for setup and support. Start small and the price follows your volume as it grows. +

+
+
+ + Get a quote + +
+
+
+ + + ); +} diff --git a/web/app/mcp-events-shim/page.tsx b/web/app/mcp-events-shim/page.tsx new file mode 100644 index 00000000..ab4978d2 --- /dev/null +++ b/web/app/mcp-events-shim/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from 'next'; + +import { EventsShimTry } from '../../components/events-shim/EventsShimTry'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { SiteNavActions } from '../../components/SiteNavActions'; +import { defaultOgImage } from '../../lib/og-meta'; +import { absoluteUrl, SITE_NAME } from '../../lib/site'; + +const TITLE = 'Webhooks that wake your agent'; +const DESCRIPTION = + 'Add one MCP server to your agent, point any webhook at your URL, and every post wakes the agent, even when it is idle. Built on Agent Relay.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: absoluteUrl('/mcp-events-shim') }, + openGraph: { + siteName: SITE_NAME, + title: TITLE, + description: DESCRIPTION, + url: absoluteUrl('/mcp-events-shim'), + type: 'website', + images: [defaultOgImage()], + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + images: [defaultOgImage().url], + }, + // The page holds per-visitor capability links in its fragment; nothing to index beyond the landing copy. + robots: { index: true, follow: true }, +}; + +export default function McpEventsShimPage() { + return ( + <> + } /> + + + + ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 293da255..724a4313 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -58,6 +58,18 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 0.9, }, + { + url: absoluteUrl('/mcp-events-shim'), + lastModified: now, + changeFrequency: 'weekly', + priority: 0.8, + }, + { + url: absoluteUrl('/mcp-events-shim/api'), + lastModified: now, + changeFrequency: 'weekly', + priority: 0.8, + }, { url: absoluteUrl('/openclaw/skill'), lastModified: now, diff --git a/web/components/events-shim/CodeBlock.tsx b/web/components/events-shim/CodeBlock.tsx new file mode 100644 index 00000000..9ecb0e0f --- /dev/null +++ b/web/components/events-shim/CodeBlock.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useState } from 'react'; + +import s from './events-shim.module.css'; + +export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +export function CodeBlock({ code }: { code: string }) { + return ( +
+
+        {code}
+      
+ +
+ ); +} diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx new file mode 100644 index 00000000..60ad6a02 --- /dev/null +++ b/web/components/events-shim/EventsShimTry.tsx @@ -0,0 +1,533 @@ +'use client'; + +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { teamsCloudUrl } from '../../lib/teams-cloud'; +import { CodeBlock, CopyButton } from './CodeBlock'; +import s from './events-shim.module.css'; + +/** Mounted by Agent Relay Cloud; see `@agent-relay/events-shim`. */ +const API = teamsCloudUrl('/api/v1/events-shim'); +const STORAGE_KEY = 'agent-relay-events-shim'; +const POLL_MS = 3000; + +type Mode = 'open' | 'github' | 'stripe' | 'linear' | 'bearer'; +type SecretMode = Exclude; + +interface Session { + /** Capability for the feed, rotate and secrets. Kept only in this browser and the page link. */ + manageToken: string; + mcpUrl: string; +} + +interface FeedEvent { + id: string; + seq: number; + received_at: string; + mode: Mode; + event_type: string | null; + content_type: string; + body: string; + delivery: 'sending' | 'woken' | 'pending' | 'read'; + wake_error: string | null; +} + +interface Feed { + webhook_urls: Record; + secrets_set: Record; + agent: { connected: boolean; last_seen_at: string | null; wake: 'linked' | 'broken' | 'none'; wake_error: string | null }; + expires_at: string; + events: FeedEvent[]; +} + +const AGENTS = [ + { + id: 'claude-code', + label: 'Claude Code', + command: (url: string) => `claude mcp add --transport http webhooks ${shellQuote(url)}`, + wakes: true, + }, + { + id: 'codex', + label: 'Codex', + command: (url: string) => `codex mcp add webhooks --url ${shellQuote(url)}`, + wakes: true, + }, + { + id: 'cursor', + label: 'Cursor', + note: 'Add to ~/.cursor/mcp.json', + command: (url: string) => JSON.stringify({ mcpServers: { webhooks: { url } } }, null, 2), + wakes: false, + }, + { + id: 'opencode', + label: 'opencode', + note: 'Add to opencode.json', + command: (url: string) => JSON.stringify({ mcp: { webhooks: { type: 'remote', url } } }, null, 2), + wakes: true, + }, + { + id: 'gemini', + label: 'Gemini CLI', + command: (url: string) => `gemini mcp add --transport http webhooks ${shellQuote(url)}`, + wakes: false, + }, + { + id: 'other', + label: 'Any MCP client', + note: 'Streamable HTTP, no authentication', + command: (url: string) => url, + wakes: false, + }, +] as const; + +const MODES: { id: Mode; label: string; help: string }[] = [ + { id: 'open', label: 'Open', help: 'Anyone with the URL can post. Limits still apply.' }, + { id: 'github', label: 'GitHub', help: 'Paste this URL as the Payload URL (content type application/json) and the same secret you set in GitHub.' }, + { id: 'stripe', label: 'Stripe', help: 'Add this URL as a Stripe endpoint, then paste its signing secret (whsec_…).' }, + { id: 'linear', label: 'Linear', help: 'Add this URL as a Linear webhook, then paste its signing secret.' }, + { id: 'bearer', label: 'Bearer token', help: 'Senders include Authorization: Bearer . Generate one below; it is shown once.' }, +]; + +const TOKEN = /^[A-Za-z0-9_-]{43}$/; + +/** The MCP URL this page's API issues: `/t/esm_<43>/mcp` on the API's own origin. */ +function isOurMcpUrl(value: string): boolean { + try { + const url = new URL(value); + const api = new URL(API, window.location.origin); + const prefix = `${api.pathname.replace(/\/$/, '')}/t/esm_`; + if (url.origin !== api.origin || url.search || url.hash || url.username || url.password) return false; + if (!url.pathname.startsWith(prefix) || !url.pathname.endsWith('/mcp')) return false; + return TOKEN.test(url.pathname.slice(prefix.length, -'/mcp'.length)); + } catch { + return false; + } +} + +function isSession(value: unknown): value is Session { + if (!value || typeof value !== 'object') return false; + const { manageToken, mcpUrl } = value as Record; + return ( + typeof manageToken === 'string' && + manageToken.startsWith('esk_') && + TOKEN.test(manageToken.slice(4)) && + typeof mcpUrl === 'string' && + isOurMcpUrl(mcpUrl) + ); +} + +/** One shell word, whatever it contains. */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function readSession(): Session | null { + try { + const hash = new URLSearchParams(window.location.hash.slice(1)); + // A page link can be crafted by anyone: accept only what this page's API issues, + // so a link cannot make the page show someone else's command to copy and run. + const fromHash = { manageToken: hash.get('k'), mcpUrl: hash.get('m') }; + if (isSession(fromHash)) return fromHash; + const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (isSession(saved)) return saved; + } catch { + /* Storage can be unavailable; the page still works for this tab. */ + } + return null; +} + +function saveSession(session: Session | null) { + try { + if (session) localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + else localStorage.removeItem(STORAGE_KEY); + } catch { + /* Best effort. */ + } + const url = new URL(window.location.href); + url.hash = session ? new URLSearchParams({ k: session.manageToken, m: session.mcpUrl }).toString() : ''; + window.history.replaceState(null, '', url.toString()); +} + +function relative(iso: string): string { + const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + return new Date(iso).toLocaleString(); +} + +function prettyBody(body: string): string { + try { + return JSON.stringify(JSON.parse(body), null, 2); + } catch { + return body; + } +} + +async function api(path: string, init: RequestInit & { token?: string } = {}): Promise { + const { token, ...rest } = init; + const headers = new Headers(rest.headers); + if (token) headers.set('authorization', `Bearer ${token}`); + if (rest.body) headers.set('content-type', 'application/json'); + return fetch(`${API}${path}`, { ...rest, headers, cache: 'no-store' }); +} + +export function EventsShimTry() { + const [session, setSession] = useState(null); + const [feed, setFeed] = useState(null); + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [agentId, setAgentId] = useState<(typeof AGENTS)[number]['id']>('claude-code'); + const [mode, setMode] = useState('open'); + const [secretDraft, setSecretDraft] = useState(''); + const [bearerToken, setBearerToken] = useState(null); + const [expanded, setExpanded] = useState(null); + const timer = useRef | null>(null); + + useEffect(() => { + const restored = readSession(); + // Keep the page link (fragment) in step with an inbox restored from this browser, + // so copying or bookmarking the address still opens it. + if (restored) saveSession(restored); + setSession(restored); + setReady(true); + }, []); + + const load = useCallback( + async (current: Session, signal: AbortSignal) => { + const response = await api('/shims/current', { token: current.manageToken, signal }); + // A late answer for an inbox this page has since left must not touch the current one. + if (signal.aborted) return; + if (response.status === 404) { + saveSession(null); + setSession(null); + setFeed(null); + setError('That webhook expired or the link was replaced. Create a new one.'); + return; + } + if (!response.ok) throw new Error(`Feed unavailable (HTTP ${response.status}).`); + const next = (await response.json()) as Feed; + if (signal.aborted) return; + setFeed(next); + setError(null); + }, + [], + ); + + useEffect(() => { + if (!session) return; + let stopped = false; + const controller = new AbortController(); + const tick = async () => { + if (document.visibilityState === 'visible') { + try { + await load(session, controller.signal); + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : 'Feed unavailable.'); + } + } + if (!stopped) timer.current = setTimeout(tick, POLL_MS); + }; + void tick(); + return () => { + stopped = true; + controller.abort(); + if (timer.current) clearTimeout(timer.current); + }; + }, [session, load]); + + async function create() { + setBusy(true); + setError(null); + try { + const response = await api('/shims', { method: 'POST' }); + if (response.status === 429) throw new Error('Too many webhooks from this network. Try again later.'); + if (!response.ok) throw new Error('Could not create a webhook. Please try again.'); + const created = (await response.json()) as { manage_token: string; mcp_url: string }; + const next = { manageToken: created.manage_token, mcpUrl: created.mcp_url }; + saveSession(next); + setSession(next); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not create a webhook.'); + } finally { + setBusy(false); + } + } + + async function rotate() { + if (!session || !window.confirm('Replace the webhook URL? The old URL stops working immediately. Your agent keeps watching.')) return; + setBusy(true); + try { + const response = await api('/shims/current/rotate', { method: 'POST', token: session.manageToken }); + if (!response.ok) throw new Error('Could not replace the URL.'); + setFeed((await response.json()) as Feed); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not replace the URL.'); + } finally { + setBusy(false); + } + } + + async function saveSecret(target: SecretMode, value: string | null | undefined) { + if (!session) return; + setBusy(true); + try { + const response = await api(`/shims/current/secrets/${target}`, { + method: 'PUT', + token: session.manageToken, + body: JSON.stringify(value === undefined ? {} : { secret: value }), + }); + const body = (await response.json()) as { secret?: string; message?: string }; + if (!response.ok) throw new Error(body.message || 'Could not save the secret.'); + if (body.secret) setBearerToken(body.secret); + setSecretDraft(''); + await load(session, new AbortController().signal); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not save the secret.'); + } finally { + setBusy(false); + } + } + + const agent = AGENTS.find((candidate) => candidate.id === agentId)!; + const modeInfo = MODES.find((candidate) => candidate.id === mode)!; + const hookUrl = feed?.webhook_urls[mode] ?? ''; + const curl = useMemo(() => { + const lines = [`curl -X POST '${hookUrl}'`, ` -H 'Content-Type: application/json'`, ` -H 'Idempotency-Key: demo-1'`]; + // Never put the live token in a copyable command: it would land in shell history + // and process listings. Senders read it from the environment. + if (mode === 'bearer') lines.push(` -H "Authorization: Bearer $WEBHOOK_TOKEN"`); + lines.push(` -d '{"hello":"from curl"}'`); + return lines.join(' \\\n'); + }, [hookUrl, mode]); + + const status = !feed + ? null + : !feed.agent.connected + ? { tone: 'idle', text: 'No agent connected yet' } + : feed.agent.wake === 'linked' + ? { tone: 'live', text: 'Agent connected · posts wake it, even when idle' } + : feed.agent.wake === 'broken' + ? { tone: 'warn', text: `Agent connected · wake link stopped working${feed.agent.wake_error ? ` (${feed.agent.wake_error})` : ''}` } + : { tone: 'wait', text: 'Agent connected · it reads posts with wait_for_webhook' }; + + return ( +
+
+

Agent Relay · MCP events shim

+

+ Connect your agent, then post. +

+

+ One MCP server, one webhook URL. Every post is delivered into your agent's session through Agent Relay, so an{' '} + idle agent wakes up and acts on it. No polling loop, no restarts. +

+

+ Building your own MCP? Use our API to wake agents with your own messages → +

+ {ready && !session && ( +
+ +

No sign-up. The page link becomes yours: anyone holding it can connect an agent and read posts.

+
+ )} + {error &&

{error}

} +
+ + {session && ( + <> +
+

+ i Connect your agent +

+
+ {AGENTS.map((candidate) => ( + + ))} +
+ {'note' in agent && agent.note &&

{agent.note}

} + +

+ Then ask your agent: Watch for webhooks and tell me when one arrives. +

+
    + {agent.wakes ? ( + <> +
  • + With Agent Relay running, the agent links its own session (one command it runs itself) and + each post is injected into the session, waking it within seconds, even when idle. +
  • +
  • Without Agent Relay, the agent keeps a background wait_for_webhook call open and wakes when it returns.
  • + + ) : ( + <> +
  • The agent keeps a background wait_for_webhook call open; it returns as soon as a post arrives.
  • +
  • + Clients that can't run tools in the background see new posts on their next get_webhook_events call. +
  • + + )} +
  • + Posts to the open URL reach the agent marked as untrusted data, never as instructions. A{' '} + text/plain post to a verified URL (bearer, GitHub, Stripe, Linear) is delivered verbatim, so only + senders holding that secret can choose the agent's words. A signed provider URL wraps a JSON body as + data whatever its header says. +
  • +
+ {status &&

{status.text}

} +
+ +
+

+ ii Post to your webhook URL +

+
+ {MODES.map((candidate) => ( + + ))} +
+

{modeInfo.help}

+ {feed && ( + <> +
+ {hookUrl} + + +
+ {mode !== 'open' && mode !== 'bearer' && ( +
{ + event.preventDefault(); + void saveSecret(mode, secretDraft); + }} + > + setSecretDraft(event.target.value)} + /> + +
+ )} + {mode === 'bearer' && ( +
+ {bearerToken ? ( + <> + {bearerToken} + + + ) : ( + {feed.secrets_set.bearer ? 'A token is set. Generating a new one replaces it.' : 'No token yet.'} + )} + +
+ )} + {(mode === 'open' || mode === 'bearer') && ( + <> +

Try it

+ {mode === 'bearer' && ( +

+ Set WEBHOOK_TOKEN to your token in your shell or secret store first. +

+ )} + + + )} +

+ A retry with the same Idempotency-Key reaches your agent once (GitHub, Stripe and Linear retries are + matched by their delivery id). Getting a new URL stops the old one immediately; your agent keeps watching. +

+ + )} +
+ +
+

+ iii Live +

+ {!feed || feed.events.length === 0 ? ( +

Nothing yet. Posts show up here as they arrive.

+ ) : ( +
    + {feed.events.map((event) => ( +
  1. + + {expanded === event.id && ( + <> + {event.wake_error &&

    Wake failed: {event.wake_error}

    } +
    {prettyBody(event.body)}
    + + )} +
  2. + ))} +
+ )} +
+ +
+

+ Limits: 60 posts per hour and 500 per day per webhook, bodies up to 64 KB. A webhook expires after 7 days without use. + The feed keeps the last 50 posts. A tool call hands the agent at most 20 posts; the rest arrive on the next one. +

+

+ Why it wakes: Agent Relay delivers each post into the session itself, the same way agents message each other, so the + agent doesn't need to be mid-turn or polling. Get Agent Relay ·{' '} + Use the API from your own MCP ·{' '} + Docs +

+
+ + )} +
+ ); +} diff --git a/web/components/events-shim/events-shim.module.css b/web/components/events-shim/events-shim.module.css new file mode 100644 index 00000000..2b640aba --- /dev/null +++ b/web/components/events-shim/events-shim.module.css @@ -0,0 +1,478 @@ +.main { + max-width: 52rem; + margin: 0 auto; + padding: 2.5rem 1rem 5rem; + color: var(--fg); + font-family: var(--font-geist-sans), sans-serif; +} + +.hero { + text-align: center; + padding: 2rem 0 2.5rem; +} + +.eyebrow { + margin: 0 0 12px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--primary); +} + +.headline { + margin: 0 0 16px; + font-family: var(--font-heading), sans-serif; + font-size: clamp(2.2rem, 6vw, 3.4rem); + font-weight: 700; + letter-spacing: -0.04em; +} + +.headline em { + font-style: italic; + color: var(--primary); +} + +.lead { + max-width: 36rem; + margin: 0 auto; + color: var(--fg-muted); + font-size: 1.02rem; + line-height: 1.7; +} + +.start { + margin-top: 1.8rem; +} + +.primary { + padding: 0.75rem 1.4rem; + border: 0; + border-radius: 0.65rem; + background: var(--primary-bg, var(--primary)); + color: var(--primary-fg, #fff); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.primary:disabled, +.ghost:disabled { + opacity: 0.55; + cursor: default; +} + +.helper, +.note, +.fine { + color: var(--fg-muted); + font-size: 0.86rem; + line-height: 1.6; +} + +.helper { + max-width: 30rem; + margin: 0.8rem auto 0; +} + +.fine { + margin: 0.9rem 0 0; + font-size: 0.8rem; +} + +.error { + margin: 1rem auto 0; + max-width: 34rem; + color: #e5484d; + font-size: 0.9rem; +} + +.section { + margin-top: 1.25rem; + padding: 1.4rem; + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--surface); +} + +.sectionTitle { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin: 0 0 1rem; + font-family: var(--font-heading), sans-serif; + font-size: 1.15rem; +} + +.numeral { + min-width: 1.6rem; + color: var(--primary); + font-family: Georgia, serif; + font-style: italic; +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.8rem; +} + +.tab, +.tabActive { + padding: 0.4rem 0.8rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--fg-muted); + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.tabActive { + border-color: var(--primary); + color: var(--fg); + background: var(--surface-strong, transparent); +} + +.codeBlock { + position: relative; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--bg); +} + +.codeBlock pre { + margin: 0; + padding: 0.9rem 5rem 0.9rem 1rem; + overflow-x: auto; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.55; + /* Wrap long URLs so they never run under the Copy button or past a phone screen. */ + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.codeBlock .copy { + position: absolute; + top: 0.55rem; + right: 0.55rem; +} + +.copy, +.ghost { + flex-shrink: 0; + padding: 0.35rem 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--surface); + color: var(--fg); + font: inherit; + font-size: 0.8rem; + cursor: pointer; +} + +.ask { + margin: 1rem 0 0.4rem; + font-size: 0.95rem; +} + +.ask q { + font-weight: 600; +} + +.bullets { + margin: 0; + padding-left: 1.2rem; + list-style: disc; + color: var(--fg-muted); + font-size: 0.88rem; + line-height: 1.65; +} + +.bullets a, +.limits a { + color: var(--primary); +} + +.bullets code, +.fine code { + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.85em; +} + +.status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--line); + font-size: 0.82rem; +} + +.status::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--fg-faint, #888); +} + +.status_live::before { + background: #30a46c; + box-shadow: 0 0 0 3px rgba(48, 164, 108, 0.2); +} + +.status_wait::before { + background: var(--primary); +} + +.status_warn::before { + background: #f5a524; +} + +.urlRow, +.secretRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.url { + flex: 1 1 18rem; + min-width: 0; + padding: 0.55rem 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +.input { + flex: 1 1 16rem; + min-width: 0; + padding: 0.55rem 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + font: inherit; + font-size: 0.85rem; +} + +.label { + margin: 1.1rem 0 0.4rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--fg-muted); +} + +.empty { + margin: 0; + color: var(--fg-muted); + font-size: 0.9rem; +} + +.feed { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.event { + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--bg); +} + +.eventHead { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.6rem 0.8rem; + border: 0; + background: transparent; + color: var(--fg); + font: inherit; + font-size: 0.85rem; + text-align: left; + cursor: pointer; +} + +.seq { + color: var(--fg-faint, var(--fg-muted)); + font-family: var(--font-geist-mono), ui-monospace, monospace; +} + +.eventType { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-geist-mono), ui-monospace, monospace; +} + +.badge { + padding: 0.12rem 0.5rem; + border-radius: 999px; + font-size: 0.72rem; + white-space: nowrap; +} + +.badge_woken { + background: rgba(48, 164, 108, 0.16); + color: #30a46c; +} + +.badge_read { + background: rgba(116, 184, 226, 0.16); + color: var(--primary); +} + +.badge_sending, +.badge_pending { + background: rgba(245, 165, 36, 0.16); + color: #c98500; +} + +.time { + color: var(--fg-muted); + font-size: 0.78rem; + white-space: nowrap; +} + +.body { + margin: 0; + padding: 0.2rem 0.8rem 0.8rem; + max-height: 20rem; + overflow: auto; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.event .fine { + margin: 0; + padding: 0 0.8rem; +} + +.limits { + margin-top: 1.5rem; + color: var(--fg-muted); + font-size: 0.8rem; + line-height: 1.65; +} + +@media (max-width: 640px) { + .section { + padding: 1rem; + } + + .time { + display: none; + } +} + +.crossLink { + margin: 1rem 0 0; + font-size: 0.88rem; + color: var(--fg-muted); +} + +.crossLink a { + color: var(--primary); +} + +.steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.8rem; +} + +.step { + padding: 1.1rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: var(--bg); +} + +.step h3 { + margin: 0.4rem 0 0.35rem; + font-size: 0.98rem; +} + +.step p { + margin: 0; + color: var(--fg-muted); + font-size: 0.86rem; + line-height: 1.6; +} + +.prose { + color: var(--fg-muted); + font-size: 0.92rem; + line-height: 1.7; +} + +.prose p { + margin: 0 0 0.8rem; +} + +.prose strong { + color: var(--fg); +} + +.prose code { + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.85em; +} + +.prose a { + color: var(--primary); +} + +.stepLabel { + margin: 1.2rem 0 0.45rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--fg); +} + +.cta { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: center; + margin-top: 1.6rem; +} + +.ctaLink { + padding: 0.7rem 1.2rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + color: var(--fg); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; +} + +.ctaPrimary { + composes: ctaLink; + border-color: transparent; + background: var(--primary-bg, var(--primary)); + color: var(--primary-fg, #fff); +} diff --git a/web/lib/signup-analytics-privacy.ts b/web/lib/signup-analytics-privacy.ts index 8254e329..cb6de673 100644 --- a/web/lib/signup-analytics-privacy.ts +++ b/web/lib/signup-analytics-privacy.ts @@ -9,7 +9,10 @@ export function sanitizeSignupAnalytics(event: T | null): T | null { if (typeof value === 'string') return value .replace(/([?&]session=)[^&#\s"']+/gi, '$1[redacted]') .replace(/([?&](?:invite_token|inviteToken|invite)=)[^&#\s"']+/gi, '$1[redacted]') - .replace(/(\/api\/v1\/signup\/agent\/sessions\/)[0-9a-f-]{36}/gi, '$1[redacted]'); + .replace(/(\/api\/v1\/signup\/agent\/sessions\/)[0-9a-f-]{36}/gi, '$1[redacted]') + // Events-shim capabilities: MCP, webhook, manage and bearer tokens, and the page link fragment. + .replace(/(es[mhkb]_)[A-Za-z0-9_-]{43}/g, '$1[redacted]') + .replace(/(\/mcp-events-shim(?:\/api)?(?:\?[^#\s"']*)?)#[^\s"']*/gi, '$1#[redacted]'); if (!value || typeof value !== 'object') return value; if (seen.has(value)) return seen.get(value); if (Array.isArray(value)) { @@ -29,6 +32,7 @@ export function maskSignupNetworkRequest(request: T) try { const path = new URL(request.name, 'https://agentrelay.com').pathname; if (/\/(?:cloud\/)?api\/v1\/signup\/agent\/sessions(?:\/|$)/.test(path)) return null; + if (/\/(?:cloud\/)?api\/v1\/events-shim(?:\/|$)/.test(path)) return null; } catch { /* A non-URL performance entry has no signup payload. */ } return request; } diff --git a/web/lib/site-analytics.ts b/web/lib/site-analytics.ts index 39abb20a..c8c1dc70 100644 --- a/web/lib/site-analytics.ts +++ b/web/lib/site-analytics.ts @@ -8,6 +8,7 @@ const TRACKED_ROUTE_PREFIXES = [ { prefix: '/blog', pageGroup: 'blog' }, { prefix: '/openclaw', pageGroup: 'openclaw' }, { prefix: '/skill', pageGroup: 'skill' }, + { prefix: '/mcp-events-shim', pageGroup: 'mcp-events-shim' }, ] as const; const EXCLUDED_ROUTE_PREFIXES = ['/openclaw/skill/invite/'] as const; diff --git a/web/lib/test/agent-signup-analytics.test.ts b/web/lib/test/agent-signup-analytics.test.ts index 52e4840d..c8385260 100644 --- a/web/lib/test/agent-signup-analytics.test.ts +++ b/web/lib/test/agent-signup-analytics.test.ts @@ -109,4 +109,24 @@ describe('agent signup funnel', () => { expect(JSON.stringify(clean)).not.toContain(token); expect(clean.properties.$current_url).toBe('https://agentrelay.com/login?next=%2Fdashboard&invite_token=[redacted]'); }); + it('scrubs events-shim capability tokens and the page link fragment, and drops its API traffic from replay', () => { + const t = (prefix: string) => `${prefix}${'A1b2_C3-d4'.repeat(4)}xyz`; + const mcp = `https://agentrelay.com/cloud/api/v1/events-shim/t/${t('esm_')}/mcp`; + const fragment = new URLSearchParams({ k: t('esk_'), m: mcp }).toString(); + const event = { + event: '$autocapture', + properties: { + $current_url: `https://agentrelay.com/mcp-events-shim#${fragment}`, + $el_text: `claude mcp add --transport http webhooks ${mcp}`, + nested: [{ hook: `https://agentrelay.com/cloud/api/v1/events-shim/h/${t('esh_')}/bearer`, token: t('esb_') }], + }, + }; + const clean = JSON.stringify(sanitizeSignupAnalytics(event)); + for (const prefix of ['esm_', 'esk_', 'esh_', 'esb_']) expect(clean).not.toContain(t(prefix)); + expect(clean).toContain('https://agentrelay.com/mcp-events-shim#[redacted]'); + const withQuery = sanitizeSignupAnalytics({ url: `https://agentrelay.com/mcp-events-shim?utm_source=x#k=esk_${encodeURIComponent('%')}abc&m=${encodeURIComponent(mcp)}` })!; + expect(withQuery.url).toBe('https://agentrelay.com/mcp-events-shim?utm_source=x#[redacted]'); + expect(maskSignupNetworkRequest({ name: 'https://agentrelay.com/cloud/api/v1/events-shim/shims/current' })).toBeNull(); + expect(maskSignupNetworkRequest({ name: '/cloud/api/v1/events-shim/shims' })).toBeNull(); + }); });