Vercel · Netlify · Deno
Verifiable agent identity — and real, source-bound egress — from a runtime that can't open a socket.
Edge and serverless functions are the worst place to prove who is calling out: the sandbox gives you a fetch and nothing else, the outbound IP is a shared, rotating address you don't control, and by the time a downstream API sees the request every identifying signal from your own infrastructure is gone. You can't node:net.connect() a proxy on Vercel Edge or Netlify Edge — there is no net module, no raw socket, no way to bind a source address at all. Most "route your agent's traffic" advice assumes a runtime with a socket API. These don't. An egress path for this world has to be fetch-shaped all the way down — not a VPN client wedged in where it doesn't fit.
whisper-edge is a zero-dependency TypeScript SDK that gives a function on Vercel, Netlify, or Deno Deploy the same two things a Whisper agent gets anywhere else: a cryptographically-checkable identity (no key needed) and, with your key, real egress sourced from a routable Whisper /128 — even when the runtime has no socket to open.
The same SDK runs unchanged on Cloudflare Workers (/docs/cloudflare) and AWS Lambda / Supabase Edge. This page is the Vercel/Netlify/Deno tour; /docs/sdk-edge is the full API reference.
Keyless first: verify without installing anything
Every agent's identity is a public fact, checkable with tools you already have.
With stock tools:
curl -s "https://rdap.whisper.online/verify-identity?ip=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4" | jq .
# { "is_whisper_agent": true, "fqdn": "acef2002a323d40d4.demo.agents.whisper.online.",
# "dane_ok": true, "jws_ok": true, "operator": "Whisper Security" }
curl -s -H 'accept: application/rdap+json' \
https://rdap.whisper.online/ip/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 | jq . # RFC 9083
With Whisper (no key needed for this call):
import { verify, resolve } from "whisper-edge";
if (await verify("2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4")) { /* a real Whisper agent */ }
const who = await resolve("2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4");
// { fqdn, operator, tenant, daneOk: true, jwsOk: true, rdapUrl }
verify/resolve run the whole chain server-side: reverse-DNS PTR → forward-confirm AAAA → the DANE-EE TLSA record (RFC 6698) pinning the agent's certificate under a DNSSEC-signed zone → the JWS identity document (RFC 7515). dane_ok is the field that matters — it means the pin chains to a signed zone, not a CA's say-so. One HTTPS GET, identical whether called from a laptop or a Vercel Edge function.
Egress: the part that actually needs a runtime-specific answer
Verifying someone else is a keyless GET. Egressing as your own agent — so a peer's reverse-DNS resolves your request back to a /128 you control — needs a transport, and Vercel/Netlify/Deno diverge here. agentEgress() detects the runtime and picks the best one automatically:
| Runtime | Egress transport | Bearer on the wire |
|---|---|---|
| Vercel Node functions, Netlify Functions, Lambda | node:net/node:tls CONNECT tunnel |
encrypted end-to-end (nested TLS) |
| Deno Deploy, Supabase Edge | Deno.connect + Deno.startTls CONNECT tunnel |
clear leg to the proxy¹ |
| Vercel Edge, Netlify Edge — no raw-socket API | fetch-forward gateway, one HTTPS hop through forward.whisper.online/forward |
encrypted (plain HTTPS to the gateway) |
¹ Deno can't layer TLS-inside-TLS, so the CONNECT preamble rides the clear leg (tokenProtected: false); Node nests TLS and keeps it encrypted throughout.
Vercel
Vercel ships two runtimes under one name, and they need different transports — agentEgress handles both without you choosing:
// api/whisper.ts — Vercel Node function (has node:net)
import { resolve, rdap, control, agentEgress } from "whisper-edge";
export default async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
const key = process.env.WHISPER_API_KEY;
if (url.searchParams.has("egress")) {
if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
const egress = await agentEgress(key); // node:net CONNECT tunnel, auto-selected
const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
}
const op = url.searchParams.get("op");
if (op) {
if (!key) return Response.json({ error: "set WHISPER_API_KEY to use the control plane" }, { status: 401 });
const res = op === "list" ? await control(key).list() : await control(key).agents(op, {});
return Response.json({ op, records: res.records });
}
const addr = url.searchParams.get("addr");
if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key) | ?op=list (key)\n", { status: 400 });
const identity = await resolve(addr);
return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
}
Add export const config = { runtime: "edge" }; and deploy the same file to the Edge runtime — there is no node:net there, so agentEgress transparently falls back to the fetch-forward gateway. Nothing at the call site changes.
vercel env add WHISPER_API_KEY # paste your whisper_live_… key, never commit it
vercel deploy --prod
curl "https://<project>.vercel.app/api/whisper?egress=1"
# {"agent":"2a04:2a01:...","sourcedFromAgent":true}
Netlify
Netlify Functions run on Node (raw CONNECT tunnel); Netlify Edge Functions run on a Deno-based runtime with no raw socket, so the same auto-fallback applies — same import, same agentEgress() call:
// netlify/functions/whisper.mts
import { resolve, rdap, control, agentEgress } from "whisper-edge";
export default async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const key = Netlify.env.get("WHISPER_API_KEY");
if (url.searchParams.has("egress")) {
if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
const egress = await agentEgress(key);
const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
}
const addr = url.searchParams.get("addr");
if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key)\n", { status: 400 });
const identity = await resolve(addr);
return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
};
declare const Netlify: { env: { get(k: string): string | undefined } };
netlify env:set WHISPER_API_KEY whisper_live_…
netlify deploy --prod
curl "https://<site>.netlify.app/.netlify/functions/whisper?addr=2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"
Deno Deploy
Deno's isolate exposes Deno.connect/Deno.startTls, so agentEgress opens a real CONNECT tunnel there too — with the clear-leg caveat from the table above:
// main.ts
import { resolve, rdap, agentEgress } from "npm:whisper-edge@^0.3.0";
Deno.serve(async (req: Request) => {
const url = new URL(req.url);
const key = Deno.env.get("WHISPER_API_KEY");
if (url.searchParams.has("egress")) {
if (!key) return Response.json({ error: "set WHISPER_API_KEY to egress" }, { status: 401 });
const egress = await agentEgress(key); // Deno.connect + Deno.startTls tunnel
const seen = await (await egress.fetch("https://whisper.online/egress-ip")).json();
return Response.json({ agent: egress.transport.address, sourcedFromAgent: seen.ip === egress.transport.address });
}
const addr = url.searchParams.get("addr");
if (!addr) return new Response("usage: ?addr=<agent /128> | ?egress (key)\n", { status: 400 });
const identity = await resolve(addr);
return Response.json({ address: addr, identity, rdap: identity ? await rdap(addr) : null });
});
deployctl deploy --project=my-agent main.ts --env WHISPER_API_KEY=whisper_live_…
curl "https://my-agent.deno.dev/?egress=1"
deno run --allow-net --allow-env main.ts # or run it locally first
Under the hood: the fetch-forward gateway
On a socket-having runtime, egress is a literal CONNECT request (RFC 9110 §9.3.6) to the egress proxy, then the real request tunneled inside it — what curl -x or an HTTP client's ProxyAgent does. A sandbox with no socket at all can't do that, so the fetch-forward gateway inverts the shape: one ordinary HTTPS POST, and the server does the egressing.
With stock tools (the exact request agentEgress makes on your behalf — runnable from any shell for debugging):
curl -s -X POST https://forward.whisper.online/forward \
-H "Authorization: Basic $(printf 'w:whisper_live_your_key' | base64)" \
-H "X-Whisper-Target: https://whisper.online/egress-ip" | jq .
# -> the target's response body; reply carries X-Whisper-Egress-Source: <your agent's /128>
With Whisper:
const egress = await agentEgress(apiKey, undefined, { transport: "forward" }); // force it anywhere
await egress.fetch("https://whisper.online/egress-ip");
The gateway authenticates the Basic credential (RFC 7617) as the agent's egress bearer, opens the real outbound connection server-side — source-bound to the agent's /128 via the same address binding every Whisper egress tier uses — and streams the response back stamped with the /128 it egressed from. agentEgress selects this path automatically whenever the runtime has no raw socket (exactly the Vercel Edge / Netlify Edge case), and retries a 407 a few times with a short capped backoff: a freshly-minted egress token needs a brief window to propagate to every gateway node, and a 407 in that window means "not yet known here," not "bad token."
Prove it, don't take the SDK's word for it
Whatever transport was used, the same keyless echo confirms the source address:
curl -s https://whisper.online/egress-ip
# {"ip":"<must equal the agent's own /128>"}
const seen = (await (await egress.fetch("https://whisper.online/egress-ip")).json()).ip;
seen === egress.transport.address; // true, or the SDK already threw
Errors and config
Every call throws a WhisperError carrying the server's exact .status/.detail — never an opaque 500 — or, for verify/resolve/rdap, simply returns false/null for "not an agent," which isn't an error. Every call accepts { timeoutMs, signal, fetch, endpoints }; agentEgress additionally takes { tier, transport, forwardUrl, retries, retryDelayMs }. Every example above uses the zero-argument defaults.
npm i whisper-edge # Vercel (Node) / Netlify Functions / Lambda
import { agentEgress } from "npm:whisper-edge@^0.3.0"; // Deno Deploy / Supabase Edge
Source and the full example set (vercel, vercel-edge, netlify, deno, cloudflare, lambda, supabase): github.com/whisper-sec/whisper-edge. Every shipped integration: /integrations.
Next: /docs/sdk-edge — the full whisper-edge API reference · /docs/cloudflare — the same SDK on Workers, plus the Agents-SDK MCP tool set.