Skip to content
Hoody.com

Hoody Browser gives you HTTP control over Chromium and Firefox instances: start browsers, navigate pages, evaluate JavaScript, manage tabs, and capture DevTools URLs. Typical uses are web scraping, automated testing, screenshot services, and AI-driven web interaction.

  • Instance management - Start, stop, and restart browser processes, each with its own tabs, cookies, and state
  • Page navigation - Browse a URL and read back its navigation metadata, HTML, or text
  • JavaScript execution - Evaluate a script against a tab over GET or POST, in the page’s own world or an isolated one depending on the engine
  • Tab management - List the open tabs and close them individually
  • DevTools access - Get the WebSocket URL for the Chrome DevTools Protocol
  • Fingerprinting - Set user agent, viewport, geolocation, locale, and Chromium version
  • Health monitoring - Track server metrics and instance health
  • Agent automation - Snapshot a page’s accessibility tree, act on an element, and wait for the result, all over HTTP
  • Multiple instances - Run concurrent browsers, one per browser-N hostname

All endpoints are relative to your Browser service URL:

https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.com

The hostname selects the instance: browser-1 is one browser, browser-2 another. The SDK takes a browser_id argument that routing ignores; only /history reads it, as a filter. The CLI’s --browser-id N picks the browser-N hostname, so --browser-id 1 reaches the same instance as the HTTP examples; 0 is a separate instance, browser-0.

Instance management:

  • GET /start - Create a browser instance, or return the metadata of the one already running (new options are ignored)
  • GET /stop - Kill the instance; returns once it is gone
  • GET /restart - Rebuild the instance from the options it was created with; parameters on the request override them, and cookies, tabs, and log buffers reset

Most endpoints below create the instance with default options when none is running. Pass start=false to get a 404 instead, or start=true to force creation on a server whose operator disabled auto-start. Seven never create one and return 404 whatever start says: both viewport routes, GET /viewport and POST /viewport, plus GET /devtools-url, GET /shutdown and the three agent endpoints GET /snapshot, POST /action and POST /wait. The three agent endpoints return 409 INSTANCE_CHANGED instead when the request carries an instanceGeneration and no instance exists. /history is server-wide and has no instance to create.

Browser interaction:

  • GET /browse?url=... - Navigate to a URL; returns tabId, the final url, the requestedUrl before redirects, title, httpStatus, created, reused and elapsedMs; both URLs come back with credentials and the value of any query or fragment parameter whose name looks secret (such as token, code, state) redacted; a secret under an unrelated name is not
  • POST /browse - The same navigation with a JSON body
  • GET /snapshot - Accessibility tree of a tab as YAML, with [ref=...] element handles and a snapshotId
  • POST /action - Click, fill, type, press, select, check or hover an element
  • POST /wait - Wait for an element state, page text, URL or load state
  • GET /eval?script=... - Evaluate JavaScript script
  • POST /eval - Evaluate JavaScript with POST body

/browse takes waitUntil (commit, domcontentloaded or load; load is the default, and networkidle is deliberately not offered) and timeoutMs (up to 30000). Omitting timeoutMs and passing one are two different behaviours, not a default value: without it the navigation may take up to 30 seconds, and with it the whole budget is yours. Either way, a navigation that runs out of time is 504 TIMEOUT with details.phase navigation. The same server timeout bounds every proxied control route that has no deadline of its own, and an instance that does not answer in time returns 504 TIMEOUT. A navigation that fails outright, on DNS or a refused connection, is 502 NAVIGATION_ERROR. A site’s own 404 is not an error here: it arrives as httpStatus: 404 in a 200 response. A response that is a file download is 422 NAVIGATION_IS_DOWNLOAD, on /screenshot?url= and /pdf?url= too. Downloads are not supported: a download started by a click in the page is dropped without an error, and no file is saved. url must be an absolute http, https or data URL, about:blank, or (on Chromium engines only) a chrome: URL; any other scheme (file: in any spelling, view-source:, javascript:, blob: and the rest) is 400 VALIDATION_ERROR (url must be an http, https or data URL, about:blank, or a chrome URL on Chromium; file and other local schemes are refused), and a value that is not an absolute URL, such as /etc/hostname, is 400 VALIDATION_ERROR (url must be an absolute URL, like https://example.com/). The same rules apply to the url of /screenshot and /pdf, and the check runs before an instance is started or a tab is created, reused or navigated. See Local files.

Page content and export:

  • GET /html - Get page HTML content
  • GET /text - Get page text content
  • GET /screenshot - Capture the page as PNG; pass url to navigate first, fullPage=true for the whole document, or format=jpeg with quality (0-100); any open tab can be captured, including one in the background
  • GET /pdf - Export page as PDF; format is Letter, Legal, Tabloid, Ledger or A0-A6 (headless Chromium only; a headful or Firefox instance returns 501 NOT_SUPPORTED)

/browse, /eval, /html, /text, /pdf, and /screenshot accept a tabId. Without one, /eval, /html, /text, /pdf and /screenshot act on the active tab, and /browse opens a new tab, unless onlyIfNotExists=true finds an open tab already on that URL. Spellings the URL parser treats as the same match: https://example.org and https://example.org/, an upper-case scheme or host, a default port. With one, the request targets that tab or fails: an id that is not open returns 404 TAB_NOT_FOUND with the list of open tabs, and a malformed id returns 400. There is no fallback to the active tab.

Which JavaScript world /eval runs in depends on the engine. The script is handed to Playwright’s page.evaluate, and the two engines put that call in different places. On a stealth=false instance it runs in the page’s main world, so a global the page defined (window.myApp, a framework’s store) is visible. On the default stealth=true instance, which runs Patchright, it runs in an isolated world. The two worlds share the underlying document, so document.title and document.querySelector(...) reach the same DOM either way, but nothing else crosses: page-defined JavaScript state is absent, and so is anything the page did to the JavaScript layer on top of the DOM. A page that shadows document.title with its own property, hangs an expando off a node, or replaces a DOM method is visible to a main-world script and invisible to an isolated one. Reading what is missing returns undefined inside a 200, with nothing to distinguish that from a value the page really did leave undefined. Start the instance with stealth=false when the script has to see the page’s own variables, or reach the same state through the DOM. /html and /text are not affected; both read the DOM.

/eval answers. A script’s value comes back as {"result": <value>}, and a script that returns nothing gives {}. A script that throws, or returns a promise that rejects, is 422 EVAL_ERROR: details.name and details.message carry the error (the message cut at 1000 characters; Firefox reports the message only), and error reads Script error: <name>: <message>. A result that JSON cannot hold, such as a BigInt or a circular object, is 422 RESULT_NOT_SERIALIZABLE. timeoutMs (1 to 30000, default 30000; query or JSON body) limits the script. One still running at the limit is stopped and the answer is 504 TIMEOUT with details.phase evaluate. On Chromium engines the script is terminated and the tab stays open (details.terminated: true). On Firefox the tab is closed, because that is the only way to end the script there (details.tabClosed: true). The script is also stopped when the caller disconnects. A missing script is 400 VALIDATION_ERROR (Missing required parameter: script), the same way a missing url is on /browse; there is no separate code for either.

When a tab or its page fails. If a tab’s page crashes, the rest of the instance keeps working. /tabs marks that tab crashed: true, every request to it (/eval, /html, /text, /screenshot, /pdf, /browse, /snapshot, /action, /wait) answers 502 PAGE_CRASHED, and POST /viewport skips it. Close it with /tab/close and open a new tab. A request that is still running when its tab or the browser closes, for example during /stop or /restart, answers 502 BROWSER_DISCONNECTED. An /eval, /html or /text that the page navigates away from answers 409 PAGE_NAVIGATED; run it again on the new page. /screenshot and /pdf with url wait at most 30 seconds for the page: 504 TIMEOUT when it is slower, 502 NAVIGATION_ERROR when it cannot be reached.

Parameter values are checked before anything runs. An invalid format, quality or fullPage on /screenshot, format, margin, landscape or printBackground on /pdf, scriptBase64 on /eval, since or clear on /console and /network, or a url on GET /cookies that is not absolute is 400 VALIDATION_ERROR naming the parameter, and so is repeating one of them in the query string.

Cookies:

  • GET /cookies - Get browser cookies
  • POST /cookies - Set browser cookies; each cookie needs a name, a value and either an absolute url or a domain with a path, and a cookie that breaks a rule is 400 VALIDATION_ERROR naming the field (like cookies[1].sameSite) with none of the request’s cookies set
  • DELETE /cookies - Clear browser cookies

Introspection and control:

  • GET /metadata - Get instance metadata and DevTools URL
  • GET /tabs - List browser tabs, including popups, target=_blank tabs the page opened and pages a CDP client opened; a tab whose page crashed carries crashed: true
  • POST /tab/close - Close a browser tab; closing the last open tab or passing a malformed tabId returns 400 VALIDATION_ERROR, and a tabId that names no open tab returns 404 TAB_NOT_FOUND
  • GET /devtools-url - Get DevTools WebSocket URL (200 with null fields on Firefox)
  • GET /viewport - Read the live viewport policy; never creates an instance
  • POST /viewport - Change the viewport of a running instance
  • GET /shutdown - Graceful shutdown; returns 200 {"message":"Shutting down"} before teardown finishes, so poll /metadata?start=false for a 404 if you need to know the instance is gone

Logs and history:

  • GET /console - Get browser console logs (full capture needs stealth=false, see below); an optional tabId filters to one open tab (malformed is 400 VALIDATION_ERROR, not open is 404 TAB_NOT_FOUND)
  • GET /network - Get network request/response logs; tabId works the same way
  • GET /history - Query browsing history across every instance on the server; filter with since, domain, or browser_id, page with limit and offset
  • DELETE /history - Delete browsing history, optionally before a timestamp or for one browser_id; answers {"deleted": N}

/console records the console lines and uncaught page errors of every tab, including the first load of a tab the page opened, and logged objects are recorded with their content. The default stealth instance records only part of that, so there the answer says "capture": "partial" with a reason. It still records what the browser reports itself, such as failed resource loads and Content Security Policy violations. It does not record the page’s own console.log, console.info, console.warn and console.error calls, uncaught errors or unhandled promise rejections, so an empty logs does not mean the page logged nothing. Both buffers hold 500 entries. dropped in the answer counts the entries discarded since the instance started or the last clear=true; above zero, older entries are missing. Start the instance with stealth=false for full capture; every other instance answers "capture": "available". since on /console and /network is an ISO 8601 date or time such as 2026-10-05T12:00:00Z; anything else is 400 VALIDATION_ERROR, never the whole buffer. /network and /history include the first request and page of a tab the page or a CDP client opened.

Health:

  • GET /api/v1/browser/health - Service health check
  • GET /metrics - Server and instance metrics, including open tabs across instances and maxTabsPerInstance
Terminal window
# Start a browser instance
hoody browser start -c <container-id> --browser-id "1"
# Navigate to a URL
hoody browser navigate -c <container-id> --browser-id "1" --url "https://example.com"
# Execute JavaScript
hoody browser evaluate -c <container-id> --browser-id "1" --script "document.title"
# Take a screenshot
hoody browser screenshots capture -c <container-id> --browser-id "1"
# List open tabs
hoody browser tabs list -c <container-id> --browser-id "1"

Starting an instance returns its metadata. A Chromium instance with remote debugging enabled, which is the default, reports a webSocketDebuggerUrl for Chrome DevTools Protocol access; on Firefox, or with useRemoteDebuggingPort=false, that field is null.

Everything about the instance except the viewport is set at /start, before the browser launches. A fingerprint profile supplies the baseline and any parameter you pass on top of it wins, so you can start from a known identity and change one thing. Calling /start on an instance that is already running returns its metadata and ignores the new options; to change one, call /restart with the new value.

For each field a fingerprint profile can set, the most specific value wins: a top-level option (locale, timezoneId, userAgent, geolocation, viewport), then the same value inside userProfile, then the fingerprint profile, then the browser’s own default. /metadata reports the locale and timezone in effect; when none was set it reports the browser’s own (the host’s), not a fixed en-US or UTC.

The options of one instance are capped at 96 KiB (400 VALIDATION_ERROR above that). Options in a query string have a lower ceiling: the whole request head, meaning the request line with its query string plus every header, may be at most 16 KiB (16,384 bytes), and headers the proxy adds count too. A larger request is refused with a bare 431 Request Header Fields Too Large that has no JSON body and no CORS headers, so a browser page sees only a network error, and the 96 KiB check is never reached. /start and /restart take only query parameters. To start an instance with an option set that does not fit, send the options in the JSON body of a POST /browse, POST /eval or POST /cookies that creates the instance; the 96 KiB cap then applies to the body.

Terminal window
# Start from the "default" fingerprint, then override identity and network
hoody browser start -c <container-id> --browser-id "1" \
--fingerprint-id default \
--chromium-version 136 \
--locale en-GB --timezone-id Europe/London \
--viewport '{"width":1920,"height":1080}' \
--proxy-server socks5://127.0.0.1:9050 \
--use-remote-debugging-port

Engine and profile

ParameterWhat it does
browserchromium (default) or firefox. Firefox runs through Playwright, ignores stealth, and rejects useRemoteDebuggingPort=true, since the DevTools Protocol is Chromium-only.
chromiumVersionOmitted, the server uses the Chrome major of a userAgent you pass, and otherwise resolves the current stable Chrome for Testing build when the instance is created. A full version (136.0.7103.113) or a channel (stable, beta, dev, canary) is used as given. Any other value is 400 VALIDATION_ERROR, and so is a full version that no download source has. A bare major version (136) is looked up in the server’s version map, then in Chrome for Testing. A major neither resolves is treated as if omitted, without an error, so check browserFullVersion in the returned metadata. A launch that does not finish within 180 seconds is 500 BROWSER_LAUNCH_ERROR. The call blocks while the build downloads. If another process is installing the same build and the start is still waiting after about 5 minutes, it fails with 503 INSTALL_IN_PROGRESS and Retry-After: 30; retry after that delay.
fingerprintIdSelects a configured fingerprint profile for its context and launch defaults. Request parameters override it. An unknown ID starts with an empty profile, without an error.
stealthtrue (default) asks for Patchright, a Playwright fork that patches common bot-detection signals such as navigator.webdriver; false uses plain Playwright. It is a request, not a guarantee: if the Patchright module cannot be loaded the instance falls back to plain Playwright with a warning in the server log, and /metadata reports engine: "playwright", the engine actually loaded. The backend is fixed for the life of the instance: /start with a different stealth value on a running instance returns 409 INSTANCE_BACKEND_MISMATCH, so /stop it first.
launchArgumentsExtra browser command-line flags. Repeat the key (launchArguments=--a&launchArguments=--b) or pass one JSON array.

Identity presented to the page

ParameterWhat it does
userAgentReplaces the user-agent string.
localeBCP 47 language tag, for example en-GB. Anything else is 400 VALIDATION_ERROR naming locale, before the browser launches.
timezoneIdIANA timezone, for example Europe/London. Anything else is 400 VALIDATION_ERROR naming timezoneId.
geolocationCoordinates reported to the geolocation API, as a JSON object. A non-object such as the query geolocation=null returns 400; a null in a JSON body is treated as omitted.
viewport / noViewportA fixed emulated size as JSON ({"width":1280,"height":800}, optional deviceScaleFactor); the default profile gives 1920x1080. noViewport=true, or viewport=null, lets the page follow the real window size. Combining noViewport=true with a fixed viewport returns 400.

Network

ParameterDefaultWhat it does
proxyServernonehttp://, https://, socks5:// or socks5h://. Paired with proxyUsername, proxyPassword, proxyBypass.
enableQuicfalseQUIC and HTTP/3 are blocked unless you turn them on.
enableDnsOverHttpstrueDNS-over-HTTPS, resolver set by dnsOverHttpsUrl.
dnsOverHttpsUrlCloudflareAny HTTPS resolver.

Debugging and display

ParameterDefaultWhat it does
useRemoteDebuggingPorttrueLaunches with a remote debugging port and fills in webSocketDebuggerUrl. The kit picks the port itself; a remoteDebuggingPort value in the request is ignored.
remoteDebuggingAddress0.0.0.0Bind address for the debugging port. Clients never reach the port directly; they connect through the paired cdp-N URL.
showBrowsertrueHeadful, on the X display below. false runs headless, which /pdf requires. Extensions require headful. A headful start that finds no display answers 503 DISPLAY_UNAVAILABLE with Retry-After and is not retried; start with showBrowser=false instead.
display500 + NX display for headful mode. On a browser-N hostname the proxy sets it to 500 + N (browser-1 shows on display-501) and ignores a caller’s query value. With showBrowser=true and no display from the route or the DISPLAY variable, the request fails with a validation error.
extensions, extensionsDir, extensionsStoreIdsnoneLoad unpacked extensions, a directory of them, or Chrome Web Store IDs. Chromium only, and all need showBrowser=true; a start that cannot load them (headless, or a directory that does not exist) is 400 VALIDATION_ERROR naming extensions or extensionsDir, and is not retried. Store downloads of the same extension are serialized across instances; a start that is still waiting on another’s download after about 5 minutes fails with 503 INSTALL_IN_PROGRESS and Retry-After: 30; retry after that delay.

An instance starts with a fixed emulated viewport, so resizing the browser window does not change the page size and the fingerprint stays stable across instances. GET /viewport returns the live policy (viewport, source, tabs, converged). POST /viewport with {"viewport":{"width":1280,"height":800}} resizes every open tab, and later tabs inherit the size. Neither route creates an instance: with none running, both answer 404 with details.reason: "no_instance" rather than starting one. {"viewport":null} switches to responsive mode, which only works on a Chromium instance that was created responsive; a fixed-created instance returns 409 REQUIRES_RESTART, and Firefox returns 501 NOT_SUPPORTED. /metadata reports "viewport": null for a responsive instance, plus viewportSource.

Each browser-N hostname is a separate instance. The proxy derives everything else from N: by default the instance listens on internal port 30000 + N and shows on display-(500 + N), and it always pairs with cdp-N. A deployment can move those two starting ports in its configuration. To run two browsers, use two hostnames:

Terminal window
# Start the first instance
curl "https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.com/api/v1/browser/start"
# Start the second instance
curl "https://PROJECT_ID-CONTAINER_ID-browser-2.SERVER.containers.hoody.com/api/v1/browser/start"
# Each instance is addressed by its hostname
curl "https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.com"
curl "https://PROJECT_ID-CONTAINER_ID-browser-2.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.org"

Each instance is isolated with its own tabs, cookies, and state. A ?browser_id= query parameter does not change which instance a request reaches.

You do not have to call /start first. Navigation, content, cookie and log endpoints create the instance when none is running, using the options on that request plus the defaults, unless the operator started the server with auto-start disabled. GET /viewport, POST /viewport, GET /devtools-url, GET /shutdown, GET /snapshot, POST /action and POST /wait never create one. The start parameter has three states: omitted means auto-create, start=false returns 404 instead of creating, and start=true creates the instance even when auto-start is disabled. /restart follows the same rule for an address with no instance: omitted creates one (unless auto-start is disabled), start=false answers 404 NOT_FOUND and creates nothing, and start=true creates one.

/start on a running instance returns its metadata without applying new options. It still rejects options in an invalid basic format with 400, though not launch-time checks such as full Chromium-version validation, and a different stealth is refused, since that cannot change without a /stop. /restart rebuilds the same instance from the options it was created with, so an instance started with chromiumVersion=136 restarts on 136 unless the restart request says otherwise. Parameters on the restart request override the inherited values, with one exception: the engine. A restart cannot switch between the default stealth engine and plain Playwright, so one that would answers 409 INSTANCE_BACKEND_MISMATCH and leaves the instance running as it was. browser=firefox counts as that switch for a default instance, even with stealth=false added: Firefox restarts only from an instance created with stealth=false. To move a default instance to Firefox, /stop it and call /start?browser=firefox. Browser state resets: cookies, tabs, and the console and network buffers start empty. Browsing history is not part of that state: it is kept on disk, 30 days by default, and survives restarts. Any request that has to start an instance, including /start, /restart, auto-starting routes and the CDP endpoints, answers 503 INSTALL_IN_PROGRESS with Retry-After: 30 when it is still waiting on another process’s Chrome, Firefox or extension install after about 5 minutes; the landing page / keeps its HTML body.

Starting an instance counts against --request-timeout like any request. A /start, /restart or auto-start whose launch takes longer answers 504 TIMEOUT with details.phase launch and details.outcome unknown. The launch carries on, so the instance may still come up: check /metadata?start=false before retrying. An auto-starting request that timed out this way is not run. 408 means only that a request body was still arriving. If another process already listens on browser_port, a request that has to start the instance answers 409 PORT_IN_USE naming the host and port, without retrying; pick another port.

/stop kills the instance and returns once it is removed. /shutdown closes it gracefully and returns 200 before teardown finishes; if the next step depends on the instance being gone, poll /metadata?start=false until it returns 404. Neither starts an instance: on an empty address both answer 404 NOT_FOUND.

An instance that nobody uses is stopped automatically. It becomes eligible once it has been idle for the server’s idle timeout (--instance-max-age, one hour by default) and is stopped at the next cleanup check, which runs every --cleanup-interval (five minutes by default); when several instances go idle together, the later ones can stop a little after that. Use means API requests addressed to the instance, from when they arrive until they finish, CDP traffic, an open CDP WebSocket connection, and page navigations, including a person browsing on the display. An instance that is only open and running is not in use, and neither the server-wide endpoints (the health check, /metrics and /history) nor the status page’s automatic refresh counts. Stopping it discards its browser state (tabs, cookies, storage), and the next auto-starting request gets a fresh instance.

/browse, /screenshot?url= and /pdf?url= refuse file: and every other scheme except http, https and data URLs, about:blank, and, on Chromium engines only, chrome: URLs, with 400 VALIDATION_ERROR on url. Every other about: page is refused (Firefox’s about:reader?url=file:… loads a local file), chrome: is refused on Firefox because there it is the browser’s own privileged UI, and blob: is refused. A value that is not an absolute URL, or is not a string, is refused too, and so is a supplied but empty url= on /screenshot and /pdf (omit url to capture the current tab). The url is checked before anything else: before an instance is started (no browser is launched for a refused url), before the tab lookup (so /screenshot?tabId=99999&url=file:///etc/hostname is 400, not 404) and before /pdf’s 501 NOT_SUPPORTED on Firefox or a headful instance. In POST /cookies, only the cookie entries must be http or https URLs; GET /cookies?url= only needs an absolute URL, because it filters cookies and does not navigate.

This is far from enough. It is a check on those HTTP routes only, and it does not stop the browser from opening local files. A tab can still reach file:// through /eval (for example location.href = 'file:///…'), through any CDP client, or through the live view, and /text, /html, /snapshot and /screenshot then show the file. A real fix is a block inside the browser itself, which the kit does not have yet. Treat local files as readable by anything that can drive the browser.

/snapshot, /action and /wait give an HTTP-only caller a native observe, act, wait loop with Playwright’s actionability checks behind it, instead of hand-written /eval JavaScript. They work on Firefox, which has no DevTools Protocol.

These three never create an instance. Start one with /api/v1/browser/start or /api/v1/browser/browse first, or they return 404 (409 INSTANCE_CHANGED if the request carries an instanceGeneration).

GET /api/v1/browser/snapshot returns the tab’s accessibility tree as YAML with [ref=...] handles, plus a snapshotId, url, title, truncated and chars. maxChars caps the text at 30000 by default, up to 200000, cutting on a line boundary.

POST /api/v1/browser/action takes {"action": "...", "target": {...}}. The action is one of click, fill, type, press, select, check or hover. fill and type carry value, press carries key, select carries values, check carries checked.

POST /api/v1/browser/wait takes {"condition": {"kind": "..."}}, where kind is target, text, url or loadState. A condition that never becomes true is 504 TIMEOUT, not a matched: false body.

Terminal window
B="https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER_NAME.containers.hoody.com/api/v1/browser"
# Observe
curl "$B/snapshot"
# Act by ref from that snapshot (replace the ref and snapshotId below with values from your own
# /snapshot response), or by a semantic target that needs no snapshot
curl -X POST "$B/action" -H 'content-type: application/json' -d '{
"action": "fill",
"target": { "ref": "e5", "snapshotId": "gen_4a1c9f2b7d3e5081:7" },
"value": "user@example.com" }'
curl -X POST "$B/action" -H 'content-type: application/json' -d '{
"action": "click",
"target": { "role": "button", "name": "Sign in" } }'
# Wait for the result
curl -X POST "$B/wait" -H 'content-type: application/json' -d '{
"condition": { "kind": "text", "target": { "selector": "#status" }, "text": "Signed in" } }'

Targets. Set exactly one of ref (with its snapshotId), selector (a single CSS selector), role (with name), label, text, placeholder or testId. exact defaults to true. Every target is strict: two matches return 409 AMBIGUOUS_TARGET with the candidates rather than acting on the first one. A candidate’s name never includes text typed into editable elements inside it. Semantic and CSS targets address the top document; use a ref to reach inside an iframe.

Refs go stale. A ref is valid only while its snapshot is the tab’s latest and the top document has not navigated. Reusing one after that returns 409 STALE_SNAPSHOT with details.reason, so the caller re-observes instead of acting on whatever now carries that ref.

A 409 STALE_SNAPSHOT does not always mean nothing happened; details.outcome says which. Reusing a ref after a newer snapshot or a top-document navigation answers 409 STALE_SNAPSHOT with details.reason, and nothing was sent to the page: re-observe and act again. A navigation that lands while an action is still waiting to act gets the same answer. details.outcome not-started means the input never reached the page, so retrying is safe. details.outcome unknown means the input had been dispatched when the tab navigated and the browser call then failed, so the action may have taken effect: take a new /snapshot and check the page before retrying, because repeating the action can run it twice. A navigation the action itself causes, once its input has been dispatched and the call completes, is its result and not a conflict: following a link, submitting a form, pressing Enter, or a script that changes the URL answers 200, and url is the address after the action. The ref that issued such an action is stale afterward, so take a new /snapshot before using it again.

Snapshots hide what you typed. A textbox, searchbox, spinbutton or slider holding entered text renders as <value hidden>, because the accessibility tree otherwise serialises the value and would hand every password or token back on the next iteration. An empty control renders with no value at all, so the two are still distinguishable. The content of editable regions, such as a contenteditable editor, is hidden the same way: the region keeps its role and ref, and its contents become <value hidden>. Pass includeValues=true when the contents are known not to be sensitive. A plain native <select> keeps its option names, since those are page content. Any other combobox, and a <select> with rich or editable content, is masked like a textbox.

Budgets. timeoutMs bounds the whole operation: 10000 by default, 30000 at most. Expiry is 504 TIMEOUT carrying details.phase; action timeouts also carry details.outcome, but a /wait condition timeout reports phase: "wait" without one. details.outcome means the same thing on every response that carries it: not-started means the action was never dispatched and is safe to retry, unknown means it may have landed, and completed means it finished before whatever else went wrong.

One controller per tab. A second /action, /browse or /snapshot on a tab that is already running one gets 409 TAB_BUSY instead of racing. /wait is never gated, so it can watch a tab while an action runs. The gate does not cover /eval, DevTools Protocol clients, or a person clicking on the display.

Dialogs. An optional dialog policy authorises handling the first dialog the action raises, if it matches; it never requires one. Any other dialog is dismissed and the call returns 409 UNEXPECTED_DIALOG. A page that opens an unrelated dialog first therefore spends the policy on that one, and the dialog you meant to accept is dismissed.

Instance identity. Pass the instanceGeneration from /metadata and a replaced browser answers 409 INSTANCE_CHANGED rather than silently acting on a different instance. A request that carries instanceGeneration never auto-starts an instance: if none is running, the answer is 409 INSTANCE_CHANGED, not 404.

A 200 means the browser operation completed. It does not mean the site’s transaction succeeded; confirm that with /wait and /snapshot.

Launch a Chromium instance with useRemoteDebuggingPort=true and it gets a paired cdp-{n} DevTools Protocol URL, where cdp-1 is the same browser as browser-1. Point Playwright, Puppeteer, or any CDP client at the HTTPS base URL and it discovers the live WebSocket for you:

const { chromium } = require('playwright');
// Connect to a Hoody Browser instance over CDP
const browser = await chromium.connectOverCDP(
'https://PROJECT_ID-CONTAINER_ID-cdp-1.SERVER.containers.hoody.com/'
);
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'screenshot.png' });
  • On demand - Connecting to a cold cdp-{n} URL starts that instance with the debugging port on and the same stealth default as /start: Patchright, or plain Playwright when Patchright can’t be loaded. A later /start on the same instance with default options returns that instance. An explicit different value, stealth=false, still returns 409 INSTANCE_BACKEND_MISMATCH until the instance is stopped. To drive a plain-Playwright instance over CDP, start it with /start?stealth=false first; connecting attaches to a running instance whatever its stealth setting.
  • Restart-stable - The URL names the instance rather than a port, so it keeps working across browser restarts. Always pass the https://PROJECT-CONTAINER-cdp-{n}.SERVER.containers.hoody.com/ base URL rather than a hard-coded wss:// link, because debugger GUIDs change on every launch.
  • Chromium only - Connecting CDP to a Firefox instance, or to a Chromium instance started with useRemoteDebuggingPort=false, returns 501. /devtools-url on a Firefox instance answers 200 with every URL field null.
  • Every page is a tab - A page a CDP client opens is a tab of the instance wherever it opens: with no context named (browser.newPage() in Puppeteer, contexts()[0].newPage() after Playwright’s connectOverCDP) or in a browser context the client created. It shows in /tabs and is recorded in /history, /network and /console like any other tab. A page created with its address already set (Target.createTarget with a url) has finished loading before the server sees it, so that first load is in /history but its requests and console lines are not; open a blank page and navigate it, as Puppeteer and Playwright do, to have them recorded.
  • Paths - Encoded separators (%2F, %2E, %5C) and backslashes are refused in the request path with 400 CDP_BAD_PATH, never in the query string, so a DevTools frontend link with an encoded ws= parameter is served.

How a relayed WebSocket session ends tells the client what happened:

Close codeReasonMeaning
1001instance stopped, instance restarting, instance idle timeout, server shutting downThe server ended the session on purpose (/stop, /restart, the idle timeout, server shutdown). Reconnect when the instance is back.
1000noneThe browser closed the session normally.
1011for example browser exited, chrome connect timeout, chrome backpressureA failure: the browser exited on its own, could not be reached, or could not keep up.

The DevTools endpoint returns the same paired URL. Open it in your browser to load Chrome DevTools against the remote instance:

Terminal window
# Get the DevTools Protocol URLs for an instance
hoody browser devtools urls get -c <container-id> --browser-id "1"
  • Web scraping - Fetch a page’s rendered HTML or text over HTTP
  • Automated testing - Run end-to-end tests against web applications, including CDP-driven Playwright and Puppeteer suites
  • Screenshot services - Capture page images on demand without running Chrome locally
  • AI web interaction - Let AI agents browse pages and evaluate scripts against them
  • PDF generation - Render HTML to PDF from a headless instance via Chrome’s print functionality
  • Performance monitoring - Audit web performance metrics