OpenClaw already gives you three useful primitives:
web_fetchfor plain HTTP fetch + readable extractionbrowserfor local or remote CDP-driven browser control- skills for teaching the agent new tool flows
browser.city fits best as the stealth browser backend when you need:
- JS rendering + clean markdown on blocked sites
- residential / geo egress
- remote interactive steps over HTTP (
/v1/do/*) - extraction as an API instead of driving a local browser profile
Practical split:
- Use OpenClaw’s built-in
web_fetchfor simple public pages. - Use OpenClaw’s
browsertool for local/manual-login flows and human verification. - Use browser.city when the page is guarded, JS-heavy, geo-sensitive, or you want deterministic remote browser infrastructure behind a skill.
1) Add a browser.city skill to OpenClaw
OpenClaw skills can live in either:
<workspace>/skills/browsercity~/.openclaw/workspace/skills/browsercity
In SKILL.md, tell OpenClaw to:
- prefer browser.city Request API for
URL -> markdown - escalate to browser.city Humanized REST (
/v1/do/*) when a page needs click/type/navigation - keep secrets in
BROWSERCITY_API_KEY - fall back to the native OpenClaw browser tool when you explicitly want a local/manual-login browser
You do not need a heavy plugin for this. A simple skill that points the agent at one of the helpers below is enough.
2) Helper: URL -> markdown (Request API)
This is the best default for OpenClaw when you want a deterministic read tool with stealth rendering.
const apiKey = process.env.BROWSERCITY_API_KEY!;const opts = { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } };export async function browsercityMarkdown(url: string): Promise<string> { const res = await fetch("https://api.browser.city/v1/requests", { ...opts, body: JSON.stringify({ url, markdown: true }), }).then((r) => r.json()); return String(res.content ?? "");}import osimport requestsapi_key = os.environ["BROWSERCITY_API_KEY"]def browsercity_markdown(url: str) -> str: res = requests.post( "https://api.browser.city/v1/requests", headers={"Authorization": f"Bearer {api_key}"}, json={"url": url, "markdown": True}, ).json() return str(res.get("content", ""))using System.Net.Http.Headers;using System.Net.Http.Json;var apiKey = Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY")!;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);async Task<string> BrowsercityMarkdown(string url){ var res = await http.PostAsJsonAsync( "https://api.browser.city/v1/requests", new { url, markdown = true }); var json = await res.Content.ReadFromJsonAsync<RequestResponse>() ?? throw new Exception("bad response"); return json.content ?? "";}public record RequestResponse(string? content);import java.net.URI;import java.net.http.*;import java.util.regex.*;var apiKey = System.getenv("BROWSERCITY_API_KEY");var http = HttpClient.newHttpClient();String browsercityMarkdown(String url) throws Exception { var body = "{\"url\":\"" + url + "\",\"markdown\":true}"; var req = HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/requests")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); var res = http.send(req, HttpResponse.BodyHandlers.ofString()); return extractJsonString(res.body(), "content");}static String extractJsonString(String json, String key) { var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json); if (!m.find()) return ""; return m.group(1);}
3) Helper: open -> navigate -> markdown (Humanized REST)
When OpenClaw needs a remote browser session but you do not want to run Playwright inside the OpenClaw runtime, use /v1/do/*.
const apiKey = process.env.BROWSERCITY_API_KEY!;const opts = { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } };const post = (path: string, body: unknown) => fetch("https://api.browser.city" + path, { ...opts, body: JSON.stringify(body), }).then((r) => r.json());export async function browsercityBrowseMarkdown(url: string): Promise<string> { const open = await post("/v1/do/open", { browser: "chromium" }); const sessionId = open.result as string; await post("/v1/do/navigate", { sessionId, url }); const md = await post("/v1/do/markdown", { sessionId }); return String(md.result ?? "");}import osimport requestsapi_key = os.environ["BROWSERCITY_API_KEY"]headers = {"Authorization": f"Bearer {api_key}"}def browsercity_browse_markdown(url: str) -> str: open_res = requests.post( "https://api.browser.city/v1/do/open", headers=headers, json={"browser": "chromium"}, ).json() session_id = open_res["result"] requests.post( "https://api.browser.city/v1/do/navigate", headers=headers, json={"sessionId": session_id, "url": url}, ) md = requests.post( "https://api.browser.city/v1/do/markdown", headers=headers, json={"sessionId": session_id}, ).json() return str(md.get("result", ""))using System.Net.Http.Headers;using System.Net.Http.Json;var apiKey = Environment.GetEnvironmentVariable("BROWSERCITY_API_KEY")!;var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);async Task<string> BrowsercityBrowseMarkdown(string url){ var open = await http.PostAsJsonAsync( "https://api.browser.city/v1/do/open", new { browser = "chromium" }); var openJson = await open.Content.ReadFromJsonAsync<DoResponse>() ?? throw new Exception("bad response"); var sessionId = openJson.result; await http.PostAsJsonAsync( "https://api.browser.city/v1/do/navigate", new { sessionId, url }); var md = await http.PostAsJsonAsync( "https://api.browser.city/v1/do/markdown", new { sessionId }); var mdJson = await md.Content.ReadFromJsonAsync<DoResponse>() ?? throw new Exception("bad response"); return mdJson.result;}public record DoResponse(string result);import java.net.URI;import java.net.http.*;import java.util.regex.*;var apiKey = System.getenv("BROWSERCITY_API_KEY");var http = HttpClient.newHttpClient();String browsercityBrowseMarkdown(String url) throws Exception { var openRes = http.send( HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/do/open")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString("{\"browser\":\"chromium\"}")) .build(), HttpResponse.BodyHandlers.ofString()); var sessionId = extractJsonString(openRes.body(), "result"); http.send( HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/do/navigate")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString( "{\"sessionId\":\"" + sessionId + "\",\"url\":\"" + url + "\"}")) .build(), HttpResponse.BodyHandlers.discarding()); var mdRes = http.send( HttpRequest.newBuilder() .uri(URI.create("https://api.browser.city/v1/do/markdown")) .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString("{\"sessionId\":\"" + sessionId + "\"}")) .build(), HttpResponse.BodyHandlers.ofString()); return extractJsonString(mdRes.body(), "result");}static String extractJsonString(String json, String key) { var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json); if (!m.find()) return ""; return m.group(1);}
4) Good OpenClaw prompt patterns
Try prompts like:
Use the browsercity skill to fetch
https://example.com/docsas markdown. If the page needs interaction, escalate to the interactive helper.
Use the native OpenClaw browser for manual login, then switch back to browser.city for large-scale extraction.
That split works well because OpenClaw remains the planner, while browser.city handles the stealth browsing primitives.
What to use when
- Use OpenClaw
web_fetchfor fast public pages with no JS requirements. - Use OpenClaw
browserfor local/manual-login browsing and human verification. - Use browser.city Request API for stealth
URL -> markdownat scale. - Use browser.city Humanized REST for remote interactive flows without Playwright inside OpenClaw.
- Use browser.city Sessions only when you already want a real Playwright workflow outside OpenClaw.