Reading, vitals, React and WebMCP
Fetch pages as Markdown with page.read and HttpReader, measure Core Web Vitals, inspect React component trees, and call WebMCP tools exposed by a page.
// Markdown first: Accept negotiation, then <path>.md, then llms.txt, then HTML converted to text.
val docs = page.read("https://kotlinlang.org/docs/coroutines-overview.html", outline = true)
println(docs.source) // e.g. "html-outline" or "markdown-outline"
println(docs.content)
// Without a browser at all:
val reader = HttpReader()
val llms = reader.read("https://example.com/docs", llms = LlmsReadMode.Index)This page covers four page tools that come from agent-browser and have no Playwright equivalent:
| Tool | Access | agent-browser |
|---|---|---|
| HTTP reading | page.read(url, …), HttpReader(config).read(url, …) | read |
| Web Vitals | page.webVitals(url, reload, settleMs, timeoutMs) | vitals |
| React inspection | page.react (ReactInspector) | react |
| WebMCP tools | page.webMcp (WebMcp) | webmcp |
Reading pages over HTTP
page.read(url) fetches url with its own HTTP client (Ktor: OkHttp on Android and JVM, Darwin on iOS), not the browser. It doesn't render JavaScript and doesn't send the browser's cookies. page.read() with no URL converts the page's current HTML instead (see Reading the current page).
HttpReader does the same thing without a page:
val reader = HttpReader(KmpwrightConfig()) // config.allowedOriginRules and allowDomExtraction apply
val result: ReadResult = reader.read("https://example.com")A URL without a scheme gets https://. Only http and https URLs are accepted, and URLs with embedded credentials are rejected. The fragment is dropped and dot segments are normalized before any policy check.
How content is chosen
The reader asks for Markdown first: by default it sends Accept: text/markdown, text/plain;q=0.9, text/html;q=0.7, */*;q=0.1 and User-Agent: Kmpwright read.
- Primary response. If it's a 2xx response with a Markdown media type (
text/markdown,text/x-markdown,application/markdown), orraw = true, return it. - Markdown URL. Otherwise, unless
rawis set or you passed your ownAcceptheader, try<path>.md(/index.mdfor the root). Use it if it's 2xx Markdown, ortext/plainwhenrequireMarkdownis false. - llms.txt link. Look for
llms.txtin the URL's directory and each parent up to the root. If a list item there links to this page (same origin and path ignoring.md,.htmland/index, or a unique last-segment or slugified-title match), fetch and return that link. - Fallback. Return the primary response. If it is HTML, it is converted to Markdown-like text. A non-2xx primary response throws
KmpwrightException("HTTP read failed with status …").
HTML conversion parses the document with Ksoup, removes script, style, noscript, svg and template, and writes headings as #, lists as - , links as [text](absolute-url), pre as fenced code, code, strong and em inline, images as their alt text, and table cells separated by |.
Parameters
page.read and HttpReader.read take the same named parameters after url (url is optional on page.read and required on HttpReader.read).
Prop
Type
Result
| Field | Type | Meaning |
|---|---|---|
url | String | Normalized request URL. |
finalUrl | String | URL of the response that was used, after redirects and fallbacks. |
status | Int? | HTTP status; null for the current-page read. |
contentType | String | Response Content-Type. |
source | String | How the content was produced (see below). |
truncated | Boolean | The body exceeded maxBytes. |
content | String | The text. |
headers | Map<String, String> | Response headers, lower-case names. |
source is one of markdown, html, text, raw, markdown-url, llms-link, llms-index, llms-full, active-tab-html or active-tab-raw. With outline it gets an -outline suffix, and with filter (without outline) a -filtered suffix. llms modes take no suffix.
llms.txt modes
page.read("https://example.com/docs/guide", llms = LlmsReadMode.Index, filter = "auth")
page.read("https://example.com/docs", llms = LlmsReadMode.Full, filter = "Webhooks")The reader starts in the URL's directory and walks up to the root. It uses the first file found that is 2xx and not HTML, and throws if none is found.
Indexreadsllms.txtand returns its list links as- [title](absolute-url)under aSource:line. Withfilter, only links whose title or URL contains it are kept. If nothing matches, the content isNo matching llms.txt links. A file with no list links and nofilteris returned as is.Fullreadsllms-full.txt.filterkeeps the matching sections.outlineis ignored in both modes.
Redirects, domains and limits
- Redirects (301, 302, 303, 307, 308) are followed by KMPwright, up to 10. Each hop is checked before the request is made. A loop throws.
- Your custom headers (API keys, for example) are only sent to the original origin.
Acceptfollows redirects. - Every request URL, including redirects and fallbacks, must pass all of these:
KmpwrightConfig.allowedOriginRules, if set.- The
allowedDomainsparameter. - For
page.read: the page'ssetAllowedDomainslist, and the page's action policy forActionType.Navigate.
- Responses are decoded with the charset from
Content-Type, defaulting to UTF-8. - HTML conversion stops with an error past 1,000,000 nodes or 4 MiB of output.
- Errors:
KmpwrightTimeoutExceptionon timeout;KmpwrightExceptionfor disallowed hosts, bad URLs, bad charsets, too many redirects, or whenKmpwrightConfig.allowDomExtractionis false.
PageCapability.HttpRead is Supported on every backend.
Reading the current page
val visible = page.read() // current DOM as text, source = "active-tab-html"
val html = page.read(raw = true) // source = "active-tab-raw"With no URL, read takes page.content() and runs the same HTML conversion, outline and filter. maxBytes still applies. llms and requireMarkdown need a URL and throw here.
Web Vitals
val vitals = page.webVitals() // reloads the current page, waits 3 s
println("LCP ${vitals.lcp?.startTime} ms, CLS ${vitals.cls.score}, TTFB ${vitals.ttfb} ms")
vitals.metrics["LCP"]?.rating // "good" | "needs-improvement" | "poor"
page.webVitals(url = "https://example.com", settleMs = 5_000)
page.webVitals(reload = false) // measure the already-loaded documentKMPwright injects the bundled web-vitals library (unmodified 6.2.3). By default it reloads the page so the collector runs from document start, waits settleMs, and reads the report. The collector is removed when it finishes.
Prop
Type
WebVitalsReport:
| Field | Type | Meaning |
|---|---|---|
url | String | Measured document. |
metrics | Map<String, WebVitalMetric> | Keys LCP, CLS, INP, FCP, TTFB when reported; each has value, rating, id and delta. |
ttfb, fcp, inp | Double? | Milliseconds. INP needs an interaction during the settle time. |
lcp | LargestContentfulPaint? | startTime, size, element (tag name) and url (image URL). |
cls | LayoutShiftReport | score and up to 100 entries (value, startTime). |
hydration | PerformancePhase? | Span of React's "Hydrated" scheduler phases, when the page's React reports performance tracks. |
phases, hydratedComponents | List<PerformancePhase> | React scheduler phases and hydrated component spans (label, startTime, endTime, duration). Components are sorted longest first. |
supportedEntryTypes | Set<String> | PerformanceObserver.supportedEntryTypes of the engine. Use it to tell "not supported" from "not reported". |
droppedTimingEntries | Int | Timing entries dropped past the 3,000-entry cap. |
PageCapability.WebVitals is Supported on JVM and Partial on Android and iOS. WebKit doesn't report every entry type, so check supportedEntryTypes. Reload and url measurements need document-start scripts (PageCapability.InitScripts), which are Partial on Android. Throws KmpwrightTimeoutException on timeout and KmpwrightException when allowEval is false.
React inspection
page.react embeds React DevTools (react-devtools-inline 8.0.0 with a private React 19.3.0 runtime) and reads the component tree of the page's own React app.
page.react.enable(reload = true) // install the DevTools hook, then reload so React finds it
val tree: List<ReactComponent> = page.react.tree()
val app = tree.first { it.name == "CheckoutForm" }
println(page.react.inspect(app.id).text) // props, state and hooks as text
page.react.suspense().filter { it.isSuspended }.forEach { println("${it.name} waits on ${it.suspendedBy}") }
page.react.startRenderProfile()
page.getByRole("button", name = "Add to cart").click()
val profile = page.react.stopRenderProfile()
profile.components.take(5).forEach { println("${it.name}: ${it.count} renders, ${it.selfTime} ms") }
page.react.disable()| Method | Returns | Notes |
|---|---|---|
enable(reload: Boolean = false) | Unit | Registers the hook as a document-start script and installs it in the current document. React only finds a hook that exists before it starts, so use reload = true for an app that's already mounted. |
disable() | Unit | Removes the hook and stops a running profile. |
tree(timeoutMs: Long? = null) | List<ReactComponent> | id, type (DevTools element type code), name, key and parent id. |
inspect(id: Long, timeoutMs: Long? = null) | ReactInspection | text (props, state, hooks) and source when available. id must be positive. |
suspense(timeoutMs: Long? = null) | List<ReactSuspenseBoundary> | Boundaries with isSuspended, suspendedBy, owners, environments and jsxSource. |
startRenderProfile() | Unit | Throws unless enable() ran first, or if a profile is already running. |
stopRenderProfile() | ReactRenderProfile | elapsed, fps (avg, min, max, drops), totals, and per-component count, mounts, reRenders, totalTime, selfTime, domMutations and changes (why it re-rendered). |
A null timeoutMs uses TimeoutConfig.default. PageCapability.ReactInspection is Supported on every backend, but enable relies on document-start scripts, which are Partial on Android. Reads need allowEval and allowDomExtraction, and every call is checked against ActionType.Evaluate.
WebMCP
WebMCP lets a page register tools for agents. page.webMcp lists them and calls them through Chromium's experimental WebMCP DevTools domain.
val tools = page.webMcp.discover()
tools.forEach { println("${it.name}: ${it.description}") }
val call = page.webMcp.invoke(
name = "searchFlights",
input = buildJsonObject { put("from", "SFO"); put("to", "JFK") }
)
if (call.status == WebMcpInvocationStatus.Completed) println(call.output)| Method | Notes |
|---|---|
discover(): List<WebMcpTool> | Tools registered by the page and its frames: name, description, inputSchema, annotations, origin, frameId, backendNodeId. |
context(): WebMcpContext | Not suspend. Bounded summary: observed, toolCount, tools, truncated, and experimental = true, untrusted = true flags. |
invoke(name, input = JsonObject(emptyMap()), frameId = null, timeoutMs = null, waitForResult = true) | Returns a WebMcpInvocation. With waitForResult = false it returns while the call is Pending. |
invocation(id) | Not suspend. Current state of an invocation. |
awaitInvocation(id, timeoutMs = null) | Waits for a pending invocation. |
cancelInvocation(id) | Cancels it. |
WebMcpInvocation has id, toolName, frameId, origin, status (Pending, Completed, Canceled, Failed, TimedOut), output, error, outputTruncated, originalOutputBytes, durationMs and rawStatus.
Tool names, descriptions and outputs come from the page. Treat them as untrusted input.
| Backend | webMcp capability | Behavior |
|---|---|---|
| JVM (CDP) | Partial | Needs a Chromium that exposes the WebMCP domain. launchBrowser(executablePath, webMcp = true) (the default) enables the WebMCPTesting and DevToolsWebMCPSupport features. Iframe sessions are capped at 128. |
| Android, iOS | Unsupported | Every call throws KmpwrightException, except context(), which returns observed = false. |
discover and context need allowDomExtraction, and invoke needs allowEval.
Related
- Security: allowed domains, origin rules, action policies
- Capabilities
- JVM platform:
launchBrowser - Page reference
Accessibility audits
Run the bundled axe-core 4.14.0 against the current page with accessibilityAudit and read violations, incomplete checks and frame coverage.
Recording, tracing and profiling
Record page video with an optional cursor and contact sheet, stream screencast frames, capture Chrome performance traces and V8 CPU profiles; all JVM (CDP) only.