# The forward gateway

**Some runtimes hand you `fetch()` and nothing else - no `net.Socket`, no `dns`, no `child_process`, not even the `CONNECT`-tunneling trick that gets Cloudflare Workers by.**

Every one of Whisper's other egress tiers assumes you can open *some* kind of socket; the forward gateway is what's left when you can't.

Vercel's Edge Runtime and Netlify Edge Functions are both built on a V8 isolate with a single primitive: a global `fetch`. No `net`, no `tls`, no `dns.resolve`, no raw `TCPSocket` - not even the restricted `cloudflare:sockets` escape hatch Workers gets. That rules out Tier 1 (WireGuard needs a UDP-capable interface) and Tier 1.5 (SOCKS5/HTTP-CONNECT needs a raw TCP socket you can hand off to a proxy) outright - there is no interface layer to bind, and no socket to negotiate a `CONNECT` handshake over. If your agent's code happens to run in one of these sandboxes, the only wire you have is HTTPS itself, so the egress mechanism has to *be* HTTPS: one request in, one request out, no tunnel in between. That's `forward.whisper.online/forward`.

## The problem: some runtimes have no socket to bind

Every other Whisper connect tier (see [Connect & egress](/docs/connect)) ultimately needs to hold a socket: WireGuard needs a UDP interface, SOCKS5/CONNECT needs a raw TCP connection to negotiate a proxy handshake over before layering TLS to the real origin. A fetch-only sandbox has neither.

**With stock tools** - the pain, no Whisper involved, from inside a Vercel Edge Function or similar isolate:

```ts
export const config = { runtime: "edge" };

export default async function handler() {
  // this is genuinely the entire socket API available to you:
  const r = await fetch("https://api64.ipify.org?format=json");
  return Response.json(await r.json());
  // -> {"ip":"76.76.21.9"}  - Vercel's edge PoP address, nothing to do with any agent
}
```

There's no `dig`, no `ip`, no raw socket to reach for here - the sandbox genuinely only offers `fetch`. Anything that fixes this has to be expressible as one more `fetch` call.

## The mechanism: one HTTPS hop, server-side dial

`<method> https://forward.whisper.online/forward` takes the outbound request you wanted to make, dials it **server-side** from a node that holds the agent's egress binding, sources the connection to the origin from the agent's routable `/128` (the same AnyIP + `IP_FREEBIND` source-binding Tier 1.5 uses over `2a04:2a01::/32` - see [Connect & egress](/docs/connect)), and streams the origin's response back to you verbatim, over the one TLS session you already had open. No `CONNECT`, no nested handshake, no interface, and no JSON envelope wrapping the response - what comes back is the origin's own body, byte for byte, with the origin's own `content-type`.

The credential is the same **egress bearer** every connect tier uses, not your tenant `X-API-Key`: mint one with `op:'connect'` (see [Egress auth](/docs/egress-auth)), then present it as HTTP Basic auth (username `w`, the bearer as the password), and name the target in an `X-Whisper-Target` header rather than a request body:

**With stock tools** - a bearer, a header, and whatever method/body you'd have sent the origin directly:

```bash
# mint an egress bearer for this agent (op:connect), then drive the gateway with curl:
curl -s -u "w:et_..." https://forward.whisper.online/forward \
  -H "X-Whisper-Target: https://api64.ipify.org?format=json"
```

```json
{"ip":"2a04:2a01:f372:9965:e393:6fc6:9920:c2d6"}
```

That's the real relayed body verbatim - not a `{"status":...,"body":...}` wrapper. The response's `X-Whisper-Egress-Source` header (and the body, if the origin echoes your address) both read back the agent's own `/128` - proof the fetch actually left from the identity, not from `forward.whisper.online`'s own address. Confirm it the same way you'd confirm any egress tier ([Connect & egress → "never trust the client's own claim"](/docs/connect#confirming-egress)):

```bash
curl -s -u "w:et_..." https://forward.whisper.online/forward \
  -H "X-Whisper-Target: https://whisper.online/egress-ip" -D - -o -
# X-Whisper-Egress-Source: 2a04:2a01:f372:9965:e393:6fc6:9920:c2d6
# {"ip":"2a04:2a01:f372:9965:e393:6fc6:9920:c2d6"}
```

The method and any body you send the gateway are forwarded to the target as-is - a `POST` with a JSON body arrives at the origin as that same `POST` with that same body, headers included (hop-by-hop headers excluded) - see the field table below for the guardrails on the target itself.

**With Whisper** - `whisper-edge`'s `agentEgress()` picks this transport automatically on runtimes with no socket API, or on request:

```ts
import { agentEgress } from "whisper-edge";

const egress = await agentEgress(env.WHISPER_API_KEY, "agent-ae3936fc69920c2d6", {
  transport: "forward",
});
const res = await egress.fetch("https://api64.ipify.org?format=json");
console.log(egress.transport.address);   // 2a04:2a01:f372:9965:e393:6fc6:9920:c2d6
console.log(await res.text());
egress.close();
```

`egress.fetch()` is a drop-in `fetch` - same signature, same `Response` object - so existing code that calls `fetch(url, init)` needs no rewrite beyond swapping the function reference. The full API surface, including the keyless `verify`/`resolve`/`rdap` calls that don't need this at all, is in the [Edge SDK](/docs/sdk-edge) reference; the Cloudflare-specific walkthrough (Workers use `forward` too, for a different reason - see below) is in [Cloudflare Workers](/docs/cloudflare).

## Why not just tunnel? The SNI problem

It's worth understanding *why* a proxy tunnel doesn't work here rather than treating `forward` as an arbitrary alternative. A `CONNECT` request ([RFC 9110 §9.3.6](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.6)) asks a proxy to open a raw byte pipe to a target host:port; the client then runs its *own* TLS handshake to the real origin inside that pipe, with its own [SNI](https://www.rfc-editor.org/rfc/rfc6066#section-3) naming the real origin. That's two TLS sessions, nested: one (implicit) trust relationship with the proxy at the TCP layer, one real handshake with the origin inside it.

Fetch-only runtimes give you exactly one TLS session, terminated by the platform's own `fetch`, with the SNI and certificate validation already decided before your code runs. There's no hook to say "now open a second handshake inside this one."

`forward.whisper.online` sidesteps the nesting problem entirely: your one real request is *to Whisper*, over one normal TLS session with its own certificate; the second hop, to the actual origin, happens server-side, where a real socket is available. You trade "the proxy tunnels my bytes" for "the proxy makes the request I asked for and hands me back the result" - one hop from your code's point of view either way, but a relay instead of a tunnel.

Cloudflare Workers hit a narrower version of the same wall: `cloudflare:sockets`' `startTls()` pins SNI to the socket you already opened, so a `CONNECT`-style tunnel can't then carry a *second* handshake to the HTTPS target. That's why the [Cloudflare recipe](/docs/cloudflare) uses `transport:"forward"` too, despite Workers technically having a raw-socket API that Vercel/Netlify Edge don't.

## Request and response shape

| Field | In | Required | Notes |
|---|---|---|---|
| `w:<bearer>` | `Authorization: Basic`, base64 | yes | the **egress bearer** from `op:'connect'` (see [Egress auth](/docs/egress-auth)) - not the tenant `X-API-Key`; a missing or wrong bearer gets `407 proxy_authentication_required`, never logged |
| `X-Whisper-Target` | header | yes | the target, an absolute `https://` or `http://` URL - a missing header is `400 missing_target`, a bad scheme or no host is `400 bad_target` |
| method | the request itself | - | whatever method you send the gateway is the method sent to the target - `GET`, `POST`, anything |
| body | the request itself | no | forwarded to the target as the origin request body, verbatim |
| headers | the request itself | no | forwarded to the origin verbatim, minus hop-by-hop headers and the two above |
| response | - | - | the origin's own status, headers, and body, relayed byte for byte - not a JSON envelope - plus an added `X-Whisper-Egress-Source` header carrying the agent's `/128` |

A target resolving to a private, loopback, link-local, or otherwise non-routable address is rejected before any dial is attempted (`403 target_not_allowed`) - the same SSRF guard Tier 1.5's proxy applies, because the mechanism is the same source-binding path, just invoked as a relay instead of a live proxy connection. Every failure mode above is a clean JSON problem body with an `error`/`detail`/`status`, never a bare stack trace or a silent pass-through - consistent with every other Whisper endpoint (see [RDAP](/docs/rdap) for the shape), though this endpoint's own problem body doesn't carry the RFC 7807 `type`/`title` fields some other Whisper endpoints do.

## When to reach for it - and when not to

Use `forward` only where you're actually stuck without a socket:

- Vercel Edge Functions, Netlify Edge Functions - no raw socket API at all.
- Cloudflare Workers, specifically for HTTPS targets - `cloudflare:sockets` exists but can't nest the second TLS handshake (see above).
- Any other fetch-only sandbox (a plugin runtime, a WASM host) with the same constraint.

Prefer Tier 1 (WireGuard) or Tier 1.5 (SOCKS5/CONNECT) everywhere else: they're a single relay away from your code (no JSON-wrapped round trip through Whisper's own infrastructure per request), they stream natively instead of buffering into a JSON body, and they don't add a second network hop's latency to every fetch. `forward` is the fallback for the sandboxes that leave you no other choice, not a general-purpose replacement for a real proxy - see [Connect & egress](/docs/connect) for the full tier comparison.

## Reliability caveat: the egress binding is node-local today

This is the one operational fact to know before you build on `forward` at scale: the state that lets a node source-bind a connection to a given agent's `/128` lives on whichever node allocated that agent's identity - it is not (yet) replicated to every node behind the gateway's name. Concretely, `forward.whisper.online` today resolves to a single node rather than round-robin anycast across the fleet - check it yourself:

```bash
dig +short forward.whisper.online AAAA
# -> 2a04:2a00::80              (one answer, one node - not a round-robin set)
dig +short forward.whisper.online A
# -> 78.141.218.153
```

If a future deployment starts answering with multiple nodes' addresses before that binding state is shared cluster-wide, a request that happens to land on a node that never allocated the agent's binding will fail closed (`502`) rather than silently egress from the wrong address - Whisper never guesses at whose `/128` to source from. The safe assumption for anything you build today: treat `forward.whisper.online` as a single logical endpoint, not a load-balanced anycast pool, until this doc says otherwise. `whisper-edge`'s `forward` transport already retries transiently-failed calls rather than surfacing a one-off `502` to your code, but a design that fans requests for the *same* agent across multiple independent client processes should still expect them to land consistently on one node for now.

## Next

[Connect & egress](/docs/connect) - the full tier comparison (WireGuard, SOCKS5, resolver-only) this gateway fits underneath · [Cloudflare Workers](/docs/cloudflare) - the recipe that uses `transport:"forward"` for a different reason (SNI nesting on `cloudflare:sockets`) · [Edge SDK](/docs/sdk-edge) - the full `whisper-edge` API including `agentEgress()`.
