---
description: Preview your agent's dev server through internal hostnames without exposing it to the public Internet. No prebuilt harness adapter is required.
title: "Agents SDK: Preview dev servers in the browser without public URLs"
image: https://developers.cloudflare.com/changelog/post/2026-10-28-browser-internal-hostnames/og.png?v=9521c50736fe9a1e
---

[Skip to content](#main-content)

[View RSS feeds](https://developers.cloudflare.com/fundamentals/new-features/available-rss-feeds/) [ Subscribe to RSS](https://developers.cloudflare.com/changelog/rss/index.xml)

[Back to all posts](https://developers.cloudflare.com/changelog)

October 28, 2026

## Agents SDK: Preview dev servers in the browser without public URLs

[Agents](https://developers.cloudflare.com/agents/) [Browser Run](https://developers.cloudflare.com/browser-run/)

Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/changelog/post/2026-10-28-browser-internal-hostnames/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

Agents that build websites need to check their work in a real browser. Internal hostnames let your agent's browser preview a dev server running in a container without exposing that server to the public Internet.

With internal hostnames, the [persistent browser](https://developers.cloudflare.com/agents/tools/browser/) in the [Cloudflare Agents SDK](https://developers.cloudflare.com/agents/) can open sites that only your agent serves. Give the browser hostnames such as `preview.internal`, and its requests to them go to your agent instead of the Internet. The model can check a dev server in its sandbox, a folder in its workspace, or another Worker in the same browser it uses for the web, and nothing is exposed publicly.

![An animated diagram of three kinds of hostname: example.com goes out to the Internet; the model points preview.internal at port 5173 with browser.assignHost(), then the browser opens it and your agent serves it from the sandbox; and api.internal goes straight to another Worker, skipping your agent](https://developers.cloudflare.com/cdn-cgi/image/onerror=redirect,width=960,height=524,format=svg/_astro/browser-internal-hostnames.FAVHfafR.svg)

Each internal host has a `fetch` function that runs in your Durable Object, so it can use the object's bindings and state. A host can also send its requests straight to another Worker through a service binding. Add a `target` schema to a host, and the model chooses what it serves, such as which port or which folder.

Internal hostnames are a capability of the browser, independent of your agent harness. Use the built-in adapters for Pi Durable, AI SDK, and TanStack AI, or connect another harness by implementing a tool with [`browser.session()`](https://developers.cloudflare.com/agents/tools/browser/#use-another-harness). No prebuilt adapter is required.

This example adds a private dev-server preview to a [Pi Durable agent](https://developers.cloudflare.com/agents/tools/browser/#use-it-with-pi-durable). Start with that guide's packages, bindings, and request handler, and install [Agents SDK v0.31.0 ↗︎](https://github.com/cloudflare/agents/releases/tag/agents%400.31.0) or later. The example has three parts:

- **Browser configuration:** `internalHosts` defines how the browser reaches the dev server.
- **Durable Object wiring:** Lifecycle and `fetchInternalHost()` connect the browser to your application.
- **Harness integration:** `browserTool` from `agents/browser/pi` registers the tool with Pi.

```js
import { DurableObject } from "cloudflare:workers";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { Browser, browserRun } from "agents/browser";
import { browserTool } from "agents/browser/pi";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { z } from "zod";
// Your own code: reach a port on your sandbox and return its Response.
import { fetchSandboxPort } from "./hosts";

// Browser Run calls back into your Worker through this entrypoint.
export { InternalHostRouter } from "agents/browser";

export class BrowserAssistant extends DurableObject {
	ai = createAI({ binding: this.env.AI });
	registry = createRegistry();

	browser = new Browser({
		provider: browserRun(this.env.BROWSER, {
			internalHosts: {
				router: this.ctx.exports.InternalHostRouter,
				hosts: {
					"preview.internal": {
						description: "Your dev servers",
						// What the model passes to browser.assignHost() to choose
						// what this host serves. fetch receives it as its second argument.
						target: z.object({ port: z.number().int().min(1024) }),
						fetch: (request, { port }) => fetchSandboxPort(port, request),
					},
				},
			},
		}),
	});

	harness = new PiHarness({
		harness: ({ storage, context, onWake }) => {
			const models = createModels();
			models.setProvider(this.ai.provider);
			this.registry.install({
				name: "browser",
				tools: [
					browserTool({ browser: this.browser, loader: this.env.LOADER }),
				],
			});
			return Harness.open(
				storage,
				{ models, registry: this.registry, onWake },
				context,
			);
		},
		defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
	});

	lifecycle = Lifecycle.install(this).use(this.browser).use(this.harness);

	// InternalHostRouter calls this method to reach your Browser.
	fetchInternalHost(message) {
		return this.browser.fetchInternalHost(message);
	}

	async ask(prompt) {
		const { text } = await this.harness.prompt(prompt);
		return text;
	}
}
```

```ts
import { DurableObject } from "cloudflare:workers";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { Browser, browserRun, type InternalHostMessage } from "agents/browser";
import { browserTool } from "agents/browser/pi";
import { PiHarness } from "agents/harness/pi";
import { Lifecycle } from "agents/lifecycle";
import { createAI } from "agents/models/pi-ai";
import { z } from "zod";
// Your own code: reach a port on your sandbox and return its Response.
import { fetchSandboxPort } from "./hosts";

// Browser Run calls back into your Worker through this entrypoint.
export { InternalHostRouter } from "agents/browser";

export class BrowserAssistant extends DurableObject<Env> {
	ai = createAI({ binding: this.env.AI });
	registry = createRegistry();

	browser = new Browser({
		provider: browserRun(this.env.BROWSER, {
			internalHosts: {
				router: this.ctx.exports.InternalHostRouter,
				hosts: {
					"preview.internal": {
						description: "Your dev servers",
						// What the model passes to browser.assignHost() to choose
						// what this host serves. fetch receives it as its second argument.
						target: z.object({ port: z.number().int().min(1024) }),
						fetch: (request, { port }) => fetchSandboxPort(port, request),
					},
				},
			},
		}),
	});

	harness = new PiHarness({
		harness: ({ storage, context, onWake }) => {
			const models = createModels();
			models.setProvider(this.ai.provider);
			this.registry.install({
				name: "browser",
				tools: [
					browserTool({ browser: this.browser, loader: this.env.LOADER }),
				],
			});
			return Harness.open(
				storage,
				{ models, registry: this.registry, onWake },
				context,
			);
		},
		defaults: { model: this.ai("@cf/zai-org/glm-5.3-flash") },
	});

	lifecycle = Lifecycle.install(this).use(this.browser).use(this.harness);

	// InternalHostRouter calls this method to reach your Browser.
	fetchInternalHost(message: InternalHostMessage) {
		return this.browser.fetchInternalHost(message);
	}

	async ask(prompt: string) {
		const { text } = await this.harness.prompt(prompt);
		return text;
	}
}
```

To use another harness, keep the browser configuration. Choose a [browser-tool adapter](https://developers.cloudflare.com/agents/tools/browser/#use-another-harness) if one is available, or implement a tool using `browser.session()`.

`browserTool` tells the model about each host, including the schema of its `target`. Because `preview.internal` has a `target` schema, the model can choose which port it serves. To check its dev server, the model calls `browser.assignHost()` with a target that matches the schema, such as `{ port: 5173 }`, and opens the URL it gets back:

```js
const { url } = await browser.assignHost({
	host: "preview.internal",
	target: { port: 5173 },
});
await browser.cdp({
	method: "Page.navigate",
	params: { url },
	sessionId: "active",
});
```

The target is checked against the schema and stored, so it lasts across turns, browser restarts, and redeploys. Each request to `http://preview.internal/` then calls the host's `fetch` function with `{ port: 5173 }`.

This release also simplifies `browserTool`. It runs the model's code in its own [Dynamic Worker](https://developers.cloudflare.com/dynamic-workers/) and no longer needs a `CodemodeRuntime` export. Refer to the [v0.31.0 upgrade notes ↗︎](https://github.com/cloudflare/agents/releases/tag/agents%400.31.0) if you already use `browserTool`.

For the complete example, limits, and security guidance, refer to [Internal hostnames](https://developers.cloudflare.com/agents/tools/browser/#internal-hostnames).

```json
{"@context":"https://schema.org","@type":"BlogPosting","@id":"https://developers.cloudflare.com/changelog/post/2026-10-28-browser-internal-hostnames/#page","headline":"Agents SDK: Preview dev servers in the browser without public URLs","description":"Preview your agent's dev server through internal hostnames without exposing it to the public Internet. No prebuilt harness adapter is required.","url":"https://developers.cloudflare.com/changelog/post/2026-10-28-browser-internal-hostnames/","inLanguage":"en","image":"https://developers.cloudflare.com/changelog/post/2026-10-28-browser-internal-hostnames/og.png?v=9521c50736fe9a1e","dateModified":"2026-10-28","datePublished":"2026-10-28","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
```
