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 (
+ {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1600);
+ } catch {
+ /* Clipboard blocked; the text is selectable. */
+ }
+ }}
+ >
+ {copied ? 'Copied' : label}
+
+ );
+}
+
+function CodeBlock({ code }: { code: string }) {
+ return (
+
+ );
+}
+
+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 && (
+
+
+ {busy ? 'Creating…' : 'Get my webhook'}
+
+
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) => (
+ setAgentId(candidate.id)}
+ >
+ {candidate.label}
+
+ ))}
+
+ {'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) => (
+ {
+ setMode(candidate.id);
+ setSecretDraft('');
+ }}
+ >
+ {candidate.label}
+
+ ))}
+
+ {modeInfo.help}
+ {feed && (
+ <>
+
+ {hookUrl}
+
+
+ Get a new URL
+
+
+ {mode !== 'open' && mode !== 'bearer' && (
+
+ )}
+ {mode === 'bearer' && (
+
+ {bearerToken ? (
+ <>
+ {bearerToken}
+
+ >
+ ) : (
+ {feed.secrets_set.bearer ? 'A token is set. Generating a new one replaces it.' : 'No token yet.'}
+ )}
+ saveSecret('bearer', undefined)} disabled={busy}>
+ Generate token
+
+
+ )}
+ {(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) => (
+
+ setExpanded(expanded === event.id ? null : event.id)}
+ aria-expanded={expanded === event.id}
+ >
+ #{event.seq}
+ {event.event_type ?? event.mode}
+
+ {event.delivery === 'woken' ? 'woke agent' : event.delivery === 'read' ? 'read by agent' : 'waiting'}
+
+
+ {relative(event.received_at)}
+
+
+ {expanded === event.id && (
+ <>
+ {event.wake_error && Wake failed: {event.wake_error}
}
+ {prettyBody(event.body)}
+ >
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+ 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.
+
+
+
+
+
+
+ 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 (
+ {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1600);
+ } catch {
+ /* Clipboard blocked; the text is selectable. */
+ }
+ }}
+ >
+ {copied ? 'Copied' : label}
+
+ );
+}
+
+export function CodeBlock({ code }: { code: string }) {
+ return (
+
+ );
+}
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 (
- {
- try {
- await navigator.clipboard.writeText(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 1600);
- } catch {
- /* Clipboard blocked; the text is selectable. */
- }
- }}
- >
- {copied ? 'Copied' : label}
-
- );
-}
-
-function CodeBlock({ code }: { code: string }) {
- return (
-
- );
-}
-
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 && (
@@ -493,6 +465,7 @@ export function EventsShimTry() {
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
index 604701d1..07de419a 100644
--- a/web/components/events-shim/events-shim.module.css
+++ b/web/components/events-shim/events-shim.module.css
@@ -385,3 +385,93 @@
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);
+}
From 533b99f6f7e0ff670b1220b7796c44b74ad9e154 Mon Sep 17 00:00:00 2001
From: Khaliq
Date: Wed, 11 Nov 2026 11:03:24 -0800
Subject: [PATCH 3/6] fix(web): keep the bearer token out of the copyable curl;
restore the page-link fragment
Co-Authored-By: Claude Opus 5.5
---
web/components/events-shim/EventsShimTry.tsx | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx
index 5731aa01..9288c837 100644
--- a/web/components/events-shim/EventsShimTry.tsx
+++ b/web/components/events-shim/EventsShimTry.tsx
@@ -153,7 +153,11 @@ export function EventsShimTry() {
const timer = useRef | null>(null);
useEffect(() => {
- setSession(readSession());
+ 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);
}, []);
@@ -252,10 +256,12 @@ export function EventsShimTry() {
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 ?? ''}'`);
+ // 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, bearerToken]);
+ }, [hookUrl, mode]);
const status = !feed
? null
@@ -409,6 +415,11 @@ export function EventsShimTry() {
{(mode === 'open' || mode === 'bearer') && (
<>
Try it
+ {mode === 'bearer' && (
+
+ Set WEBHOOK_TOKEN to your token in your shell or secret store first.
+
+ )}
>
)}
From 6f50357c8d5663d643d983ac3f2a656e3c27c8e7 Mon Sep 17 00:00:00 2001
From: Khaliq
Date: Wed, 11 Nov 2026 11:07:32 -0800
Subject: [PATCH 4/6] fix(web): reject crafted page links, quote install
commands, ignore stale feed answers
- Restored sessions must be an esk_ token and an MCP URL on this page's own
API origin/path; install commands shell-quote the URL.
- Feed requests are aborted when the inbox changes, so a late 404 cannot
clear a newer inbox.
- Say precisely which posts are wrapped as untrusted (open) and which are
verbatim (text/plain on verified URLs).
- Redact the page fragment even after a query string.
Co-Authored-By: Claude Opus 5.5
---
web/components/events-shim/EventsShimTry.tsx | 71 ++++++++++++++++----
web/lib/signup-analytics-privacy.ts | 2 +-
web/lib/test/agent-signup-analytics.test.ts | 2 +
3 files changed, 61 insertions(+), 14 deletions(-)
diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx
index 9288c837..6de28b48 100644
--- a/web/components/events-shim/EventsShimTry.tsx
+++ b/web/components/events-shim/EventsShimTry.tsx
@@ -45,13 +45,13 @@ const AGENTS = [
{
id: 'claude-code',
label: 'Claude Code',
- command: (url: string) => `claude mcp add --transport http webhooks ${url}`,
+ 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 ${url}`,
+ command: (url: string) => `codex mcp add webhooks --url ${shellQuote(url)}`,
wakes: true,
},
{
@@ -71,7 +71,7 @@ const AGENTS = [
{
id: 'gemini',
label: 'Gemini CLI',
- command: (url: string) => `gemini mcp add --transport http webhooks ${url}`,
+ command: (url: string) => `gemini mcp add --transport http webhooks ${shellQuote(url)}`,
wakes: false,
},
{
@@ -91,13 +91,48 @@ const MODES: { id: Mode; label: string; help: string }[] = [
{ 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));
- const fromHash = hash.get('k') && hash.get('m') ? { manageToken: hash.get('k')!, mcpUrl: hash.get('m')! } : null;
- if (fromHash) return fromHash;
+ // 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 (saved && typeof saved === 'object' && 'manageToken' in saved && 'mcpUrl' in saved) return saved as Session;
+ if (isSession(saved)) return saved;
} catch {
/* Storage can be unavailable; the page still works for this tab. */
}
@@ -162,8 +197,10 @@ export function EventsShimTry() {
}, []);
const load = useCallback(
- async (current: Session) => {
- const response = await api('/shims/current', { token: current.manageToken });
+ 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);
@@ -172,7 +209,9 @@ export function EventsShimTry() {
return;
}
if (!response.ok) throw new Error(`Feed unavailable (HTTP ${response.status}).`);
- setFeed((await response.json()) as Feed);
+ const next = (await response.json()) as Feed;
+ if (signal.aborted) return;
+ setFeed(next);
setError(null);
},
[],
@@ -181,12 +220,13 @@ export function EventsShimTry() {
useEffect(() => {
if (!session) return;
let stopped = false;
+ const controller = new AbortController();
const tick = async () => {
if (document.visibilityState === 'visible') {
try {
- await load(session);
+ await load(session, controller.signal);
} catch (failure) {
- setError(failure instanceof Error ? failure.message : 'Feed unavailable.');
+ if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : 'Feed unavailable.');
}
}
if (!stopped) timer.current = setTimeout(tick, POLL_MS);
@@ -194,6 +234,7 @@ export function EventsShimTry() {
void tick();
return () => {
stopped = true;
+ controller.abort();
if (timer.current) clearTimeout(timer.current);
};
}, [session, load]);
@@ -243,7 +284,7 @@ export function EventsShimTry() {
if (!response.ok) throw new Error(body.message || 'Could not save the secret.');
if (body.secret) setBearerToken(body.secret);
setSecretDraft('');
- await load(session);
+ await load(session, new AbortController().signal);
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'Could not save the secret.');
} finally {
@@ -340,7 +381,11 @@ export function EventsShimTry() {
>
)}
- Webhook bodies reach the agent marked as untrusted data, never as instructions.
+
+ 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.
+
{status && {status.text}
}
diff --git a/web/lib/signup-analytics-privacy.ts b/web/lib/signup-analytics-privacy.ts
index 218ced9c..cb6de673 100644
--- a/web/lib/signup-analytics-privacy.ts
+++ b/web/lib/signup-analytics-privacy.ts
@@ -12,7 +12,7 @@ export function sanitizeSignupAnalytics(event: T | null): T | null {
.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]');
+ .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)) {
diff --git a/web/lib/test/agent-signup-analytics.test.ts b/web/lib/test/agent-signup-analytics.test.ts
index f84a3db7..c8385260 100644
--- a/web/lib/test/agent-signup-analytics.test.ts
+++ b/web/lib/test/agent-signup-analytics.test.ts
@@ -124,6 +124,8 @@ describe('agent signup funnel', () => {
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();
});
From 081f2efb25c16b93646c1dac2cfe64063ecad4ff Mon Sep 17 00:00:00 2001
From: Khaliq
Date: Wed, 11 Nov 2026 11:20:16 -0800
Subject: [PATCH 5/6] =?UTF-8?q?fix(web):=20show=20posts=20a=20wake=20is=20?=
=?UTF-8?q?still=20sending=20as=20'waking=E2=80=A6'?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
web/components/events-shim/EventsShimTry.tsx | 4 ++--
web/components/events-shim/events-shim.module.css | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx
index 6de28b48..46249349 100644
--- a/web/components/events-shim/EventsShimTry.tsx
+++ b/web/components/events-shim/EventsShimTry.tsx
@@ -29,7 +29,7 @@ interface FeedEvent {
event_type: string | null;
content_type: string;
body: string;
- delivery: 'woken' | 'pending' | 'read';
+ delivery: 'sending' | 'woken' | 'pending' | 'read';
wake_error: string | null;
}
@@ -495,7 +495,7 @@ export function EventsShimTry() {
#{event.seq}
{event.event_type ?? event.mode}
- {event.delivery === 'woken' ? 'woke agent' : event.delivery === 'read' ? 'read by agent' : 'waiting'}
+ {event.delivery === 'woken' ? 'woke agent' : event.delivery === 'read' ? 'read by agent' : event.delivery === 'sending' ? 'waking…' : 'waiting'}
{relative(event.received_at)}
diff --git a/web/components/events-shim/events-shim.module.css b/web/components/events-shim/events-shim.module.css
index 07de419a..2b640aba 100644
--- a/web/components/events-shim/events-shim.module.css
+++ b/web/components/events-shim/events-shim.module.css
@@ -341,6 +341,7 @@
color: var(--primary);
}
+.badge_sending,
.badge_pending {
background: rgba(245, 165, 36, 0.16);
color: #c98500;
From 5a573e092301d231f9f1fd04e7c3769f28e774b8 Mon Sep 17 00:00:00 2001
From: khaliqgant
Date: Wed, 11 Nov 2026 11:54:02 -0800
Subject: [PATCH 6/6] docs(web): match events-shim nudge limits and async wake
ack
Signed provider URLs wrap JSON bodies as data whatever the header says;
nudges are trimmed and cut at 6,000 characters; a post is acknowledged
with wake "forwarding" before it is forwarded (relay#2151 7877b8bf4).
Co-Authored-By: Claude Opus 5.5
---
web/app/mcp-events-shim/api/page.tsx | 8 +++++---
web/components/events-shim/EventsShimTry.tsx | 3 ++-
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/web/app/mcp-events-shim/api/page.tsx b/web/app/mcp-events-shim/api/page.tsx
index 34ee884d..66b530df 100644
--- a/web/app/mcp-events-shim/api/page.tsx
+++ b/web/app/mcp-events-shim/api/page.tsx
@@ -46,7 +46,7 @@ curl -X POST "$WEBHOOK_URL" \\
-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" }`;
+# → 202 { "ok": true, "seq": 7, "wake": "forwarding" }`;
const STATUS = `curl ${BASE}/shims/current -H "Authorization: Bearer $MANAGE_TOKEN"
# → { "agent": { "connected": true, "wake": "linked" },
@@ -136,8 +136,10 @@ export default function McpEventsShimApiPage() {
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.
+ 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.
diff --git a/web/components/events-shim/EventsShimTry.tsx b/web/components/events-shim/EventsShimTry.tsx
index 46249349..60ad6a02 100644
--- a/web/components/events-shim/EventsShimTry.tsx
+++ b/web/components/events-shim/EventsShimTry.tsx
@@ -384,7 +384,8 @@ export function EventsShimTry() {
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.
+ 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}
}