GhostBrowser

Forgotten the password?

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

AccountsThe logins the agent works through — one per platform

0Connected
0Sign in
0Live

Your platforms

Click a card to open that platform in its own 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.

🛰️Connection & exit route

Where this browser's traffic leaves from. A datacentre IP is why a login can be refused — route it through one of your own devices on your tailnet instead.

checking…

Your phone must have “Use as exit node” switched on in its own Tailscale settings before it appears here — it is off by default. Android supports it; iOS does not offer it at all.

🧠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. (A browser session may also authenticate with the console's own cookie; the key is for scripts and agents.)

GET/api/auth/key cookie

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 that carries it — for example { "error": "not your session" } with 403, or a 409 whose body often includes the way out (hint, yourSessions).

🧠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 (your VPS) 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 that carry out what your LLM decides. You keep full control of the logic, the prompts and any coupling to your own database. Start at Sessions, then loop: analyze (read the page as numbered boxes) → decide in your LLM → click / type. That look → act loop is the whole of mode 1.

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. Fastest to start; a little less fine control.


🔌Bring your own model

Add your own key so nothing runs on someone else's account. Supported: OpenRouter (so you can run it on Claude / Claude Code) and your own Ollama endpoint. Set it in the console under Settings → Agent & AI. Your key lives in the app's environment and is never returned in listings.

GET/v1/agent/settings bearer

The current agent configuration (provider + model), secrets redacted. You set the model and your key from Settings → Agent & AI in this console; it applies to mode 2 (the built-in agent). Mode 1 uses whatever model your own code calls — the Ghost Browser needs no key for that.


🔑Signing in

Two ways in. SSO from the platform — sign in on my-app.engineer and open the Ghost Browser; your platform login carries over, no separate password. And a bearer key for scripts and your own agent (mode 1) — 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. Sessions are capped per plan and expire; list yours to see what you are holding.

POST/v1/sessions bearer

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

FieldTypeReqDescription
profilestringnoNamed profile to open — its own saved cookies and pinned exit route.
presetstringnoA known site preset; configures the profile before the browser starts and lands on its login page.
reusebooleannoReturn an existing session for you if one is open, 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 }'
Request body
{ "profile": "facebook", "reuse": true }
Response 201
{
  "sessionId": "a1b2c3d4",
  "profile": "facebook",
  "expiresAt": 1756497200000,
  "plan": "solo",
  "reused": false
}
GET/v1/sessions bearer

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

Request · curl
curl "$BASE/v1/sessions" -H "Authorization: Bearer $KEY"
Response 200
{
  "sessions": [
    {
      "sessionId": "a1b2c3d4",
      "url": "https://www.facebook.com/",
      "profile": "facebook",
      "createdAt": 1756490000000,
      "lastUsed": 1756490120000,
      "expiresAt": 1756497200000
    }
  ],
  "maxConcurrent": 1,
  "plan": "solo"
}
GET/v1/sessions/:id bearer

One session's current URL and lifetimes.

Response 200
{
  "sessionId": "a1b2c3d4",
  "url": "https://www.facebook.com/",
  "createdAt": 1756490000000,
  "lastUsed": 1756490120000,
  "expiresAt": 1756497200000
}
DELETE/v1/sessions bearer

Close every session you hold — the way out of one you lost track of. Returns how many were closed. Close a single one with DELETE /v1/sessions/:id (returns { "closed": true }).

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

Go to a URL. Only public addresses are allowed, checked again after any redirect — a hop to a private address is stopped and the page emptied.

FieldTypeReqDescription
urlstringyesWhere to go. Must be a public http(s) URL.
waitUntilstringnoPlaywright wait state. Defaults to domcontentloaded.
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/analyze bearer

The page as numbered, clickable boxes — Set-of-Mark. Each element has an index you then click or type into. A JPEG screenshot with the boxes drawn on comes back as base64 unless you pass ?screenshot=false.

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/click bearer

Click a box by its index from the last analyze, or by visible text. Clicking by index re-analyzes automatically if the page moved.

FieldTypeReqDescription
indexnumber*The box number from the last analyze.
textstring*Click the first element containing this text instead. * one of index / 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/type bearer

Type into a field box, human-paced. Pass submit: true to press Enter afterwards. Requires a prior analyze.

FieldTypeReqDescription
indexnumberyesThe field's box number.
textstringyesWhat to type.
submitbooleannoPress Enter after typing.
Request body
{ "index": 1, "text": "someone@example.com", "submit": false }
Response 200
{ "typed": 19, "into": "Email or phone number", "url": "https://www.facebook.com/" }

🤖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: it streams steps, saves leads, and — for anything it wants to say or do under your name — raises a proposal that waits for your approval.

POST/v1/agent/jobs bearer

Dispatch a job. It runs in the session you name (open one first). A model must be configured in Settings. 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. Must be yours and not already running a job.
rolestringnoThe specialist to run as (see GET /v1/agent/roles). Defaults to general.
companyIdstringnoWhich company profile it is selling for.
autoApprovebooleannoRun this one job with acting-without-asking on, without changing the global setting.
unattendedbooleannoNobody is watching: conclude and hand the session back rather than parking at the step limit.
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",
  "id": "k7f2a9",
  "owner": "carla",
  "goal": "Find people in r/smallbusiness asking how to invoice EU clients",
  "role": "reddit.demand",
  "sessionId": "a1b2c3d4",
  "status": "running",
  "createdAt": 1756490500000,
  "steps": [],
  "leads": [],
  "proposals": []
}
GET/v1/agent/jobs bearer

Your live jobs, plus a short history of finished ones.

Response 200
{
  "jobs": [ { "id": "k7f2a9", "goal": "…", "status": "running", "role": "reddit.demand" } ],
  "history": [ { "id": "j3d0x1", "goal": "…", "status": "done", "endedAt": 1756480000000 } ]
}
GET/v1/agent/jobs/:id bearer

Poll one job. This is the whole story: status (running · idle · done · stopped · failed), the steps it has taken, the leads it saved, and any proposals waiting on you.

Request · curl
curl "$BASE/v1/agent/jobs/k7f2a9" -H "Authorization: Bearer $KEY"
Response 200
{
  "id": "k7f2a9",
  "goal": "Find people in r/smallbusiness asking how to invoice EU clients",
  "role": "reddit.demand",
  "status": "running",
  "steps": [
    { "n": 1, "at": 1756490501000, "kind": "think", "text": "Searching Reddit for invoicing complaints" },
    { "n": 2, "at": 1756490507000, "kind": "act",   "text": "navigate → reddit.com/search?q=invoicing" }
  ],
  "leads": [
    {
      "at": 1756490540000,
      "name": "u/freelancejen",
      "why": "asking how others invoice EU clients",
      "quote": "what do you all use for VAT invoices?",
      "contact": "",
      "url": "https://reddit.com/r/smallbusiness/comments/…"
    }
  ],
  "proposals": [
    {
      "pid": "p-1-9f3c",
      "at": 1756490550000,
      "state": "pending",
      "kind": "comment",
      "text": "We built exactly this — it handles EU VAT invoices out of the box.",
      "url": "https://reddit.com/r/smallbusiness/comments/…"
    }
  ]
}
POST/v1/agent/jobs/:id/proposals/:pid bearer

Approve or reject one proposal. edit rewrites the text before it is sent — the normal case. A decision is final; a proposal is decided once.

FieldTypeReqDescription
approvebooleanyestrue sends it, false skips it.
editstringnoReplacement text to send instead of what it drafted (approve only).
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." }'
Response 200
{
  "pid": "p-1-9f3c",
  "state": "approved",
  "kind": "comment",
  "text": "We built exactly this — happy to show you.",
  "decidedAt": 1756490600000
}
POST/v1/agent/jobs/:id/say bearer

Say something to a running job — steer it, or nudge an idle one back to work. Queued and read at the next safe point, not injected mid-action.

Request body
{ "text": "Only save people who mention VAT specifically." }
Response 200
{ "at": 1756490620000, "text": "Only save people who mention VAT specifically." }
POST/v1/agent/jobs/:id/stop bearer

Stop a job. Any pending proposals are skipped and the session is handed back. Returns the final job view with status: "stopped".

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 — so a caller only ever passes a name.

GET/v1/agent/roles bearer

Every role, grouped the way a picker should show it. 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 and post search for people who need what you sell. Cannot post.",
      "site": "facebook", "group": "Facebook" },
    { "name": "reddit.demand", "label": "Research · Reddit demand",
      "description": "…", "site": null, "group": "Research" }
  ]
}

🗂️Profiles

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

GET/v1/profiles bearer

The profile names that exist right now.

Request · curl
curl "$BASE/v1/profiles" -H "Authorization: Bearer $KEY"
Response 200
{ "profiles": ["facebook", "linkedin", "reddit"] }

📊Capacity & health

Two open endpoints — no key needed. Capacity is public on purpose: a client that can see the worker is full can queue politely instead of hammering it.

GET/v1/capacity open

How full the worker is, and the limits it enforces. accepting: false means open a session later.

Response 200
{
  "sessions": 2,
  "maxSessions": 8,
  "memoryPct": 41,
  "memoryLimitMb": 4096,
  "draining": false,
  "accepting": true,
  "limits": { "maxContexts": 8, "idleMs": 300000, "ttlMs": 7200000 }
}
GET/healthz open

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

Response 200
{ "ok": true }

🧭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 and gets the logged-in one it already holds.
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 against that session. It returns a jobId at once; the browser keeps working after the response.
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. Read steps for the narrative and status for the state — keep going while it 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. Anything it wants to send under your name arrives as a pending proposal. Approve it — optionally rewording — and the job carries on.
4 · approve
# pid comes from the poll above: .proposals[] | select(.state=="pending") .pid
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 (or idle), the deliverable is in leads — real, de-duplicated rows, not scraped text.
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 — it is off by default. Android supports it; iOS does not offer it at all. A profile then set to exit through the tailnet leaves from that device instead of this server.

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.