@underactive/pi-topping-web-tools
Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.
Package details
Install @underactive/pi-topping-web-tools from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@underactive/pi-topping-web-tools- Package
@underactive/pi-topping-web-tools- Version
0.2.8- Published
- Oct 12, 2026
- Downloads
- 1,048/mo · 187/wk
- Author
- underactive
- License
- MIT
- Types
- extension
- Size
- 183.4 KB
- Dependencies
- 6 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"./src/fetch-markdown.ts",
"./src/pdf-extract.ts",
"./src/web-browser/index.ts",
"./src/web-permissions.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@underactive/pi-topping-web-tools
Web tools bundle for pi: keyless URL-to-markdown fetching, PDF text extraction, and headless Playwright browser automation, sharing one preapproved-host allowlist and permission model.
Install
pi install npm:@underactive/pi-topping-web-tools
The web_browser tool additionally requires Playwright's Chromium (~150MB):
npx playwright install chromium
Why this extension
Web work has two distinct needs: clean context from public sources and browser-level verification of the live experience. This bundle gives pi both, so an agent can move from a documentation question to an interactive check without stitching together unrelated extensions. Public-page retrieval is keyless, while browser automation remains available for the cases where a static fetch cannot answer the question.
- Keep research focused.
fetch_markdownconverts public documentation, READMEs, and articles into Markdown, extracting the primary article instead of a page's surrounding navigation. Its cache makes iterative questions against the same source faster. - Reach PDF-only sources.
pdf_extractreads the text layer of specifications, datasheets, and papers that markdown fetchers treat as undisplayable binary content, and that pi'sreadtool cannot parse. - Validate the rendered product.
web_browserruns a fresh headless Chromium context for client-rendered pages, local development servers, and user journeys that static HTML cannot represent. It can inspect the accessibility tree, interact with controls, and surface console and failed-network diagnostics. - Use the appropriate tool, not the heavier tool by default. Fetch static public material first; move to browser automation when JavaScript, visual state, or user input is part of the question.
- Keep control over access. The tools share a host allowlist and confirmation workflow;
web_browseralso enforces approved URLs on HTTP(S) requests and WebSocket connections inside its isolated browser context. Fetched page text is explicitly marked as untrusted, and browser sessions do not reuse the system browser profile.
Examples
Give pi a task like the following; the extension supplies the web capabilities needed to complete it.
Research a dependency before changing code
Read
https://react.dev/reference/react/useEffect. Summarize the cleanup behavior, identify the parts relevant to subscriptions, and cite the sections I should review before changing this component.
fetch_markdown returns the page as focused Markdown, making it useful for documentation research without spending context on site chrome. Repeated follow-up questions can reuse the cached response.
Verify a local user journey
Open
http://localhost:3000, set a 390×844 viewport, add an item to the cart, and report any failed network requests or console errors. Save a screenshot of the final state.
web_browser can drive the local application as a user would, then collect the diagnostics that explain a failed interaction rather than only reporting that it failed.
Investigate a JavaScript-heavy page
Review the migration guide at
https://example.com/migration. If the important content is not present in the static page, inspect the live page in the browser and return the breaking changes that affect this project.
Start with fetch_markdown for efficient static content; use web_browser when the page depends on client-side rendering or requires interaction. The two tools let the agent choose the lowest-cost path that still produces reliable evidence.
Tools
| Tool | Description |
|---|---|
fetch_markdown |
Fetch a public URL and return its content as markdown (cached, keyless) |
pdf_extract |
Extract the text layer from a remote or local PDF, with page markers |
web_browser |
Headless Chromium automation — navigate, click, type, screenshot, evaluate, logs, cookies |
All three tools are direct-exposure and pi activates those on registration (defaultActive defaults to true), so nothing needs to be enabled.
Enabling and disabling tools
- To hide a tool for a session, name it in
--exclude-tools(or start with--no-toolsand re-add what you want with--tools). - To change the startup set in your
settings.json, usedefaultTools. Bare names replace pi's defaults, so write"+fetch_markdown"to add a tool without droppingread,bash,edit, andwrite. On pi 0.99.2+,/reloadactivates tools newly added todefaultToolswithout a restart. The reverse does not follow: a tool removed fromdefaultToolsstays active until you restart, and a tool you turned off during the session stays off.--tools,--no-tools, and--no-builtin-toolsstill override the setting, on reload as well.
Codemode tool listing
With codemode.mode: "only", pi hides the direct declarations of active tools, so the model calls them from codemode scripts, and the codemode description lists each callable tool instead, with its prompt guidelines on pi 1.0.4+. Listed tools share codemode.inlineBudget, which defaults to 3000 estimated tokens (characters / 4); tools that do not fit are left out and are reached with searchTools() or describeTool(). On pi 1.0.4+, the seven-tool catalog fetch_markdown, pdf_extract, web_browser, plus built-in read, bash, edit, and write needs about 3,700 tokens as of pi 1.1.0 — web_browser alone is about 1,600, the largest section — so the default budget drops web_browser from that list. Set the budget to 4096 or more if you use only with these tools; real sessions that load more extensions need more.
{
"codemode.mode": "only",
"codemode.inlineBudget": 4096
}
fetch_markdown
Complements pi's built-in search-backed web_fetch with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
- Native fetch — no API keys, no provider routing
- HTML → markdown — turndown conversion for readable agent context
- Readability extraction — @mozilla/readability (via linkedom) strips nav/footer/sidebar boilerplate from article pages by default; pass
raw: truefor the full page. Falls back to full-page conversion when a page doesn't look like an article - 15-minute LRU cache with revalidation — faster repeat lookups; expired entries are revalidated with
If-None-Match/If-Modified-Sincewhen the server sentETag/Last-Modified, so unchanged pages cost a 304 instead of a full download - Strict redirect handling — redirects that change host, scheme, or port, or that leave a preapproved path, return the redirect URL instead of being followed; call again to follow (
www.prefixes are treated as the same host) - Host confirmation — prompts for non-preapproved hosts (session or durable allow/deny; see Saved permissions)
Usage notes:
- HTTP URLs are upgraded to HTTPS automatically
- Content is returned in 100K-character windows; truncated responses report the
offsetto pass for the next window - For GitHub repo content, prefer
web_fetchor theghCLI - JS-rendered SPAs may return empty markdown (static HTML only)
- Authenticated or private pages (Google Docs, Confluence, Jira) are not supported
Command: /clear-fetch-markdown-cache — clear the in-memory URL cache.
pdf_extract
PDFs are otherwise unreachable: fetch_markdown and pi's web_fetch classify application/pdf as binary, and the read tool cannot parse it. This tool extracts the text layer via unpdf (a serverless build of Mozilla's PDF.js, keyless and with no native dependencies).
- Remote or local — pass
urlfor a remote PDF orpathfor an absolute or relative path, or afile://URL; provide exactly one - Page markers — output is delimited with
--- Page N ---so pages can be cited - Page selection —
pagesaccepts"3","1-5", or"1,4,7-9"; defaults to every page - 15-minute LRU cache — the whole parse is cached, so a later
pagesoroffsetcall reuses it; local files are re-read when their modification time changes - Host and file confirmation — remote URLs use the shared allowlist; local files inside the working directory are read without prompting, anything outside prompts (session-scoped allow/deny for local files; session or durable for remote URLs)
Usage notes:
- Content is returned in 100K-character windows; truncated responses report the
offsetto pass for the next window - Scanned or image-only PDFs have no text layer and report that explicitly — there is no OCR
- Limits: 25MB download, 2000 pages, and a 30s extraction timeout
Command: /clear-pdf-extract-cache — clear the in-memory PDF text cache.
web_browser
Single tool with an action parameter:
| Action | Parameters | Description |
|---|---|---|
navigate |
url |
Open a URL or file:// path (HTTP upgraded to HTTPS except localhost/.local/private IPs) |
screenshot |
fullPage?, selector?, toFile? |
Capture PNG screenshot (base64 image, or temp file path when toFile=true) |
click |
selector |
Click a CSS selector |
type |
selector, text, mode? |
Fill (mode=fill, default) or key-by-key type (mode=press) into a selector |
hover |
selector |
Hover over a CSS selector (tooltips, hover-menus) |
press |
key |
Press a keyboard key globally (Enter, Escape, Tab, etc.) |
select_option |
selector, values |
Select option(s) in a <select> dropdown (comma-separated values) |
set_viewport |
width, height |
Resize the browser viewport (persists until changed or browser closes) |
evaluate |
script |
Run JavaScript in the page context (re-checks permission for the current page URL) |
get_content |
— | Return raw page HTML |
get_text |
selector? |
Return visible text (layout-aware innerText; default selector body) |
get_markdown |
— | Return page content as markdown (headings, lists, links) |
wait_for |
selector?, state?, networkidle? |
Wait for selector state and/or network idle |
get_accessibility_snapshot |
selector? |
Return ARIA accessibility tree (YAML) |
get_console_logs |
— | Drain captured console/page errors |
get_network_logs |
— | Drain captured egress blocks, failed requests, and 4xx/5xx responses |
get_cookies |
— | Return cookie metadata as JSON (name, domain, path, flags; values are redacted) |
set_cookies |
cookies |
Set cookies (each needs url or domain+path) |
go_back / go_forward / reload |
— | Browser history navigation / reload |
scroll |
selector?, frame?, deltaX?, deltaY? |
Scroll an element into view (selector) or scroll by pixel deltas (default deltaY = viewport height) |
drag |
selector, targetSelector, frame? |
Drag-and-drop from selector to targetSelector |
upload_file |
selector, files, frame? |
Set files on an <input type=file> (absolute or relative paths; prompts when a file is outside the working directory) |
set_dialog_behavior |
dialogAction?, promptText? |
Configure how future JS dialogs (alert/confirm/prompt) are resolved for the rest of the session (default: dismiss) |
get_dialog_logs |
— | Drain captured dialogs (type, message, default value, resolution) |
list_tabs |
— | List open tabs (index, URL, title, active) |
switch_tab |
index |
Switch the active tab (from list_tabs) |
close |
— | Close the browser |
Optional timeout (ms, default 30000) applies to navigation and to selector-based actions (click, type, hover, select_option, screenshot, get_text, get_markdown, wait_for, get_accessibility_snapshot, scroll, drag, upload_file). Other actions use Playwright defaults.
Screenshots and image resize
When screenshot returns an inline result (toFile omitted or false), web_browser places the captured PNG in the conversation as a base64 image block. Base64 adds roughly one-third to the payload, and full-page PNGs can be several MiB. On pi 0.87+, the active model's inputLimits.images.resize profile is applied to these tool-result images before they enter history; the same profile covers @file attachments, read, and images returned by other tools. Images are encoded once when they enter history, so changing models later does not rewrite historical images.
toFile=true and /browser-screenshot save the original PNG to disk and put only the path and dimensions in the tool result. The file is resized only if a later read or @file attachment sends it to the model.
In codemode, a tool without an output schema, such as web_browser, resolves to its text blocks only, so an inline screenshot reaches the script as its caption with no image bytes. To let a script inspect a screenshot, take it with toFile=true and read the saved path: on pi 1.0.4+, tools.read({ path }) resolves an image file to an image block, and image() adds that block to the script output. On pi before 1.0.4, read resolves an image file to only its Read image file [...] caption, so the pixels are not reachable from a script; on those hosts, call screenshot as a direct tool call instead of from a script.
The resize fields are:
maxWidth/maxHeight: pixel bounds (pi defaults to 2000×2000)maxBytes: maximum base64-encoded payload size (pi defaults to 4.5 MiB)jpegQuality: JPEG quality when the PNG does not fit undermaxBytes(pi defaults to 80)
The default capture is the viewport (1280×720 unless set_viewport changed it); fullPage: true captures the whole document. web_browser applies a 1920px maximum-width clamp to the reported dimensions of full-page screenshots; this is metadata only, not image resizing—the returned PNG remains the original capture. Tall full-page screenshots regularly exceed the default 2000px height bound and are scaled before they enter history; for normal viewport screenshots, maxBytes is usually the effective cost lever.
Configure a profile per model in ~/.pi/agent/models.json; use the provider and model ID shown by pi --list-models. modelOverrides.inputLimits is deep-merged, so a resize override can be scoped to one model:
{
"providers": {
"<provider>": {
"modelOverrides": {
"<model-id>": {
"inputLimits": {
"images": {
"resize": {
"maxWidth": 1568,
"maxHeight": 1568,
"maxBytes": 524288,
"jpegQuality": 75
}
}
}
}
}
}
}
}
This matches pi's documented example. It leaves a default 1280×720 viewport screenshot unscaled on the long edge, making maxBytes the practical size lever. When pi resizes an image, it adds a coordinate note such as [Image: original 1280x4000, displayed at 800x2500. Multiply coordinates by 1.60 to map to original image.]; the tool's own Screenshot <dims> line is only a summary of the capture, so the resize note is the authoritative account of the dimensions sent to the model. An overly tight maxBytes does not fail loudly: pi keeps shrinking dimensions until the encoded image fits. Aggressive values can make small page text (navigation labels, fine print, or code) unreadable, and JPEG fallback adds artifacts; prefer get_text, get_markdown, or get_accessibility_snapshot when the task is reading copy. Set images.autoResize: false in pi's settings to disable resizing globally.
Browser network egress
web_browser enforces its permissions inside every fresh browser context:
- Every HTTP(S) document, script, image, XHR/fetch, beacon, and other routed request is checked against the live preapproved, session, and durable permissions before the request is sent. Path-scoped preapprovals remain path-scoped; session and durable grants remain exact-origin grants except that bare and
www.host variants are treated as equivalent. - Redirect responses are inspected before Chromium receives them. Approved top-level redirects, including simple bare-domain ↔
www.redirects, are followed one gated hop at a time. Duringnavigate, a redirect to an unapproved URL returnsBlocked: redirect to <origin> requires approval; redirects triggered by later page interactions are blocked and reported in network logs. Redirected subresources are blocked rather than followed and also appear in network logs. - WebSocket handshakes use the same live permission check. Service workers are disabled so they cannot bypass request routing.
- Client-side navigation and popup destinations are gated. A blocked popup is closed without becoming active, and a blocked same-tab navigation returns to the prior approved page. Approved popups become active only after their main-frame navigation commits.
data:andblob:subresources are allowed because they do not create network egress, while top-leveldata:/blob:documents are denied.file:documents require approval for the target path; local-file subresources are allowed only from an approved local-file page.- Decision errors fail closed. Route handlers never prompt; call
navigateon a blocked target to use the normal approval prompt. Blocks are reported byget_network_logsasblocked by egress policy.
A navigation approved with Allow once is retained as a browser-session origin grant so the document and its later same-origin requests can continue to load under request-level enforcement. Choosing a durable duration still controls whether that origin is available in later sessions.
Strict egress can make an approved page incomplete when it depends on an unapproved CDN, API, font, or analytics origin. Navigate directly to that origin to approve it, then return to the page and reload.
Selectors are CSS selectors, or Playwright's role= and text= selector engines (e.g. role=button[name='Submit'], text=Sign in). Pass frame (a CSS selector for the containing <iframe>) to scope click/type/hover/select_option/screenshot/get_text/wait_for/get_accessibility_snapshot/scroll/upload_file/drag into that frame.
Opening a link with target=_blank or window.open opens a new tab whose destination is checked against live permissions. An approved popup becomes the active tab; a popup to an unapproved URL is closed without becoming active and the current tab stays active. Use list_tabs to see all open tabs.
JS dialogs (alert/confirm/prompt) are dismissed by default so they never block automation; call set_dialog_behavior before the action that triggers one if you need accept instead (and, for prompt, a promptText value).
Commands:
/browser— show open/closed status, current URL, title, tab count, console/network/dialog counts, session-approved host count, and saved durable host count/browser-close— force close the browser/browser-screenshot— save a full-page screenshot to a temp PNG file
Status line: a footer segment (browser: example.com) appears while the browser is open.
Statusline feed
web_browser also publishes its current state as a pi custom session entry, so pi-topping-statusline can render it without depending on this package:
- Type:
pi-topping-web-tools/browser label(text): current hostname,local file,ready, or an empty string when closedopen(number):1when open,0when closed
For example, subscribe to label with prefix browser: and text format:
{
"type": "pi-topping-web-tools/browser",
"field": "label",
"prefix": "browser: ",
"format": "text"
}
Entries are published only when the state changes, except that the current state is republished immediately at session start and once after the first turn. Closing the browser publishes { "label": "", "open": 0 } so consumers can clear stale labels. Set PI_SUPPRESS_NOTIFICATIONS to 1, true, or yes (case-insensitive) to hide the native footer; feed publication continues.
Saved permissions
When the host confirmation prompt offers a durable option ("Allow for 1 day", "Allow for 1 week", "Allow for 30 days"), the grant is persisted to ~/.pi/agent/web-permissions.json (mode 0600) and applies across all sessions and projects.
- Per-tool scope. A
fetch_markdowngrant does not authorizeweb_browser, which executes JavaScript and drives interaction. Each tool scopes its grants independently. - Exact origin. Grants are stored as
scheme://host[:port]and do not cross ports or schemes. Forweb_browseregress, only the bare andwww.variants of the same remote hostname are equivalent; no other subdomains inherit the grant. - Max 30 days. All grants expire; there is no indefinite option. Expired entries are pruned automatically.
- Local files excluded.
file://prompts never offer durable options; local-file access needs a path-scope design that is out of scope here. - Headless behavior. Active durable grants apply when there is no interactive UI (e.g. RPC or print mode). Without a grant, non-preapproved hosts remain blocked.
Commands:
/web-permissions— list and revoke saved grants. Bundled preapproved hosts are not listed (they are code, not user grants).
Security model (shared)
- Preapproved hosts: common documentation and dev sites are allowed without prompting. The allowlist lives in
src/permissions.tsand is shared by all three tools. - User confirmation: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For
web_browsernavigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests. - Tool annotations: on pi 0.99+,
fetch_markdownandpdf_extractdeclarereadOnlyHintandopenWorldHint, andweb_browserdeclaresopenWorldHintwithreadOnlyHint: false, for permission extensions that confirm calls by hint. The host confirmation above applies regardless of how another extension treats the hints. - URL validation: all tools reject embedded credentials and overlong URLs.
fetch_markdownandpdf_extractadditionally reject loopback, private, and link-local hosts and any non-HTTP(S) scheme on aurl.web_browseracceptslocalhost, private IPs, andfile://paths so it can drive local dev servers and local HTML — each still prompts for confirmation unless session-approved or covered by a saved durable grant. - Local file resolution:
pdf_extractresolves apaththroughrealpathbefore any check, so a symlink pointing outside the working directory is treated as outside and prompts. Directories, FIFOs, and devices are rejected, and the size limit is enforced fromstatbefore any bytes are read.web_browserupload_filefollows the same rule: files outside the working directory prompt for confirmation (session-scoped) before they are read. - Isolated browser context and routed egress: each session uses a fresh Chromium context (no shared profile/cookies with your system browser). HTTP(S) requests, redirect targets, client navigations, popups, and WebSocket connections are checked against live
web_browserpermissions; service workers are disabled. - Untrusted content boundary:
fetch_markdownresults andweb_browserpage-derived results are wrapped in<untrusted-content url="…">tags, andpdf_extractresults in<untrusted-content source="…">, so the model treats fetched page and document text as data, not instructions. - PDF resource limits: extraction is bounded by download size, page count, and a timeout. Because the bundled PDF.js runs on the event loop rather than a worker, the timeout bounds extraction across its await points but cannot interrupt a fully synchronous parse — the size and page caps are the primary defence.
Limitations
web_browserrequires the Chromium download.- Screenshots consume significant context on vision models — prefer
get_text,get_markdown, orget_accessibility_snapshot. On pi 0.87+, a model-side resize profile (Screenshots and image resize) can cut screenshot request size and cost; aggressive resizing can make small page text unreadable. - Network logs capture blocked/failed requests and 4xx/5xx responses, not successful traffic.
- Request routing buffers HTTP(S) responses and blocks redirected subresources, so streaming endpoints and resources that rely on redirects may not behave like an unrestricted browser.
- Playwright routing covers HTTP(S) and WebSockets, not browser transports such as WebRTC; OS-level egress sandboxing remains outside this extension's scope.
pdf_extractreads only an existing text layer; scanned documents need OCR, which is out of scope.
Development
Requires Node >= 22.19 (tests run TypeScript directly via node --test).
npm install
npm run typecheck
npm test # browser tests require Chromium