Files
donutbrowser/sdk
2026-09-09 10:09:14 +04:00
..
2026-09-09 10:09:14 +04:00
2026-09-09 10:09:14 +04:00
2026-09-09 10:09:14 +04:00
2026-09-09 10:09:14 +04:00
2026-09-09 10:09:14 +04:00
2026-09-09 10:09:14 +04:00

Donut Browser SDKs

Two thin clients for the REST API that Donut Browser serves on this machine: python/ (donutbrowser) and node/ (@donutbrowser/sdk).

They are deliberately thin. Every method is one request to one path that the app publishes in its own /openapi.json, with the request and response shapes taken from the Rust handlers in src-tauri/src/api_server.rs. Nothing is cached, nothing is retried, and no endpoint is invented. What the two add on top of a bare HTTP call is the part that is tedious to redo in every script:

  • the bearer token and the port, read from arguments or the environment,
  • one exception class per documented status, with Retry-After parsed and the app's {"code": ...} error bodies decoded,
  • a launch-and-stop helper, so a script cannot leave a browser running,
  • a drift check that fails the tests when the app grows an endpoint the SDK does not cover.

Neither package is part of the pnpm workspace. They build, test and publish on their own, so they never slow the desktop app's own checks down.

Switch the API on first

The local REST API is off by default. It must be enabled in the app under Settings → Integrations → Local API → "Enable Local API Server".

That screen also shows the two things a client needs:

  • the port, 10108 unless it was already taken or you changed it, and
  • the authentication token, sent as Authorization: Bearer <token>.

The server binds 127.0.0.1 only, so it is never reachable from another machine. Requests are also refused with 403 until the Wayfern terms have been accepted in the app.

Both SDKs read arguments first, then the environment:

Setting Argument Environment Default
Token token DONUT_API_TOKEN none; required
Port port DONUT_API_PORT 10108
Host host 127.0.0.1

base_url / baseUrl overrides host and port entirely, for the rare case of a tunnel or a path prefix in front of the app.

Python

Requires Python 3.10 or newer. No runtime dependencies: the client talks to a loopback server on the same machine, so http.client from the standard library is enough. That keeps pip install donutbrowser from dragging anything into an automation environment, and it sidesteps a real trap — urllib.request honours http_proxy from the environment, which would send calls meant for the local app through whatever proxy the shell happens to have set.

cd sdk/python
pip install -e .

A worked example: launch a profile, drive the page through the agent endpoints, and stop the browser.

from donutbrowser import Conflict, DonutClient, NotFound, RateLimited

PROFILE_ID = "your-profile-id"

with DonutClient(token="...") as client:
    # `run` starts the browser on entry and stops it on exit, even if the body
    # raises. `session.cdp_url` is the DevTools endpoint the launch returned.
    with client.run(PROFILE_ID, url="https://example.com", headless=True) as session:
        print("CDP:", session.cdp_url)

        # Read the page the way the agent sees it: roles, names, text, bounds.
        page = client.agent_perceive(PROFILE_ID, viewport_only=True)
        print(page["stats"]["returnedNodes"], "nodes,", len(page["text"]), "characters")

        # Name an element without a selector, and check it is unambiguous.
        search = {"role": "textbox", "nameContains": "Search"}
        resolved = client.agent_resolve_locator(PROFILE_ID, locator=search)
        assert resolved["matchCount"] == 1

        client.agent_type(PROFILE_ID, locator=search, text="donut browser")
        client.agent_click(PROFILE_ID, locator={"role": "button", "name": "Search"})

        # Pull a table out of whatever came back.
        rows = client.agent_extract(
            PROFILE_ID,
            container={"role": "listitem"},
            field_map=[
                {"key": "title", "locator": {"role": "heading"}, "source": "text"},
                {"key": "link", "locator": {"role": "link"}, "source": "link"},
            ],
            max_pages=3,
        )
        for row in rows["rows"]:
            print(row["values"])
    # The browser is stopped here.

Errors are classes, not status codes:

try:
    client.run_profile(PROFILE_ID)
except Conflict as busy:
    print("someone else has it:", busy.code)      # PROFILE_LOCKED_BY_MEMBER, ...
except RateLimited as limited:
    print("wait", limited.retry_after, "seconds")
except NotFound:
    print("no such profile")

Tests

cd sdk/python
pip install -e ".[dev]"
pytest

Node

Requires Node 22 or newer, for the built-in fetch. No runtime dependencies; typescript is a development dependency and is needed only to build dist/ for publishing. The tests run straight from the TypeScript sources through Node's own type stripping, so npm test works with nothing installed at all.

cd sdk/node
npm install   # only needed for `npm run build`
npm run build

The convenience helper is withProfile(profileId, options, work), a callback rather than await using. await using is not yet syntax any released V8 understands, so TypeScript has to down-level it — which would stop the sources running under Node's type stripping, and with it npm test on a clean checkout. The callback form works on every Node 22. A RunSession does also implement Symbol.asyncDispose, so await using is there for anyone whose toolchain already handles it.

import { Conflict, DonutClient, NotFound, RateLimited } from "@donutbrowser/sdk";

const PROFILE_ID = "your-profile-id";
const client = new DonutClient({ token: "..." });

// The browser starts before `work` runs and is stopped after it, even when it
// throws. `session.cdpUrl` is the DevTools endpoint the launch returned.
const titles = await client.withProfile(
  PROFILE_ID,
  { url: "https://example.com", headless: true },
  async (session) => {
    console.log("CDP:", session.cdpUrl);

    const page = await client.agentPerceive(PROFILE_ID, { viewport_only: true });
    console.log(page.stats.returnedNodes, "nodes,", page.text.length, "characters");

    const search = { role: "textbox", nameContains: "Search" };
    const resolved = await client.agentResolveLocator(PROFILE_ID, { locator: search });
    if (resolved.matchCount !== 1) {
      throw new Error("the search box is ambiguous");
    }

    await client.agentType(PROFILE_ID, { locator: search, text: "donut browser" });
    await client.agentClick(PROFILE_ID, {
      locator: { role: "button", name: "Search" },
    });

    const extraction = await client.agentExtract(PROFILE_ID, {
      container: { role: "listitem" },
      field_map: [
        { key: "title", locator: { role: "heading" }, source: "text" },
        { key: "link", locator: { role: "link" }, source: "link" },
      ],
      max_pages: 3,
    });
    return extraction.rows.map((row) => row.values.title);
  },
);
// The browser is stopped here.

try {
  await client.runProfile(PROFILE_ID);
} catch (error) {
  if (error instanceof Conflict) {
    console.log("someone else has it:", error.code);
  } else if (error instanceof RateLimited) {
    console.log("wait", error.retryAfter, "seconds");
  } else if (error instanceof NotFound) {
    console.log("no such profile");
  } else {
    throw error;
  }
}

Tests

cd sdk/node
npm test

npm test runs the TypeScript sources directly, which needs Node 22.18 or newer (type stripping is unflagged from that release). The published package ships compiled .mjs, so consumers only need Node 22.

Errors

Both packages map the app's documented statuses onto the same set of classes. The 5xx classes share one base, so a single ServerError branch catches every server-side failure.

Status Python Node Meaning
400 ValidationError ValidationError Malformed request, duplicate name, unsupported input
401 Unauthorized Unauthorized Missing or wrong bearer token
402 PaymentRequired PaymentRequired Automation needs an active paid plan
403 Forbidden Forbidden Wayfern terms not accepted, or not signed in
404 NotFound NotFound No entity with that id
408 RequestTimeout RequestTimeout agent/pick waited and nothing was picked
409 Conflict Conflict A browser, a teammate or a remote session holds the profile
429 RateLimited RateLimited Automation quota spent; retry_after / retryAfter
500 ServerError ServerError Internal failure
502 BadGateway BadGateway The browser or the relay answered wrongly
503 ServiceUnavailable ServiceUnavailable Cloud, fleet or lock service unreachable

Anything else becomes DonutAPIError / DonutApiError (a ServerError for an unrecognised 5xx), so a status added to the app later still arrives as something a caller can catch. A transport failure — the app not running, the API switched off, the wrong port — is DonutConnectionError, never an API error, so "Donut is not there" is never confused with "Donut said no".

Every error carries status, body, method and path. When the body is one of the app's structured {"code": ..., "params": {...}} strings, code and params are filled in too.

A 503 from stopping something means the fleet could not be reached and the remote browser is still running, not that it stopped.

Staying in step with the app

api-paths.json in this directory lists every operation the app publishes. It is generated from the #[utoipa::path] annotations and the ApiDoc paths(...) list in src-tauri/src/api_server.rs — the two things the served /openapi.json is actually built from — and the generator fails if a handler is annotated but missing from ApiDoc, which is exactly how an endpoint silently disappears from the spec.

python3 sdk/tools/extract-api-paths.py

Each SDK keeps its own table of operation to method (donutbrowser.coverage and OPERATIONS in the Node package), and both test suites hold that table against the snapshot in both directions:

  • an operation in the snapshot that the SDK neither wraps nor lists as omitted fails the suite, so a new endpoint cannot slip past unnoticed;
  • an entry the app no longer publishes fails too, so a removed endpoint cannot linger as a dead method;
  • every wrapped operation must name a method that really exists, no two operations may claim the same method, and every omission must carry a reason.

On top of that, one parameterised test per method drives it against a fake server and asserts the exact verb, path, query string and JSON body it sends. That is what ties the table to reality rather than to a comment.

Of the 71 published operations, 70 are wrapped. The one omission:

  • GET /v1/remote-sessions/{id}/cdp is a WebSocket upgrade, not a request an HTTP client can make, and bundling a websocket implementation would end the zero-dependency promise for one endpoint. remote_session_cdp_url() / remoteSessionCdpUrl() builds the ws:// address instead, so a websocket library of your choosing can connect — send the same Authorization: Bearer header on the handshake.

Tests

Both suites run offline against a fake HTTP server on an ephemeral loopback port. Neither needs the desktop app, a browser, a network, or credentials.