# 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`](/docs/cloudflare)) and AWS Lambda / Supabase Edge. This page is the Vercel/Netlify/Deno tour; [`/docs/sdk-edge`](/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:**

```bash
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):**

```ts
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:

```ts
// 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.

```bash
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:

```ts
// 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 } };
```

```bash
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:

```ts
// 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 });
});
```

```bash
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):

```bash
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:**

```ts
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:

```bash
curl -s https://whisper.online/egress-ip
# {"ip":"<must equal the agent's own /128>"}
```

```ts
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.

```bash
npm i whisper-edge                                        # Vercel (Node) / Netlify Functions / Lambda
```
```ts
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](https://github.com/whisper-sec/whisper-edge). Every shipped integration: [`/integrations`](/integrations).

---

**Next:** [`/docs/sdk-edge`](/docs/sdk-edge) — the full `whisper-edge` API reference · [`/docs/cloudflare`](/docs/cloudflare) — the same SDK on Workers, plus the Agents-SDK MCP tool set.
