AccountsThe logins the agent works through — one per platform
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.🛰️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.
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.
🚀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.)
/api/auth/key
cookieReturn 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.
curl -b cookies.txt https://your-ghost-host/api/auth/key
{
"keys": [
{ "key": "gb_live_9f3c2a71b0e4…", "plan": "solo", "maxConcurrent": 1 }
]
}
Set these once in your shell and every example below just works:
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.
/v1/agent/settings
bearerThe 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.
/v1/sessions
bearerOpen 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.
| Field | Type | Req | Description |
|---|---|---|---|
| profile | string | no | Named profile to open — its own saved cookies and pinned exit route. |
| preset | string | no | A known site preset; configures the profile before the browser starts and lands on its login page. |
| reuse | boolean | no | Return an existing session for you if one is open, rather than failing at the limit. |
| takeover | boolean | no | Close a conflicting session of yours and open here instead. |
curl -X POST "$BASE/v1/sessions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "profile": "facebook", "reuse": true }'
{ "profile": "facebook", "reuse": true }
{
"sessionId": "a1b2c3d4",
"profile": "facebook",
"expiresAt": 1756497200000,
"plan": "solo",
"reused": false
}
/v1/sessions
bearerList the sessions you hold, with your concurrency limit and plan.
curl "$BASE/v1/sessions" -H "Authorization: Bearer $KEY"
{
"sessions": [
{
"sessionId": "a1b2c3d4",
"url": "https://www.facebook.com/",
"profile": "facebook",
"createdAt": 1756490000000,
"lastUsed": 1756490120000,
"expiresAt": 1756497200000
}
],
"maxConcurrent": 1,
"plan": "solo"
}
/v1/sessions/:id
bearerOne session's current URL and lifetimes.
{
"sessionId": "a1b2c3d4",
"url": "https://www.facebook.com/",
"createdAt": 1756490000000,
"lastUsed": 1756490120000,
"expiresAt": 1756497200000
}
/v1/sessions
bearerClose 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 }).
{ "closed": 1 }
/v1/sessions/:id/navigate
bearerGo 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.
| Field | Type | Req | Description |
|---|---|---|---|
| url | string | yes | Where to go. Must be a public http(s) URL. |
| waitUntil | string | no | Playwright wait state. Defaults to domcontentloaded. |
curl -X POST "$BASE/v1/sessions/a1b2c3d4/navigate" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://www.facebook.com" }'
{ "url": "https://www.facebook.com/", "status": 200, "title": "Facebook" }
/v1/sessions/:id/analyze
bearerThe 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.
curl "$BASE/v1/sessions/a1b2c3d4/analyze?screenshot=false" \ -H "Authorization: Bearer $KEY"
{
"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
}
/v1/sessions/:id/click
bearerClick a box by its index from the last analyze, or by visible
text. Clicking by index re-analyzes automatically if the page moved.
| Field | Type | Req | Description |
|---|---|---|---|
| index | number | * | The box number from the last analyze. |
| text | string | * | Click the first element containing this text instead. * one of index / text. |
curl -X POST "$BASE/v1/sessions/a1b2c3d4/click" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "index": 2 }'
{ "clicked": { "index": 2, "text": "Log in" }, "url": "https://www.facebook.com/" }
/v1/sessions/:id/type
bearerType into a field box, human-paced. Pass submit: true to press Enter
afterwards. Requires a prior analyze.
| Field | Type | Req | Description |
|---|---|---|---|
| index | number | yes | The field's box number. |
| text | string | yes | What to type. |
| submit | boolean | no | Press Enter after typing. |
{ "index": 1, "text": "someone@example.com", "submit": false }
{ "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.
/v1/agent/jobs
bearerDispatch 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.
| Field | Type | Req | Description |
|---|---|---|---|
| goal | string | yes | Plain-language description of what to do. |
| sessionId | string | yes | The open session to work in. Must be yours and not already running a job. |
| role | string | no | The specialist to run as (see GET /v1/agent/roles). Defaults to general. |
| companyId | string | no | Which company profile it is selling for. |
| autoApprove | boolean | no | Run this one job with acting-without-asking on, without changing the global setting. |
| unattended | boolean | no | Nobody is watching: conclude and hand the session back rather than parking at the step limit. |
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"
}'
{
"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": []
}
/v1/agent/jobs
bearerYour live jobs, plus a short history of finished ones.
{
"jobs": [ { "id": "k7f2a9", "goal": "…", "status": "running", "role": "reddit.demand" } ],
"history": [ { "id": "j3d0x1", "goal": "…", "status": "done", "endedAt": 1756480000000 } ]
}
/v1/agent/jobs/:id
bearerPoll 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.
curl "$BASE/v1/agent/jobs/k7f2a9" -H "Authorization: Bearer $KEY"
{
"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/…"
}
]
}
/v1/agent/jobs/:id/proposals/:pid
bearerApprove or reject one proposal. edit rewrites the text before it is
sent — the normal case. A decision is final; a proposal is decided once.
| Field | Type | Req | Description |
|---|---|---|---|
| approve | boolean | yes | true sends it, false skips it. |
| edit | string | no | Replacement text to send instead of what it drafted (approve only). |
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." }'
{
"pid": "p-1-9f3c",
"state": "approved",
"kind": "comment",
"text": "We built exactly this — happy to show you.",
"decidedAt": 1756490600000
}
/v1/agent/jobs/:id/say
bearerSay 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.
{ "text": "Only save people who mention VAT specifically." }
{ "at": 1756490620000, "text": "Only save people who mention VAT specifically." }
/v1/agent/jobs/:id/stop
bearerStop a job. Any pending proposals are skipped and the session is handed back.
Returns the final job view with status: "stopped".
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.
/v1/agent/roles
bearerEvery role, grouped the way a picker should show it. Use name as the
role when dispatching a job.
{
"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.
/v1/profiles
bearerThe profile names that exist right now.
curl "$BASE/v1/profiles" -H "Authorization: Bearer $KEY"
{ "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.
/v1/capacity
openHow full the worker is, and the limits it enforces. accepting: false
means open a session later.
{
"sessions": 2,
"maxSessions": 8,
"memoryPct": 41,
"memoryLimitMb": 4096,
"draining": false,
"accepting": true,
"limits": { "maxContexts": 8, "idleMs": 300000, "ttlMs": 7200000 }
}
/healthz
openLiveness. Always { "ok": true } while the process is up.
{ "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.
reuse and
gets the logged-in one it already holds.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)
jobId at once; the browser keeps working after
the response.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)
steps for the narrative and status for the state — keep going while it is
running.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
pending proposal. Approve it — optionally
rewording — and the job carries on.# 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 }'
status is done (or idle), the deliverable is in
leads — real, de-duplicated rows, not scraped text.curl -s "$BASE/v1/agent/jobs/$JOB" -H "Authorization: Bearer $KEY" \
| jq '.leads[] | { name, why, quote, url }'
Loading…