# Test a local app

Source: https://webagent-docs-staging.lookover.ai/guides/local-apps

> Why localhost does not work from a cloud browser, and how to test an app on your machine or network with a tunnel or your own proxy.

The browser that webagent runs lives in the cloud. It cannot open `http://localhost:3000` on your laptop, because that address means "this machine", and "this machine" is our server.

This page explains what happens if you try, and the two ways that work:

1. **A tunnel** to your dev server. Best for one developer on a laptop.
2. **Your own proxy** plus an allow list. For an app on a company network.

If your app is already on a public preview or staging URL, skip all of this and use that URL. See [Protected sites](/guides/protected-sites) if it is behind a gate.

## Why localhost fails [#why-localhost-fails]

Every navigation passes an SSRF guard. The guard refuses `localhost`, `*.localhost`, `*.internal`, loopback addresses (`127.0.0.1`), link-local and cloud metadata addresses, and private addresses such as `192.168.x.x` or `10.x.x.x`. No option you can send turns it off. It protects our network and yours.

If you pass such a URL as `startUrl`, session creation fails with HTTP 403:

```json title="create_session with startUrl http://localhost:3000"
{
  "error": {
    "code": "navigation_blocked",
    "message": "startUrl http://localhost:3000 was blocked (private, link-local and metadata addresses are not reachable): navigation blocked: internal host",
    "details": { "url": "http://localhost:3000", "reason": "internal host", "fields": ["startUrl"] }
  }
}
```

The `reason` changes with the address:

| Address | `reason` |
|---|---|
| `localhost`, `*.localhost`, `*.internal` | `internal host` |
| `127.0.0.1` and other loopback or link-local addresses | `blocked address 127.0.0.1` |
| `192.168.1.20`, `10.0.0.5` | `private address 192.168.1.20` |

If the agent calls `navigate` inside a running session, the same refusal comes back as `navigation blocked: internal host`. A click or redirect onto a refused host comes back as `navigation to <host> was blocked: <reason>`. A blocked test is a test that could not run. It is not a bug in your app.

## Option 1: a tunnel [#option-1-a-tunnel]

A tunnel gives your local port a public HTTPS address. The cloud browser opens that address, and the tunnel forwards the traffic to your machine. The tunnel host resolves to a public IP, so the guard lets it through.

> **Warning:** A tunnel URL is public. Anyone who has the URL can open your dev server, and the agent can click anything on it. Use test data. Do not tunnel an app that holds real customer data. Stop the tunnel when the test is done.

### 1. Start your app [#start-your-app]

Run your dev server as usual and note the port, for example `3000`.

### 2. Start a tunnel in a second terminal [#start-a-tunnel-in-a-second-terminal]

Pick one of the two tools.

**cloudflared**

Install `cloudflared` (see the [Cloudflare quick tunnel page](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)), then run:

```bash
cloudflared tunnel --url http://localhost:3000
```

It prints a temporary `https://<random-words>.trycloudflare.com` URL. You need no Cloudflare account. The URL changes every time and stops working when you stop `cloudflared`. A quick tunnel does not support server-sent events (SSE) and handles up to 200 requests at once.

**ngrok**

Install ngrok, add your authtoken once, then start the tunnel:

```bash
ngrok config add-authtoken <your-ngrok-authtoken>
ngrok http 3000
```

ngrok prints a public `https://...` URL for port 3000. The free plan puts a browser warning page in front of HTML pages. Section "ngrok warning page" below shows how to skip it.

### 3. Check the URL yourself [#check-the-url-yourself]

Open the tunnel URL in your own browser. You should see your app. If your dev server answers with a "host not allowed" error, add the tunnel host to its allow list. In Vite that is `server.allowedHosts`.

### 4. Give the URL to the agent [#give-the-url-to-the-agent]

In Claude Code or Codex, ask for the test with the tunnel URL, not `localhost`:

```text
Smoke test https://<random-words>.trycloudflare.com. Log in with the test user and
check that the dashboard shows "Welcome". Give me the verdict and the failed steps.
```

Or call `smoke_test` yourself with `url` set to the tunnel URL, or `run_task` with `startUrl`. See [MCP tools](/mcp/tools).

### 5. Stop the tunnel [#stop-the-tunnel]

Press Ctrl+C in the tunnel terminal. The URL stops working at once.

We ran a webagent session through an ngrok free tunnel to a static page. Without the `ngrok-skip-browser-warning` header the browser saw ngrok's warning page ("You are about to visit: ..."). With the header, in the session `headers` option, it saw the page. We have not run the `cloudflared` steps.

### ngrok warning page [#ngrok-warning-page]

The ngrok free plan shows an interstitial page in front of all HTML browser traffic. The agent would see that page instead of your app. ngrok skips it for requests that carry a header named `ngrok-skip-browser-warning` (any value). ngrok's [free plan limits](https://ngrok.com/docs/pricing-limits/free-plan-limits) page describes it.

Send that header with the session `headers` option. It goes only to the host you name. `run_task` and `smoke_test` take it directly, so one call is enough. The steps are in [Protected sites](/guides/protected-sites#ngrok-browser-warning).

## Option 2: your own proxy and an allow list [#option-2-your-own-proxy-and-an-allow-list]

If the app lives on a private network, for example a staging server at `10.0.0.5`, there is one supported route. You run a proxy that can reach the app. The session sends its traffic through your proxy, and an allow list tells the guard which private hosts to accept.

```json title="Body of POST /v1/sessions"
{
  "proxyUrl": "http://user:pass@proxy.example.com:3128",
  "allowHosts": ["10.0.0.5"],
  "startUrl": "http://10.0.0.5:8080"
}
```

How it works:

- `allowHosts` takes hosts, IPs or CIDRs. It is only valid together with `proxyUrl`, or with a saved proxy (`proxyId`). Without one of them the request fails with `invalid_input`.
- The proxy URL is `http://user:pass@host:port` or `socks5://host:port`.
- Private hosts are reached through your proxy, never from our network. Our own network stays blocked even if you list it.
- Loopback, link-local and metadata addresses stay blocked, even in `allowHosts`. This option cannot open `localhost`.
- Our servers must be able to reach your proxy over the internet. A proxy behind your laptop's NAT does not work. A proxy on a company network with a public address and a password does.

`run_task` takes `proxyUrl` and `allowHosts` itself, so one call is enough. `smoke_test` and a skill run take them inside their `session` object:

```json title="smoke_test arguments"
{
  "url": "http://10.0.0.5:8080",
  "expectText": "Welcome",
  "session": { "proxyUrl": "http://user:pass@proxy.example.com:3128", "allowHosts": ["10.0.0.5"] }
}
```

`run_task` refuses these options together with `sessionId`: put them on `create_session` instead. See [Sessions](/concepts/sessions) and [Skills](/concepts/skills).

We have not run this route against a real corporate proxy for this page.

## Which one to pick [#which-one-to-pick]

| Your app is | Use |
|---|---|
| On a public preview or staging URL | That URL. No tunnel. |
| On your laptop | A tunnel. |
| On a private network, and you can run a public proxy | `proxyUrl` and `allowHosts`. |
| Public but behind a WAF, Vercel protection or Cloudflare Access | [Protected sites](/guides/protected-sites). |
