---
title: "Run AI agents in private browser profiles · Docs"
description: "Connect Claude Code, Cursor, Codex or OpenClaw to Donut Browser's MCP server, or run a local agent over CDP with a proxy per profile and a local model."
url: "https://donutbrowser.com/docs/ai-agents/"
---

# Run AI agents in private browser profiles

**Donut Browser gives each AI agent a browser profile with its own fingerprint, cookies and proxy.** Donut contains no AI model and sends nothing to model providers: you bring the agent. Connect it in one of two ways.

|                      | Remote MCP                                                                    | Local REST API and CDP                                                              |
| -------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Agent                | An MCP client, such as Claude Code, Claude Desktop, Cursor, Codex or OpenClaw | Your own script or agent on the same computer                                       |
| Where page data goes | Through Donut's relay at `api.donutbrowser.com` to your agent                 | From the browser to your script, on `127.0.0.1`                                     |
| Plan                 | Solo for profiles and proxies; Pro, Team or Enterprise for browser control    | Profile and proxy management on every plan; launching needs Pro, Team or Enterprise |

Your agent's model receives what your agent sends it. With a local model on the local path, page data stays on your computer. For what agents do with this, see [AI agents](https://donutbrowser.com/use-cases/ai-agents).

## Connect an MCP client

1. Sign in to the desktop app on Solo or above. Open **Integrations** in the sidebar (Cmd/Ctrl+I), select the **Remote MCP** tab and turn on **Remote control**.
2. Under **Clients**, click **Add** next to your client. The app creates a `dmk_` credential if you have none, and adds a server named `donut-browser` to the client's config. For Claude Desktop, it installs an extension.
3. To test it, ask the agent to list your Donut profiles.

![Integrations page, Remote MCP tab. Remote control is on and connected as a desktop instance. Below: the credential prefix with a Rotate button, the remote MCP endpoint https://api.donutbrowser.com/api/mcp, an example curl request, and the MCP clients found on this computer: Claude Code (configured), Claude Desktop, Cursor, VS Code and Codex, each with an Add button, plus Add to all.](https://donutbrowser.com/img/docs/integrations-mcp.webp "Connect an AI client to Donut in one click.")

| Client         | What the app writes                                            |
| -------------- | -------------------------------------------------------------- |
| Claude Code    | `~/.claude.json`                                               |
| Claude Desktop | An extension in the `Claude Extensions` folder of Claude's app |
| Cursor         | `~/.cursor/mcp.json`                                           |
| Codex          | `config.toml` in `~/.codex`, or in `$CODEX_HOME` if set        |

The same button works for 18 more clients, among them VS Code, Zed, Windsurf, Gemini CLI, GitHub Copilot CLI, Goose, OpenCode and MCPorter. The app edits each config file in place and keeps your other servers.

Every tool schema costs the agent context. To load fewer, choose groups under **Tools agents see** in the app, or name [toolsets](https://donutbrowser.com/docs/remote-mcp#toolsets) on the URL. What the relay can read is listed in [What the relay sees](https://donutbrowser.com/docs/remote-mcp#what-the-relay-sees).

### Add it by hand

Create a credential on your [account page](https://donutbrowser.com/account) or in the app, then add the endpoint to your client.

Claude Code:

```bash
claude mcp add --transport http --scope user donut-browser \
  https://api.donutbrowser.com/api/mcp \
  --header "Authorization: Bearer dmk_…"
```

Cursor, in `~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "donut-browser": {
      "url": "https://api.donutbrowser.com/api/mcp",
      "headers": { "Authorization": "Bearer dmk_…" }
    }
  }
}
```

Codex, in `~/.codex/config.toml`. Codex reads the credential from the environment variable you name:

```toml
[mcp_servers.donut-browser]
url = "https://api.donutbrowser.com/api/mcp"
bearer_token_env_var = "DONUT_MCP_TOKEN"
```

### OpenClaw

The app does not write OpenClaw's config. Add the server with the OpenClaw CLI, and set `DONUT_MCP_TOKEN` to your credential in the environment that runs OpenClaw. The single quotes keep your shell from expanding `${DONUT_MCP_TOKEN}`; OpenClaw resolves it.

```bash
openclaw mcp set donut-browser '{"url":"https://api.donutbrowser.com/api/mcp","transport":"streamable-http","headers":{"Authorization":"Bearer ${DONUT_MCP_TOKEN}"}}'
openclaw mcp doctor donut-browser --probe
```

To give OpenClaw's own browser tool a Donut profile and its proxy, see [OpenClaw over CDP](https://donutbrowser.com/docs/ai-agents/#openclaw-over-cdp).

## Run the agent locally

The local REST API listens on `127.0.0.1:10108`. It launches a profile and returns a DevTools port, which the browser also binds to `127.0.0.1`. Your script attaches to that port with Playwright or the MIT-licensed [`wayfern`](https://donutbrowser.com/docs/wayfern) package. Page content and screenshots go from the browser to your script and do not pass through Donut's servers. The browser's requests to websites go out through the profile's proxy.

1. Download Wayfern in the app if you have not yet.
2. Open **Integrations** in the sidebar (Cmd/Ctrl+I). On the **Local API** tab, turn on **Enable Local API Server** and copy the token.
3. Export it as `DONUT_API_KEY`. See [Authentication](https://donutbrowser.com/docs/authentication).

### Create a proxy and a profile

```bash
# A proxy: http, https, socks4 or socks5.
curl -sS -X POST http://127.0.0.1:10108/v1/proxies \
  -H "Authorization: Bearer $DONUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"acme-us","proxy_settings":{"proxy_type":"socks5","host":"proxy.example.com","port":1080,"username":"user","password":"secret"}}'
# {"id":"…","name":"acme-us",…}

# A profile that browses through it. Wayfern generates its fingerprint.
curl -sS -X POST http://127.0.0.1:10108/v1/profiles \
  -H "Authorization: Bearer $DONUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"acme-sales","browser":"wayfern","proxy_id":"<proxy id>","tags":["acme"]}'
# {"profile":{"id":"…",…}}
```

Add `"temporary": true` for a profile that keeps its data in memory and is deleted when its browser stops. Profile and proxy management works on every plan.

### Launch it and attach

```bash
curl -sS -X POST http://127.0.0.1:10108/v1/profiles/<profile id>/run \
  -H "Authorization: Bearer $DONUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"headless":true}'
# {"profile_id":"…","remote_debugging_port":53127,"headless":true,"attached":false}
```

- `url` opens a page at launch. Only `http`, `https` and `about:blank` are accepted.
- The port is a free port, chosen at each launch. If the profile is already open, the call returns its current port with `attached: true` and does not restart it.
- Without Pro, Team or Enterprise, the call answers `402`.

```js
import { chromium } from "wayfern"; // or from "playwright"

const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());
```

`wayfern` re-exports Playwright and adds human-like mouse movement, typing and scrolling. `POST /v1/profiles/{id}/kill` stops the profile.

### A fully local agent with Ollama

This script runs the model with [Ollama](https://ollama.com) on the same computer. The model reads each page and picks a link to open or gives the answer. Ollama, the page text and the browser stay on your computer, so neither Donut nor a model provider receives the page data.

```js
// agent.mjs. Run: npm install wayfern playwright, then
// node agent.mjs <profile id> <start url>
import { chromium } from "wayfern";

const API = "http://127.0.0.1:10108";
const OLLAMA = "http://127.0.0.1:11434/api/chat";
const MODEL = process.env.OLLAMA_MODEL ?? "qwen3"; // any model you pulled
const [profileId, startUrl] = process.argv.slice(2);
const goal = "Find the price of the cheapest plan.";
const headers = {
  Authorization: `Bearer ${process.env.DONUT_API_KEY}`,
  "Content-Type": "application/json",
};

// Launch the profile. It browses through its own proxy.
const run = await fetch(`${API}/v1/profiles/${profileId}/run`, {
  method: "POST",
  headers,
  body: JSON.stringify({ headless: true, url: startUrl }),
});
if (!run.ok) throw new Error(`run: HTTP ${run.status}`);
const { remote_debugging_port: port } = await run.json();

const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());

// One step: the local model reads the page, then opens a link or answers.
async function decide() {
  const text = (await page.innerText("body")).slice(0, 6000);
  const links = await page.$$eval("a[href^='http']", (anchors) =>
    anchors.slice(0, 40).map((a) => ({ text: a.innerText.trim(), href: a.href })),
  );
  const response = await fetch(OLLAMA, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: MODEL,
      stream: false,
      format: "json",
      messages: [
        {
          role: "system",
          content:
            'Reply with JSON only: {"action":"open","href":"<a link from the list>"} or {"action":"answer","text":"<the answer>"}.',
        },
        {
          role: "user",
          content: `Goal: ${goal}\nURL: ${page.url()}\nLinks: ${JSON.stringify(links)}\nPage text:\n${text}`,
        },
      ],
    }),
  });
  const { message } = await response.json();
  return { step: JSON.parse(message.content), links };
}

for (let i = 0; i < 6; i++) {
  const { step, links } = await decide();
  if (step.action === "answer") {
    console.log(step.text);
    break;
  }
  // Open only links that are on the page.
  if (step.action !== "open" || !links.some((link) => link.href === step.href)) break;
  await page.goto(step.href);
}

await fetch(`${API}/v1/profiles/${profileId}/kill`, { method: "POST", headers });
```

Page text is untrusted input to the model. The script opens only links from the page, stops after six steps and has no action that sends, posts or pays.

### OpenClaw over CDP

OpenClaw can drive a Donut profile as one of its browser profiles. For a profile with a CDP URL, OpenClaw launches no browser of its own: it uses the Donut profile, with its fingerprint, cookies and proxy.

```bash
PORT=$(curl -sS -X POST http://127.0.0.1:10108/v1/profiles/<profile id>/run \
  -H "Authorization: Bearer $DONUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' | jq -r .remote_debugging_port)

openclaw browser create-profile --name acme-sales --cdp-url "http://127.0.0.1:$PORT"
openclaw browser --browser-profile acme-sales open https://example.com
```

The port changes when the Donut profile restarts. Then set the new port in `browser.profiles.acme-sales.cdpUrl` in `~/.openclaw/openclaw.json`. What OpenClaw sends to a model depends on the model you configure in OpenClaw.

## Isolation pattern

Use one profile and one proxy for each account.

- Create one profile per account. Each profile has its own fingerprint, cookies and storage, so two accounts never share a device or a session.
- Give each profile its own proxy or VPN, in the region the account normally uses. Do not share a proxy between accounts. The app matches each profile's timezone and language to its proxy's exit IP.
- Name and tag each profile after its account. The agent finds it with `list_profiles` (`query`, `tag`) or `GET /v1/profiles`.
- Keep a person on steps that cannot be undone. Over MCP, `ask_human` shows a question on the **Agent** page of the app and waits for your answer, and `request_human_help` hands you the profile for a sign-in, a verification challenge or a payment. In a local script, ask on the terminal before the step.

Put rules like these in your agent's instructions (`CLAUDE.md`, `AGENTS.md` or the system prompt):

```text
Each account has one Donut profile. Find it with list_profiles. Do not create, delete or switch profiles.
Before you send a message, post, submit a form or pay, call ask_human with what you will do and wait for the answer.
For a sign-in, a verification challenge or a payment page, call request_human_help with the profile_id.
```

Some sites forbid automated messages in their terms. Read them before an agent sends anything.

## Plans

| What                                                                                | Plan                                                                                                    |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Local REST API: profiles, groups, proxies, VPNs                                     | Every plan                                                                                              |
| Local REST API: `run`, `kill`, `open-url`, batch run and stop, `agent` routes       | Pro, Team or Enterprise. Other plans get `402`.                                                         |
| Remote MCP: profiles, groups, proxies, VPNs, extensions, human-in-the-loop tools    | Solo, Pro, Team or Enterprise                                                                           |
| Remote MCP: browser control (launch, navigate, click, type, read pages) and recipes | Pro, Team or Enterprise. Solo gets `PLAN_REQUIRED` for browser tools and does not see the recipe tools. |

Free has no MCP: the endpoint answers `402`. See [Pricing](https://donutbrowser.com/pricing) for each plan.
