Web and Browser Tools
websearch, webfetch, and the browser_* tools are built-in external-content tools:
websearch: query registered providers. Bundled providers are Brave (default), Exa, Perplexity Sonar, Tavily, Kagi, Jina, keyless DuckDuckGo, and user-hosted SearXNG.webfetch: extract readable content through registered providers. Guarded direct extraction is the default and optionally falls back to Firecrawl. Direct extraction converts PDF responses to=== Page N ===text with the same system backends asread(pdftotext, mutool, or Python pypdf/PyMuPDF on the operator's PATH). Only absolute PATH entries are used; workspace binaries and ancestor.lemontrees are excluded, including canonical symlink targets. Normal absolute user-installed toolchain paths outside those project trees remain available. Unlike localread, fetching never discovers or probes project/ancestor.lemon/read-formats-venvinterpreters. An operator may explicitly trust one withWebFetch.tool(cwd, pdf_python: "/trusted/venv/bin/python3"); this constructor option is not exposed to model-supplied fetch arguments. Python runs with-Ifor both probing and extraction, excluding cwd,PYTHONPATH, and user-site imports. A project backend must be trusted before even a capability probe can execute; permittingwebfetchdoes not grant project code execution. Without a backend the error names the install/trust options. Extracted PDF text remains fenced, untrusted web data. Concurrent identical fetches share work: canceling one caller releases only that caller, while canceling every caller aborts the shared operation. The shared abort signal stops active direct, Firecrawl, and Exa requests as well as fetched PDF extraction, including backend descendants. An abandoned fetch never starts a fallback provider or publishes its result to the cache. GitHubblob/<ref>/<path>URLs fetch the raw file fromraw.githubusercontent.com, andissues/<n>/pull/<n>URLs render title, state, labels, body and the first 100 comments from the public REST API (unauthenticated, subject to GitHub's anonymous rate limit). All of these stay on the guarded, SSRF-checked path.browser_navigate: navigate the supervised local browser session.browser_tabs,browser_tab_open,browser_tab_activate,browser_tab_close: enumerate and manage tabs through stable Chrome target IDs.browser_snapshot: inspect the current page as a compact DOM snapshot.browser_get_content: return text and optionally sanitized HTML from the current page.browser_click,browser_type,browser_hover,browser_select_option,browser_upload_file,browser_download,browser_press,browser_scroll,browser_back: interact with the current page, or an explicittargetId.browser_wait_for_selector: wait until a selector appears.browser_evaluate: evaluate a JavaScript expression in the current page and return untrusted JSON-serializable output.browser_events: return buffered console, dialog, page error, and request failure events.browser_get_cookies,browser_set_cookies,browser_clear_state: inspect, seed, or clear browser session state through the supervised browser context.browser_screenshot: save a screenshot as a local artifact and return metadata; passincludeImage: truewhen the model also needs to inspect the screenshot, orsendToChannel: truewhen the artifact should be sent back through the final Telegram/Discord answer path.browser_analyze: capture a managed screenshot and pass it throughmedia_analyze_imagein one supervised BEAM-owned operation.
Full content after truncation
When webfetch, browser_snapshot or browser_get_content truncates displayed content, a session-scoped artifact retains the complete pre-truncation text. The result's JSON/details include artifact_uri (artifact://…) and a one-line artifactNotice. Use read on that handle with line selectors or offset/limit; artifact reads remain untrusted. Untruncated results do not create artifacts.
Webfetch artifacts contain the full extracted text from direct, Firecrawl or Exa extraction, not necessarily the original HTML. Cached and concurrent fetches register artifacts separately for each requesting session. Browser artifacts are JSON objects with complete snapshot, html and/or text fields, after normal HTML sanitization and whitespace normalization but before character truncation. Snapshot inspection options (selector, maxNodes, interactiveOnly) still define its scope.
Artifact storage requires the tool host's session context. Storage failures, or browser backends that report truncation without supplying the complete source, return artifact_error instead of a misleading handle to partial output. The Node browser driver supplies complete sources internally and the tool removes those private payloads before returning visible results.
Search and extraction providers
Both web tools use CodingAgent.Search.Provider and the shared capability-aware registry. Providers declare search, extract, or both. Selection is ordered and deterministic: an explicit request provider is attempted first, followed by the request's fallbackProviders; otherwise the configured provider and fallback are used. Each result includes the requested/used provider and a safe attempt summary.
Identical concurrent requests are single-flight. One supervised operation calls the provider while other callers await the same result; successful results then enter the existing bounded ETS and optional persistent cache. Providers run in isolated, timeout-bounded processes, so an exception, exit, or slow provider can fall through without crashing the agent turn. URL-policy failures from guarded direct extraction are terminal and never fall back through a remote extractor.
| Provider | Capability | Setup |
|---|---|---|
brave | search | BRAVE_API_KEY or runtime.tools.web.search.api_key |
exa | search/highlights and batch extract | EXA_API_KEY or configured key |
perplexity | search/answer | PERPLEXITY_API_KEY, OPENROUTER_API_KEY, or configured key |
tavily | search | TAVILY_API_KEY or runtime.tools.web.search.providers.tavily.api_key |
kagi | search (V1 API: web, then video/news/infobox buckets) | KAGI_API_KEY or runtime.tools.web.search.providers.kagi.api_key |
jina | search (s.jina.ai) | JINA_API_KEY or runtime.tools.web.search.providers.jina.api_key |
duckduckgo | keyless search | none |
searxng | search | runtime.tools.web.search.providers.searxng.base_url |
direct | extract | built in; guarded HTTP plus readability |
firecrawl | extract | FIRECRAWL_API_KEY or configured key |
Extensions may register additional providers with type: :search; the module must implement CodingAgent.Search.Provider. Built-ins win identifier conflicts, and extension providers are removed during extension reload.
Browser tools use the unified browser provider. The default auto selection checks connected LCU, the authenticated Lemon MV3 relay, then managed local Chrome. LCU exposes Chrome through cua_js; other providers use the backend-neutral LemonBrowser facade. Explicit providers fail closed and never switch browser identity or profile. The local/CDP driver needs Node.js 24 or later; release installs bundle it, and a source checkout builds it:
npm --prefix clients/lemon-browser-node install
npm --prefix clients/lemon-browser-node run buildAdditional built-ins cover Browserbase (browserbase), Browser Use Cloud (browser_use), Firecrawl hosted browser sessions (firecrawl), Camofox REST/Firefox (camofox), and explicit local/public routing (hybrid). Hosted backends require an exact Lemon session_id, create one provider session per session/profile/run scope, attach the Node driver without launching a browser, redact CDP authority from status, and release provider state on idle. Camofox uses snapshot refs such as @e5 as selectors. Hybrid requires LEMON_BROWSER_HYBRID_PUBLIC_BACKEND; local/private URLs stay on its local backend, public URLs use that configured backend, and provider failure never falls back across identities.
The local provider manages Chrome/Chromium over loopback CDP. To attach an existing CDP browser instead, set LEMON_BROWSER_PROVIDER=cdp and LEMON_BROWSER_CDP_ENDPOINT (or pass --cdp-endpoint when using the driver directly). CDP attachment never launches a replacement if the endpoint is unreachable, and connection errors redact endpoint credentials.
Every page-scoped browser tool accepts optional targetId. When omitted it uses the session's active target; browser_tab_activate changes that active target. browser_tabs returns stable real Chrome target IDs. Lemon separately tracks browsers it launched and browsers it merely attached to; stopping an attached session never sends Browser.close and never terminates the user's browser.
Existing signed-in Chrome via MV3
The opt-in extension relay exposes existing signed-in tabs without launching a debugging profile:
export LEMON_BROWSER_RELAY_TOKEN="$(openssl rand -hex 32)"
./bin/lemon-browser-relay start
./bin/lemon-browser-relay install
export LEMON_BROWSER_PROVIDER=relayLoad the directory printed by install through Chrome's Load unpacked UI, then enter the same token and port in the extension settings. The relay binds only to loopback, requires constant-time token authentication on discovery and both WebSocket paths, hides restricted Chrome pages, and acknowledges rather than forwards Browser.close. The extension displays connection state in its badge and reconnects with bounded backoff. See clients/lemon-browser-node/README.md.
Screenshots default to .lemon/browser-artifacts/ under the active working directory unless a path is provided. By default, screenshot responses return only redacted metadata and a local artifact path. includeImage: true adds a model-visible image content block for the captured screenshot while keeping raw base64 out of result details. sendToChannel: true adds redacted auto_send_files metadata pointing at the local screenshot artifact; Telegram and Discord renderers can then deliver the file as a real attachment on finalization.
browser_analyze is the one-step browser vision workflow. It captures the current page as a managed screenshot artifact, analyzes that image through media_analyze_image with local_vision or openai_vision, writes a managed analysis artifact, and returns untrusted analysis text plus safe screenshot and analysis metadata. Pass includeImage: true only when the model also needs the raw screenshot pixels as an image block.
browser_navigate accepts an optional route guard: auto, public, or local. The default auto keeps Lemon's local-first browser behavior and classifies each target as public, private, or local-document before forwarding to the browser worker. public rejects local/private/data/file targets, local rejects public web targets, and cloud metadata endpoints are blocked before the supervised worker in every route. The LemonBrowser.RoutePolicy guard applies before navigation dispatch; route arguments are stripped before node dispatch. Progress updates include only safe route classification metadata plus hashed host data, never the raw URL.
browser_evaluate is page-scoped JavaScript only. The evaluated value is returned as untrusted tool output, while progress updates omit the expression and only record that a sensitive argument was present.
browser_upload_file attaches one or more project-local files to a file input selected by CSS selector. It accepts path for one file or paths for several, validates every file under the current project before dispatch, uses the browser worker's browser.setInputFiles method, and returns untrusted output. Progress updates redact selectors and upload paths.
browser_download waits for the next browser download, optionally after clicking a CSS selector. If path is omitted, Lemon saves the file under the managed .lemon/browser-artifacts/ directory using a sanitized suggested filename; explicit path values must resolve under the current project and must not point at an existing directory. The tool returns untrusted download metadata including the saved path, suggested filename, and byte count. Progress updates redact selectors, output paths, filenames, and downloaded file contents.
browser_events returns the latest buffered page-side events from the current session. Pass clear: true to drain the buffer after reading it. Dialog events are dismissed by the browser helper after they are recorded so they do not block later page actions.
browser_get_cookies accepts an optional url to scope the returned cookies and redacts cookie values by default; pass includeValues: true only when raw cookie values are needed for an explicit browser-state task. browser_set_cookies accepts Playwright cookie objects. browser_clear_state clears cookies, current-page localStorage/sessionStorage, and buffered events by default; pass clearCookies, clearStorage, or clearEvents as false to leave a specific state surface intact.
Doctor support bundles include redacted browser metadata in browser_diagnostics.json, including local driver status, request counters, the last worker error, artifact directory, and recent artifact cleanup metadata. The metadata includes a redacted driver config summary showing local-CDP vs remote-CDP mode, attach-only state, launch behavior, local CDP port, and an endpoint hash when a managed endpoint is configured. Screenshot bytes are not embedded in the bundle. Browser artifacts are local files and screenshot writes now enforce managed retention for the artifact directory: keep 14 days or the newest 100 files, whichever retains fewer old artifacts.
Browser tools emit channel-safe partial progress updates into Lemon's normal tool-status pipeline. The updates carry the browser method, phase, timeout, safe result counts, artifact flags, and hashed host metadata where relevant; they do not include raw URLs, selectors, evaluated expressions, typed text, selected values, upload paths, download paths, filenames, uploaded or downloaded file contents, cookie values, page text, screenshot bytes, or artifact paths. Web, TUI, Telegram, and Discord status surfaces can render these updates as browser child actions while the supervised request is running.
The deterministic browser proof lives in apps/coding_agent/test/coding_agent/tools/browser_test.exs. It drives a local data: page through browser_navigate, browser_snapshot, browser_wait_for_selector, browser_evaluate, browser_hover, browser_select_option, browser_upload_file, browser_download, browser_type, browser_click, browser_get_content, browser_events, and the cookie/state control wrappers using the supervised LemonBrowser.LocalServer boundary, and checks browser progress update redaction for URL, selector, upload-path, download-path, filename, and failure paths.
API Key Setup
Set keys in your shell or .env (autoloaded by Lemon startup scripts):
export BRAVE_API_KEY="..."
export PERPLEXITY_API_KEY="..." # optional alternative to OPENROUTER_API_KEY
export OPENROUTER_API_KEY="..." # optional alternative to PERPLEXITY_API_KEY
export FIRECRAWL_API_KEY="..." # optional; used by webfetch fallbackKey resolution behavior:
- Brave search uses
runtime.tools.web.search.api_key, thenBRAVE_API_KEY. - Exa uses
runtime.tools.web.search.providers.exa.api_key, thenEXA_API_KEY. - Perplexity search uses
runtime.tools.web.search.perplexity.api_key, thenPERPLEXITY_API_KEY, thenOPENROUTER_API_KEY. - Firecrawl uses
runtime.tools.web.fetch.firecrawl.api_key, thenFIRECRAWL_API_KEY.
Config Example
[runtime.tools.web.search]
enabled = true
provider = "brave" # brave | exa | perplexity | tavily | kagi | jina | duckduckgo | searxng | extension id
max_results = 5
timeout_seconds = 30
cache_ttl_minutes = 15
[runtime.tools.web.search.failover]
enabled = true
provider = "perplexity"
[runtime.tools.web.search.perplexity]
api_key = "<perplexity-api-key>"
# base_url can be omitted; Lemon auto-selects Perplexity vs OpenRouter by key source/prefix.
model = "perplexity/sonar-pro"
[runtime.tools.web.search.providers.searxng]
base_url = "https://search.example.com"
[runtime.tools.web.fetch]
enabled = true
max_chars = 50000
timeout_seconds = 30
cache_ttl_minutes = 15
max_redirects = 3
max_body_bytes = 5242880
readability = true
allow_private_network = false
allowed_hostnames = []
[runtime.tools.web.fetch.firecrawl]
# If enabled is omitted, fallback auto-enables when api_key exists.
enabled = true
api_key = "fc-..."
base_url = "https://api.firecrawl.dev"
only_main_content = true
max_age_ms = 172800000
timeout_seconds = 60
[runtime.tools.web.cache]
persistent = true
path = "~/.lemon/cache/web_tools"
max_entries = 100Safety Notes (SSRF)
webfetch enforces URL/network guardrails by default:
- Only
http://andhttps://URLs are allowed. - Blocks local/internal targets (
localhost,.localhost,.local,.internal, and private/internal IP ranges). - Re-checks redirect targets on every hop.
Config knobs:
allow_private_network = true: bypasses private-network blocking for most hosts. Cloud metadata endpoints (for examplemetadata.google.internaland169.254.169.254) remain blocked.allowed_hostnames = ["internal.example"]: host allowlist override for specific names.
Caching Behavior and Defaults
websearch and webfetch cache through ETS plus optional disk persistence:
- Default
cache_ttl_minutes = 15for both tools. cache_ttl_minutes = 0disables writes (effectively no caching).- Global cache settings live under
runtime.tools.web.cache. runtime.tools.web.cache.max_entriesdefaults to100entries per tool.runtime.tools.web.cache.persistent = truepersists cache to disk across restarts.runtime.tools.web.cache.pathdefaults to~/.lemon/cache/web_tools.- Cache hits include
"cached": truein tool output.
Troubleshooting
missing_brave_api_key: setBRAVE_API_KEYorruntime.tools.web.search.api_key.missing_perplexity_api_key: setPERPLEXITY_API_KEYorOPENROUTER_API_KEY(or config key).No provider in the search chain supports the requested freshness filter: select a capable provider (Brave, Exa, Tavily, Kagi, DuckDuckGo or SearXNG). DuckDuckGo/SearXNG accept relative filters only.Invalid URL: must be http or https: use a valid HTTP(S) URL.Blocked hostname/resolves to private/internal IP: target is blocked by SSRF guards; use safer public host, or explicitly allow only what you trust.Web fetch failed ... (Firecrawl fallback failed ...): verifyFIRECRAWL_API_KEY,base_url, and network reachability.
Query operators and response bounds
websearch parses site:, -site:, inurl:, intitle:, filetype:, after:YYYY-MM-DD, before:YYYY-MM-DD, quoted phrases, -term, and OR. Providers declare supported syntax and mapped filters. Unsupported URL constraints are applied to returned results; operators that cannot be enforced are returned in dropped_operators. query_rewritten shows the actual provider query. Boolean queries are not approximated with conjunctive URL filters.
freshness accepts pd, pw, pm, py, or a date range. recency accepts day, week, month, or year. Freshness takes precedence. The chain skips providers without the required time-filter capability rather than silently dropping the filter; fallback between capable providers is retained.
Guarded fetches stream at most runtime.tools.web.fetch.max_body_bytes bytes (default 5 MiB) per response, including redirect hops. Oversized bodies halt early with body_too_large; this is terminal and does not trigger extraction fallback.
Local browser driver not found: reinstall Lemon, or in a source checkout runnpm install && npm run buildinclients/lemon-browser-node.Could not find Chrome/Chromium executable: install Chrome/Chromium or setLEMON_CHROME_EXECUTABLE.
For Firecrawl-specific behavior, see docs/tools/firecrawl.md.