Reliable web access for AI agents.
Your agents need to read web pages, but half the web fights back. Cloudflare challenges, SPAs that return empty <div>s, paywalls for content you already pay for. You can throw a headless browser at every request or route through a scraping API, but that's wasteful when a plain fetch would've worked for most URLs.
agent-fetch tries the cheapest method first and escalates only when needed:
fetchjsdom- configured plugins (e.g. scrape.do)
agent-browser(headless Chrome)
Each response passes lightweight checks — word count, blocked-page detection, paywall patterns — so bad results get caught and the next strategy fires automatically.
It runs on your machine, with your IP and your browser sessions. Sites see a residential visitor, not a datacenter. When you've logged into Stratechery or your company wiki, agent-fetch uses those sessions so your agents aren't locked out.
Run it as a CLI, use it as a library, or start the HTTP server and let any agent POST a URL to get markdown back.
bun add @andypai/agent-fetchagent-fetch is Bun-only. Use Bun for install, runtime, tests, and local tooling. Node.js and other package managers are not supported.
The quickest path: bind agent-fetch on your dev machine, then put it behind Tailscale so agents on your tailnet can reach it without exposing anything to the public internet.
agent-fetch server --host 0.0.0.0Any agent can POST a URL and get clean markdown:
curl -X POST -H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}' \
http://your-machine:7411/fetchThis is how tools like openclaw get web content — POST a link, get text back, no browser management on the agent side.
Set up a browser profile, log into your subscriptions once, and agent-fetch reuses those sessions for every request:
# Create a profile and log in interactively
agent-browser --profile ~/.agent-browser/profiles/work --headed open https://stratechery.com/
# Now agents can fetch paywalled content through the server
curl -X POST -H 'Content-Type: application/json' \
-d '{"url":"https://stratechery.com/","options":{"strategyMode":"authenticated"}}' \
http://localhost:7411/fetchThe profile stores cookies and auth state — not your full browser history, just what's needed for fetching. If the profile isn't configured, agent-fetch fails fast rather than silently returning a login page.
# Markdown output (default)
agent-fetch fetch https://example.com
# Shorthand: a bare URL implies `fetch`
agent-fetch https://example.com
# Output mode overrides
agent-fetch fetch https://example.com --mode markdown
agent-fetch fetch https://example.com --mode primary
agent-fetch fetch https://example.com --mode html
agent-fetch fetch https://example.com --mode structured
agent-fetch fetch https://example.com --mode screenshot
# JSON output
agent-fetch fetch https://example.com --json
# Print per-attempt diagnostics to stderr
agent-fetch fetch https://example.com --debug-attempts
# Use an explicit config file
agent-fetch fetch https://example.com --config /tmp/agent-fetch.json
# Use a persistent browser profile for a one-off authenticated request
agent-fetch fetch https://example.com --with-credentials --profile ~/.agent-browser/profiles/work
# Launch agent-browser in headed mode for debugging
agent-fetch fetch https://example.com --method agent-browser --headed
# Disable fallback stages
agent-fetch fetch https://example.com --no-jsdom --no-plugins
# Authenticated fast path (agent-browser only)
agent-fetch fetch https://example.com --with-credentials
# Force strategy mode
agent-fetch fetch https://example.com --strategy simple
agent-fetch fetch https://example.com --strategy authenticated
# Force one exact method
agent-fetch fetch https://example.com --method fetch
agent-fetch fetch https://example.com --method jsdom
agent-fetch fetch https://example.com --method agent-browser
agent-fetch fetch https://example.com --method scrape.domarkdown(default): convert the cleaned rendered page into markdown and keep broad page structure such as headings, cards, and tables when possible.primary: extract article-style primary content with Readability, with metadata fallback for pages that do not have a clear article body.html: return the rendered HTML thatagent-fetchfetched.structured: return structured section data derived from markdown headings and links.screenshot: take a full-page screenshot throughagent-browserand return the saved image path.
Use --method when you want one exact stage instead of the usual fallback chain.
fetchjsdomagent-browser- built-in plugin types such as
scrape.do
Notes:
--method scrape.donormalizes to the built-inscrape-doplugin type.--mode screenshotalways usesagent-browser; combining it with another method is rejected.
# Guided setup
agent-fetch setup
# Alias
agent-fetch init
# Write config somewhere other than the default
agent-fetch setup --config /tmp/agent-fetch.json
# Non-interactive setup from env vars
AGENT_FETCH_TIMEOUT=45000 \
AGENT_FETCH_ENABLE_PLUGINS=true \
SCRAPEDO_TOKEN=your-token \
AGENT_FETCH_ENABLE_AGENT_BROWSER=true \
AGENT_FETCH_PROFILE=~/.agent-browser/profiles/work \
agent-fetch setup --no-input --overwrite
# Authenticated defaults in non-interactive mode require a profile
AGENT_FETCH_STRATEGY_MODE=authenticated \
AGENT_FETCH_PROFILE=~/.agent-browser/profiles/work \
agent-fetch setup --no-input --overwriteagent-fetch setup --no-input only requires AGENT_FETCH_PROFILE when you are
writing authenticated defaults. In auto or simple mode, it can write config
without any browser profile settings.
The setup walkthrough configures:
- default strategy mode
- timeout and content validation thresholds
- fetch/jsdom/plugin/agent-browser fallbacks
- optional scrape.do plugin wiring
- authenticated browser profile defaults
- whether
agent-browserwaits fornetworkidlebefore extraction
agent-fetch does not create browser sessions by itself. It passes a persistent
profile path to agent-browser, and agent-browser reuses that Chrome user-data
directory for authenticated requests.
# Install browser binaries once
agent-browser install
# Create or warm a persistent browser profile and log in once
agent-browser --profile ~/.agent-browser/profiles/work --headed open https://app.example.com/login
# After logging in, verify the same profile is authenticated
agent-browser --profile ~/.agent-browser/profiles/work open https://app.example.com/dashboardThen save that profile for agent-fetch:
# Interactive
agent-fetch setup
# Non-interactive
AGENT_FETCH_PROFILE=~/.agent-browser/profiles/work \
agent-fetch setup --no-input --overwrite
# One-off authenticated fetch without saving defaults
agent-fetch fetch https://app.example.com/protected \
--with-credentials \
--profile ~/.agent-browser/profiles/workIf --with-credentials or --strategy authenticated is used and no profile is
configured, agent-fetch fails fast instead of silently falling back to
unauthenticated strategies.
If you want a hosted fallback before browser automation, the fastest setup is:
SCRAPEDO_TOKEN=your-token \
AGENT_FETCH_ENABLE_PLUGINS=true \
agent-fetch setup --no-input --overwriteThat writes the built-in scrape-do plugin to ~/.agent-fetch/config.json
using ${SCRAPEDO_TOKEN} interpolation.
You can also wire it manually:
{
"enablePlugins": true,
"plugins": [
{
"type": "scrape-do",
"token": "${SCRAPEDO_TOKEN}"
}
]
}SCRAPEDO_TOKEN=your-token agent-fetch fetch https://example.com --json --debug-attemptsStart a local HTTP server that exposes fetch capabilities over HTTP.
# Start with defaults (127.0.0.1:7411)
agent-fetch server
# Custom port and host
agent-fetch server --port 8080 --host 0.0.0.0
# With a config file for default fetch options
agent-fetch server --config ~/.agent-fetch/config.jsonPOST a URL to fetch it:
# Returns markdown by default
curl -X POST -H 'Content-Type: application/json' \
-d '{"url":"https://example.com"}' \
http://localhost:7411/fetch
# With options
curl -X POST -H 'Content-Type: application/json' \
-d '{"url":"https://example.com","options":{"outputMode":"primary","strategy":"simple"}}' \
http://localhost:7411/fetch
# JSON response with full FetchResult
curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"url":"https://example.com"}' \
http://localhost:7411/fetch
# Health check
curl http://localhost:7411/healthThe request body requires a url field. The options field is optional and accepts the same fields as FetchOptions (outputMode, method, timeout, strategy, etc.). When a --config file is provided, its defaults are merged with per-request options, with request options taking precedence.
Default response is text/markdown. Send Accept: application/json to get the full FetchResult JSON envelope.
Constraints:
- Binds to
127.0.0.1by default (localhost-only). - Request body limited to 1MB.
- HTTP-level timeout of 120s (independent of fetch strategy timeouts).
- CORS headers (
Access-Control-Allow-Origin: *) included on all responses. screenshotPathin responses is a local filesystem path, only meaningful on the server host.
agent-fetch plugins
agent-fetch plugins --json
agent-fetch plugins list
agent-fetch plugins list --jsonDefault config path:
~/.agent-fetch/config.json- override with
--config <path>orAGENT_FETCH_CONFIG_PATH
At runtime, precedence is:
- CLI flags
- environment variables
- config file
- built-in defaults
Example config:
{
"timeout": 30000,
"outputMode": "markdown",
"enableFetch": true,
"enableJsdom": true,
"enablePlugins": true,
"enableAgentBrowser": true,
"strategyMode": "auto",
"plugins": [
{
"type": "scrape-do",
"token": "${SCRAPEDO_TOKEN}"
}
],
"waitForNetworkIdle": false
}Notes:
waitForNetworkIdleaffectsagent-browsernavigation timing.- Plugin config values support
${ENV_VAR}interpolation. pluginsare only used inautomode, afterfetchandjsdom.agentBrowser.profileis the normal place to persist authenticated browser state.agentBrowser.commandis an advanced override whenagent-fetchshould invoke something other than plainagent-browser.
Legacy config files are now rejected with a hard error:
.fetchrc.jsonfetch.config.json
Move settings to ~/.agent-fetch/config.json.
AGENT_FETCH_TIMEOUTAGENT_FETCH_OUTPUT_MODEAGENT_FETCH_ENABLE_FETCHAGENT_FETCH_ENABLE_JSDOMAGENT_FETCH_ENABLE_PLUGINSAGENT_FETCH_ENABLE_AGENT_BROWSERAGENT_FETCH_STRATEGY_MODEAGENT_FETCH_WAIT_FOR_NETWORK_IDLEAGENT_FETCH_USER_AGENTAGENT_FETCH_MIN_HTML_LENGTHAGENT_FETCH_MIN_MARKDOWN_LENGTHAGENT_FETCH_MIN_WORD_COUNTAGENT_FETCH_BLOCKED_WORD_COUNT_THRESHOLDAGENT_FETCH_PROFILESCRAPEDO_TOKEN
import { fetchUrl } from '@andypai/agent-fetch'
const result = await fetchUrl('https://example.com', {
strategyMode: 'auto',
outputMode: 'markdown',
})
console.log(result.outputMode)
console.log(result.strategy)
console.log(result.content)The package also exports FetchError, registerPlugin(), listBuiltinPlugins(), parseCliArgs(), runCli(), and the public fetch/plugin types if you want to embed the engine or reuse the CLI parser programmatically.
{
"url": "string",
"title": "string",
"author": "string | null",
"content": "string",
"outputMode": "markdown | primary | html | structured | screenshot",
"screenshotPath": "string | null",
"markdown": "string",
"primaryMarkdown": "string",
"html": "string",
"structuredContent": {
"title": "string",
"description": "string | null",
"headings": [{ "level": 2, "text": "Example" }],
"sections": [{ "heading": "Example", "level": 2, "content": "..." }],
"links": [{ "text": "Example", "href": "https://example.com" }]
},
"wordCount": 123,
"strategy": "fetch | jsdom | plugin-name | agent-browser",
"fetchedAt": "ISO-8601",
"attempts": [
{
"strategy": "fetch",
"ok": true,
"durationMs": 120
}
]
}content always matches the selected output mode. markdown remains available in JSON output even when --mode is primary, html, structured, or screenshot, so callers can inspect both the selected output and the full-page markdown snapshot.
When --mode structured is used without --json, content is the pretty-printed JSON string for structuredContent.
When --mode screenshot is used, content and screenshotPath are the saved image path.
bun install
bun run build
bun run check
bun run testbun run dev # run with watch mode
bun run start # run once
bun run build # bun build ./src/index.ts --target bun --outdir ./dist
bun run format # prettier write
bun run lint # eslint
bun run typecheck # tsc --noEmit
bun run test # bun test src
bun run test:watch # bun test --watch src
bun run check # prettier check + lint + typecheck + test