Read the post on Substack
A headless browser you drive over HTTP, built to run on Railway.
Other services POST commands to it — "go to this URL", "click this", "run this JavaScript", "give me this page as Markdown" — and it drives a real Chromium (via Playwright). Browsers stay warm between requests, and login sessions persist across redeploys, so you can log in once and reuse that authenticated session from anywhere.
This answers the question: "Can I run a headless browser on Railway with an API, that saves its session so it stays logged in, and call it from other services for complex JS interactions?" — yes. This repo is a working implementation.
- Rendered-page → Markdown. Get any JavaScript-heavy page as clean, LLM-ready Markdown — reader-mode extraction (drops nav/ads/footers) plus GitHub-flavored tables and lists. See Get a page as Markdown.
- Persistent logins. Named sessions keep cookies/
localStorageon a Railway Volume, so an authenticated session survives redeploys. - Full browser control.
goto,click,fill,type,waitForSelector, screenshots, and arbitrary in-page JS viaevaluate. - Batch actions. Send a whole "go here → do this → read that" sequence in one request.
- Natural-language tasks. POST a plain-English prompt ("go to this site and collect every product name and price, paginating through all pages") and Claude drives the browser with these same actions until it's done. See Just send a prompt.
- Token-protected. Bearer auth on every route, so it's not an open proxy.
curl -s -X POST "$BASE_URL/sessions/scrape/actions" \
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{ "actions": [
{ "type": "goto", "url": "https://example.com/article", "waitUntil": "networkidle" },
{ "type": "markdown" }
] }' your other service this service (on Railway)
┌────────────────┐ HTTP + Bearer ┌──────────────────────────┐
│ worker / app │ ─────────────────► │ Fastify API │
└────────────────┘ │ │ │
│ ▼ │
│ Playwright → Chromium │
│ │ │
│ ▼ │
│ persistent profile ─────┼──► Railway Volume
│ (cookies, localStorage) │ (survives redeploys)
└──────────────────────────┘
- Sessions are named browser profiles. Reusing a name (
/sessions/my-bot/...) reuses the same logged-in browser. Each profile is stored on disk via Playwright'slaunchPersistentContext, so cookies,localStorage,IndexedDBand the rest of the Chrome profile survive. - The profile lives under
DATA_DIR, which you point at a mounted Railway Volume. Railway's normal container filesystem is wiped on every redeploy — the Volume is what keeps you logged in. - On shutdown (redeploy =
SIGTERM) the service closes the browser cleanly so Chromium flushes the profile to the Volume first. (Playwright's own force-kill signal handlers are disabled for exactly this reason — seesrc/sessionManager.ts.) - Every request needs
Authorization: Bearer $API_TOKEN, so it isn't an open browser proxy on the public internet.
Want to drive this from Claude Desktop instead of raw HTTP? mcp/ is a
local MCP server that exposes these endpoints as tools (navigate, click, read a
page as Markdown, screenshot, run a natural-language task). Point it at your
Railway URL + token and Claude can browse for you. See mcp/README.md.
All routes except /health require Authorization: Bearer $API_TOKEN.
| Method & path | Purpose |
|---|---|
GET /health |
Liveness probe (unauthenticated). |
GET /sessions |
List currently live (in-memory) sessions. |
POST /sessions/:id/actions |
Run a batch of actions in order (the main endpoint). |
POST /sessions/:id/prompt |
Give it a plain-English task and Claude drives the browser until it's done. Needs ANTHROPIC_API_KEY. |
POST /sessions/:id/goto |
{ "url": "...", "waitUntil": "load" } |
POST /sessions/:id/click |
{ "selector": "..." } |
POST /sessions/:id/fill |
{ "selector": "...", "text": "..." } (sets value directly) |
POST /sessions/:id/type |
{ "selector": "...", "text": "..." } (realistic keystrokes) |
POST /sessions/:id/evaluate |
{ "script": "return document.title" } — arbitrary JS |
GET /sessions/:id/content |
Current page HTML. |
GET /sessions/:id/markdown |
Rendered page as Markdown (?readability=false, ?selector=...). |
GET /sessions/:id/screenshot |
PNG image (?fullPage=true for the whole page). |
POST /sessions/:id/close |
Close the live browser but keep the login on disk. |
DELETE /sessions/:id |
Destroy the session and delete its profile (full logout). |
POST /sessions/:id/actions takes { "actions": [ ... ] } and runs them in
order, returning one result per action. It stops at the first failure and tells
you which step failed. Supported action types:
goto, click, fill, type, press, waitForSelector, waitForTimeout,
evaluate, screenshot, content, markdown, url.
curl -X POST https://YOUR-APP.up.railway.app/sessions/my-bot/actions \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{ "type": "goto", "url": "https://example.com/login" },
{ "type": "fill", "selector": "#email", "text": "me@example.com" },
{ "type": "fill", "selector": "#password", "text": "hunter2" },
{ "type": "click", "selector": "button[type=submit]" },
{ "type": "waitForSelector", "selector": ".dashboard" },
{ "type": "evaluate", "script": "return document.querySelector(\".user-name\").innerText" }
]
}'After this runs once, the my-bot session is logged in. Later calls with the
same session id skip the login and go straight to work — even after a redeploy.
The evaluate action is the escape hatch for complex JS interactions: the
script is a function body, so use return to send a value back, and you can
await inside it.
The batch endpoint above needs you to know the selectors and steps in advance.
POST /sessions/:id/prompt doesn't: you send a plain-English task and Claude
drives the browser for you — deciding which pages to open, what to click, how to
extract, and when it's done. Under the hood it's an agentic loop where Claude
calls the same browser actions (goto, click, evaluate, markdown,
screenshots, …) until the task is complete, then returns its answer.
This is the "go to this site and get this data after paginating all pages" endpoint:
curl -s -X POST "$BASE_URL/sessions/scrape/prompt" \
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{
"prompt": "Go to https://example.com/products and collect every product name and price. Page through all the pages until there are no more, and return the full list as JSON."
}'Returns the model's final answer plus a log of what it did:
{
"ok": true,
"result": "{ \"products\": [ { \"name\": \"...\", \"price\": \"...\" }, ... ] }",
"steps": 12,
"stopReason": "end_turn",
"transcript": [
{ "step": 1, "tool": "goto", "input": { "url": "https://example.com/products" }, "ok": true, "preview": "{\"status\":200,...}" },
{ "step": 2, "tool": "evaluate", "input": { "script": "return [...].map(...)" }, "ok": true, "preview": "{\"result\":[...]}" },
{ "step": 3, "tool": "click", "input": { "selector": "a.next" }, "ok": true, "preview": "{\"ok\":true}" }
]
}resultis the deliverable — Claude's final message, which contains the data it was asked for (ask for JSON if you want to parse it).transcriptis the ordered list of browser actions it took (with short result previews), so you can see how it got there.stepsis how many model turns it used;stopReasonisend_turnwhen it finished,max_stepsif it hit the cap, orrefusalif it declined.
Body fields: prompt (required) and optional maxSteps (override the per-call
step cap for this request).
Because it runs against a named session, it reuses that session's login and current page — so you can log in once (via the batch endpoint), then hand Claude authenticated tasks against the same session id.
Setup: this endpoint needs an ANTHROPIC_API_KEY set in the service
Variables (get one at console.anthropic.com).
Without it, /prompt returns 503; every other route keeps working. The model
(claude-opus-4-8 by default), reasoning effort, step cap, and token limits are
all configurable — see the Configuration reference.
Note: the model can navigate to any URL and run arbitrary in-page JavaScript to accomplish the task — that's the point — so only give it prompts you'd be comfortable running yourself, and keep the service token-protected.
Everything another program — or an AI that's wired this up as a tool — needs to
call /prompt correctly. This is the whole interface; there is nothing else to
know.
Request
POST {BASE_URL}/sessions/{session_id}/prompt
Authorization: Bearer {API_TOKEN}
Content-Type: application/json
{
"prompt": "<the task, in plain English>", // required, non-empty string
"maxSteps": 40 // optional int, 1–200; caps agent<->browser round trips
}
session_id— any name you choose,1–64chars of[a-zA-Z0-9_-]. Reusing the same name reuses the same browser + login. Use a fresh name for an isolated, logged-out browser; reuse a name to continue where a prior call (or a prior batch login) left off. It does not need to be created ahead of time.API_TOKEN— the service's bearer token (this is not the Anthropic key).
Response — always JSON:
resultis always a string. If you want structured data back, ask for JSON in the prompt ("…return the result as a JSON array of {name, price}") and thenJSON.parseresultyourself. The service does not parse it for you.stopReason:end_turn= the agent decided it was done (normal success).max_steps= it hit the step cap before finishing (ok:false; raisemaxStepsor narrow the task).refusal= the model declined the task (ok:false).
HTTP status codes
| Code | Meaning | What to do |
|---|---|---|
200 |
Task finished (ok:true). |
Use result. |
422 |
Agent stopped early (ok:false, e.g. max_steps / refusal). |
Read error; retry with a higher maxSteps or a clearer prompt. |
400 |
prompt missing/empty. |
Fix the body. |
401 |
Bad/missing bearer token. | Fix Authorization. |
503 |
ANTHROPIC_API_KEY not configured on the server. |
Server-side setup; the caller can't fix it. |
502 |
Upstream model call failed. | Transient — retry with backoff. |
Timeouts — a single call runs multiple model turns plus real page loads, so it can take tens of seconds to a few minutes. Set the client/tool HTTP timeout generously (e.g. 300s) and treat it as a long-running call, not a quick fetch.
Writing good prompts for it — it behaves like a capable operator who can see and act on the page but can't read your mind:
- State the goal and the exact data you want, and the output shape
("return JSON:
[{title, url, price}]"). - Say where to start — include the URL, or rely on the session already being on the right page.
- For multi-page jobs, say "page through all pages until there are none left" so it knows to paginate rather than stop at page one.
- Give stop conditions for open-ended tasks ("stop after 200 items" / "only
the first 5 pages") to bound cost and avoid hitting
max_steps. - It's fine to describe interactions ("dismiss the cookie banner if present, then …", "log in with the form if you see one") — it will figure out selectors.
Minimal example a tool would issue
curl -sS --max-time 300 -X POST "$BASE_URL/sessions/my-agent/prompt" \
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{"prompt":"Go to https://news.ycombinator.com and return the titles and links of the top 10 stories as a JSON array of {title, url}."}'Because pages are JavaScript-rendered, markdown converts the live, rendered
DOM — not the empty shell a plain curl gets. By default it runs
Readability (reader-mode extraction)
to keep the main article and drop nav/sidebars/footers/ads, then converts to
GitHub-flavored Markdown (tables, lists, links, code). Ideal for feeding pages
to an LLM.
# Quick one-shot: fetch a JS page and get clean Markdown back
curl -s -X POST "$BASE_URL/sessions/scrape/actions" \
-H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
-d '{
"actions": [
{ "type": "goto", "url": "https://example.com/article", "waitUntil": "networkidle" },
{ "type": "markdown" }
]
}'Returns:
{ "ok": true, "results": [
{ "status": 200, "url": "https://example.com/article" },
{ "markdown": "## Heading\n\nBody text...", "title": "Article Title",
"byline": "Jane Doe", "extractedWith": "readability" }
]}Or use the convenience endpoint on the current page:
curl -s "$BASE_URL/sessions/scrape/markdown" -H "Authorization: Bearer $API_TOKEN"Options (on the action, or as query params on the GET endpoint):
readability: false— convert the whole<body>instead of the main article.selector: ".content"— convert only that element's HTML.
extractedWith in the response tells you which path produced the output
(readability, selector, or body — it falls back to body when Readability
can't identify an article, or if a strict CSP with BYPASS_CSP=false prevents
injecting the extractor).
- Create a service from this repo. Railway detects
railway.jsonand builds theDockerfile(the Playwright base image ships Chromium + all system libs). - Add a Volume to the service and set its mount path to
/data. - Set variables (Service → Variables):
API_TOKEN— required. Generate one:openssl rand -hex 32.DATA_DIR=/data— must match the Volume mount path.- Optional:
HEADLESS,SESSION_IDLE_MS,DEFAULT_TIMEOUT_MS,MAX_SESSIONS.
- Deploy. Health check is
GET /health. Your API is at the service's public URL.
PORT is injected by Railway automatically — don't set it.
Scaling note: because logins live on a Volume and Volumes attach to a single instance, run this as one replica. If you need more throughput, raise
MAX_SESSIONSand the instance's memory rather than adding replicas.
cp .env.example .env # then edit API_TOKEN
npm install
npx playwright install chromium # first time only, for local runs
npm run dev # watches src/, restarts on changeThen hit http://localhost:3000 with the same curl calls (using your local
API_TOKEN). Set HEADLESS=false in .env to watch the browser while you
debug. To run against a system-provided Chromium instead of the bundled one, set
CHROMIUM_EXECUTABLE_PATH to its binary.
| Variable | Default | Meaning |
|---|---|---|
API_TOKEN |
— (required) | Bearer token every caller must send. |
DATA_DIR |
/data |
Where profiles + sessions are stored. Point at a Volume. |
HEADLESS |
true |
Run Chromium headless. false for local debugging. |
PORT |
3000 |
HTTP port. Railway injects this. |
SESSION_IDLE_MS |
600000 |
Idle time before a live browser is closed (profile kept). |
DEFAULT_TIMEOUT_MS |
30000 |
Default per-action timeout. |
MAX_SESSIONS |
5 |
Max concurrent live browsers (memory guard). |
BYPASS_CSP |
true |
Bypass page Content Security Policy so markdown can inject Readability on CSP-strict sites (GitHub, etc.). |
CHROMIUM_EXECUTABLE_PATH |
— | Optional path to a specific Chromium binary. |
LOG_LEVEL |
info |
Pino log level. |
ANTHROPIC_API_KEY |
— | Enables POST /sessions/:id/prompt. Without it that route returns 503; all others work. |
ANTHROPIC_MODEL |
claude-opus-4-8 |
Model the /prompt agent uses. |
AGENT_EFFORT |
high |
Reasoning effort for the agent: low, medium, high, xhigh, max. |
AGENT_MAX_STEPS |
40 |
Max agent↔browser round trips per /prompt call (runaway-loop guard). |
AGENT_MAX_TOKENS |
16000 |
Max output tokens per model turn in the agent loop. |
AGENT_MAX_TOOL_RESULT_CHARS |
20000 |
Truncate a single tool result before feeding it back, so big page dumps don't blow up context/cost. |
This repo is the self-hosted, full-control option. If you'd rather not own the browser layer:
browserless/chromium— a prebuilt Docker image with a REST + WebSocket API; deploy it directly on Railway. Less custom code, less control.- Managed (Browserbase, Steel, Browserless cloud) — point an API at their hosted browsers and skip hosting entirely. Higher cost, near-zero ops.
The persistence approach here (persistent profile on a Volume + clean shutdown) is the part most worth keeping regardless of which browser layer you choose.
{ "ok": true, // true = task finished; false = it stopped early (see stopReason/error) "result": "…", // STRING: the agent's final answer — the deliverable "steps": 12, // how many model turns it took "stopReason": "end_turn", // "end_turn" | "max_steps" | "refusal" "transcript": [ // ordered log of the browser actions it took { "step": 1, "tool": "goto", "input": { "url": "…" }, "ok": true, "preview": "…" } ], "error": "…" // present only when ok=false }