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 machinehttp://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

ResourceWhat you needNotes
A local serverAn HTTP server listening on 127.0.0.1:<port> (or 0.0.0.0:<port>) on the daemon machineBind 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 binarynightjar-daemon (nightjar-daemon.exe on Windows)Build it, or copy a prebuilt one. See Step 2.
An njk_ API keyThe same key the Nightjar MCP authenticates with, in the org you’ll create the network inFor 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 tokenA network in your org and a current njt_… join tokenTokens can be revoked/rotated — a stale one fails with 401. Mint fresh if unsure (Step 4).
The control endpointThe daemon’s control-channel WS URLLive deploy: wss://tunnel.nightjar.cloud/ws/control.
The API baseThe control-plane HTTP baseLive deploy: https://api.nightjar.cloud.
The MCP toolssession_start / navigate / perceive / act / screenshot / session_endThese 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> → your njk_… API key secret
  • <NETWORK> → your network’s name/slug (e.g. local-demo)
  • <NJT_TOKEN> → your network’s njt_… 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.exe on Windows)
  • core/target/debug/nightjar (the CLI; nightjar.exe on 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, no curl | sh, no winget, no npx. Build-from-source is the Today path; signed one-line distribution is the local reach Target (ADR-035 Stage 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:

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 a 127.0.0.1 (IPv4) dial. Bind your server to 0.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.

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-url against 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.cloud has no port and its control channel is on the separate tunnel. 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 varFlagRequiredMeaning
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-tokenThe 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-hostoptionalHuman label shown in the daemon list (defaults to unknown).
NIGHTJAR_DAEMON_ID--daemon-idoptionalStable id ⇒ a reconnect reuses the same registry row. Omit ⇒ a fresh id is minted each connect.
--ready-file <path>optionalWrites 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__*):

  1. Start the sessionsession_start:

    { "egress": "local", "network": "local-demo" }

    The result has "status":"live", "egress":"local", and "network":"nnet_…" (your network’s id). openedBy confirms which key/org you’re in — it must be the same org as the network.

  2. Navigatenavigate:

    { "sessionId": "nsess_…", "url": "http://127.0.0.1:<PORT>/" }

    Success returns the resolved url. (localhost also works; 127.0.0.1 avoids any IPv6/::1 ambiguity.)

  3. Read the pageperceive (accessibility tree + text) and/or screenshot (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.)

  4. Interact as needed — act with verbs click / type / press / goto / scroll, using a CSS selector (and text/key/url as the verb requires).

  5. End the sessionsession_end { "sessionId": "nsess_…" } when done (releases the browser).

egress modes (session_start)

egressBehaviourUse when
localAll browser traffic exits through your daemon.Testing a purely-local app, or you want every request to traverse your network.
splitLocal/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).
platformEverything 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

SymptomLikely causeFix
Daemon: join rejected (unauthorized): 401Token revoked/rotated/wrong network, or wrong orgMint a fresh token (Step 4); ensure NIGHTJAR_NETWORK matches the token’s network.
POST /v1/networks403/401Key lacks networks:write scope, or wrong/empty keyUse 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 networkCheck the daemon is still alive and its logs show “registered”; re-check the network name.
Navigation denied with E_REACHABILITY_DENIEDThe 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 dialsConfirm 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 localYou’re hitting a public host, not your local oneFor local reach you must use egress:local/split and network.
session_start(network:X) can’t find the networkNetwork is in a different org than the MCP keyCreate the network with the same key the MCP uses (Step 0/3).
Page loads but public assets are missing/slow under egress:localPublic CDNs forced through your tunnelSwitch to egress:split.
Daemon stops when your shell/sandbox closesDaemon is foreground and dies with its processRun it under a supervisor / nohup / background job; keep it alive for the test’s duration.
CLI nightjar network create fails/forbidden remotelyTrusted-local surface is operator-gated on the deployUse the control-plane POST /v1/networks (Step 4a).
CLI daemon up can’t derive a control URLServer URL has no port / control host differsPass --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)

  1. End every session you opened: session_end { "sessionId": "nsess_…" }.
  2. 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.
  3. Delete the network if it was throwaway (this also invalidates its tokens):
    curl -s -X DELETE -H "Authorization: Bearer <NJK_KEY>" 
      <API_BASE>/v1/networks/<nnet_id>     # → HTTP 204
    (Or keep the network and just rotate/revoke the token if you’ll reuse it.)
  4. 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.)