A logged-in browser behind a clean HTTP API. Drop in your AI, your model, your keys.

  • Isolated profiles per platform — cookies kept between runs
  • Set-of-Mark numbered boxes the agent clicks by index
  • Dispatch jobs, poll results, approve what it drafts
  • Automation flows that wire roles and profiles together

Sign in

Secure access to your browsing agent

Forgotten the password?

Paste the API key (in the app environment as API_KEYS) to clear the account and start again. Browser profiles are untouched.

AccountsThe logins the agent works through — one per platform

0 Connected
0 Live

Your platforms

Click a platform to open its profile and sign in

Live now

What the agent is doing right now — open it to watch or step in

Each account is its own isolated profile — its own saved cookies, its own pinned exit route.

Agent & AI

The model the agent thinks with, and whether it may act on its own. Keys are stored on this browser's own volume and never sent back out — only the last characters are shown.

Account

This browser's API key grants everything the console can do — it is also the way back in if the password is lost. Keep it somewhere safe.


Per-login exit route

Override the default for one login. "Always my tailnet" refuses to open while the tailnet is down rather than quietly leaving from this server. Applies on the login's next session.

Loading logins…

🚀Getting started

The Ghost Browser is a real logged-in browser behind an HTTP API. Everything the console does — open a session, drive a page, dispatch an autonomous agent job — is a call you can make yourself. This is the same interface the platform's master agent uses. All requests and responses are JSON.


Base URL. Every path is relative to the origin serving this console — for you right now that is this origin.

Authentication. Programs authenticate with a bearer key. Signed in here, you can read the key straight from the API — then send it on every call as an Authorization: Bearer <key> header.

GET/api/auth/keycookie

Return this console's API key(s). Requires the signed-in cookie — a key cannot fetch itself. Every returned key grants everything the console can do.

Request · curl
curl -b cookies.txt https://your-ghost-host/api/auth/key
Response 200
{
  "keys": [
    { "key": "gb_live_9f3c2a71b0e4…", "plan": "solo", "maxConcurrent": 1 }
  ]
}

Set these once in your shell and every example below just works:

Setup
BASE="https://your-ghost-host"   # the origin serving this console
KEY="gb_live_9f3c2a71b0e4…"      # from GET /api/auth/key

A refusal comes back as an error object with the HTTP status — for example { "error": "not your session" } with 403.

🧠Two ways to use it

There are two ways to run the Ghost Browser. Pick either — or start with the built-in agent and move to your own later. Both run on your model and your costs.

1 · Your own LLM drives it (this reference). Your agent runs on your side and is the brain. For every step it calls the API below — open a session, read the page as numbered boxes, click / type / act. The Ghost Browser is purely the hands. Start at Sessions, then loop: analyze → decide in your LLM → click / type.

2 · The built-in agent, on your model. Put your own LLM key in Settings and the agent that ships with the Ghost Browser decides and acts by itself. You write no code — you configure roles and agent jobs / workflows as data.


🔌Bring your own model

Add your own key so nothing runs on someone else's account. Supported: OpenRouter and your own Ollama endpoint. Set it in Settings → Agent & AI.

GET/v1/agent/settingsbearer

The current agent configuration (provider + model), secrets redacted.


🔑Signing in

Two ways in. SSO from the platform — sign in on the platform and open the Ghost Browser. And a bearer key for scripts — read it with GET /api/auth/key and send it as Authorization: Bearer <key> on every call.

🪟Sessions

A session is one browser tab you hold. Open one, point it at a URL, read the page as numbered boxes (Set-of-Mark), and act on a box by its number.

POST/v1/sessionsbearer

Open a session. With reuse you get the one you already hold instead of an error; with profile it opens a named, cookie-keeping login.

FieldTypeReqDescription
profilestringnoNamed profile to open — its own saved cookies and pinned exit route.
presetstringnoA known site preset; configures the profile and lands on its login page.
reusebooleannoReturn an existing session rather than failing at the limit.
takeoverbooleannoClose a conflicting session of yours and open here instead.
Request · curl
curl -X POST "$BASE/v1/sessions" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "profile": "facebook", "reuse": true }'
Response 201
{
  "sessionId": "a1b2c3d4",
  "profile": "facebook",
  "expiresAt": 1756497200000,
  "plan": "solo",
  "reused": false
}
GET/v1/sessionsbearer

List the sessions you hold, with your concurrency limit and plan.

Request · curl
curl "$BASE/v1/sessions" -H "Authorization: Bearer $KEY"
DELETE/v1/sessionsbearer

Close every session you hold. Close a single one with DELETE /v1/sessions/:id.

Response 200
{ "closed": 1 }
POST/v1/sessions/:id/navigatebearer

Go to a URL. Only public addresses are allowed.

Request · curl
curl -X POST "$BASE/v1/sessions/a1b2c3d4/navigate" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://www.facebook.com" }'
Response 200
{ "url": "https://www.facebook.com/", "status": 200, "title": "Facebook" }
GET/v1/sessions/:id/analyzebearer

The page as numbered, clickable boxes — Set-of-Mark. Each element has an index you then click or type into.

Request · curl
curl "$BASE/v1/sessions/a1b2c3d4/analyze?screenshot=false" \
  -H "Authorization: Bearer $KEY"
Response 200
{
  "url": "https://www.facebook.com/",
  "title": "Facebook",
  "elementCount": 42,
  "elements": [
    { "index": 1, "tag": "input", "type": "text", "editable": true,
      "placeholder": "Email or phone number", "text": "", "x": 512, "y": 288 },
    { "index": 2, "tag": "button", "role": "button", "text": "Log in", "x": 512, "y": 360 }
  ],
  "summary": "[1] ✎ FIELD[text] — \"Email or phone number\" …\n[2] button — \"Log in\" @ (512,360)",
  "screenshot": null
}
POST/v1/sessions/:id/clickbearer

Click a box by its index from the last analyze, or by visible text.

Request · curl
curl -X POST "$BASE/v1/sessions/a1b2c3d4/click" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "index": 2 }'
Response 200
{ "clicked": { "index": 2, "text": "Log in" }, "url": "https://www.facebook.com/" }
POST/v1/sessions/:id/typebearer

Type into a field box, human-paced. It re-reads the page if it moved (so the number still names the right field), scrolls the field on-screen first, and clears it before typing. For a rich / markdown / React editor, pass paste: true: it inserts the whole block so the editor actually registers it — plain typing into such a box lands in a hidden backing field and silently does nothing (this is what made a Bugcrowd biography or a GitHub comment look filled but submit empty). submit: true presses Enter after.

FieldTypeReqDescription
indexnumberyesThe field’s number from the last analyze.
textstringyesWhat to type.
pastebooleannoInsert the whole block at once, firing the input events a rich / markdown / contenteditable editor needs. Use for any editor that ignores plain typing.
clearbooleannoSelect and replace the field’s existing content first. Default true.
submitbooleannoPress Enter after typing.
Request body — rich editor
{ "index": 14, "text": "Security researcher focused on web and API security...", "paste": true }
Response 200
{ "typed": 371, "into": "biography", "mode": "paste", "url": "https://bugcrowd.com/h/onboarding" }

🤖Agent jobs

The important one. A job is "go and do this in the browser I am already logged into". You dispatch it with a goal and a role against an open session, then poll it.

POST/v1/agent/jobsbearer

Dispatch a job. It runs in the session you name (open one first). The loop outlives the request — this returns immediately with the job to poll.

FieldTypeReqDescription
goalstringyesPlain-language description of what to do.
sessionIdstringyesThe open session to work in.
rolestringnoThe specialist to run as. Defaults to general.
autoApprovebooleannoRun this job with acting-without-asking on.
unattendedbooleannoNobody is watching: conclude and hand the session back.
Request · curl
curl -X POST "$BASE/v1/agent/jobs" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "sessionId": "a1b2c3d4",
        "role": "reddit.demand",
        "goal": "Find people in r/smallbusiness asking how to invoice EU clients"
      }'
Response 200
{
  "jobId": "k7f2a9",
  "status": "running",
  "createdAt": 1756490500000,
  "steps": [],
  "leads": [],
  "proposals": []
}
GET/v1/agent/jobs/:idbearer

Poll one job — status, steps, leads, and any proposals waiting on you.

Request · curl
curl "$BASE/v1/agent/jobs/k7f2a9" -H "Authorization: Bearer $KEY"
POST/v1/agent/jobs/:id/proposals/:pidbearer

Approve or reject one proposal. edit rewrites the text before it is sent.

Request · curl
curl -X POST "$BASE/v1/agent/jobs/k7f2a9/proposals/p-1-9f3c" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{ "approve": true, "edit": "We built exactly this — happy to show you." }'
POST/v1/agent/jobs/:id/saybearer

Say something to a running job — steer it, or nudge an idle one back to work.

Request body
{ "text": "Only save people who mention VAT specifically." }
POST/v1/agent/jobs/:id/stopbearer

Stop a job. Any pending proposals are skipped and the session is handed back.

Request · curl
curl -X POST "$BASE/v1/agent/jobs/k7f2a9/stop" -H "Authorization: Bearer $KEY"

🎭Roles

The specialists a job can run as. A role fixes the tools and the prompt server-side — a scout cannot post, a poster cannot invent leads.

GET/v1/agent/rolesbearer

Every role, grouped for display. Use name as the role when dispatching a job.

Response 200
{
  "roles": [
    { "name": "general", "label": "General", "description": "Everything in reach, no site assumed.", "site": null, "group": "Anything" },
    { "name": "facebook.scout", "label": "Facebook · Lead scout", "description": "Reads Facebook groups for people who need what you sell. Cannot post.", "site": "facebook", "group": "Facebook" }
  ]
}

🗂️Profiles

The saved logins on disk — one isolated profile per platform. Open one by passing its name to POST /v1/sessions.

GET/v1/profilesbearer

The profile names that exist right now.

Response 200
{ "profiles": ["facebook", "linkedin", "reddit"] }

📊Capacity & health

Two open endpoints — no key needed.

GET/v1/capacityopen

How full the worker is. accepting: false means open a session later.

Response 200
{
  "sessions": 2, "maxSessions": 8, "memoryPct": 41,
  "draining": false, "accepting": true
}
GET/healthzopen

Liveness. Always { "ok": true } while the process is up.

🧭How the master uses it

The whole loop the platform's master agent runs, end to end. Five calls: get a session, dispatch a job, poll it, approve what it drafts, read the structured result.

1
Open or reuse a session. The master wants a session, not specifically a new one, so it passes reuse.
1 · session
SID=$(curl -s -X POST "$BASE/v1/sessions" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "profile": "linkedin", "reuse": true }' | jq -r .sessionId)
2
Dispatch the job with a role and a goal. Returns a jobId at once.
2 · dispatch
JOB=$(curl -s -X POST "$BASE/v1/agent/jobs" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{ \"sessionId\": \"$SID\", \"role\": \"linkedin.buyers\",
        \"goal\": \"Find heads of finance at 20-100 person agencies\" }" | jq -r .jobId)
3
Poll until it settles. Keep going while status is running.
3 · poll
while : ; do
  J=$(curl -s "$BASE/v1/agent/jobs/$JOB" -H "Authorization: Bearer $KEY")
  echo "$J" | jq -r .status
  [ "$(echo "$J" | jq -r .status)" = "running" ] || break
  sleep 3
done
4
Approve what it drafts. Pending proposals arrive with state: "pending".
4 · approve
curl -s -X POST "$BASE/v1/agent/jobs/$JOB/proposals/$PID" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "approve": true }'
5
Read the structured result. When status is done, the deliverable is in leads.
5 · results
curl -s "$BASE/v1/agent/jobs/$JOB" -H "Authorization: Bearer $KEY" \
  | jq '.leads[] | { name, why, quote, url }'

Loading…

GhostBrowser
Browser
exit: …
…
Exit through your own device checking…

On, this is what every login does unless it says otherwise in its own Setup. A login that insists on the tailnet refuses to open while the tailnet is down, rather than quietly leaving from this server — which is the failure nobody notices until an account is locked.

Your phone must have "Use as exit node" switched on in its own Tailscale settings before it appears here. Android supports it; iOS does not offer it at all.

Open a session and go somewhere. This is a live browser — click, type, scroll and use the page exactly as you would in a normal tab. Pick a named profile first and whatever you log into stays logged in for the agent.