From 2dddcb7bbece1c72999792e7925cc3b3259cfa40 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 11 Nov 2026 10:40:43 -0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(web):=20/mcp-events-shim=20=E2=80=94?= =?UTF-8?q?=20webhooks=20that=20wake=20your=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One MCP server and one webhook URL: create an inbox, connect Claude Code, Codex, Cursor, opencode, Gemini CLI or any MCP client, pick a verification mode (open, GitHub, Stripe, Linear, bearer), and watch posts arrive live with whether each one woke the agent. Backed by Cloud's /cloud/api/v1/events-shim (@agent-relay/events-shim). Capability tokens never reach analytics: the page is blocked from session replay, its API traffic is dropped from replay, and es*_ tokens and the page-link fragment are scrubbed from captured events. Co-Authored-By: Claude Opus 5.5 --- web/app/mcp-events-shim/page.tsx | 44 ++ web/app/sitemap.ts | 6 + web/components/events-shim/EventsShimTry.tsx | 503 ++++++++++++++++++ .../events-shim/events-shim.module.css | 387 ++++++++++++++ web/lib/signup-analytics-privacy.ts | 6 +- web/lib/site-analytics.ts | 1 + web/lib/test/agent-signup-analytics.test.ts | 18 + 7 files changed, 964 insertions(+), 1 deletion(-) create mode 100644 web/app/mcp-events-shim/page.tsx create mode 100644 web/components/events-shim/EventsShimTry.tsx create mode 100644 web/components/events-shim/events-shim.module.css 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..f5cc435a 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -58,6 +58,12 @@ 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('/openclaw/skill'), lastModified: now, diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx new file mode 100644 index 00000000..b261d40b --- /dev/null +++ b/web/components/events-shim/EventsShimTry.tsx @@ -0,0 +1,503 @@ +'use client'; + +import Link from 'next/link'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { teamsCloudUrl } from '../../lib/teams-cloud'; +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: '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 ${url}`, + wakes: true, + }, + { + id: 'codex', + label: 'Codex', + command: (url: string) => `codex mcp add webhooks --url ${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 ${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.' }, +]; + +function readSession(): Session | null { + try { + const hash = new URLSearchParams(window.location.hash.slice(1)); + const fromHash = hash.get('k') && hash.get('m') ? { manageToken: hash.get('k')!, mcpUrl: hash.get('m')! } : null; + if (fromHash) return fromHash; + const saved: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); + if (saved && typeof saved === 'object' && 'manageToken' in saved && 'mcpUrl' in saved) return saved as Session; + } 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 CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function CodeBlock({ code }: { code: string }) { + return ( +
+
+        {code}
+      
+ +
+ ); +} + +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(() => { + setSession(readSession()); + setReady(true); + }, []); + + const load = useCallback( + async (current: Session) => { + const response = await api('/shims/current', { token: current.manageToken }); + 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}).`); + setFeed((await response.json()) as Feed); + setError(null); + }, + [], + ); + + useEffect(() => { + if (!session) return; + let stopped = false; + const tick = async () => { + if (document.visibilityState === 'visible') { + try { + await load(session); + } catch (failure) { + setError(failure instanceof Error ? failure.message : 'Feed unavailable.'); + } + } + if (!stopped) timer.current = setTimeout(tick, POLL_MS); + }; + void tick(); + return () => { + stopped = true; + 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); + } 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'`]; + if (mode === 'bearer') lines.push(` -H 'Authorization: Bearer ${bearerToken ?? ''}'`); + lines.push(` -d '{"hello":"from curl"}'`); + return lines.join(' \\\n'); + }, [hookUrl, mode, bearerToken]); + + 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. +

+ {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. +
  • + + )} +
  • Webhook bodies reach the agent marked as untrusted data, never as instructions.
  • +
+ {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

+ + + )} +

+ 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 ·{' '} + 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..604701d1 --- /dev/null +++ b/web/components/events-shim/events-shim.module.css @@ -0,0 +1,387 @@ +.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_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; + } +} diff --git a/web/lib/signup-analytics-privacy.ts b/web/lib/signup-analytics-privacy.ts index 8254e329..218ced9c 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)#[^\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..f84a3db7 100644 --- a/web/lib/test/agent-signup-analytics.test.ts +++ b/web/lib/test/agent-signup-analytics.test.ts @@ -109,4 +109,22 @@ 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]'); + expect(maskSignupNetworkRequest({ name: 'https://agentrelay.com/cloud/api/v1/events-shim/shims/current' })).toBeNull(); + expect(maskSignupNetworkRequest({ name: '/cloud/api/v1/events-shim/shims' })).toBeNull(); + }); }); From a90c1e93bc5dd930e0db283b96994e448378a354 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Wed, 11 Nov 2026 10:47:40 -0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(web):=20/mcp-events-shim/api=20?= =?UTF-8?q?=E2=80=94=20wake=20agents=20from=20your=20own=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For MCP and mailbox vendors: create an inbox per agent, hand the user one MCP line, and POST a text/plain nudge to the bearer URL; the agent sees exactly that text and wakes, then fetches the real content from the vendor's MCP. Covers delivery states, idempotency, hosted vs self-hosted (relay-control events_shim), and per-message pricing. Linked from the try page. Co-Authored-By: Claude Opus 5.5 --- web/app/mcp-events-shim/api/page.tsx | 195 ++++++++++++++++++ web/app/sitemap.ts | 6 + web/components/events-shim/CodeBlock.tsx | 37 ++++ web/components/events-shim/EventsShimTry.tsx | 37 +--- .../events-shim/events-shim.module.css | 90 ++++++++ 5 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 web/app/mcp-events-shim/api/page.tsx create mode 100644 web/components/events-shim/CodeBlock.tsx 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..34ee884d --- /dev/null +++ b/web/app/mcp-events-shim/api/page.tsx @@ -0,0 +1,195 @@ +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": "forwarded" }`; + +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, so you control the prompt. 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. +

+
+ +
+
+ + + ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index f5cc435a..724a4313 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -64,6 +64,12 @@ export default function sitemap(): MetadataRoute.Sitemap { 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 index b261d40b..5731aa01 100644 --- a/web/components/events-shim/EventsShimTry.tsx +++ b/web/components/events-shim/EventsShimTry.tsx @@ -4,6 +4,7 @@ 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`. */ @@ -115,38 +116,6 @@ function saveSession(session: Session | null) { window.history.replaceState(null, '', url.toString()); } -function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { - const [copied, setCopied] = useState(false); - return ( - - ); -} - -function CodeBlock({ code }: { code: string }) { - return ( -
-
-        {code}
-      
- -
- ); -} - 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`; @@ -309,6 +278,9 @@ export function EventsShimTry() { 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 && (