browser-tools / bt -- the agent manual

This is the whole of `bt`. Reading it is enough to drive a browser with this
tool; nothing else needs to be read first. `bt guide` prints it.

The install also puts down `browser-tools-profiler`, a separate command for
CPU profiling. It is the last section here.

`bt --version` prints the installed version. Check it first when behaviour
does not match this manual: an older copy earlier on PATH answers the same
verbs and answers them differently.

The tool tracks named browser instances in a registry. Each instance is one
running browser process, named after the directory it was launched from.
Liveness is engine-aware: Chrome is process identity plus CDP port
attribution, Camoufox is process identity plus a user-data-dir hold. Never PID
existence alone.


WHAT THIS IS FOR

  Driving a real browser: one with logins, extensions, and whatever state a
  site keeps. Reach for it when the browser is the point.

  It fits:

  - Work behind a login. `launch --profile NAME` keeps the session, so you
    log in by hand once and every later run is already signed in.
  - Pages that resist automation. Camoufox is an anti-detect engine, and
    `detect` says whether a challenge page is what you are looking at.
  - Reading what a page renders rather than what its HTML says. `snapshot`
    is the accessibility tree, so it sees what a screen reader sees, after
    the JavaScript has run.
  - Watching a page work: console messages, network traffic, CDP events,
    screenshots, screencasts.
  - A browser the person already has open and logged in, through --endpoint.

  It is not an HTTP client. Fetching a URL and parsing the body is faster
  with curl. Use this when the page has to actually run.

  Two shapes of work, and they cost very differently. Each invocation opens
  its own connection and session, which costs about 70 to 85 ms before any
  browser work. One verb at a time is fine. A long chain of them pays that
  every step, and some things cannot be split across two invocations at all:
  see WHAT DOES NOT CARRY BETWEEN INVOCATIONS.


NAMING THE INSTANCE

  Every verb that drives a browser takes the instance ahead of the verb:

    bt web-01 snapshot
    bt web-01 frames select checkout
    bt web-01 Page.navigate '{"url": "https://..."}'

  A bare leading token is an instance name when the registry knows it, and the
  verb otherwise, so `bt frames select checkout` needs no escaping. INSTANCE
  may be omitted when exactly one instance is registered; with several, every
  verb names the candidates rather than guessing (exit 1). Naming the instance
  twice is a usage error (exit 2).


LIFECYCLE VERBS

  launch [--engine chrome|camoufox] [--profile NAME] [--channel NAME]
         [--headless] [--port PORT] [--fingerprint FILE] [--no-window-border]
         [-- BROWSER_ARGS]
      Launch a browser and register it. Prints the new instance as JSON.
      --engine camoufox starts an anti-detect Camoufox instance. launch takes
      no positional argument: the name is assigned by the registry, and a bare
      token before -- is a usage error (exit 2), because it would otherwise be
      handed to the browser along with every flag after it.

  status [INSTANCE]
      Every registered instance with liveness, engine, profile and page
      targets, as JSON. With INSTANCE, only that one. It reports the registry,
      so a browser driven with --endpoint does not appear.

  stop [INSTANCE] [--target SPEC]
      Stop a browser and retire its registry entry, or close one tab with
      --target. A profile-bound instance keeps its user-data-dir; an unbound
      one has its session dir reaped. Closing a tab is Chrome only (exit 1 on
      Camoufox), and needs a live instance (exit 1).

  cleanup
      Remove stale registry entries and orphaned session directories. Live
      instances are never touched, and no path under a profile root is ever
      deleted -- profiles go away through `profile delete`, never on age. An
      unparseable registry deletes nothing and is quarantined instead.

  profile list
      Every named profile with its name, its path, and the live instance
      holding it (null when free). Needs no running instance; an empty root
      lists nothing. A profile still in the old /tmp root is marked
      "legacy": true.

  profile delete NAME
      Remove one profile directory, and its login with it. A name outside the
      permitted character set, or one whose resolved path leaves the profile
      root, is a usage error (exit 2) and deletes nothing. A profile a live
      instance holds is refused (exit 1) naming the holder.

  profile migrate [--dry-run] [--back]
      Move profiles still in the old /tmp root into the durable one.
      --dry-run reports what would move and moves nothing. --back reverses it,
      and is the rollback for the root move: once the profiles have moved on
      disk, reverting the code does not move them back. A live holder is
      refused, and a name present on both sides is refused rather than merged.

  guide
      Print this manual. Plain text, not JSON.

  window-border [on|off]
      Show, or persistently set, whether marked windows draw the colored
      border and corner badge over the page (default on). They cover the
      page's outer edge and top-left corner; 'off' removes them from every
      running browser within a second and keeps them off for later launches.
      The tab-title prefix stays either way. --no-window-border on launch
      turns off all marking for that one launch.


CURATED VERBS

  Each takes [INSTANCE] as above, and --endpoint URL to drive an external
  browser. Each prints one JSON document on stdout.

  snapshot [--target SPEC]
      The accessibility tree, one UID per node. This is how you find something
      to click or fill.

  click --uid UID [--target SPEC]
  fill --uid UID --text T [--target SPEC]
      Act on the node a snapshot named. Missing --uid or --text is a usage
      error (exit 2). A UID that names a node with no DOM node behind it, or
      a node that is not an element, is exit 1.

  wait-idle [--timeout-ms MS] [--idle-ms MS]
  wait-stable [--timeout-ms MS] [--stable-ms MS]
      Wait for network idle (default 5000ms deadline, 500ms quiet window), or
      for the DOM to stop changing (5000ms, 300ms).

  detect [--wait SECONDS | --no-wait]
      Run interstitial detection against the current page. --wait bounds the
      retry for a self-clearing challenge; --no-wait reports the current state
      at once. Only JS-solvable challenges auto-retry. Vendor presence is
      reported alongside the blocked answer, never in place of it: a cookie
      proving a site uses a bot-protection vendor is on every page of that
      site and does not by itself mean the page is blocking you.

  console-list [--target SPEC | --url SUBSTRING] [--duration SECONDS]
  network-list [--target SPEC | --url SUBSTRING] [--duration SECONDS]
      Collect console messages, or network requests and responses, over a
      short attach window (default 2 seconds). network-list correlates
      requests and responses by requestId, keeps rows in first-observation
      order, and keeps request-only and response-only rows with the missing
      fields null. Both subscribe before enabling their domain, so an event
      emitted during enable is in the result.

  frames list | frames select PATTERN | frames reset
      Inspect and select page frames. PATTERN is a frame URL substring. Any
      other sub-action is a usage error (exit 2).

      SAME-PROCESS FRAMES ONLY, unless you pass --frames all. A cross-origin
      iframe runs in its own renderer process under Chrome's site isolation,
      with its own CDP target. By default it is absent from `frames list`,
      `frames select` cannot select it, and `snapshot` shows the Iframe node
      with nothing under it. `--target` does not reach it either: only page
      targets are attachable. Logins, payment forms and consent banners are
      usually cross-origin iframes, so this is the common case rather than
      the exotic one. `frames select` says so when it can tell that is what
      happened.

  --frames page | --frames all
      Which frames `frames list` and `frames select` can see. Default `page`,
      which is the behaviour above. `all` attaches to each cross-origin
      iframe as well and splices its frames into the one tree.

      READ THIS BEFORE YOU USE IT. Today the flag does exactly two things:
      it lists cross-origin iframes, and it lets you select one. NOTHING
      READS FROM THE FRAME YOU SELECT. A cross-origin iframe has no
      execution context in this tool yet, so:

          storage get      returns cookies only, with no localStorage and
                           no sessionStorage, and exits 0
          snapshot         still prints the Iframe node with nothing under
                           it, so there is no uid inside the frame
          click, fill      have no uid to address, so they cannot reach it

      Routing a read to the frame's own session is the next phase of the
      work (RFC-04, Decisions 4 and 5) and is not built. Until it is, drive
      the iframe's URL as a page of its own when you need to read or click
      inside it.

      What the flag is good for today is finding out what is there: which
      cross-origin iframes the page has, their URLs, and where they sit in
      the tree.

      `frames list --frames all` marks a frame in another process
      [out-of-process]. A frame past a bound is listed [unreachable] with its
      URL rather than dropped, so a frame you cannot reach is still a row you
      can see. The bounds are 32 frame sessions and 10 levels of nesting.

      Accepted by `frames list`, `frames select`, `frames reset`, `storage
      get`, `snapshot`, `click`, `fill`, and `run`, where it covers every
      step of the run. The last four accept it so that a run can carry one
      flag throughout; it does not make them see into the frame.

      Off by default because it is opt-in for this release, not because it
      costs much: measured over 9 runs each on a page with twenty sibling
      cross-origin iframes, `frames list` took 109.2 ms by default and
      119.7 ms with --frames all. On a page with no cross-origin iframe the
      flag costs one CDP round trip, about 8 ms.

      It does not change `screenshot` or `screencast`. Neither is
      frame-scoped; both capture the page, with or without the flag.

  storage get [--key K]
      The selected frame's storage. --key is a frame URL pattern to select
      before reading. It is NOT a cookie name or a local-storage key. Without
      a selected frame and without --key, exit 1.

  screenshot [--path FILE] [--target SPEC | --url SUBSTRING]
      A full-page PNG. Without --path, the base64 data URI. A near-uniform
      capture is retried once before it is returned.

  screencast --dir DIR [--duration SECONDS] [--format FMT] [--max-frames N]
      Capture a screencast and write its frames plus a frames.json manifest to
      DIR, in one invocation. Capture ends at whichever comes first: the
      duration (default 5 seconds) or the frame cap (default 600). --dir is
      required (exit 2). There is no separate start and stop, and naming one
      is a usage error: the frame buffer belongs to the process that captured
      it, so a stop in a second process could never reach the first one's
      frames.


RUNNING MANY STEPS IN ONE INVOCATION

  run FILE [--timeout SECONDS]
  run -    [--timeout SECONDS]
      Run an ordered list of steps against one browser, in one invocation, over
      one CDP connection. FILE is the list; `-` reads it from stdin. Takes
      [INSTANCE] and --endpoint URL as above, and --target SPEC or --url
      SUBSTRING to pick the page the whole run drives.

      One step per line, each line a verb phrase exactly as you would type it
      after the instance name. Blank lines and lines starting with # are
      ignored; a # anywhere else is an ordinary character, so a URL fragment
      or a --text value carrying one needs no quoting for that reason. Lines
      are split the way a shell splits them, so quoting works as it does at
      the prompt.

          # select the checkout frame once, then read it twice
          frames select checkout
          storage get
          Page.getNavigationHistory '{}'
          snapshot

      This is not a scripting language. There are no variables, no conditions,
      no loops, and no way for one step to use another step's output. A run does
      what the same commands would do one after another; it cannot decide
      anything. Where you need a decision, read the output and run again.

      A STEP IS A VERB, NOT AN INVOCATION. Steps may be: snapshot, click, fill,
      wait-idle, wait-stable, wait, detect, console-list, network-list, frames,
      storage get, screenshot, screencast, and any raw Domain.method. A step may
      NOT name an instance, --endpoint, --target or --url: those belong to the
      run, which resolves them once. attach is not a step (it runs until stdin
      ends, so nothing could follow it), and neither are launch, status, stop,
      cleanup, profile, window-border, guide, help or run itself. Any of these
      in a step list is a usage error (exit 2).

      FRAME SELECTION LASTS. `frames select` in one step governs the steps after
      it, so `storage get` with no --key works inside a run. A selection
      remembers the pattern you gave it, so after a navigation it re-points at
      whatever frame now matches. If nothing matches any more, the selection is
      cleared and the next frame-scoped step fails rather than reading the wrong
      frame. The selected frame going away clears it too, but the pattern is
      kept either way, so a later navigation that brings a matching frame back
      selects it again. Only `frames reset` and a new `frames select` change the
      pattern. `storage get --key` does not: the key names the frame for that
      one read, and the selection is put back afterwards.

      AFTER A NAVIGATION, WAIT BEFORE YOU READ. Page.navigate returns when the
      navigation commits, and the frame tree updates from the event that
      follows it. A step placed straight after a navigate can run before that
      event arrives and see the frames of the page you just left. Put a
      `wait-idle` step in between.

      Use `wait-idle` and not `wait --event Page.loadEventFired` here. `wait`
      reports events that arrive after it subscribes, and it subscribes when
      its own step starts. A load that fired in the gap after the navigate step
      is already gone, and the wait then times out on a page that has finished
      loading. `wait-idle` reads the page's current state instead of waiting
      for an edge, so it cannot miss one. It answers a narrower question than
      `load` - the new document has stopped fetching - which is the question
      this paragraph is about.

      EVERYTHING ELSE BEHAVES AS IT WOULD ALONE, WITH ONE EXCEPTION. A step
      inside a run does what the same command does on its own. Domains a step
      turns on are turned off when that step ends, so a second `network-list`
      in one run sees what the first one saw.

      The exception is the Page and Runtime domains. A run needs them on
      throughout to track frames, so they are never turned off between steps.
      A step that relies on being the first to turn one of them on does not
      get that. `wait --event Runtime.executionContextCreated` after any step
      that touched Runtime reports nothing, where the same command on its own
      reports the contexts that already exist; `console-list` is in the same
      position, because it turns Runtime on too. Run either one on its own.

      A raw step you write yourself is yours to undo. `Network.enable {}`
      stays on until a later step turns it off; the rule above covers only the
      domains a verb turns on for you. It wins over that rule where the two
      meet: a `network-list` step after a hand-written `Network.enable` leaves
      the domain on, because turning it off would make your step a no-op. The
      price is that the `network-list` is not the first enable, the same
      position Page and Runtime are already in.

      The two exceptions are the run's own: `Page.disable` and
      `Runtime.disable` are usage errors as steps (exit 2), because turning
      either off breaks every step after it without failing any of them. Run
      either on its own, outside a run.

      UIDS ARE UNCHANGED. A UID is still valid until the page navigates, no
      longer and no shorter. One process does not extend it. Because no step can
      read another step's output, every UID in a step list is one you put there
      from a snapshot you already read.

      IT STOPS AT THE FIRST FAILURE, and nothing rolls back. A step that has run
      has already reached the browser. Steps after the failure do not run.

      --timeout SECONDS bounds the whole run; without it there is no whole-run
      deadline, because every step already bounds itself. Use it when a step
      opts out of its own bound, as `wait --timeout 0` does.

      OUTPUT. One JSON document, on success and on failure alike: a `run` object
      with the step count, how many completed, and the status; and a `steps`
      array with one entry per step attempted, each carrying the JSON that step
      would have printed alone. Steps never reached are absent.

      READ IT IN THIS ORDER: the exit code, then `run.status`, then the steps.
      A failed run still contains successful step entries, and each of those
      carries exactly what that step prints on its own. If you parse the steps
      without checking the exit code first, a run that died at step 7 reads
      like a run that finished.

      Exit 0   every step succeeded.
      Exit 1   a step failed, or the run timed out. The document is still
               printed, so you can see what already ran. This is the one verb
               that prints on stdout when it exits 1.
      Exit 2   the step list was malformed, or a step named an instance,
               --endpoint, --target or --url. The whole list is checked before
               the first step runs, so nothing ran.


EVENT VERBS

  attach [INSTANCE] +Domain.event [+Domain.event ...]
         [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Stream subscribed CDP events as JSON Lines, one event per line, until
      stdin reaches EOF or the process is signalled. It prints a
      {"status": "ready"} line first. While it runs, a line of `+Domain.event`
      on stdin adds a subscription and `-Domain.event` removes one. Two
      attached observers never see each other's subscriptions. At least one
      +Domain.event is required (exit 2), and a token that is not
      Domain.event-shaped is a usage error (exit 2).

  wait [INSTANCE] --event Domain.event [--match SUBSTRING]
       [--timeout SECONDS] [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Block until one matching event fires, then print it as JSON. It
      subscribes before it begins examining events, so an event that fires in
      between is buffered, not lost. --match is a substring test against the
      event's whole JSON serialization, not against its parameters alone.
      --timeout defaults to 30 seconds; --timeout 0 means no deadline. On the
      deadline: a diagnostic on stderr, exit 1, and nothing on stdout.


RAW PROTOCOL

  [INSTANCE] Domain.method '{...json params...}'
             [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Send any CDP method the installed browser supports straight to it and
      print the JSON result. No curated verb needs to exist for the method.
      Parameters must parse to a JSON object; anything else is a usage error
      (exit 2). Methods that raise the window are refused; see NEVER TAKE THE
      SCREEN.

  help [INSTANCE] [Domain.method] [--endpoint URL]
      With a running instance, print the live CDP protocol schema read from
      that browser: every domain, one domain's commands and events, or one
      method's full signature. With zero or several live instances, or an
      unreachable one, it prints static usage instead. Plain text, not JSON.


NO CURATED VERB? SEND THE PROTOCOL

  The curated verbs cover the common path. Everything else the browser can
  do is one CDP call away and needs no new verb. The passthrough prints the
  method's result exactly as the browser returned it, with nothing wrapped
  around it.

  Each recipe here was run against Chrome 153 before it was written down.

  UPLOAD A FILE. Snapshot the page, find the file input, and pass the number
  after the dash in its UID as backendNodeId:

    bt snapshot
      [uid=2F0B815AF686-32] button "Choose File" = 'No file chosen'
    bt DOM.setFileInputFiles '{"files": ["/abs/path"], "backendNodeId": 32}'

  A DOM nodeId from DOM.querySelector will NOT work across invocations; see
  the next section. The backendNodeId inside a UID will.

  TYPE KEYSTROKES. `fill --uid` is the normal path. Use these when the field
  needs real key events, such as a key handler or a contenteditable:

    bt Input.insertText '{"text": "hello"}'
    bt Input.dispatchKeyEvent '{"type": "keyDown", "key": "Enter"}'

  SET THE VIEWPORT, or emulate a phone:

    bt Emulation.setDeviceMetricsOverride '{"width": 390, "height": 844,
       "deviceScaleFactor": 3, "mobile": true}'

  The override outlives the invocation that set it. A later
  Emulation.clearDeviceMetricsOverride does NOT undo it, because only the
  session that set an override can clear it. Set the size you want back.

  READ AND SET COOKIES:

    bt Network.getCookies '{"urls": ["https://example.com/"]}'
    bt Network.setCookie '{"name": "k", "value": "v",
       "domain": "example.com", "path": "/"}'

  SAVE THE PAGE AS PDF. The result carries base64 in `data`:

    bt Page.printToPDF '{}'

  WORK WITH SEVERAL PAGES:

    bt Target.getTargets '{}' --target 1
    bt Target.createTarget '{"url": "https://...", "newWindow": true}'
    bt Target.closeTarget '{"targetId": "<id>"}' --target 1

  Target.* is browser-level, but it is sent over a page session, so once a
  second page exists these calls need --target to say which page to send
  over. `status` lists the pages and their ids.

  DRAG AND DROP is Input.dispatchDragEvent, one call per dragEnter, dragOver
  and drop. `bt help Input.dispatchDragEvent` prints its signature.

  For anything not listed, `bt help Domain.method` reads the signature live
  from the browser you are driving, so the parameters are the ones that
  browser accepts rather than the ones some other version documented.


WHAT DOES NOT CARRY BETWEEN INVOCATIONS

  Each invocation opens its own session, works, and detaches. Page state
  stays behind: the page you navigated is still loaded, cookies are still
  set, a viewport override is still in force. Session state does not, and
  four things an agent reaches for are session state. Each fails loudly
  rather than quietly doing nothing.

  A DOM nodeId. DOM.getDocument and DOM.querySelector mint node ids owned by
  the session that asked. One reused in the next invocation fails with
  "Could not find node with given id" (exit 1). Use a snapshot UID's
  backendNodeId, which is stable for the life of the document.

  A domain you enabled. `Profiler.enable` in one invocation is gone by the
  next, so a following `Profiler.start` fails with "Profiler is not enabled"
  (exit 1). Any enable-then-collect pair split across two commands fails the
  same way. This is why console-list and network-list take a --duration and
  do their own enable inside one invocation, rather than offering a start
  and a stop, and why CPU profiling is its own command rather than a verb
  here. See CPU PROFILING at the end.

  A JavaScript dialog, and there is no way around this one. A dialog opened
  by one invocation is invisible to the next: Page.handleJavaScriptDialog
  fails with "No dialog is showing" (exit 1), even while the command that
  opened it is still running. The unhandled dialog then blocks the page and
  later commands against it hang. Navigating away can clear it; when that
  hangs too, `stop` the instance and `launch` again.

  Page.addScriptToEvaluateOnNewDocument does not rescue this. Registering
  window.alert=()=>{} works when the same invocation also navigates, and
  does nothing when a later invocation navigates, because the registration
  belongs to the session that made it. Measured both ways. So a page that
  raises dialogs cannot be driven through them one command at a time.

  A selected frame. `frames select` binds to the process that ran it, so a
  later `storage get` does not see it. That is what `storage get --key` is
  for, and --key is the only way to read a frame's storage from a separate
  invocation.


SELECTING A PAGE

  --target SPEC means one thing everywhere: a 1-based index into the page
  targets sorted by target ID, or a target ID prefix. A value is read as an
  index only when every character is a digit. The sort is normative, so
  --target 1 names the same page in every verb.

  --url SUBSTRING selects by URL substring instead. --target and --url are
  mutually exclusive; giving both is a usage error (exit 2).


THE UID RULE

  A snapshot gives each node a UID of the form <docToken>-<backendNodeId>.
  One rule covers its whole lifetime:

    A UID is valid until the page navigates. After a navigation, take a new
    snapshot. Nothing else invalidates it.

  So a fresh snapshot invalidates nothing: taking one between two fills is
  unnecessary, and the second fill's UID from the first snapshot still
  resolves. Clicking, filling, scrolling and DOM changes do not invalidate a
  UID either. A UID minted against a previous document fails with exit 1 and
  says to take a new snapshot.

  A node the accessibility tree reports with no DOM node behind it gets a UID
  of the form <docToken>-x<n>. Those are readable in the snapshot but cannot
  be clicked or filled.


NEVER TAKE THE SCREEN

  A person is working on this machine. A browser window that comes to the
  front takes their keyboard focus and moves their window manager to it, and
  the next call takes it again. Three refusals keep windows in the background,
  and each names a remedy that works:

  - Target.activateTarget and Page.bringToFront are refused (exit 2).
  - Target.createTarget with background:false is refused (exit 2);
    Target.createTarget always opens in the background.
  - Input sent to a background tab -- a tab that is not the selected tab of
    its window -- fails (exit 1). Chrome drops it without an error, which is
    what makes an agent reach for activateTarget.

  Input and screenshots reach the selected tab of a window even when the
  window is behind other windows or on another workspace. To work in a second
  page, open it in its own window and target it:

    bt Target.createTarget '{"url": "https://...", "newWindow": true}'
    bt Input.dispatchMouseEvent '{...}' --target <targetId>

  Or navigate the tab you already have with Page.navigate. launch opens its
  window in the background too.


PROFILES AND LOGIN

  A profile is a persistent identity: a browser user-data-dir that keeps
  cookies and logins across restarts. A fingerprint profile is a different
  thing entirely -- a file of launch flags passed with --fingerprint, which
  shapes how the browser presents itself and stores no login at all.

  To establish a login, do it by hand once:

    bt launch --profile shopify-admin          # headed, so you can see it
    bt Page.navigate '{"url": "https://admin.shopify.com"}'
    # log in yourself in that window: username, password, 2FA, whatever it asks
    bt stop

  From then on, the same flag reuses the same identity:

    bt launch --profile shopify-admin
    bt snapshot                                # already signed in

  The profile directory is <profile root>/shopify-admin, and it survives
  `stop`, reboots, headed and headless switches, viewport changes, and the
  directory you invoke from. Without --profile a launch gets a fresh throwaway
  directory and starts logged out. Camoufox keeps a login only with --profile.

  PROFILE EXCLUSIVITY. A profile is held by at most one live instance.
  Launching into a profile another instance holds fails (exit 1) naming the
  holder, rather than opening a second browser on the same directory:

    Profile 'shopify-admin' is already held by live instance 'web-01'.
    Stop it first, or launch a different profile.

  The remedy is `bt stop web-01`, and then the launch succeeds.

  THE PROFILE ROOT is durable storage, resolved in this order, with an empty
  value falling through to the next:

    1. $BROWSER_TOOLS_PROFILES_DIR
    2. $XDG_DATA_HOME/browser-tools/profiles
    3. ~/.local/share/browser-tools/profiles

  It used to be /tmp/browser-tools-profiles, where the operating system
  deleted every signed-in session at boot, silently. A profile still there is
  listed with "legacy": true, `profile migrate` moves them all across, and
  `launch --profile NAME` brings that one forward by itself. The registry
  stays in /tmp: a cleared registry after a reboot is self-consistent, because
  no browser survives one.


EXTERNAL BROWSERS

  --endpoint URL drives a browser this tool did not launch: one the person
  already has open, and already logged in. It goes on the browser-driving
  verbs, per invocation.

    bt snapshot --endpoint http://127.0.0.1:9222
    bt Page.navigate '{"url": "https://..."}' --endpoint http://127.0.0.1:9222

  Start the browser yourself with --remote-debugging-port=PORT, log in by hand
  in that browser, then pass that port. There is no instance name, so every
  invocation carries the flag.

  THE BROWSER IS NOT REGISTERED. Nothing is written to the registry, so
  `status` does not list it, and `stop` and `cleanup` neither see it nor touch
  it. That absence is deliberate: those verbs act on registry entries, and an
  external browser's user-data-dir is the person's real profile directory.

  Three refusals, each exit 2, each naming a remedy that works:

  - LOOPBACK ONLY. 127.0.0.1 and ::1 are accepted and nothing else, not even
    localhost. A CDP endpoint is unauthenticated full control of a logged-in
    browser, cookies included, so a remote one is a takeover channel. Reach a
    browser on another machine by forwarding it, and the endpoint is loopback
    again:

      ssh -L 9222:127.0.0.1:9222 <host>

  - Browser.close and Browser.crash are refused: they would end every window
    and tab the person had open. Everything else passes. To close one tab,
    send Target.closeTarget with that tab's id in the params, and --target
    SPEC to pick the page the call is sent over when several are open:

      bt Target.closeTarget '{"targetId": "<id>"}' --target 1 --endpoint URL
  - --endpoint with --profile is refused: a profile is a launch-time identity
    and --endpoint launches nothing. Use `bt launch --profile NAME` instead.

  launch, status, stop, cleanup, guide and profile reject --endpoint (exit 2).
  Everything under NEVER TAKE THE SCREEN applies unchanged. A browser that
  dies mid-session shows up as a connection error on the next invocation,
  which names the port, any process holding it, and the user-data-dir that
  process holds.


CPU PROFILING

  `browser-tools-profiler` is installed next to `bt` and profiles the page's
  JavaScript. It is a separate command because a profile is an
  enable-start-stop sequence, and those cannot be split across `bt`
  invocations; see WHAT DOES NOT CARRY BETWEEN INVOCATIONS.

  It takes a debug port, not an instance name, so read the port out of
  `bt status` or the JSON `bt launch` printed. The default is 9222, which is
  whatever happens to be on that port rather than an instance you named, so
  pass --port.

    browser-tools-profiler --port PORT [--format text|json] timed
        [--duration SECONDS]
      Profile for a fixed window, 5 seconds by default, and print the
      functions by self time.

    browser-tools-profiler --port PORT [--format text|json] watch
        [--threshold PERCENT] [--timeout SECONDS] [--window SECONDS]
      Wait until CPU crosses the threshold (80 percent by default), then
      capture for --window seconds. Gives up after --timeout.

  --format json prints a JSON array of {name, url, line, selfTime,
  hitCount}. --format text is the default and is for a person.


OUTPUT AND EXIT CODES

  Machine-readable output goes to stdout; diagnostics go to stderr.

  Every verb prints one JSON document on stdout, with three exceptions:
  `guide` and `help` print plain text, and `attach` prints JSON Lines, one
  event per line.

  Exit 0   success.
  Exit 1   operational failure: the browser is gone, a CDP call failed, a
           deadline passed, a profile is held, a UID no longer resolves.
           Nothing is printed on stdout, except by `run`, which prints its
           run document so you can see which steps already ran.
  Exit 2   usage error: the invocation was malformed or refused before
           anything happened. Nothing was sent, written or deleted.
