Agents SDK: Preview dev servers in the browser without public URLs
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 in the Cloudflare Agents SDK 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.
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(). No prebuilt adapter is required.
This example adds a private dev-server preview to a Pi Durable agent. Start with that guide's packages, bindings, and request handler, and install Agents SDK v0.31.0 ↗︎ or later. The example has three parts:
- Browser configuration:
internalHostsdefines how the browser reaches the dev server. - Durable Object wiring: Lifecycle and
fetchInternalHost()connect the browser to your application. - Harness integration:
browserToolfromagents/browser/piregisters the tool with Pi.
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;
}
}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 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:
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 and no longer needs a CodemodeRuntime export. Refer to the v0.31.0 upgrade notes ↗︎ if you already use browserTool.
For the complete example, limits, and security guidance, refer to Internal hostnames.