A typed Java SDK for driving the Sketerm MCP server.
It speaks MCP over newline-delimited JSON to a sketerm mcp child process. Protoblast is the
only runtime dependency: Dry supplies the JSON codec and JobRunner the reader and stderr
threads. There is no Jackson, no Reactor and no SLF4J.
Each layer knows only the one below it, so a future HTTP transport is a swap and not a rewrite.
| Package | Responsibility |
|---|---|
json |
Json, a facade over one shared Dry: parseObject, an NDJSON-safe write, and typed getters that fail with the offending key named |
process |
SketermProcess: argv/cwd/env, UTF-8 line streams and a 200-line stderr tail, supervised by protoblast's Subprocess. close() sends end-of-file (Sketerm then stops its own helpers and flushes profile cookies), waits up to the close timeout, and only then stops the WHOLE process tree (SIGTERM, SIGKILL after STOP_GRACE); a crashed JVM never orphans a browser |
rpc |
SketermTransport (the seam), StdioTransport (newline framing), JsonRpcConnection (id correlation, per-call timeouts, EOF drains every pending call with the child's exit code and stderr tail) |
mcp |
McpSession (initialize, tools/list, tools/call, ping), ToolDescriptor, ToolResult, Content, ToolException |
api |
The Playwright-SHAPED synchronous face over the browser tool group: Sketerm, Browser, Page, Snapshot/Ref, NetworkPolicy/PolicyStatus, Evidence, and one typed exception per error code |
Sketerm has two structured-result shapes in the wild, so ToolResult.decode applies one rule:
- use
structuredContentwhen the server sent it; - otherwise, if the first content block is text whose trimmed form starts with
{and parses as a JSON object, adopt that (what the installed 0.1.3 build emits); - otherwise the result is prose only and
structured()is null.
A current sketerm mcp answers text only unless it runs with --structured, so
SketermOptions.command() always passes it. A server older than the flag refuses it as an unknown
flag (exit 2) before it speaks MCP, and Sketerm.launch relaunches it once with
legacyCommand(); such a server sends structuredContent anyway. A prose-only answer where the api
layer needs facts is a ProtocolMismatchException that names the flag.
Tool failures arrive as a successful JSON-RPC result carrying isError, a channel distinct from
a JSON-RPC error object. ToolResult.orThrow(name) turns one into a ToolException, reading
structuredContent.error's code, message and retryable when the newer server shape is present.
A text-only error reads error CODE: message ( (retryable) after a retryable code), which gives
the same code and flag.
SketermProcess process = SketermProcess.start( List.of("sketerm", "mcp", "--tools", "browser,core"), null, null); try (McpSession session = McpSession.initialize( new JsonRpcConnection(new StdioTransport(process)))) { for (ToolDescriptor tool : session.listTools()) { System.out.println(tool.name()); } ToolResult result = session.callToolOrThrow("web_open", Map.of("url", "about:blank")); System.out.println(Json.str(result.structured(), "url")); }
Sketerm.launch composes process, transport, connection and session into one AutoCloseable, and
its browser() opens views. It is shaped like Playwright, not compatible with it: everything is
synchronous, and a Ref is a semantic id from the server's own accessibility tree rather than a
CSS selector.
SketermOptions options = SketermOptions.builder() .binaryPath("sketerm") .toolGroups("browser,core") .defaultTimeout(Duration.ofSeconds(30)) .build(); try (Sketerm sketerm = Sketerm.launch(options)) { Page page = sketerm.browser().openPage("https://example.com/"); // web_open already sent the first tree; asking again would only get a delta Snapshot snapshot = page.lastSnapshot(); Ref moreInfo = snapshot.find("More information").orElseThrow(); page.click(moreInfo); page.waitFor(WaitFor.LOAD); Article article = page.read(); System.out.println(article.markdown()); System.out.println(page.evaluate("document.querySelectorAll('a').length")); page.screenshot().writeTo(Path.of("page.png")); for (NetworkLog.NetworkRequest request : page.networkRequests()) { System.out.println(request.method() + " " + request.status() + " " + request.url()); } }
page.navigation() is what the last web_open or web_navigate of the view produced (Navigation: a
NavigationOutcome, the main document's HTTP status, the final url and its redirect hops), so a 429 or a refused
connection is never mistaken for a page. waitFor(WaitFor.SELECTOR, css) waits for an element (a selector list
waits for the first of several), WaitFor.NETWORK_IDLE for a quiet network, and a NetworkLog.NetworkRequest links
a redirect hop to the one it continues (redirect, prevSeq), a hop the policy refused included. A BLOCKED
navigation names why the engine refused it (blockedReason: the filter list or a policy rule).
Sketerm.connect(options, transport) completes the same handshake over a transport someone else
opened (a scripted server in a test, a future non-stdio transport); its process() is then null.
SketermOptions.startTimeout bounds the launch's handshake on its own (a caller with a deadline gives what is left;
a start past it is a CallTimeoutException), and sketerm.isAlive() says whether the server still answers (not
closed, its connection not failed, its child alive), so a pool notices a crashed browser before using it.
browser().isAvailable() reads the capabilities report's web flag: whether this server can run
the web tools at all, the preflight before opening anything. browser().isHeadless() says whether they
drive the server's own headless helper rather than a GUI the user owns (the web_gui grant, where
policies, profiles and capture are refused); a server that must never touch the user's browser
launches with SKETERM_MCP_WEB_GUI=0, which overrides a config grant.
Four more page methods cover the reads and inputs an accessibility or layout review needs:
KeyResult keys = page.key("Tab Tab Enter"); // TRUSTED key chords, the path a keystroke rides Viewport size = page.resize(390, 844); // in place; 320-3840 by 240-2160 logical pixels ConsoleLog log = page.console(); // every console line still held for the view ConsoleLog newer = page.console(log.lastId(), 50); // only what arrived since Inspection review = page.inspect(); // landmarks, focus, control names, overflow, errors
key (chords) and act on a Ref are the inputs a script normally needs. A person driving a headless
view by hand (a remote viewer) needs raw input at coordinates and the painted frames; a Sketerm that
advertises web_input and web_frames (browser.supportsInput(), browser.supportsFrames()) takes both:
page.input(InputEvent.click(120, 80)); // a down and an up edge at one point page.input(InputEvent.text("hello"), InputEvent.press("Enter"), InputEvent.wheel(400, 300, 0, 240)); page.input(InputEvent.keyDown("Shift"), InputEvent.keyUp("Shift")); // raw edges; a viewer releases them Frame frame = page.frame(0, Duration.ofSeconds(2)); // the current paint Frame next = page.frame(frame.serial(), FrameFormat.JPEG, 70, 960, Duration.ofMillis(120)); if (!next.unchanged()) show(next.bytes()); // only a newer paint carries pixels
Coordinates are viewport (CSS) pixels; a frame reports its own pixel size and the viewport it shows, so a
viewer scales its surface coordinates by the two. input takes at most Page.MAX_INPUT_EVENTS events per
call, in order; a refused event stops the batch and the result says how many were sent. frame long-polls:
it answers as soon as a paint newer than since exists, or unchanged once the timeout passes. Sketerm
answers one call at a time, so a long frame poll delays every other call on that session.
Every call names its view handle explicitly (the pane argument), so several pages can be driven
from one session without the server's "current view" ever deciding for you.
Headless pages default to 60 FPS, or the MCP config's web_max_fps. Set an
explicit per-view cap with OpenOptions.defaults().withMaxFps(30), or use
page.stream(listener, 15) to override an existing view at stream open.
Caps are integers from 1 through 240 and control CEF's own paint scheduler,
not Java callback timing. An omitted stream cap preserves the view's rate;
a stream override remains after that stream closes. GUI monitor-driven
pacing is unchanged. browser.supportsMaxFps() reads web_max_fps; explicit
caps fail closed on unsupported servers/helpers, and the SDK requires the
applied-rate echo. Other fluent open options preserve maxFps.
Configure the server with [mcp] web_max_fps = 30; a selected
[mcp.<name>] section can override it. capabilities.web_default_max_fps
reports the resolved headless default before the helper starts.
browser.supportsStreams() reads capabilities.web_stream (missing/null means false). A supported
headless view can push raw paints through a local Unix socket independently of MCP:
PageStream stream = page.stream(new PageStream.Listener() { @Override public void surfaceSize(PageStream stream, PageStream.SurfaceSize size) { resizeSurface(size.pixelWidth(), size.pixelHeight()); } @Override public void damage(PageStream stream, PageStream.Damage damage) { uploadPackedBgra(damage.x(), damage.y(), damage.width(), damage.height(), damage.pixels()); } @Override public void frameEnd(PageStream stream, PageStream.FrameEnd frame) { presentSurface(); stream.ack(frame.serial()); } }); stream.input(InputEvent.click(120, 80)); stream.input(InputEvent.press("Enter"), InputEvent.text("hello")); stream.key(KeyAction.PRESS, KeyCode.ENTER); // protoblast's shared physical-key vocabulary stream.resize(640, 480); stream.focus(); stream.blur(); stream.close();
Page.stream(listener)installs the listener before AUTH; callbacks may run before the method returns and receive their stream explicitly. There is no listener-less overload that could lose the initial surface or damage. Callbacks run serially on a protoblastJobRunnerreader.Damage.pixels()is a read-only, reusable-buffer view of exactlywidth * height * 4packed BGRA-premultiplied bytes, with no stride padding; bands carry at most 1 MiB of pixel bytes. Damage, cursor-image and Opus buffers are valid only inside their callback. Upload or copy them before returning; retaining theByteBufferdoes not retain its contents.SurfaceSizereports physical pixel size and logical input size separately. Pointer coordinates use the logical viewport.Cursorcarries either a UTF-8 name or a borrowed BGRA image with its hotspot; cursor pixels are premultiplied like damage pixels.Audiocarries unsignedptsUs(CEF capture time in microseconds with an unspecified epoch), sample rate, channel count, per-channel sample count and a borrowed Opus packet; the SDK does not decode or play audio.- Audio normally starts at the next audible start after stream_open, not immediately when
attaching to an already-playing page; an already-active capture can be attached immediately.
hasAudio()reports stream support, not whether packets are currently arriving. CEF capture diverts the page's normal audio output into the stream and cannot be switched off mid-capture; it remains active until the page has been quiet for two seconds. Closing the stream is not an immediate restoration of normal audio output while the page keeps playing. - The listener must explicitly
ack(serial)after presenting aFrameEnd. ACK is cumulative through that delivered serial; no automatic acknowledgement is sent. The server permits two unacknowledged frames, then unions pending damage and sends the latest pixels when credit opens. Javalongpreserves all unsigned serial/timestamp bits; use unsigned comparison when needed. - Input, resize, focus/blur and ACK use binary messages, not
web_inputor another MCP request. Input remains responsive while an MCP wait is pending.KeyAction.PRESSsends down and up; blur and disconnect release held keys/buttons at the helper.stream.close()closes only the stream, not the Page. Page and Sketerm shutdown close their owned streams. stream.key(action, KeyCode, modifiers...)translates supported protoblast physical key codes to the helper's US-layout key names; unsupported codes are refused before sending. Use text events for layout-dependent characters. Binary reading and writing use protoblast's sharedBinaryReaderandBinaryWriter; the decoder still borrows its bounded receive buffer.- Text events carry at most
PageStream.MAX_TEXT(4096) UTF-8 bytes, and raw key names at mostPageStream.MAX_KEY_NAME(64) UTF-8 bytes; these are byte limits, not Java character counts. An oversized or malformed event refuses the entire input batch before its first edge is sent. - Stream V1 uses little-endian
u32 lengthincluding the tag byte, capped at 4 MiB, and exactly 32 lowercase ASCII hex token bytes in AUTH. Unknown tags, invalid UTF-8, truncation, incorrect lengths, out-of-surface damage, overflow and incompatible endpoint facts fail closed. Theclosed(stream, failure)callback reports termination;failure()is null for orderly EOF or local close. A stream socket must be reachable on the JVM's own machine; no remote socket forwarding or Java-side pixel JSON encoding is implied.
The first semantic snapshot is opportunistic, not the view's identity. A slow web_open may return
a still-live Page whose lastSnapshot() is null; wasOpeningSettled() and
openingSnapshotError() preserve that outcome, and a later snapshot(FULL) can recover. If the
returned handle is not actually present in web_tabs, the SDK closes it best-effort and throws
UnavailableException instead of exposing a phantom page.
Operation timeouts and JSON-RPC deadlines are separate on the wire. The SDK keeps the RPC request
alive beyond the operation's timeout_ms so Sketerm's bounded timeout result can arrive. If the RPC
deadline of a tool call itself expires, it throws UnansweredCallException, a TimeoutException
whose outcomeUnknown() is true: the request was sent, so a mutating call must not be retried
blindly, and the server did not answer at all, so a pool should not trust that browser again. The
handshake (initialize) still throws the transport's CallTimeoutException.
The tool schemas own the vocabularies; the enums mirror them one for one and carry their wire token
(Action, NavigateAction, NavigationOutcome, WaitFor, ScrollTo, SnapshotMode, QueryKind, NetworkAction,
ErrorCode, ProfileKind, ResourceType, UrlScheme, PolicySource, DenialReason,
DownloadState, EvalFormat, CaptureState, CaptureTruncation, BodyEncoding, BodyPart). An unknown
token fails closed rather than being folded into a neighbour.
Failures are typed off structuredContent.error.code by one switch in SketermApiException.from:
InvalidArgsException, NotFoundException, UnavailableException, TimeoutException,
RefusedException, ConflictException, IoFailedException, UnknownToolException,
FailedException, plus isRetryable() on all of them. Four more are the api layer's own:
StaleRefException- aReffrom a document the page has left, refused before the call goes out, and also what the server's own "stale reader id" / "unknown id" refusal decodes to.ProtocolMismatchException- the answer had nostructuredContent, or no view handle in it. That is a Sketerm predating the result-shape migration, and it is named as such rather than guessed at.AmbiguousMatchException-Snapshot.findmatched several nodes. An exact name (ignoring case) wins over names that merely contain the text, sofind("button", "Subscribe")never lands on "Unsubscribe"; two nodes at the deciding level are refused, never guessed.matches()lists them; act on one by its id.UnansweredCallException- a tool call Sketerm did not answer before its client-side deadline (above).
page.close() calls web_close and decodes a CloseResult (closed, remaining, current,
profile, profileReleased). What it destroys depends on the backend and the result says which:
headless it ends a helper view, but with a GUI attached it closes the USER'S pane, exactly as
close_pane does (closedAPane()). Every later call on that Page is refused locally with a
PageClosedException, because the handle is free to be handed to the next view somebody opens.
Headless views can hold an isolated browsing identity:
Browser browser = sketerm.browser(); if (browser.supportsProfiles()) { // Its own cookie jar and cache; logins survive the close and a server restart Page work = browser.openPage("https://example.com/", OpenOptions.inProfile("work")); // ... sign in, then commit every persistent jar to disk while the view stays open browser.saveProfiles(); work.close(); // A throwaway identity instead, destroyed with the view Page once = browser.openPage("https://example.com/", OpenOptions.ephemeralIdentity()); once.close(); for (BrowserProfile profile : browser.profiles().profiles()) { System.out.println(profile.name() + " " + profile.views() + " views"); } browser.resetProfile("work"); }
The rules the wire contract insists on, mirrored here:
- profiles are HEADLESS ONLY; with a GUI attached the identity containers belong to the user and both the open and the profile tools refuse;
- the refusal is FAIL CLOSED - nothing is opened and no page is loaded, never a silent fall back to
the shared jar - so
UnavailableExceptionfromopenPagemeans there is no view; profileandephemeralare opposite answers to the same question andOpenOptionsrefuses both at build time, as the server does at call time;- a name is
[a-z0-9_-]{1,64}minusdefaultandnone, checked client-side byProfileNamesso a doomed call never goes out; - SESSION cookies never persist: they die with the browser process, profile or not;
- a
contextid is opaque and is retired by a reset, so a profile is addressed by NAME everywhere; supportsProfiles()reads thecapabilitiesreport'sweb_profilesflag, the preflight before offering the feature at all.
A headless view can be opened under a policy the browser engine enforces itself, deciding every request before it leaves the process:
NetworkPolicy policy = NetworkPolicy.builder() .allowHosts("example.com", "cdn.example.com") .allowSubresourceHosts("static.example.com") .blockTypes(ResourceType.MEDIA, ResourceType.FONT) .allowSchemes(UrlScheme.HTTPS) .maxRequests(60) .maxBytes(8_000_000L) .maxNavigations(4) .deadline(Duration.ofSeconds(30)) .build(); Page page = browser.openPage("https://example.com/", OpenOptions.withNetworkPolicy(policy)); PolicyStatus status = page.policy(); System.out.println(status.requests() + " requests, " + status.bytes() + " bytes"); System.out.println(status.denied(DenialReason.SUB_HOST) + " subresource hosts refused"); // A live policy can only ever be narrowed; a field that would widen it is named, not applied PolicyUpdate update = page.tightenPolicy(NetworkPolicy.builder() .blockTypes(ResourceType.IMAGE) .maxRequests(20) .build()); System.out.println(update.tightened() + " narrowed, " + update.ignored() + " refused as loosenings"); // And a profile can carry a session default for every later open in it browser.setProfilePolicy("work", policy);
The rules the wire contract insists on, mirrored here:
- policies are HEADLESS ONLY, and are installed at OPEN and never added to a live view, whose earlier requests would predate them;
- the refusal is FAIL CLOSED: a helper without the net-policy capability answers
unavailableand NOTHING is opened, never a view running unpoliced, and past the helper's policied-view cap it is aconflictinstead; - budgets LATCH, permanently and per view. Past the first one that is hit,
web_navigate,web_act,web_evaland a load wait all throwRefusedException(non-retryable), while read tools keep answering and carry the fact - sopage.isPolicyExhausted()andpage.policyExhaustedReason()are up to date after any call, andpage.policy()has the accounting; max_bytesis accounted at response completion, so the response that CROSSES the cap completes and the NEXT request is refused; a redirect hop counts as a navigation;- nothing is durable:
PolicyStatus.durable()is always false, because a policy and a profile default live for this MCP server's lifetime by design; - host entries are ASCII/punycode names or complete IP literals, optionally
host:portor[IPv6]:port(ports 1–65535). Names match their subdomains; IP literals match exactly (equivalent spellings included), never as suffixes.*, schemes, paths, userinfo, scoped IPv6, malformed addresses and numeric fragments such as0.1are refused locally byPolicyHosts; - explicit ports apply in every mode. A bare entry permits all ports ordinarily, but only the scheme's default port in untrusted mode. An omitted/empty top-level list in untrusted mode defaults to the opened URL's host and effective port.
The vocabularies mirror the schema enums: ResourceType, UrlScheme, PolicySource and
DenialReason. exhausted_reason is derived from members whose latches() is true, including
POLICY_REFUSED; unknown reasons fail closed even when their refusal count is zero.
Minimum Sketerm build: commit 22c4b452 ("web: untrusted headless mode and native media
emulation") or later, whose version field is 0.2.1. No release tag contains that commit at the
time of writing; an older binary reporting 0.2.1 is not sufficient. Check capability facts and
use matching server/helper builds. Ordinary policies keep their existing wire shape and behavior.
Browser browser = sketerm.browser(); if (!browser.supportsUntrusted()) { throw new IllegalStateException("This renderer requires Sketerm untrusted loading"); } NetworkPolicy policy = NetworkPolicy.builder() .untrusted() // policy.untrusted:true; untrusted(boolean) also exists .allowHosts("content.example:443") .allowSchemes(UrlScheme.HTTPS) .deadline(Duration.ofSeconds(30)) .build(); Page page = browser.openPage("https://content.example/", OpenOptions.viewport(1200, 800) .withEphemeral() // required; shared and named identities are refused .withRoute("direct") // required; omitted route also means direct .withPolicy(policy) .withColorScheme(ColorScheme.LIGHT) // LIGHT -> light; DARK -> dark .withTimeout(Duration.ofSeconds(30))); assert page.isPolicyActive(); assert page.policy().untrusted(); // fresh, correlated enforcement attestation
browser.supportsUntrusted()readscapabilities.web_untrusted; missing/null/false means no support. This is a Linux headless build/platform fact, not proof of a particular installation.browser.supportsPolicyAcknowledgement()readscapabilities.web_policy_ack. Missing/null means false; null is normal before the first helper handshake, so do not require this pre-open. Each untrusted open independently requires the helper's verifieduntrusted-webandnet-policy-ackcapabilities and successful correlated installation acknowledgement before browser creation.openPagepreflights untrusted support, requires an active untrusted echo fromweb_open, then readsweb_policyand verifies its restrictedenforcedfacts. It throws a typed exception and closes the returned handle best-effort if the attestation is missing or disagrees. It never retries with ordinary loading.isPolicyActive()cannot report an unacknowledged untrusted install as active;PolicyStatus.untrusted()reports verified enforcement, not requested mode.- Untrusted policies require ephemeral identity, a
direct,tororproxy:route and HTTP/HTTPS-only schemes. Java rejects invalid combinations withInvalidArgsExceptionbefore a call. Mode cannot be set in either direction on a live patch (omituntrustedentirely), and untrusted policies cannot be named-profile defaults. Set.withEphemeral()before attaching an untrusted policy. withRoutetakes the grammar inRoutes.GRAMMAR:direct,tor,via:host,on:host, or a forward proxy you run,proxy:socks5h://HOST:PORTorproxy:http://HOST:PORT. Unknown routes fail locally, and so dosocks5://(it would resolve page hosts locally), credentials, paths and missing ports. An untrusted open without a route sendsroute:"direct"; with one, it sends that route, andopenPagerefuses (closing the view) when the server's echo names another route, or names none for a proxy route.
Page page = browser.openPage("http://abc.folio.localhost:4420/", OpenOptions.ephemeralIdentity() .withRoute("proxy:socks5h://127.0.0.1:1080") // or proxy:http://127.0.0.1:3128 .withPolicy(NetworkPolicy.builder().untrusted() .allowHosts("abc.folio.localhost:4420") .build()));
Every request of the view goes through that proxy, which resolves every host and decides what is
reachable: a loopback render host is served through it too, there is no bypass list, and
allowPrivateAddresses does not need to be set for it. SOCKS5 carries the hostname (ATYP 3); an
HTTP proxy gets a CONNECT for https and an absolute-form request for http. An unreachable proxy fails
the load and nothing falls back to a direct connection. In untrusted mode the restricted loader
connects to the proxy's address alone, and the web_policy attestation reads
http_broker:"route-proxy-only", which PolicyStatus.untrusted() checks against the view's route.
Views opened on the same proxy url share one browser instance. Needs a Sketerm build with
capabilities.web_route_proxy; an older server refuses the route text and opens nothing.
RouteExit exit = sketerm.routeExit("via:me@fra-1"); // needs sketerm.supportsRouteExit() String proxy = exit.proxy(); // socks5h://127.0.0.1:PORT; null for direct
web_route_exit hands a client that makes its own connections (an HTTP client beside the browser)
the proxy a route leaves through: a via: host's loopback SOCKS5 bridge (kept and shared per host for
the server's life, DNS on the host), the proxy a tor or proxy: route names, or none for direct.
on: has no exit (UnavailableException). A bridge connects in the background: an unreachable host
refuses every CONNECT, and the next ask makes a new one (reused() false). ScriptedSketerm
answers it after exitingThrough(proxyUrl).
withColorSchemeis headless-only and requires verifiedweb-emulation. Sketerm applies it before initial loading and requires a successful native media acknowledgement. The SDK checks the returned colour echo, so an older server ignoring the option fails closed withProtocolMismatchException. Null clears the preference; every other fluent option preserves it.- Restricted loading confines native Internet sockets, including WebSocket, WebRTC and WebTransport, uses an actual-address-validating HTTP broker, persists nothing, disables kernel core dumps and independently supervises helper cleanup. JavaScript constructors may still exist: test network reachability rather than their presence.
- Typed refusal accounting includes
RESOLVED_PRIVATE_ADDRESS,UNTRUSTED_HTTP,UNTRUSTED_TRANSPORT,UNTRUSTED_BROKER,UNTRUSTED_TIMEOUT,UNTRUSTED_QUEUE_FULL,URL_TOO_LONG,MALFORMED_URLandPOLICY_REFUSEDthroughPolicyStatus.denied(reason)andNetworkLog.NetworkRequest.reason().
Folio development/test origins work in untrusted mode. Sketerm explicitly honors
.allowPrivateAddresses(true) after DNS resolution as well as for literals. For
http://abc.folio.localhost:4420/, use .allowHosts("abc.folio.localhost:4420") and
.allowSchemes(UrlScheme.HTTP) with that opt-in. Use the concrete host, not *.folio.localhost;
folio.localhost:4420 also admits its subdomains. The scheme/host/port checks and native socket
restrictions remain active. Without the opt-in loopback-resolving origins are refused. The
ordinary policy's private-address check remains literal-only.
Bytes leave a page in two ways, and neither passes through the session. page.download fetches a
url INSIDE this view's browser, so its cookies, session and route carry and a file behind a login
needs no token of its own; page.evaluateToFile writes a whole eval result to disk.
if (sketerm.browser().supportsDownloads()) { Download one = page.download("https://example.com/report.pdf", Path.of("/tmp/report.pdf")); System.out.println(one.state() + " " + one.bytes() + " " + one.sha256()); DownloadBatch batch = page.download(urls, Path.of("/tmp/out")); System.out.println(batch.completed() + " done, " + batch.failed() + " failed"); for (Download known : page.downloads().downloads()) { System.out.println(known.path() + " " + known.state()); } } EvaluatedFile rows = page.evaluateToFile("[...document.links].map(a => a.href)", Path.of("/tmp/rows.json")); System.out.println(rows.bytes() + " bytes of " + rows.format());
The rules the wire contract insists on, mirrored here:
- a
TIMED_OUTfile is DATA, not a failure - the call's budget ran out while the transfer was still running - so onlyFAILEDsays the file will never arrive, andpage.downloads()can say later what became of it; - a batch is capped at
DownloadBatch.MAX_URLS(64) and its files are fetched ONE AT A TIME, so the urls the budget never reached come backNOT_STARTEDand the call is repeated with what is left; - a batch is never all-or-nothing: one bad url is one failed entry beside the files that landed;
page.downloads()LISTS what this view knows, page-initiated downloads included - those land in the user's XDG download directory and are the only way a save button's file is found afterwards;- every destination is an absolute path on the machine running the server, refused here before the call goes out;
supportsDownloads()reads thecapabilitiesreport'sweb_downloadsflag, the preflight before offering the feature at all.
page.printPdf prints a view to a PDF file with its own browser (vector text, embedded fonts, the
page's print CSS); the answer names the path, size and SHA-256. PrintOptions.cssPage() lets the
document's @page rule decide the sheet and drops the engine's margins (a 1920x1080px slide prints
as one 1440x810pt page). supportsPrintPdfCssPage() is its preflight, the report's
web_print_pdf_css_page. Sketerm reports it only once the headless helper started: before that it is
null and answers true, because nobody can know yet. Ask again with a view open for the helper's own
answer; a helper too old for the layout refuses it at print (UnavailableException) rather than
print on the wrong paper.
if (sketerm.browser().supportsPrintPdfCssPage()) { PdfFile pdf = page.printPdf(Path.of("/tmp/slides.pdf"), PrintOptions.cssPage()); System.out.println(pdf.bytes() + " bytes, sha256 " + pdf.sha256()); }
A headless view can record the RESPONSES its page receives - the JSON a single-page app fetches from its own API, exactly as the page got it (decompressed), with the request body and the response headers - instead of scraping a virtualized DOM or calling a private API yourself:
if (browser.supportsCapture()) { CaptureFilter filter = CaptureFilter.builder() .hosts("spotify.com") .urlContains("/pathfinder/") .methods("POST", "GET") .mimePrefixes("application/json") .maxBodyBytes(8L * 1024 * 1024) .build(); Page page = browser.openPage("https://open.spotify.com/playlist/...", OpenOptions.withCapture(filter)); // Mark BEFORE acting, then wait from the mark: a response that finishes in between still counts long mark = page.captureMark(); page.scrollTo(ScrollTo.BOTTOM); WaitedResponse next = page.waitForResponse(filter, mark, Duration.ofSeconds(20)); CapturedBody json = page.responseBody(next.exchange().seq()); CapturedBody query = page.requestBody(next.exchange().seq()); // the GraphQL POST body page.responseBodyToFile(next.exchange().seq(), Path.of("/tmp/page-2.json")); for (CapturedExchange exchange : page.captured(0, 100).exchanges()) { System.out.println(exchange.seq() + " " + exchange.status() + " " + exchange.url()); } page.clearCaptured(next.nextSince()); // free what has been read }
The rules the wire contract insists on, mirrored here:
- capture is HEADLESS ONLY, installed at OPEN and never added to a live view, so it is live before the page's first request and survives SPA route changes and full navigations;
- the refusal is FAIL CLOSED: a helper without the capture capability answers
unavailableand NOTHING is opened, never a view that silently records nothing; - every clause of a
CaptureFiltermust hold, an omitted one does not restrict, and nothing that fails one is ever buffered.ResourceType.XHR(the default) covers fetch() and XMLHttpRequest alike, because the engine reports them as one class; - nothing is cut silently: a body past
maxBodyBytesis kept up to it and flagged with the size the page received (CaptureTruncation), exchanges the total cap could not hold are counted inCapturedExchanges.dropped(), and an inline answer carrying a prefix saysinlineTruncated(); seqis the request'sweb_networkseq (the join key withpage.network()), while the list CURSOR follows the order exchanges finished: responses finish out of request order, and paging by seq would skip a slow one;- a body the page never reads keeps its load open in the engine, so that exchange stays in flight
(
capturedWithInFlightlists it, and its bytes are readable) until the page reads it or leaves; - a live capture can only be NARROWED:
clearCapturedfrees what was read,disableCapturestops recording, and a wider capture needs a new view; supportsCapture()reads thecapabilitiesreport'sweb_captureflag.
Evidence freezes one page at one moment out of the reads that already exist, so the pieces are
known to describe the same moment rather than three round trips apart:
Evidence evidence = Evidence.capture(page); System.out.println(evidence.finalUrl() + " at " + evidence.capturedAt()); System.out.println("screenshot sha256: " + evidence.screenshotSha256()); evidence.writeTo(Path.of("build/evidence/run-1")); // -> evidence.json, screenshot.png, snapshot.txt, article.md
capture(page, EnumSet.of(Part.SCREENSHOT, Part.NETWORK)) takes only the parts named. Every part is
a READ tool, so a capture still works on a view whose budgets have latched - which is exactly the
view whose evidence someone wants. A policy is attested only when one is actually installed, rather
than recorded as a row of zeroes that would read like enforcement.
The testFixtures artifact publishes ScriptedSketerm, a scripted MCP server, FakeTransport, and
SketermBuild, which finds the real Sketerm a consumer's own real-binary test runs (see Building).
A consumer's tests drive the real api layer over it instead of a browser:
testImplementation(testFixtures(libs.sketerm.java))
ScriptedSketerm server = ScriptedSketerm.browser() // capable headless Sketerm: capabilities, open, tabs, .on("web_read", ScriptedSketerm.map("markdown", "# Hi")); // policy (attested), screenshot, wait try (Sketerm sketerm = server.connect()) { Page page = sketerm.browser().openPage("https://example.com/"); } assert server.callsTo("web_open").size() == 1;
on replaces a tool's answer (a map, or a function of the arguments), onEval answers web_eval with the value of
its code (inline, or written to its out_file as the server writes one), landing(asked, landed, status) makes an
open of a url land elsewhere with a status (a redirect, a refusal page), onError makes it fail with a
typed error code, answeringAfter(tool, delay) delivers a tool's (or a method's, such as initialize) answers
after the delay on another thread, so calls on several pages overlap as against a real server and a slow start can
be scripted, crash() ends every session it answers, and calls()/lastArguments(tool) show what reached the
server. The browser preset
also answers web_input (every event repaints the view) and web_frame (a long poll that answers a
ScriptedSketerm.PIXEL_JPEG image once the view repainted); frameSize(w, h) sets the frame size it
reports against its 1280x800 viewport. Its web_print_pdf writes ScriptedSketerm.PRINTED_PDF (a
one-page 1440x810pt document) to the path it was given.
The socket fixture is opt-in and publishes the same typed stream API without an engine:
try (ScriptedSketerm server = ScriptedSketerm.browser().onStream(socket -> { socket.surfaceSize(1, 1, 320, 240); socket.damage(0, 0, 1, 1, new byte[]{0, 0, (byte) 255, (byte) 255}); socket.frameEnd(1); // socket.receive(Duration.ofSeconds(5)) reads a binary upstream tag and body. }); Sketerm sketerm = server.connect()) { Page page = sketerm.browser().openPage(); PageStream stream = page.stream(listener); }
ScriptedPageStream scripts named/image cursors, timed Opus packets, damage and frame ends;
message/raw deliberately permit hostile bytes for decoder tests. Its script begins after
AUTH, receive(timeout) records socket input/ACKs (never MCP calls), and ended() observes
disconnect. Close the fixture as well as Sketerm to release any unauthenticated listener.
./gradlew build
Protoblast must be published to mavenLocal first (zenit-dev build in the javaweb workspace).
Every real-binary test finds Sketerm through the test fixture SketermBuild: the checkout build named by
-Dsketerm.it.bin=<dir> (with zenit-dev, --define sketerm.it.bin=<dir>) or the SKETERM_IT_BIN
environment variable, a zig-out/bin holding sketerm, sketerm-webengine and sketerm-mux. copy(dir)
copies all three into the test's own directory, so a rebuild mid-run cannot swap a binary underneath it, and
refuses a build missing one of them; resolve(dir) falls back to the installed sketerm on PATH with both
helpers beside it. No sibling checkout is guessed.
SketermSessionIT drives sketerm mcp from resolve and skips itself when there is none.
PageApiIT drives the whole api layer against a real headless view from resolve, and skips itself
when there is none. Its second journey proves close and the
profile lifecycle over a loopback HTTP fixture (a data: document carries no cookies at all in
Chromium), under its own XDG_STATE_HOME and instance name so the profile store is per run; it
skips those steps when the copied binary predates the profile tools. Its review journey proves
key, resize, console and inspect against the real browser; its hand journey proves input and
frame and skips itself on a binary that does not advertise web_input and web_frames.
UntrustedPageIT reuses the copied binaries and controls HTTP, WebSocket and UDP/STUN listeners.
It proves ordinary network reachability, restricted HTTP loading at a loopback-resolving
abc.folio.localhost:<port>, native dark emulation, and zero WebSocket/WebRTC traffic from the
untrusted view. Missing binaries or an absent web_untrusted capability skip the journey;
startup/enforcement failures on an advertised supported build fail the test.
PageStreamTest covers the binary decoder, borrowed-buffer reuse, exact upstream bytes, hostile
framing/UTF-8/ranges and stream ownership through the published socket fixture. PageStreamIT
runs only a checkout build (SketermBuild.copy), never installed binaries, and walks one real page
through pixel equality with web_screenshot, trusted input during a pending MCP wait, typing and clicking with damage limited to the changed box (input-to-damage under 100ms), wheel
scrolling, a composed <select> popup, a premultiplied custom cursor, an idle helper that sends nothing,
an animation's frame rate, a stalled reader with bounded helper memory that then gets the newest pixels,
resize, blur releasing held input, single-use tokens, decodable non-silent Opus (system libopus) and
teardown without leaked helper descriptors or socket files. After both checkouts are ready, run the
targeted wave through zenit-dev test --unit --class PageStreamTest,PageStreamIT,PageApiTest --define sketerm.it.bin=<sketerm checkout>/zig-out/bin --skip-deps.