Drive a locally-running server from a Nightjar hosted browser
Who this is for. An autonomous agent (or person) that wants a Nightjar cloud-hosted browser to load, click through, and verify a web server running on its own machine —
http://localhost:…or a LAN address — that the public internet cannot reach.This is the full, tested recipe as it works today (manual, multi-step). The smooth one-command version of the same journey is the local reach Target (
ADR-035); getting started Part 2 walks a condensed version of this page in context.
How it’s possible: the browser runs server-side on a Nightjar node (in the cloud), but
its egress is tunnelled back out through a daemon you run on your local machine. When the
hosted browser navigates to 127.0.0.1:<port>, the request travels browser → node → (tunnel) → your daemon → 127.0.0.1:<port> on your machine. The page sees your
local network. This is Nightjar’s core design (ADR-018 — daemon attachment topology).
YOUR MACHINE │ NIGHTJAR CLOUD
│
┌───────────────────┐ ┌──────────────┐ │ ┌───────────────┐ ┌──────────────────┐
│ your local server │◄──┤ nightjar- │ │ │ node │ │ hosted browser │
│ 127.0.0.1:<port> │ │ daemon │◄─┼──►│ (browser host)│◄──►│ (headless Chrome)│
└───────────────────┘ └──────────────┘ │ └───────────────┘ └──────────────────┘
joins a NETWORK │ placement + ▲
over a persistent│ ticket-bound │ you drive it via
control WS ──────┼──────► data tunnel │ the Nightjar MCP tools
│ 0. The one invariant you cannot skip: org alignment
The network your daemon joins must belong to the same Nightjar org as the API key the hosted browser is driven with.
The hosted browser is driven through the Nightjar MCP, authenticated by an agent-bound njk_… API key (sent as Authorization: Bearer). When you call session_start(network: "X"),
the platform resolves network X inside that key’s org. If the daemon’s network lives in a
different org, the session simply won’t find the daemon and local egress silently won’t work.
The simplest way to guarantee alignment: create the network with the same njk_ key your MCP
uses. If you (the agent) self-create the network, you’re automatically aligned. If a human or
another key creates it, confirm it’s the same org.
You can confirm the key your MCP is using from any session result — openedBy.principalId / openedBy.credentialId / openedBy.name identify it.
1. Prerequisites & resources
| Resource | What you need | Notes |
|---|---|---|
| A local server | An HTTP server listening on 127.0.0.1:<port> (or 0.0.0.0:<port>) on the daemon machine | Bind to 127.0.0.1/0.0.0.0. A server bound only to a container’s internal interface won’t be reachable. |
| The daemon binary | nightjar-daemon (nightjar-daemon.exe on Windows) | Build it, or copy a prebuilt one. See Step 2. |
An njk_ API key | The same key the Nightjar MCP authenticates with, in the org you’ll create the network in | For the MCP: just the agent-bound key (resolved automatically). To self-create a network you also need the networks:write scope on that key. |
| A network + join token | A network in your org and a current njt_… join token | Tokens can be revoked/rotated — a stale one fails with 401. Mint fresh if unsure (Step 4). |
| The control endpoint | The daemon’s control-channel WS URL | Live deploy: wss://tunnel.nightjar.cloud/ws/control. |
| The API base | The control-plane HTTP base | Live deploy: https://api.nightjar.cloud. |
| The MCP tools | session_start / navigate / perceive / act / screenshot / session_end | These are how you drive the browser. The agent is resolved from your key — never pass it. |
Throughout, replace these placeholders:
<API_BASE>→https://api.nightjar.cloud<CONTROL_URL>→wss://tunnel.nightjar.cloud/ws/control<NJK_KEY>→ yournjk_…API key secret<NETWORK>→ your network’s name/slug (e.g.local-demo)<NJT_TOKEN>→ your network’snjt_…join-token secret<PORT>→ the port your local server listens on (e.g.8799)
Secret hygiene: never print or commit
njk_…/njt_…secrets. Read them from a file or env var directly into the command that needs them.
2. Get the nightjar-daemon binary
If you have the repo checked out, build the daemon (and the CLI, if you’ll use the CLI path in Step 4):
# from the repo root
cargo build -p nightjar-daemon -p nightjar # debug
# or: cargo build --release -p nightjar-daemon -p nightjar Binaries land in:
core/target/debug/nightjar-daemon(…\nightjar-daemon.exeon Windows)core/target/debug/nightjar(the CLI;nightjar.exeon Windows)- (
…/release/…with--release)
The daemon needs no Chrome / no browser locally — the browser lives on the node. The daemon is just an egress tunnel client.
If you don’t have the repo, copy a prebuilt nightjar-daemon binary onto the machine. It’s a
single self-contained executable; the only thing it talks to is the control URL over WSS.
Honesty note (Today): there are no published binaries yet — no
brew, nocurl | sh, nowinget, nonpx. Build-from-source is the Today path; signed one-line distribution is the local reach Target (ADR-035Stage 1).
3. Get an API key and confirm the org
The hosted browser is driven with an agent-bound njk_… key. In a configured agent
environment this is already wired into the Nightjar MCP (sent as Authorization: Bearer, the
agent resolved from the key) — you don’t pass it to the MCP tools.
To do the network admin steps below over the control plane you’ll use that same key as a
Bearer token in curl. Listing networks (GET /v1/networks) needs the networks:read scope;
creating, deleting, or rotating join tokens needs networks:write. (There is no bare networks scope — they are separate. A key limited to sessions can still drive the browser, but a
human/another key with networks:write must create the network for it.)
Confirm the key works and see your org’s networks:
curl -s -H "Authorization: Bearer <NJK_KEY>" <API_BASE>/v1/networks
# → JSON array of { id, name, daemonCount, onlineDaemonCount, ... }; [] means none yet A 200 with a JSON array means the key is valid and you’re looking at your org’s networks. (This
probe itself needs the networks:read scope — a sessions-only driving key will 403 here even
though it can still drive the browser; use it only with a key that already has networks:read.)
4. Create a network + join token
You need a network in your org and a current join token. Two ways:
4a. Control plane (works from anywhere — recommended)
This is the org-scoped path the web console uses. Requires the networks:write scope.
curl -s -X POST
-H "Authorization: Bearer <NJK_KEY>"
-H "Content-Type: application/json"
-d '{"name":"local-demo"}'
<API_BASE>/v1/networks Returns HTTP 201 and a body like:
{
"network": { "id": "nnet_…", "name": "local-demo", "daemonCount": 0, "onlineDaemonCount": 0 },
"token": { "tokenId": "ntok_…", "secret": "njt_…" }
} Copy token.secret (njt_…) now — it is shown once and the platform stores only its hash. Keep network.id (nnet_…) for verification/cleanup.
4b. CLI (nightjar network create)
nightjar network create local-demo # prints the network id + token secret ONCE ⚠️ The CLI’s network commands authenticate as a trusted-local surface against the node’s
own network-admin surface — which on the live deploy is operator-IP-gated. From an ordinary
machine this path typically won’t be authorized; use 4a instead. (The CLI path is intended
for node-local / operator use.)
Reuse vs. mint fresh: if you already have a network, you can reuse it — but verify its token still authorizes (a revoked/rotated token fails at daemon start with
401). When in doubt, mint a fresh token:POST <API_BASE>/v1/networks/<nnet_id>/rotate-token(or just create a new network as above).
4c. Declare your reachable hosts (do NOT skip — a fresh network reaches nothing)
A fresh network has an empty declared-hosts allowlist, and the tunnel is closed by default (ADR-021): every navigation is denied with E_REACHABILITY_DENIED until you declare the host(s)
you intend to reach. Patch them in — include every form the hosted browser will navigate to
(localhost, 127.0.0.1, ::1, and/or your LAN IP), each optionally with a :port:
curl -s -X PATCH
-H "Authorization: Bearer <NJK_KEY>"
-H "Content-Type: application/json"
-d '{"declaredHosts":["localhost","127.0.0.1","::1"]}'
<API_BASE>/v1/networks/<nnet_id> The node reads a network’s declared hosts fresh at each session_start, so patch before you open the session (a live session keeps its launch-time list). Two gotchas that look like bugs:
- Reach grants skip this step.
local_reach_ensure(the agent self-serve path) auto-seeds the grant’s backing network from the target — declaredHosts is only a manual-network chore. - Declaring a host is not the same as your server answering on it. The daemon dials the
declared host on its own machine; an IPv6-only
[::1]dev server won’t answer a127.0.0.1(IPv4) dial. Bind your server to0.0.0.0(or match the exact form you declare).
5. Start the daemon
The daemon runs in the foreground and holds a persistent outbound control WS to the node. It dies when its process exits (Ctrl-C, shell close, sandbox teardown) — so start it as a long-lived background process and keep it alive for as long as you need the tunnel.
5a. Standalone binary (env-only — no install, recommended for agents/CI)
The binary enters control mode from env vars alone (this is the “ephemeral, no-install” path):
NIGHTJAR_NETWORK=local-demo
NIGHTJAR_NETWORK_TOKEN=<NJT_TOKEN>
NIGHTJAR_CONTROL_URL=wss://tunnel.nightjar.cloud/ws/control
NIGHTJAR_DAEMON_HOST=$(hostname)
NIGHTJAR_DAEMON_ID=my-stable-daemon-id
RUST_LOG=info
./core/target/debug/nightjar-daemon # & to background, or run under your supervisor Windows (PowerShell):
$env:NIGHTJAR_NETWORK = "local-demo"
$env:NIGHTJAR_NETWORK_TOKEN = "<NJT_TOKEN>"
$env:NIGHTJAR_CONTROL_URL = "wss://tunnel.nightjar.cloud/ws/control"
$env:NIGHTJAR_DAEMON_HOST = $env:COMPUTERNAME
$env:NIGHTJAR_DAEMON_ID = "my-stable-daemon-id"
$env:RUST_LOG = "info"
.core\targetdebug\nightjar-daemon.exe 5b. Via the CLI (nightjar daemon up)
nightjar daemon up local-demo
--token <NJT_TOKEN>
--control-url wss://tunnel.nightjar.cloud/ws/control Always pass
--control-urlagainst the live deploy. The CLI otherwise tries to derive the control URL from the HTTP server URL as<host>:<httpPort+1>/ws/control, which needs an explicit port and assumes the WS port is on the same host.https://api.nightjar.cloudhas no port and its control channel is on the separatetunnel.host, so derivation fails — pass it explicitly.
What success looks like
The daemon logs (to stderr) two lines within ~1s:
INFO nightjar_daemon: Daemon joining network over control channel network="local-demo"
INFO nightjar_http_stub::control_client: Control channel registered with node daemon_id=… generation=1 If instead you see Control channel permanently failed: join rejected (unauthorized): HTTP error: 401 Unauthorized, your token is wrong/stale/revoked → mint a fresh one (Step 4).
Daemon env var / flag reference
| Env var | Flag | Required | Meaning |
|---|---|---|---|
NIGHTJAR_NETWORK | --network | ✅ (for control mode) | Network name/slug to join. Presence of this (with no --session) is what selects control mode. |
NIGHTJAR_NETWORK_TOKEN | --network-token | ✅ | The njt_… join-token secret. |
NIGHTJAR_CONTROL_URL | --control-url | ✅ (standalone) | Control-channel WS(S) URL, e.g. wss://tunnel.nightjar.cloud/ws/control. The client appends ?network=<name> itself. |
NIGHTJAR_DAEMON_HOST | --daemon-host | optional | Human label shown in the daemon list (defaults to unknown). |
NIGHTJAR_DAEMON_ID | --daemon-id | optional | Stable id ⇒ a reconnect reuses the same registry row. Omit ⇒ a fresh id is minted each connect. |
| — | --ready-file <path> | optional | Writes ready to a file once the control task starts (for supervisors). |
6. Verify the platform sees your daemon
Before driving the browser, confirm the node registered your daemon (catches “daemon looked like it started but isn’t actually attached”):
curl -s -H "Authorization: Bearer <NJK_KEY>"
<API_BASE>/v1/networks/<nnet_id>/daemons Expect your daemon, online/reachable:
[ { "id": "my-stable-daemon-id", "host": "…", "version": "0.1.0",
"connectedSince": "…", "reachable": true } ] CLI equivalent: nightjar daemon status local-demo (or nightjar network daemons local-demo).
An empty [] here means the daemon isn’t attached (not started, wrong network, or it already
exited) — fix that before Step 7.
7. Drive the hosted browser via the MCP
Now open a session bound to your network with local egress, navigate to your local server, and
read the result. These are MCP tool calls (mcp__nightjar__*):
Start the session —
session_start:{ "egress": "local", "network": "local-demo" }The result has
"status":"live","egress":"local", and"network":"nnet_…"(your network’s id).openedByconfirms which key/org you’re in — it must be the same org as the network.Navigate —
navigate:{ "sessionId": "nsess_…", "url": "http://127.0.0.1:<PORT>/" }Success returns the resolved
url. (localhostalso works;127.0.0.1avoids any IPv6/::1ambiguity.)Read the page —
perceive(accessibility tree + text) and/orscreenshot(PNG). Confirm you see content that only your local server could have produced. (A good trick: serve a unique marker string and assert it appears.)Interact as needed —
actwith verbsclick/type/press/goto/scroll, using a CSSselector(andtext/key/urlas the verb requires).End the session —
session_end { "sessionId": "nsess_…" }when done (releases the browser).
egress modes (session_start)
egress | Behaviour | Use when |
|---|---|---|
local | All browser traffic exits through your daemon. | Testing a purely-local app, or you want every request to traverse your network. |
split | Local/LAN targets go through the daemon; public web goes direct from the node. | Your local page also pulls public CDNs/fonts/APIs — avoids forcing those through your tunnel (faster, and they won’t fail if your local network can’t reach them). |
platform | Everything direct from the node; no local reach. | Public sites only. (This is the mode that fails to reach localhost — see the negative control below.) |
local and split need a network to have any daemon to route through — without one there is
no local reach. (The server doesn’t reject a local/split session that omits network; it just
resolves to an empty declared-hosts list, so there is nothing to tunnel to.)
Omitting egress uses the agent’s configured default.
8. Confirm it’s really the tunnel (negative control)
To be certain the daemon is what bridged the browser to your machine (and you’re not, say, hitting
something that happens to exist on the node), run the same navigation from a platform-egress
session:
session_start { "egress": "platform" }(no network) →navigate http://127.0.0.1:<PORT>/- Expected: it fails with
net::ERR_CONNECTION_REFUSED(E_UPSTREAM/NAVIGATION_FAILED), because the node has no path to your localhost.
local+network succeeds, platform refuses → the only difference is your daemon tunnel. QED.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Daemon: join rejected (unauthorized): 401 | Token revoked/rotated/wrong network, or wrong org | Mint a fresh token (Step 4); ensure NIGHTJAR_NETWORK matches the token’s network. |
POST /v1/networks → 403/401 | Key lacks networks:write scope, or wrong/empty key | Use a key with networks:write scope, or have a human create the network and hand you the token. |
/v1/networks/<id>/daemons returns [] | Daemon not running / exited / joined a different network | Check the daemon is still alive and its logs show “registered”; re-check the network name. |
Navigation denied with E_REACHABILITY_DENIED | The network’s declared-hosts allowlist doesn’t include the target (a fresh network’s is empty; closed-by-default) | Patch declaredHosts to include the target host(s) (Step 4c) before opening the session. |
Navigation net::ERR_CONNECTION_REFUSED (with egress:local) | Local server not listening, wrong port, daemon died, or your server binds a different interface than the daemon dials | Confirm curl http://127.0.0.1:<PORT>/ works locally; bind the server to 0.0.0.0 (an IPv6-only [::1] server won’t answer a 127.0.0.1 dial); confirm daemon is registered (Step 6). |
Navigation works with platform but you expected local | You’re hitting a public host, not your local one | For local reach you must use egress:local/split and network. |
session_start(network:X) can’t find the network | Network is in a different org than the MCP key | Create the network with the same key the MCP uses (Step 0/3). |
Page loads but public assets are missing/slow under egress:local | Public CDNs forced through your tunnel | Switch to egress:split. |
| Daemon stops when your shell/sandbox closes | Daemon is foreground and dies with its process | Run it under a supervisor / nohup / background job; keep it alive for the test’s duration. |
CLI nightjar network create fails/forbidden remotely | Trusted-local surface is operator-gated on the deploy | Use the control-plane POST /v1/networks (Step 4a). |
CLI daemon up can’t derive a control URL | Server URL has no port / control host differs | Pass --control-url wss://tunnel.nightjar.cloud/ws/control explicitly. |
More general connection problems (0 tools, allowlist denials, silent 502s) are covered in troubleshooting.
10. Cleanup (do this when finished)
- End every session you opened:
session_end { "sessionId": "nsess_…" }. - Stop the daemon process (Ctrl-C / kill the background job). Scope kills to your daemon’s PID — never blanket-kill processes on a shared machine.
- Delete the network if it was throwaway (this also invalidates its tokens):
(Or keep the network and just rotate/revoke the token if you’ll reuse it.)curl -s -X DELETE -H "Authorization: Bearer <NJK_KEY>" <API_BASE>/v1/networks/<nnet_id> # → HTTP 204 - Scrub any file that held a token/key secret.
11. Copy-paste runbook (TL;DR)
# ---- config ----
API_BASE=https://api.nightjar.cloud
CONTROL_URL=wss://tunnel.nightjar.cloud/ws/control
NJK_KEY=njk_xxx # same key your MCP uses; needs `networks:write` scope to self-create
PORT=8799
# 0) make sure your local server is listening
curl -s http://127.0.0.1:$PORT/ >/dev/null && echo "local server up"
# 1) create a network in your org, capture the join token (njt_…)
RESP=$(curl -s -X POST -H "Authorization: Bearer $NJK_KEY" -H "Content-Type: application/json"
-d '{"name":"local-demo"}' $API_BASE/v1/networks)
NNET=$(printf '%s' "$RESP" | python -c "import sys,json;print(json.load(sys.stdin)['network']['id'])")
NJT=$(printf '%s' "$RESP" | python -c "import sys,json;print(json.load(sys.stdin)['token']['secret'])")
echo "network=$NNET"
# 2) start the daemon (background); wait for "registered"
NIGHTJAR_NETWORK=local-demo NIGHTJAR_NETWORK_TOKEN=$NJT NIGHTJAR_CONTROL_URL=$CONTROL_URL
NIGHTJAR_DAEMON_HOST=$(hostname) RUST_LOG=info
./core/target/debug/nightjar-daemon &
# 3) verify the platform sees it (reachable:true)
curl -s -H "Authorization: Bearer $NJK_KEY" $API_BASE/v1/networks/$NNET/daemons
# 4) drive the hosted browser via the Nightjar MCP:
# session_start { "egress":"local", "network":"local-demo" }
# navigate { "sessionId":"nsess_…", "url":"http://127.0.0.1:8799/" }
# perceive / screenshot → confirm your local content
# session_end { "sessionId":"nsess_…" }
# 5) cleanup
curl -s -X DELETE -H "Authorization: Bearer $NJK_KEY" $API_BASE/v1/networks/$NNET # 204
# stop the daemon process; scrub token files References
ADR-018— Daemon attachment topology (control plane vs. data plane; alpha single-node).ADR-006— Networks ·ADR-002— Traffic routing (network/tunnel model).ADR-021— Egress hardening (declared-host allowlist, closed-by-default tunnel).- Local reach — the one-command Target this manual path becomes.
- Daemon source:
core/crates/nightjar-daemon/src/main.rs(control mode + env wiring). - CLI daemon/network wiring:
core/crates/nightjar-cli-bind/src/{daemon_cmd,network_cmd}.rs.
Note on alpha scope: today Nightjar runs single-node, so the daemon dials the node’s control endpoint directly via
tunnel.*. The Phase-4 move terminates the control channel at the control plane and brokers per-session tunnels — but the daemon already speaks an abstract control URL, so nothing in this guide changes for you. (ADR-018.)