Troubleshooting

Concrete fixes for the things people actually hit first. Each entry is what you see → why → what to do. If something here describes a capability that isn’t fully shipped, it says so and links to the honest build status.


Your agent connects to Nightjar but sees 0 tools

What you see. Your MCP client (Claude Code, Claude Desktop, a custom client) reports the nightjar server as connected, but no tools are available — the agent can’t call session_start, navigate, and the rest, so it can’t drive a browser.

Why. The MCP handshake completed at the HTTP level but no callable tools ended up in your client — which most clients surface as “connected, 0 tools” rather than a hard failure. Common causes:

  1. A stale connection (try this first). If your client connected before the tools were available, it can cache an empty tool list: the server shows connected, but your client still exposes 0 tools until it re-runs the handshake. Reconnect or restart your client to pick up the current tool list. (A fresh connection binds all tools.)
  2. Authentication. The hosted endpoint validates your API key at initialize. A missing, mistyped, revoked, or expired njk_ key in the server’s Authorization: Bearer … config is rejected as unauthorized.
  3. Protocol version. The server negotiates the mainstream MCP protocol versions (2024-11-05, 2025-03-26, 2025-06-18). A client that negotiates a different version is rejected as an unsupported protocol version.

How to tell which. First reconnect/restart your client (cause 1) — it’s the most common and costs nothing. If tools still don’t appear, run your client with MCP debugging — for Claude Code, claude --mcp-debug — and look at the initialize response for the nightjar server:

  • an unauthorized / auth error → it’s the key (cause 2);
  • an unsupported protocol version error → it’s the version (cause 3).

Fix.

  • Reconnect: restart or reconnect the MCP server in your client so it re-runs the handshake and fetches the current tool list. This clears a stale/cached empty tool list.
  • Key: confirm the Authorization: Bearer njk_… value in your MCP server config is a current, un-revoked key. If in doubt, mint a fresh one and update the config:
    nightjar-control issue-key …      # mint a new njk_ key
    Then restart/reconnect the MCP server so it re-runs initialize with the new key.
  • Version: use a current MCP client release (current Claude Code negotiates 2025-06-18, which is supported). After any change, fully reconnect the server so the client re-fetches the tool list.

“host is not in the network reachability allowlist”

What you see. Your agent navigates to http://127.0.0.1:…, a Docker hostname, or a LAN address, and instead of your service the page body reads:

Nightjar: host is not in the network reachability allowlist

Why. By default a cloud session reaches public sites only — your localhost and LAN are not reachable until you say so. The allowlist is read once, at session launch, so a session that was already running when you changed the setting keeps its old list.

Fix (today — manual until the one-command path lands). Either declare the host on your network, or enable org-wide intranet mode, then start a fresh session:

PATCH /v1/networks/{networkId}
{ "declaredHosts": ["127.0.0.1"] }     # a bare host/IP allows any port; "127.0.0.1:5173" = that port
PUT /v1/governance
{ "intranetMode": true }               # reaches undeclared loopback/LAN; needs the org:admin scope

Then open a new session (the allowlist and intranet flag are resolved at launch). See the local reach guide for the full walk-through — note the smooth one-command path there is a Target (planned), and these manual steps are the path that works today.


credential_fill fails with “no bound identity”

What you see. A credential_fill call (or a {{credential:NAME}} placeholder resolved during act) returns 400 session has no bound identity; cannot fill a credential.

Why. Credential fill resolves the named credential against the session’s assumed identity. A session started anonymously has no identity to resolve against, so the fill is refused before any lookup — this is a setup gap, not a denial of a specific field.

Fix. Start the session with the identity that owns the credential: pass identity to session_start. To discover which identities your key may assume — and their credential slots — call identity_list_assumable. If that returns 403 missing identities:read, your key lacks the identities:read scope: ask your operator to grant it, or to provision an assumable identity for your key. Once the session is bound to an identity that owns the slot, retry the fill. The secret is still filled node-side and never handled by the agent.


Credential fill is refused (origin-denied or field-denied)

What you see. A credential_fill call fails with an origin-denied or editable-field error rather than filling the value.

Why. Credential fill is deliberately gated, and both gates are enforced before any keystroke:

  • Origin — the credential is filled only on an origin in the intersection of the credential’s own origin-allowlist and the grant’s allowed origins. A live origin outside that set is denied.
  • Editable field — the target element must be an editable input; a non-editable target is denied.

Fix. Confirm the live page origin is on the credential’s origin-allowlist and on the grant you’re using, and that your selector targets an editable field. The secret value is never returned to the agent and never appears in the journal — only a value-free record that a fill occurred — so a missing fill shows up as a denial, not a leaked value.


“Identity is in use” / a second session can’t assume the same identity

What you see. A session that assumes an identity starts fine, but a second session assuming the same identity is refused.

Why. Assuming an identity acquires an exclusive single-writer lease — one live session per identity at a time — so two sessions can’t drive the same logged-in persona simultaneously.

Fix. End the first session (or wait for its lease to release), then start the second. For parallel work, use different identities, or run anonymous sessions where no identity is needed.


401 Unauthorized on every request

What you see. Every API or MCP call returns 401 — even ones that worked before.

Why. The Bearer credential itself is being rejected, before any scope or resource check. The common causes, in order:

  1. A revoked, rotated, or mistyped njk_ key. Keys can be revoked from the console; a config still holding the old value fails uniformly.
  2. An expired OAuth access token that didn’t refresh. njo_ access tokens live 15 minutes; if the refresh didn’t happen (or the whole grant was revoked), every call 401s.
  3. An audience mismatch. An njo_ token is audience-bound — minted for one MCP resource URI. Presenting it to a different deployment fails closed as a plain 401.

Fix. Re-mint: for a static key, create a fresh njk_ in the console and update your config; for OAuth, re-run the login so a fresh token pair is minted against this host. Then fully reconnect your MCP client so it re-runs initialize with the new credential. The credentials & identity guide covers the token model (and why an invalid token of either kind fails closed the same way, with no hint which store rejected it).


403 missing required scope — e.g. creating a network → “mint a key with networks:write

What you see. A call your role should plainly allow — creating a network, listing keys — fails with 403 missing required scope, most commonly networks:write when setting up local reach.

Why. Your credential’s scopes, not your org role, decide what a call may do. The per-developer remote-MCP OAuth login is deliberately scope-clamped to {sessions:write, sessions:drive, identities:read} — it can drive a browser but can never create networks or mint keys, even if you are the org owner. And the no-escalation gate means a narrow token cannot bootstrap itself to a wider one.

Fix. Mint a key that actually carries the scope, from a place that has it: log in to the console (a human console session carries your role-derived scopes), create an API key including networks:write, and use that key for the network-admin calls. Keep the clamped token for driving. This split is by design — the credentials & identity guide explains the clamp and the role→credential bridge.


egress: "local" session opens fine, then every navigation fails with a 502

What you see. session_start { "egress": "local", "network": "…" } succeeds — no error, a live session — but the first navigate to your localhost fails with a tunnel 502 (E_UPSTREAM / connection refused), or hangs and then errors.

Why. This is the silent dead-end: opening a local/split session does not check that a daemon is actually connected to the network. The failure only surfaces later, at navigation time, when the tunnel has nowhere to route. Common causes: the daemon was never started, it exited with your shell, it joined a different network (or an entirely different org), or its join token was stale (401 in the daemon’s own log).

Fix. Verify the daemon is attached before driving:

GET /v1/networks/{networkId}/daemons     # expect your daemon with "reachable": true

An empty [] means nothing is attached — restart the daemon and watch its log for Control channel registered. The drive a local server guide walks the full recipe including this verification step and a negative control. (A typed E_LOCAL_REACH_NOT_READY at session start — instead of this late 502 — is part of the local reach Target.)


“Certificate expired” from curl/Python — but the cert is valid (stale CA bundle)

What you see. An HTTP client — most often an older Python (urllib/requests) or a distro curl with an old CA bundle — refuses to talk to api.nightjar.cloud with a certificate-expiry or verification error. Your browser, meanwhile, opens the same host with a green padlock.

Why. The server’s certificate is valid — the problem is the client’s trust store. Nightjar’s certificate chains through a newer Let’s Encrypt intermediate that pre-2026 CA bundles don’t carry, and some clients misreport that as “expired.” A stale bundle on your side looks exactly like a broken cert on ours.

Fix. Update the client’s trust store (OS update, pip install --upgrade certifi, or a current ca-certificates package), or use a client with a current bundle (modern curl, a browser). To confirm it’s the bundle and not the server: if a current browser shows the certificate as valid, the stale-bundle diagnosis is confirmed — don’t chase a server-side bug.


Reading an error: which field carries what, and where

If you are matching on error strings, or dual-reading two spellings at two nesting levels, this is the section you want. The same information rides a different shape on each path, and only one of them puts everything at the top level.

On /v1 (REST)

The error body is the error object — it is not nested under an error key. Nine fields, all camelCase:

fieldwhat it is
codethe stable token, e.g. E_ELEM_NF. Match on this, never on message.
errorClasswhether the failure is TRANSIENT or terminal
retryHintwhat a retry should do differently
outcomeCertaintywhether the side effect is known to have happened
requestIdthe id to quote when asking us about it
messagefor humans. It is not a contract and it changes.
action, reason, retriablecontext, nullable

On MCP, a business failure is a tool result with two content blocks

A /v1 call returning non-2xx becomes a tool result with isError: true — never a JSON-RPC error. That is reserved for protocol problems: an unknown tool, a malformed tools/call envelope, or the initialize auth gate.

content[0].text   the human string. Stable in position, not in wording.
content[1].text   a JSON envelope carrying outcomeCertainty, retryHint and errorClass

The second block is the one to parse. It arrived with the error envelope work and is pinned by a round-trip test, v1_error_envelope_rides_a_second_content_block — round-trip rather than substring, so a mangled block fails rather than passing.

If you are string-matching an MCP error today, you are probably reading block 0 and missing block 1. That is the common case this section exists to fix.

What does not ride either path

hints is declared internally and attached to every error at construction, and it is absent from every contract a consumer reads. Do not build on it, and do not delete error-string handling on the expectation that it is coming.

Still stuck?

Check the build status first — if a capability is marked Target, it isn’t shipped yet, so the issue may be expected. For a suspected security issue, email security@nightjar.cloud; to report abuse, abuse@nightjar.cloud.