Architecture

This document is the canonical contract for afhttp. The philosophy-only content of design.md (acquisition facts, structured errors, artifact field conventions) still holds and is referenced where it applies.

1. Motivation

Agents fail before they can reason about a page when URL acquisition is opaque. Plain HTTP returns enough that a human at a terminal can guess the next step, but not enough that a program can branch deterministically. The interesting failure surface is concentrated where plain HTTP cannot produce usable artifacts:

afhttp exists to make this whole surface deterministic for agents: the tool returns facts and artifacts that let the agent decide what to do next. It is not a browser automation framework.

The contract is deliberately observation-first. A successful fetch should leave the agent with enough raw evidence to choose the next branch: what the server returned, what the browser rendered, what the page said to the console, what network requests actually carried the data, what interactive elements exist, and what this host is capable of doing.

2. Boundary

The tool may:

The tool must not:

The agent decides what a page means and what to do next. Mechanical, well-defined transformations (innerText, accessibility tree projection, DOM bounding boxes, gzip decompression, base64 encoding, request/response body capture) are not “thinking” and are allowed as artifacts. Heuristic transformations are not.

3. Roles

The networked runtime has two roles. Every command that talks to a running browser-host is one of these roles.

RoleResponsibility
browser-hostRuns Chromium (or compatible). Holds an on-disk profile. Exposes a CDP endpoint. Optionally serves real-display takeover.
agent-driverA CDP client. Issues commands to a browser-host endpoint. Writes artifacts to its own local disk. Comes in two flavors: programmatic (afhttp fetch, afhttp cdp, or the SDK from Rust code) and interactive (real-display takeover served by the host, opened in any browser).

There is no third “operator UI” role. Interactive operation is a CDP client served by the browser-host, opened by a human in a normal browser. The host does not know who its CDP clients are; that is not its concern.

afhttp profile ... commands are local administration helpers. They inspect or modify profile directories on the machine where they run and do not join the endpoint protocol.

4. Topology

Connectivity is not afhttp’s problem. The tool assumes any two afhttp instances that need to talk can reach each other over the network. Mesh, VPN, SSH tunnels, or direct LAN — that is the user’s infrastructure.

Process lifecycle is also not afhttp’s problem. afhttp host is a long-running foreground process. The user starts it however suits them and stops it with a normal signal; afhttp does not fork, does not write pidfiles, and does not maintain a registry of running hosts. That said, because the host runs a real browser and holds live sessions, the recommended environment for the host is a container — the container is the isolation boundary, where the image disables Chromium’s own sandbox via AFHTTP_NO_SANDBOX (afhttp keeps the sandbox on when run natively). This spore ships one at container/docker/. See deployment.md. The driver commands stay a thin client and run anywhere.

These two non-concerns mean afhttp only ever sees endpoint URLs. The CLI and SDK take an endpoint and speak CDP. Whether the endpoint is unix:/run/afhttp/work.sock, ws://localhost:9222, or ws://browser.mesh.internal:9222 does not affect the protocol layer.

5. CLI Surface

The CLI has 11 commands and no stdin protocol. The command set is: host, fetch, upload, cdp, panel, health, capabilities, profile, tabs, skill, and container.

CommandRolePurpose
afhttp hostbrowser-hostStart a foreground browser host, profile, CDP/HTTP listener, and optional real-display takeover.
afhttp fetchagent-driverAcquire a URL through HTTP-only or browser-backed fetch and write requested artifacts.
afhttp uploadagent-driverAttach a local file to an existing <input type=file> through DOM.setFileInputFiles.
afhttp cdpagent-driverSend one raw CDP method to a target tab.
afhttp panelagent-driver/humanPrint a short-lived takeover URL for a host.
afhttp healthagent-driverQuery /health readiness.
afhttp capabilitiesagent-driverQuery /capabilities planning metadata.
afhttp profilelocal adminList, inspect, lock-check, list captured downloads, delete, prune, or inspect redacted cookies for local profiles.
afhttp tabsagent-driverList or close existing CDP targets.
afhttp skilllocal adminInstall, remove, or check the embedded Agent Skill for supported coding agents.
afhttp containerlocal admin/operatorBuild, run, inspect, log, or remove the managed local host container.

Common conventions

These hold across every command (the per-flag reference is generated into cli.md from afhttp --help --recursive --output markdown):

afhttp host

Long-running foreground process. Holds exactly one browser profile identity and exposes one CDP/HTTP listener.

afhttp host
  --listen <tcp:HOST:PORT|unix:/path>
  --profile <name|->
  --display headless|headful
  --takeover-provider off|<provider> # provider name (currently only kasmvnc); off = no takeover
  --takeover-quality-percent <0-100>
  --browser auto|chromium|chrome|chrome-headless-shell|fingerprint-chromium|edge|brave|lightpanda|camoufox
  --browser-bin <path>
  --token-secret <string>
  --no-health
  --health-public off|minimal
  --proxy-url <url>
  --engine-env K=V
  --browser-arg FLAG
  --recent-requests-cap <N>

Lifecycle: when --takeover-provider <provider> (e.g. --takeover-provider kasmvnc) is set, starts that display provider first, waits for the X display and localhost web client, launches the browser headful on that display, then opens the listener and serves CDP plus host HTTP routes (/takeover/panel, /health, /capabilities). With --takeover-provider off (the default for the raw host binary), starts the browser directly and serves no takeover surface. On exit, terminates browser/display subprocesses, removes the profile dir if ephemeral, releases the listener.

afhttp fetch

One-shot URL acquisition. With --endpoint-url, it drives an existing host. Without --endpoint-url, --render none uses a lightweight reqwest HTTP client and never starts a browser; --render auto tries that HTTP path first and lazily starts an inline ephemeral host only when escalation is needed; --render always starts the inline host immediately.

afhttp fetch <url>
  --endpoint-url <url>
  --token-secret <string>
  --render none|auto|always
  --tab new|<id>
  --takeover                 # prepare the tab for human takeover (see below)
  --wait auto|load|idle|selector:<css>|selector-visible:<css>|ms:<n>
  --method GET|POST|PUT|PATCH|DELETE|...
  --data <string|@file>
  --form name=value
  --header NAME:VALUE
  --cookie 'name=value[; Path=/; Domain=...; Secure; HttpOnly; SameSite=Lax]'
  --user-agent <string>
  --evaluate-after-wait <js>
  --want body,rendered_html,text,content,content_json,screenshot,network,console,observation,storage
  --network-bodies off|xhr|all
  --network-body-max-bytes <n>
  --readiness-idle-ms <n>
  --readiness-stable-ms <n>
  --readiness-min-text-bytes <n>
  --no-network-redact
  --capture-ws
  --capture-sse
  --out <dir>
  --cookie-jar <path>
  --no-cookie-jar
  --retry <N>
  --backoff-ms <ms>
  --proxy-url <url>
  --ca-cert <path>
  --tls-insecure
  --timeout-ms <ms>

Output: one JSON object on stdout. Successful fetches include status, final_url, tab_id, top-level *_file artifact paths, trace (render decision, escalation reason, wait/readiness signals, phase timings, cookie-jar path/warnings, sensitive-capture flags), and warnings (for non-fatal artifact failures). Fetch execution failures use the standard error envelope plus the same trace shape. --no-network-redact, --capture-ws, and --capture-sse can expose tokens or PII in artifacts and are reflected in trace.sensitive_capture.

Custom request options are applied before acquisition, not silently ignored. On the HTTP fast path, headers, user-agent, and applicable cookies are attached to the per-request reqwest request. Secure cookies are skipped for http:// URLs rather than failing the fetch, and host-only cookies match only the exact origin host. On the browser path, ordinary headers use Network.setExtraHTTPHeaders, user-agent uses Network.setUserAgentOverride, and cookies are installed through CDP before Page.navigate.

--evaluate-after-wait executes JavaScript after the configured wait condition and before artifact capture. It only works when the final path is browser-backed; it does not by itself trigger --render auto escalation.

--takeover is the human-takeover entry point. It requires a running takeover-ready host and a browser render (--render auto or always, never none). When --endpoint-url / AFHTTP_ENDPOINT_URL is omitted, the driver auto-discovers the standard local afhttp-host container and reads its token; it does not auto-create containers. It opens or reuses a persistent tab (--tab) and navigates it. If --profile is omitted, it switches the host to the URL’s registrable-domain profile (e.g. contabo.com) before opening the tab. If the warmed profile already reaches the target, it returns the captured content with no next_action. Otherwise it leaves the tab open and returns next_action with kind: "human_takeover", a complete short-lived takeover_url (the real-display URL a human opens to clear a login/captcha/2FA wall), its expiry metadata, and a recommended_command that re-fetches the same --tab once the human is past the wall.

Other endpoint commands

afhttp cdp, afhttp upload, and afhttp tabs are raw CDP/target-management helpers. They require --endpoint-url and do not introduce Playwright-style semantic action wrappers. afhttp panel, afhttp health, and afhttp capabilities are thin HTTP helpers over /takeover/handoff, /health, and /capabilities; panel output is a short-lived takeover URL with handoff=..., not the host token.

Local commands

Downloads are browser session artifacts, not a standalone command: host-side Browser.setDownloadBehavior captures them inside the active profile’s downloads/ directory, fetch reports download_file when navigation becomes a download, and afhttp profile downloads <name> lists the captured files read-only for interaction-triggered downloads. afhttp profile ... operates only on local profile directories and never deletes or mutates profiles over a remote endpoint.

6. Host Health and Capabilities Endpoints

afhttp host serves JSON host metadata on the same listener as CDP and display takeover.

RouteAuthPurpose
GET /healthToken required unless --health-public minimal is setLiveness/readiness for agents and supervisors.
GET /capabilitiesToken requiredDetailed backend and artifact support for planning fetch requests.

When --token-secret is configured, authenticated control requests use Authorization: Bearer <token>; legacy token_secret query auth remains only for non-takeover control endpoints and CDP WebSocket setup. /takeover/handoff mints a short-lived URL capability with default TTL 900 seconds (allowed range 60-3600 seconds), scoped only to /takeover/*; host restart invalidates all handoffs. Unauthenticated public health is intentionally minimal: it may return only { "status": "ok" } / { "status": "starting" } / { "status": "degraded" } and never exposes profile names, browser versions, paths, tabs, or network policy.

/health response shape:

{
  "code": "health",
  "status": "ok",
  "version": "0.5.0",
  "uptime_s": 42,
  "backend": {"family": "chromium", "version": "124.0.0.0", "connected": true},
  "profile": {"kind": "persistent", "name": "work", "locked": true},
  "tabs_active": 3,
  "capabilities_url": "/capabilities"
}

/capabilities response shape:

{
  "code": "capabilities",
  "backend": {"family": "chromium", "version": "124.0.0.0"},
  "artifacts": {
    "body": {"supported": true},
    "rendered_html": {"supported": true},
    "text": {"supported": true},
    "screenshot": {"supported": true},
    "network": {"supported": true, "body_capture": ["off", "xhr", "all"]},
    "console": {"supported": true},
    "observation": {"supported": true, "source": "accessibility+dom"}
  },
  "wait_modes": ["auto", "load", "idle", "selector", "ms"],
  "takeover": {
    "backend_capable": true,
    "supported": true,
    "panel_url": "/takeover/panel",
    "provider": "kasmvnc"
  },
  "profile": {"persistent": true, "ephemeral": true},
  "limits": {"network_body_max_bytes_default": 10485760}
}

Capabilities are descriptive, not a reservation. A later fetch can still return per-artifact warnings if the page crashes, permissions change, or a CDP method fails.

7. Profile Model

Profiles are Chromium user-data directories. They are host-local on disk and never copied between hosts. A profile holds cookies, localStorage, sessionStorage, IndexedDB, service worker registrations, and cached browser fingerprint state.

One afhttp host serves one active profile at a time, but it switches at runtime: a client that passes ?profile=<name> on the /cdp connection (or afhttp fetch --profile <name>) makes the host relaunch its browser under that profile, handing off the on-disk profile lock. fetch --takeover derives the profile from the URL’s registrable domain (eTLD+1) by default, so each site gets an isolated identity. Switching is sequential — an in-flight fetch keeps its own browser alive until it finishes, but the host has one foreground browser, so for genuinely parallel multi-domain work run multiple hosts.

Profile portability is explicitly out of scope. Sessions bound to a specific IP/device fingerprint should remain on a single host; the right way to “move a session” is to put afhttp host where the session needs to be and connect to it remotely.

Isolation invariant

Per design.md “Browsing environments are isolated”, every browsing environment afhttp creates is sandboxed:

What the invariant honestly does not cover: the rendering engine itself reads system fonts, the OS timezone, the OS locale, and graphics/device surfaces. The fingerprint-chromium and camoufox backends address parts of that fingerprint surface; the default chromium backend leaks them and §10 says so.

Profile lifecycle metadata

Persistent profile directories include a small afhttp-profile.json metadata file maintained by afhttp host and afhttp profile ... commands:

{
  "schema_version": 2,
  "name": "work",
  "backend": "brave",
  "created_at_rfc3339": "2026-05-27T00:00:00Z",
  "last_used_at_rfc3339": "2026-05-27T01:23:45Z",
  "last_host_version": "0.6.0"
}

The metadata is advisory, but schema/backend mismatches are rejected because the backend directory is part of the isolation boundary. Old unscoped $XDG_DATA_HOME/afhttp/profiles/<name> directories are ignored as deprecated data; there is no implicit migration.

Profile lifecycle commands:

CommandBehavior
profile listLists persistent profiles with backend, name, path, size, metadata status, last used time, and lock status.
profile info <name> [--backend <backend>]Reports metadata, profile path, approximate disk usage, active lock owner when known, and browser-family hints. --backend is required when multiple backends have the same logical name.
profile lock-status <name> [--backend <backend>]Returns whether the backend-scoped profile is locked and, when possible, the owning pid/start time.
profile downloads <name> [--backend <backend>]Read-only listing of files captured under <profile>/downloads, with path, byte size, and completion state.
profile delete <name> [--backend <backend>]Deletes an unlocked persistent profile after --confirm <name>. Refuses ephemeral profiles and locked profiles.
profile pruneDeletes unlocked persistent profiles older than --older-than; --dry-run reports the candidate list without deleting.

profile delete and profile prune are intentionally local-only. Remote deletion over the CDP/HTTP endpoint would make a stolen token able to destroy browser identities.

8. Artifacts

Ten artifact tokens are identified by stable names. With no explicit --want, the HTTP fast path captures body; when a browser render is actually used, afhttp expands to the browser default set (body, rendered_html, text, content, content_json, screenshot, network, console, observation). storage is opt-in because it can expose sensitive local state.

TokenContentFilenameNotes
bodyRaw HTTP response bodybody.<ext>Always produced when an HTTP response was received. Ext derived from content-type.
rendered_htmlPost-JS DOM serialized to HTMLrendered.htmlOnly when render was used.
textdocument.body.innerTexttext.txtOnly when render was used. Mechanical, not heuristic.
contentAgent-oriented composed page view: visible text from open shadow DOM, same-origin frames, cards, tables, linkscontent.mdOnly when render was used. The artifact an agent reads first.
content_jsonStructured form of content with link/action candidatescontent.jsonOnly when render was used. Use to choose a follow-up link/action.
screenshotFull-page PNGpage.pngOnly when render was used.
networkDeep request/response log from CDP Network.* eventsnetwork.jsonIncluded in the browser default set and produced when requested. Optional captured bodies live under network-bodies/.
consoleConsole eventsconsole.jsonOnly when render was used.
observationAgent-readable accessibility/DOM snapshotobservation.jsonOnly when render was used. Mechanical projection of page state; no semantic ranking or intent inference.
storagelocalStorage/sessionStorage/IndexedDB-name snapshotstorage.jsonOpt-in with --want storage; default-off because of sensitive data risk.

Files are written to --out <dir> (default: the system temporary directory under afhttp-out/<request-id>/) on the agent-driver’s machine, not the browser-host’s. The response JSON references them as absolute paths. The default temp artifacts persist for inspection; pass --out when you want a project-local or long-lived directory.

Each artifact can fail independently of the overall fetch. A missing screenshot returns warnings: [{artifact: "screenshot", code: "backend_unsupported"}] rather than failing the whole fetch. The agent decides whether the partial result is useful.

Observation artifact

observation.json is the artifact meant for LLM and agent planning loops. It is smaller and more action-oriented than full HTML, but still mechanical data:

{
  "schema_version": 1,
  "url": "https://example.com/dashboard",
  "title": "Dashboard",
  "viewport": {"width": 1280, "height": 720, "device_scale_factor": 1},
  "frames": [{"frame_id": "main", "url": "https://example.com/dashboard"}],
  "nodes": [
    {
      "ref": "obs-17",
      "frame_id": "main",
      "role": "button",
      "name": "Export",
      "text": "Export",
      "visible": true,
      "enabled": true,
      "bbox": {"x": 1032, "y": 88, "width": 91, "height": 36},
      "actions": ["click"]
    }
  ],
  "forms": [],
  "focused_ref": null
}

Refs are stable only within one observation snapshot and the current DOM revision. They are not durable selectors. An agent that wants to act still uses raw CDP and may resolve a ref by coordinates, accessibility node id, backend DOM node id, or a best-effort selector hint included in the node when available. Selector hints are scoped to the node’s real context: iframe descendants carry the child frame_id and frame-relative hints; open-shadow descendants use host >> shadow >> inner chains; cross-origin iframes expose only the iframe box plus frame_ref.

Allowed observation fields are mechanical: accessibility role/name/state, visible text, bounding box, frame id, href/src/action URLs, form ownership, enabled/checked/selected/focused states, input type, and redacted input value metadata. Disallowed fields: “important”, “likely login”, “best button”, “captcha”, “paywall”, or any page-intent label.

Observation node collection walks the main document, open shadow roots, and same-origin iframe documents. It starts with native interactive elements (a[href], button, form controls, summary, iframe) plus explicit interaction markers (role, tabindex, contenteditable=true). It also appends non-semantic elements whose computed cursor is pointer, scanning at most 2,000 elements and emitting at most 100 nodes across the whole tree. If either cap stops traversal, truncated records the mechanical reason and limits; truncation is never silent.

Network artifact depth

network.json is a structured capture, not a flat HAR dump. It keeps enough information for an agent to discover whether the useful data came from an XHR/fetch request, GraphQL endpoint, document load, script, iframe, service worker, cache hit, or failed resource.

Each entry includes, when available:

Response body capture is opt-in because network logs often contain credentials, PII, and large binary resources.

ModeBehavior
--network-bodies offDefault. Metadata only; no response bodies saved.
--network-bodies xhrSaves text/JSON/XHR/fetch response bodies up to --network-body-max-bytes each.
--network-bodies allAttempts to save every response body up to --network-body-max-bytes each, including documents/scripts/images when CDP exposes them.

Captured bodies are written under network-bodies/<request_id>.<ext> and referenced from network.json via body_file. With --wait auto, browser fetches capture XHR/fetch/EventSource bodies by default. Binary bodies may be base64 files if the original bytes cannot be represented as UTF-8. Per-entry body capture failures become warnings with artifact: "network" and do not fail the fetch.

network.summary also reports readiness diagnostics: responses_total, finished_total, incomplete_total, inflight_total_at_capture, and pending_by_resource_type. These are mechanical counts; they do not classify which payload is important.

9. Takeover

Human takeover lets a person drive the same browser the agent is using — for a manual login, 2FA, captcha, or any step the agent cannot complete on its own. The single takeover surface is real-display takeover: when afhttp host is launched with --takeover-provider <provider> (currently only kasmvnc), afhttp starts that display provider, runs the browser headful on its X display, and reverse-proxies the provider’s web client through the authenticated listener at /takeover/panel. The container shipped by afhttp container install is takeover-ready by default (Brave + KasmVNC + an ephemeral initial profile + 2g /dev/shm).

The driver-side entry point is afhttp fetch <url> --takeover against such a host (see §5). It opens or reuses a persistent tab, and either returns the content directly (if the warmed profile already reaches the target) or hands back a takeover_url and a recommended_command re-fetch for the same tab. The human opens the takeover_url in a local browser and drives the real display; the agent re-fetches once the wall is cleared.

Risk-control honesty. Real-display takeover drives a real X display through KasmVNC, so the human’s pointer/keyboard input reaches the browser as native OS input events rather than synthesized CDP events. This is the strongest takeover fidelity afhttp offers (full input realism, IME/CJK input, real handedness/timing), but it does not by itself bypass captcha reputation systems or the headless/browser fingerprint surface. When real-display takeover still fails, the likely remaining causes are IP/network reputation, account state, or site policy — not the takeover surface. Takeover uses the host’s active profile; by default fetch --takeover switches to a persistent per-site profile derived from the URL. KasmVNC stays an external GPLv2 process located on PATH; afhttp does not link or bundle it.

Multi-attach. Display takeover is a VNC/X client; the agent (via afhttp fetch or afhttp cdp) remains a CDP client. CDP supports multiple flattened sessions, so the agent stays attached while the human drives the display. Whichever side sends input is the one acting. There is no handoff protocol; coordination between agent and human is the agent’s concern.

10. Backends

afhttp’s protocol layer (fetch logic, CDP escape hatch, display takeover) is CDP-generic. The launcher layer (afhttp host) knows specific browser families.

BackendLaunch profile in hostCapabilities
Chromium / Chrome / Edge / Bravechromium (and aliases)Full: body, rendered_html, text, content, content_json, screenshot, network, console, observation, network body capture, real-display takeover, health/capabilities, multi-attach.
chrome-headless-shellchromium (binary chrome-headless-shell)Same capability matrix as Chromium — chrome-headless-shell is Google’s slimmer headless distribution of the same engine, identical CDP surface. Use when the full Chrome/Chromium browser is unavailable or too heavy.
fingerprint-chromiumfingerprint-chromiumSame capability matrix as Chromium, including real-display takeover. Engine surface (UA, navigator props, WebGL vendor, canvas/font enumeration, CDP-detection evasion) is spoofed per adryfish/fingerprint-chromium. The host derives a stable 32-bit --fingerprint=<seed> from the resolved profile path so identity stays consistent within a profile and diverges between profiles. Per-surface overrides (--fingerprint-brand, --fingerprint-platform, etc.) reach the engine via --browser-arg.
Lightpandalightpandabody, rendered_html (modulo JS engine limits), text, network metadata, console, limited observation. No screenshot, no display takeover (no rendering), network body capture depends on backend support.
Camoufox (via foxbridge)camoufoxFirefox stealth fork driven by the foxbridge CDP→Juggler proxy. Same artifact subset as Lightpanda for CDP-only features: no chromium-only screenshot. Display takeover is supported because the human drives the real X display. Body, rendered_html, text, network metadata, console, observation work. Persistent profiles refused with backend_unsupported until Firefox profile lifecycle is wired explicitly. The host spawns foxbridge with --binary <camoufox> on a pre-reserved port; the SDK sees a chromium-style WebSocket.
Any other CDP-compatible browsernone — user launches it themselvesWhatever the backend implements. afhttp clients connect via --endpoint-url.

Unsupported per-artifact operations return per-artifact warnings (backend_unsupported), not whole-fetch failures.

Why both fingerprint-chromium and camoufox exist

Both backends add stealth, but they address different threat models and are complementary rather than redundant:

fingerprint-chromiumcamoufox
EngineChromium (Blink)Firefox (Gecko)
Stealth mechanismPatches applied at the Chromium binary level: UA, navigator props, WebGL/Canvas entropy, CDP-detection evasionFirefox stealth fork with Gecko-native fingerprint randomization; engine-level font/audio/WebGL divergence from stock Chromium
Full artifact supportYes — screenshotNo — subset only (no screenshot)
Target profileSites that block stock headless Chromium or navigator.webdriver detectionSites that actively fingerprint Blink engine characteristics (WebGL vendor strings, V8 timing side-channels) and block Chromium-family browsers regardless of stealth patches
Real-display takeoverSupported, currently backed by KasmVNCSupported, currently backed by KasmVNC

Sites that specifically block all Chromium-family browsers (rare but real) require camoufox. Sites that just block unpatched headless work fine with fingerprint-chromium and get the full capability matrix. Both stay so operators can choose the right tool for the threat.

11. Error Codes

All error events carry error.code (stable enum), error.message (human-readable detail), and error.retryable (bool). Agents match on error.code only — the message is for human logs and may change between versions.

Three categories:

The full enum with example error strings and per-code agent guidance lives in reference.md §Error Codes.

12. Multi-Client Attach

CDP allows multiple flattened sessions per target. The agent and the human (via real-display takeover) are independent clients. The browser is shared state.

Coordination is the agent’s concern, not the protocol’s. Common pattern: the agent emits an out-of-band signal (e.g. to its own orchestrator) saying “I need help on <endpoint>/tab <id>”. A human runs afhttp panel --endpoint-url ..., opens the display takeover, does their part, and closes it. The agent’s next afhttp fetch --tab <id> or afhttp cdp continues from the new browser state.

The Rust SDK keeps one lazy CDP WebSocket per Client and reuses it across fetch / cdp calls until Client::close().await or drop. This cache is per SDK client, not a browser-wide lease: other SDK clients still attach through their own CDP connections (and a human can drive the real display in parallel), and all of them can continue to multi-attach to the same target. Each one-shot cdp --tab / fetch --tab operation detaches its temporary flattened session when the call completes; --tab controls target lifetime, not connection ownership.

There is no “lease,” “lock,” or “active driver” in the protocol. Both clients can issue commands at any time; if they conflict, that is the user’s coordination bug to solve.

13. Library / SDK

The Rust library exposes the same surface as the CLI, in-process. It is not an embedded browser engine; it is an SDK that talks to a browser-host over CDP/HTTP. Everything that physically requires a Chromium process — launching, active profile locking, and display takeover — stays in afhttp host. Local profile lifecycle helpers operate on disk and do not pull browser-launch dependencies into SDK-only consumers.

use afhttp::{Client, RenderMode, Wait, Artifact};
use afhttp::sdk::{FetchCookie, FetchCookieSameSite};

let client = Client::connect("ws://chromium-host:9222")?;

let result = client.fetch("https://example.com")
    .render(RenderMode::Always)
    .wait(Wait::Auto)
    .user_agent("agent-script/1")
    .cookie_full(
        FetchCookie::build(("session", "abc"))
            .path("/")
            .http_only(true)
            .same_site(FetchCookieSameSite::Lax)
            .build()
    )
    .evaluate_after_wait("document.body.dataset.agentReady = '1'")
    .timeout(Duration::from_secs(30))
    .want([Artifact::RenderedHtml, Artifact::Observation, Artifact::Screenshot])
    .network_bodies(NetworkBodies::Xhr)
    .send()
    .await?;
// result.rendered_html_file -> path on the caller's local disk
// result.observation_file -> agent-readable page snapshot

let health = client.health().await?;
let capabilities = client.capabilities().await?;

let cdp = client.cdp("Runtime.evaluate")
    .tab(tab_id)
    .params(json!({ "expression": "document.title" }))
    .send()
    .await?;

client.close().await; // optional: closes the cached CDP connection

// Dev / test convenience: spawn a private host in-process, use it, kill it
// on drop. Requires the `host` feature; pure `features = ["sdk"]` consumers
// connect to an externally started afhttp host instead.
let local = Client::inline_ephemeral().await?;

What the SDK exposes: Client, fetch/cdp/health/capabilities builders, the artifact and error enums, request/response/cookie/render-mode/network-capture types, and local profile-store helpers.

What the SDK does not expose: chromiumoxide types, host launch internals, display-takeover internals, or a remote profile-administration API.

CLI is the first SDK consumer. afhttp fetch and afhttp cdp parse args, call into the SDK, format the response. They are not parallel implementations.

Cargo features.

[features]
default  = ["sdk", "cli"]
sdk      = []                                   # client-side; what library consumers want
host     = ["dep:chromiumoxide", ...]           # browser-launch deps; only in the bin
cli      = ["sdk", "host", "dep:clap",
            "dep:agent-first-data"]             # the afhttp binary

External consumers (e.g. the fetch service) depend on the crate with default-features = false, features = ["sdk"] and link only the SDK weight, not chromiumoxide or any browser-launch code.

14. Non-Goals