Credentials & identity

Who this is for. Developers and operators who want to use Nightjar to drive authenticated browser sessions as an authorized persona, where the underlying secret is never seen by the model, the journal, screenshots, or audit logs.

Companion docs:

  • Credential security model — the how it’s enforced deep-dive: chokepoints, fail-closed crypto, sentinels, audit schema.
  • ADR-029 — Claude Code ↔ Nightjar integration (the governing decision for everything in this guide) · ADR-020 — MCP authorization & credential integration (the two-plane model) · ADR-019 — agent identity & delegation (the spawn / child-principal model behind spawn_and_start_session).

1. Overview

This feature lets you drive a hosted Nightjar browser as an authorized persona — for example, “log in to GitHub as our ci-bot persona and open a PR” — without the secret ever passing through the model.

The model (Claude Code, or any MCP client holding your agent-bound token) does two things and no more:

  1. Names a persona to assume when it opens a session (session_start identity=…).
  2. Names a stored credential to fill into a field (credential_fill {credentialName, selector}).

It never sees, types, or chooses where a secret value lands as free text. The control plane fetches the sealed secret, just-in-time decrypts it into a wipe-on-drop buffer, enforces an origin allowlist and an editable-field check at the node, types it, and zeroizes it. The fill operation itself is value-free: the secret is never returned in the result, never written to the session journal, never captured in the fill act’s screenshot, and never audited — audit details carry ids, the credential name, a host, or a field kind only, never the value. One scoping note: filling a non-masked (text/email) field leaves the value visible on the rendered page, where a later perceive or screenshot can observe it — prefer masked (password) fields.

Where each of those is enforced. Symbol citations, not line numbers: a line rots on any edit above it, while these are checked against the tree by the docs status gate.

  • JIT decrypt into a wipe-on-drop buffer: sessions_golden::decrypt_credential.
  • Fill-time origin intersection (credential allowlist ∩ the winning live grant; disjoint = hard deny): sessions_golden::effective_fill_origins.
  • Live-origin check at the node, after navigation, so a redirect cannot outrun it: text_input::live_origin_on_allowlist.
  • Editable-field check at the node: session::editable_input_kind.
  • Value-free audit — the row carries ids, the credential name, a host and a field kind, never the value: audit::credential_audit.

For the full trust-boundary argument, the gate-by-gate table and the schema evidence that the tool has no value field at all, see Credential security model.

What you get:

  • Authenticated sessions as a persona without handing the model a password.
  • Bounded fill: a credential can only ever be typed into an allow-listed origin and (on the structured path) an editable text field — both checked at the node before any keystroke, default-deny.
  • Per-developer, least-privilege access for Claude Code itself via a per-workspace remote-MCP OAuth login — no shared static key to leak.

2. The model in 60 seconds

org
 └── agent            ← the principal your token resolves to (per developer/workspace)
 └── identity         ← a persona ("ci-bot", "support-agent"); has 0+ credentials
       └── credential ← a named, sealed secret + an origin allowlist (default-deny)
 └── grant            ← "agent X may assume/manage identity Y", with constraints
 └── session          ← a live browser; may bind ONE identity (the persona it acts as)
  • Org — your tenant. Every lookup is scoped to your org; org B can never resolve, decrypt, or fill org A’s credential.
  • Agent — the principal your Bearer token resolves to. The model never passes an agent argument; it is derived from the credential server-side.
  • Identity (persona) — a named role a session can embody. Holds credentials and an optional default open mode (exclusive | fork | readonly).
  • Credential — a named secret on an identity (e.g. github). Sealed with a per-org KMS envelope; the API only ever returns a masked value (last-4). Has an origin_allowlist that is default-deny — a credential with an empty allowlist can never fill (and creation rejects an empty allowlist as a footgun).
  • Grant — authorizes an agent (or user / org-role) to assume or manage an identity, with constraints (expires_at, allowed_origins, propagation, max_session_duration_secs).

One live session per identity (the lease)

An identity can be embodied by at most one live session at a time. This is an exclusive single-writer lease (identity_leases.identity_id is a primary key). A second concurrent bind gets 409 identity in use by another session, raised in sessions::open_session_inner. The lease is freed only when the holding session ends, or by a liveness-based reaper (sessions::reap_dead_leases) that never reaps a still-live session — so two sessions can never both drive the same persona.

Two credential planes (see ADR-020)

PlaneWhat it securesExampleDepth
Plane 1Who Claude Code is — the agent-bound credential the MCP client presentsyour njk_/njo_ Bearer§3
Plane 2The persona’s stored secrets — the vault of identity credentials the platform fillsthe github password on ci-bot§4–6

Plane-1 possession ≠ Plane-2 authority: holding a Plane-1 token only proves who you are; what you can do with a persona’s secrets is bounded by the grant and the credential’s own allowlist. (Full discussion in the security reference.)


3. Provisioning your Claude Code access (Plane 1)

Claude Code authenticates to Nightjar with an agent-bound, least-privilege OAuth credential, minted per developer (and per workspace) via a standard OAuth 2.1 flow. Control acts as both the Authorization Server and the Resource Server.

Discovery (.well-known)

A client discovers the endpoints from two public metadata documents (no auth):

# RFC 9728 — protected-resource metadata
curl https://api.nightjar.cloud/.well-known/oauth-protected-resource

# RFC 8414 — authorization-server metadata
curl https://api.nightjar.cloud/.well-known/oauth-authorization-server

The AS document advertises authorization_endpoint, token_endpoint, device_authorization_endpoint, revocation_endpoint, code_challenge_methods_supported: [S256], token_endpoint_auth_methods_supported: [none] (a public PKCE client), and the default scope set (§7).

Two ways to log in

A. Authorization code + PKCE (interactive, browser available). The client opens /oauth/authorize with response_type=code, an S256 code_challenge, its client_id (claude-code), and a loopback or oob redirect_uri. You sign in with GitHub (the nj_session cookie), and control 302s back with a single-use code. The client exchanges it at /oauth/token with the PKCE code_verifier:

curl -X POST https://api.nightjar.cloud/oauth/token 
  -d grant_type=authorization_code 
  -d code=<the-code> 
  -d code_verifier=<the-PKCE-verifier> 
  -d client_id=claude-code 
  -d redirect_uri=http://127.0.0.1:53117/callback

B. Device flow (RFC 8628, headless CLI). The client starts at /oauth/device, gets a user_code you type at the verification URL, and polls /oauth/token:

# 1) start
curl -X POST https://api.nightjar.cloud/oauth/device 
  -d client_id=claude-code
# → { "device_code": "...", "user_code": "ABCD-EFGH",
#     "verification_uri": "https://api.nightjar.cloud/oauth/authorize",
#     "expires_in": 900, "interval": 5 }

# 2) you open verification_uri, type ABCD-EFGH, approve as the signed-in human

# 3) client polls until approved
curl -X POST https://api.nightjar.cloud/oauth/token 
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code 
  -d device_code=<device_code> 
  -d client_id=claude-code
# → authorization_pending … then the token pair

The verification step reuses GET /oauth/authorize with a ?user_code= parameter (the same endpoint is overloaded as the device verify/consent page). user_code is 8 characters from an unambiguous alphabet (no 0/O/1/I), grouped XXXX-XXXX.

Clients without a built-in client_id (Dynamic Client Registration)

Claude Code ships the pre-registered client_id=claude-code. A different MCP/OAuth client (Codex, Gemini, a custom client) that does not ship a Nightjar client_id can self-register a public PKCE client via RFC 7591 Dynamic Client Registration, then run flow A or B above with the client_id it gets back:

curl -X POST https://api.nightjar.cloud/oauth/register 
  -H 'content-type: application/json' 
  -d '{
        "client_name": "Codex CLI",
        "redirect_uris": ["http://127.0.0.1:53117/callback", "urn:ietf:wg:oauth:2.0:oob"],
        "token_endpoint_auth_method": "none",
        "grant_types": ["authorization_code", "refresh_token"],
        "response_types": ["code"]
      }'
# → 201 { "client_id": "ncli_…", "client_id_issued_at": …, "redirect_uris": [...],
#         "token_endpoint_auth_method": "none",
#         "scope": "sessions:write sessions:drive identities:read" }

The endpoint is advertised as registration_endpoint in the authorization-server metadata, so a stock OAuth client discovers it automatically. Constraints, all enforced:

  • Public PKCE clients onlytoken_endpoint_auth_method must be none (no client secret to store/rotate); anything else is invalid_client_metadata.
  • Loopback / oob redirects only — every redirect_uri must be http://127.0.0.1[:port] / http://localhost[:port] or urn:ietf:wg:oauth:2.0:oob (the same pin the built-in client has). A public (e.g. https://…) redirect is rejected invalid_redirect_uri, so a registered client can only ever redirect to the developer’s own machine — no open-redirect/phishing vector.
  • Rate-limited per source IP (registration is the unauthenticated machine bootstrap).

Registration confers no authority on its own — the client_id is only a handle. A token still requires a real human GitHub-session consent at /oauth/authorize, and the grant’s scopes are clamped to the same least-privilege default set regardless of what the client requested. A self-registered client can therefore do nothing a human did not sign in and consent to.

DCR is on by default; disable it per-deployment with NIGHTJAR_OAUTH_DCR_ENABLED=false (the endpoint then 404s and registration_endpoint is dropped from discovery). Operators list registered clients with nightjar-control list-oauth-clients and disable one with nightjar-control revoke-oauth-client --id ncli_… (which also revokes its grants, so any live tokens stop at once). The built-in claude-code client cannot be disabled this way.

What gets minted

On success the token endpoint returns:

{
  "access_token": "njo_…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "njr_…",
  "scope": "sessions:write sessions:drive identities:read"
}
  • A per-developer agent is created (or reused) and bound to the grant. Re-logging in from the same workspace reuses the grant + agent (a UNIQUE(org, user, client, workspace) constraint — no sprawl).
  • The grant’s scopes are clamped to the least-privilege set {sessions:write, sessions:drive, identities:read}. A Claude Code grant can never carry identities:write, agents:write, keys:write, org:*, billing:*, or networks:write — even if a client asks for them. Clamping happens at the authorize, device, and provision boundaries against one source of truth.

njk_ vs njo_ (and njr_)

PrefixWhat it is
njk_A static API key (the legacy / dogfood path). Long-lived.
njo_An OAuth access token (Plane 1). Short-lived (15 min), refreshable, audience-bound, dies with its grant.
njr_The OAuth refresh token (30 days, single-use rotation).

Both njk_ and njo_ are presented identically as Authorization: Bearer …, and both resolve to the same AuthContext {org, agent, scopes}. The control plane routes by prefix deterministically — an njo_ is verified only against the OAuth tables, an njk_ only against the API-key table — and an invalid token of either kind fails closed the same way. Prefer the remote-MCP OAuth login: it is per-developer, least-privilege, expiring, and revocable in one shot (revoke the grant and every derived token stops working instantly).

Your org role is not in your token — this trips up owners. A Bearer credential (njk_ or njo_) carries only its own scopes: an API key’s literal minted scopes, or an OAuth token’s grant scopes (clamped least-privilege, as above). It does not inherit your org_members role. Your owner/admin role — the full surface including keys:write and sessions:read — materializes only when you authenticate as a human via the console session (the signed browser cookie). So an owner acting purely through the API can hit a 403 missing required scope on something their role plainly allows, and the no-escalation gate (you can’t mint a key with a scope you don’t already hold) then means a narrow token cannot bootstrap itself to a wider one. This is deliberate — a token must never silently carry more than was minted/consented — but it means:

To get a credential that reflects your role, mint an API key from an owner console session. The console session authenticates as you (role-derived scopes), so the mint is capped at your role and includes what your role grants. The create-key form is deep-linkable — you can prefill name + scopes (+ optional agent binding) straight from a URL:

https://console.nightjar.cloud/keys?new&name=my-key&scopes=sessions:write,sessions:drive,sessions:read&agent=nagent_…

The prefill only fills the form — nothing is submitted, and every server gate still runs at Create; a scope your session can’t grant renders disabled (“Not grantable”), which also tells you at a glance what your role actually authorizes. (A headless, browser-free owner path — an owner device-flow login that consents up to your role — is on the roadmap; until then the console session is the role→credential bridge.)

Revoke your own token (RFC 7009, possession-gated):

curl -X POST https://api.nightjar.cloud/oauth/revoke -d token=njo_…
# 200 always (no existence oracle); soft-revokes the whole grant

Operators can revoke a grant out-of-band with nightjar-control revoke-grant --id <nkey_…>.


4. Setting up a persona (Plane 2)

A persona is an identity plus its credentials. Authoring identities, credentials, and grants requires identities:write — a scope your Claude Code token deliberately does not hold. Do this from the console or with an operator/admin key, not from the autonomous MCP surface.

4.1 Create an identity

curl -X POST https://api.nightjar.cloud/v1/identities 
  -H "Authorization: Bearer njk_<admin-key>" 
  -H "Content-Type: application/json" 
  -d '{"name":"ci-bot","defaultOpenMode":"exclusive"}'
# → 201 { "id": "nident_…", "name": "ci-bot", … }

defaultOpenModeexclusive | fork | readonly (default exclusive). A duplicate (org, name)409.

4.2 Store a credential (placeholder + origin allowlist)

curl -X POST https://api.nightjar.cloud/v1/identities/nident_abc/credentials 
  -H "Authorization: Bearer njk_<admin-key>" 
  -H "Content-Type: application/json" 
  -d '{
        "name": "github",
        "placeholder": "{{credential:github}}",
        "value": "<the-real-secret>",
        "originAllowlist": ["github.com"]
      }'
# → 201 { "name":"github", "maskedValue":"••••abcd", … }   (secret structurally absent)
  • value is sealed with the per-org KMS envelope and stored as base64; the API only ever returns maskedValue (last-4) — the response type carries identity::masked_value and no plaintext field at all. The plaintext is held in a wipe-on-drop buffer and never logged or returned.
  • originAllowlist is default-deny. An empty allowlist is rejected (400) at create by identities::add_credential — a credential that can never fill is a silent dead-end.
  • If no KMS object store is configured, the write fails closed (500) in that same identities::add_credential guard, never storing an unencrypted secret.

Rotate or remove later:

curl -X POST .../v1/identities/nident_abc/credentials/ncred_xyz/rotate 
  -d '{"value":"<new-secret>"}'            # re-seals; allowlist unchanged
curl -X DELETE .../v1/identities/nident_abc/credentials/ncred_xyz   # 204

SDK (@nightjarhq/sdk)

import { createClient } from "@nightjarhq/sdk";
const nj = createClient({ baseUrl: "https://api.nightjar.cloud", token: process.env.NIGHTJAR_ADMIN_KEY });

const id = await nj.identities.createIdentity({ name: "ci-bot", defaultOpenMode: "exclusive" });

await nj.identities.addCredential(id.id, {
  name: "github",
  placeholder: "{{credential:github}}",
  value: process.env.GH_BOT_PASSWORD,   // sealed server-side; never echoed back
  originAllowlist: ["github.com"],
});

In the console: Identities → New identity, then Add credential (name, placeholder, secret, origin allowlist). The list view shows only masked values; the stored secret is structurally absent from every read response.


5. Granting your agent to assume a persona

A session can only bind a persona the acting agent has an assume (or manage) grant for. Authoring a grant is identities:write.

curl -X POST https://api.nightjar.cloud/v1/identities/nident_abc/grants 
  -H "Authorization: Bearer njk_<admin-key>" 
  -H "Content-Type: application/json" 
  -d '{
        "principalAgentId": "agent_ci_bot_dev",
        "role": "assume",
        "expiresAt": "2026-07-01T00:00:00Z",
        "allowedOrigins": ["github.com"],
        "propagation": "descendants",
        "maxSessionDurationSecs": 1800,
        "reason": "CI persona for the webapp repo"
      }'
# → 201 { "id": "ngrnt_…", … }

What each constraint bounds:

FieldBoundsNotes
roleassume or managemanage subsumes assume.
expiresAtgrant livenessAfter it passes, binds fail closed (403 … identity grant not live (grant_expired)). Must be a future RFC 3339 timestamp.
allowedOriginswhere a credential may fillAt fill time, effective origins = credential allowlist ∩ grant allowedOrigins. Empty list = no grant-side narrowing. A disjoint non-empty list = hard deny (secret never forwarded). A grant can only ever tighten, never widen.
propagationwhether a spawned child inherits this grantnone blocks a spawned-child bind; same_conversation / descendants both permit a spawn (v1 treats them identically). The direct create path is never blocked. See ADR-019.
maxSessionDurationSecsthe lease TTL capLEAST(1h, this). Records/caps the lease; does not force-end a still-live session. null → platform default 1h.
reason / createdByUserIdaudit onlywho consented + why.

Only agent-principal grants are enforced at bind today (the /v1 path is key/token-based). principalUserId / principalOrgRole grants are stored and listed but not yet checked at bind.

Revoke vs delete

# SOFT revoke — preserves the audit trail, fails FUTURE binds closed,
# does NOT disturb an already-live session. Idempotent.
curl -X POST .../v1/identities/nident_abc/grants/ngrnt_xyz/revoke   # 204

# HARD delete — erases the grant row (and its audit trail).
curl -X DELETE .../v1/identities/nident_abc/grants/ngrnt_xyz        # 204

Prefer revoke for consent withdrawal (it keeps the who/when). A revoked grant is excluded from the winning-grant selection at the next bind (sessions::open_session_inner, whose grant query carries revoked_at IS NULL and orders most-restrictive-first) and skipped in the fill-time origin intersection — but a session already holding the lease keeps it until it ends.

List who may assume a persona: GET /v1/identities/nident_abc/grants.


6. Driving it from Claude Code

This is the runtime loop the model actually runs — all within the {sessions:write, sessions:drive, identities:read} it holds.

Step 1 — discover (identity_list_assumable)

Before binding, the model can list exactly the personas it may assume — a faithful preview of what session_start identity=… would permit, with no trial-and-error 403s:

curl https://api.nightjar.cloud/v1/identities/assumable 
  -H "Authorization: Bearer njo_…"
# → 200 [
#   { "id":"nident_abc", "name":"ci-bot",
#     "grantRole":"assume", "leaseState":"available",
#     "openModes":["exclusive"],
#     "credentialSlots":[
#       { "name":"github", "placeholder":"{{credential:github}}",
#         "allowedOrigins":["github.com"] } ] } ]

The slot metadata exposes the credential name, placeholder, and allowed originsnever a value or mask (those fields don’t exist in the response schema). leaseState is available | in_use with no holder id disclosed.

Step 2 — bind (session_start identity=…)

The model calls the session_start MCP tool, naming the persona to assume — the agent is resolved from the Bearer credential, never passed as an argument:

session_start(identity = "nident_abc")
→ 409 if the persona is already embodied by another live session.

(Scripting from the TypeScript SDK instead? Use nj.sessions.create({ identity })agent is optional and defaults to the agent resolved from your credential. Older generated clients can send agent: "" for the same behavior. There is no sessions.start. Also note that SessionCreate.url is set-not-navigated today: call nj.sessions.goto(id, url) / MCP navigate after create.)

The bind checks: scope → org-ownership → that the agent has a live assume/manage grant → propagation vs. how the session was admitted → acquires the exclusive lease (TTL capped by maxSessionDurationSecs).

Step 3 — fill (credential_fill {credentialName, selector})

Navigate to an allow-listed origin, then fill by name — the model passes only the credential name and the target selector:

navigate(url = "https://github.com/login")
credential_fill(credentialName = "github", selector = "#password")

The structured fill is exposed as the credential_fill MCP tool and the raw POST /v1/sessions/:id/fill-credential endpoint (below) — not as a TypeScript SDK method. The SDK’s golden-path session surface is goto/perceive/act/screenshot.

curl -X POST https://api.nightjar.cloud/v1/sessions/<id>/fill-credential 
  -H "Authorization: Bearer njo_…" 
  -H "Content-Type: application/json" 
  -d '{ "credentialName": "github", "selector": "#password" }'
# → 200 ActResult

Behind that single call: control resolves the bound identity, JIT-decrypts the sealed secret into a wipe-on-drop buffer, computes the effective origin allowlist (credential ∩ grant), and forwards verb=type to the node with requireEditableField=true. The node refuses before any keystroke unless (a) the live navigation host is on the forwarded allowlist and (b) the target is an editable text field. The plaintext is dropped immediately after the node accepts; the audit row is value-free.

Why you must NOT use free-text {{credential:}}

The legacy path — putting a {{credential:NAME}} marker in act’s free-text text field — is deprecated and denied-by-default. Every org starts with the credential_freetext_fill governance flag FALSE (governance::credential_freetext_fill), so an act carrying a {{credential:}} placeholder is refused (403) before any decrypt or node dial, and the refusal is recorded value-free as audit::CREDENTIAL_FREETEXT_DENIED. Reasons to avoid it:

  • It lets the model choose where the secret lands as free text — exactly the structural risk credential_fill removes. credential_fill has no text/value field by construction, so the model physically cannot place a secret.
  • It skips the node’s editable-field gate (the structured path enforces it).
  • It is off by default and only re-enable-able by an org:admin via PUT /v1/governance during migration — treat it as a temporary compatibility shim, not an API.

Always use the structured credential_fill tool.


6b. Cloak warm-resume — pass a challenge once, resume warm (ADR-039 §D5)

Some advisor sites gate automation behind a challenge (e.g. Cloudflare Turnstile) that a plain headless browser cannot pass. The cloak browser mode (browserKind: "cloak") is a governed real-user-fidelity browser that clears such a challenge. Warm-resume then means you clear it once, capture the resulting auth-state, and resume warm on every later run — so most runs never re-hit the challenge. Cloak provides the launch fidelity; the identity auth-state capture/rehydrate (the credential vault’s §D2 substrate) provides the warm-resume.

The lifecycle splits by authority — a clean mint-vs-use asymmetry:

  1. Setup (once per advisor account, an admin action). Open a cloak session bound to the persona identity, pointed at the advisor, and let it clear the challenge and log in. Then an org:admin-authority key (holding identities:write and sessions:drive) captures the session’s auth-state — sealing it as an identity_versions row (cookies + storage, value-free, credential-equivalent). Capturing a version is deliberately an explicit admin verb, never automatic and never reachable by a plain agent (an agent lacks identities:write).

  2. Runtime (every later run, an agent action). Open a cloak session with warmResume: true (optionally fromVersion: "niver_…" to pin a specific captured version; omit for the latest). The node restores the sealed auth-state before the first navigation, so the session opens already-authed and skips the challenge. Warm-resume requires only the identity’s assume/manage grant (plus sessions:drive) — an agent can use a captured version but cannot mint one.

Both halves are governed by the same session-start gates, which are ANDed (neither satisfies the other):

  • Cloak §D4 gate — the org must have enabled cloak (governance.cloakEnabled) and the session’s url must target one of the org’s declared advisor origins (governance.declaredAdvisorOrigins); otherwise a typed 403, never a silent downgrade to chrome.
  • Identity bind §D2 — a warm-resume is a live session on the identity, so it takes the identity’s single-writer lease (a second concurrent warm-resume on the same identity → 409) and requires the assume/manage grant (403 otherwise). A warmResume/fromVersion without an identity is a 400; a request for an identity with no captured version is a 404 (no cross-identity oracle).
// Runtime warm-resume: a cloak session that resumes the persona's captured auth-state.
session_start {
  identity: "advisor-persona",        // the captured persona; you hold its assume grant
  url: "https://chatgpt.com",         // must be a declared advisor origin
  browserKind: "cloak",
  warmResume: true                    // restore the latest captured auth-state before first nav
  // fromVersion: "niver_…"           // optional — pin a specific captured version
}
// → the session opens already-authed; the challenge it cleared once is skipped.

Egress is unchanged by cloak (ADR-039 §D3): cloak affects only the browser’s presentation, never where it may connect — the ADR-021 declared-host allowlist + deny-floor still bound reach exactly as for a chrome session.


7. The MCP tool reference

The MCP surface is a closed, generated set of 38 tools, of which this guide walks through the 9 most relevant to credentials, below. The agent is always resolved from the Bearer credential — never an argument. Authority comes from the credential’s scopes, never from MCP metadata.

ToolInputsScopeWhat it does
session_startidentity?, egress?, network?, viewport?sessions:writeOpen a hosted browser session, optionally binding a persona to assume.
spawn_and_start_sessionchildName (req), identity?, egress?, network?, viewport?sessions:writeOpen a session as a named child of the agent (distinct child principal + provenance edge; see ADR-019).
identity_list_assumablenoneidentities:readRead-only list of personas the agent may assume — id, name, grantRole, leaseState, open modes, and credential slot metadata (no secret/mask).
navigatesessionId (req), url (req)sessions:driveNavigate the session browser to a URL.
perceivesessionId (req), full?sessions:driveRead the current page (url, title, perception snapshot). full is advisory, not forwarded.
actsessionId (req), verb (req), selector?, text?, key?, url?, direction?sessions:driveOne page action: click \| type \| press \| goto \| scroll. Do not use type + a {{credential:}} marker (deprecated free-text fill).
credential_fillsessionId (req), credentialName (req), selector (req)sessions:driveHeadline secure fill. Type a stored credential by name — no value field exists, so the model can’t place the secret. Origin- and field-gated; zeroized after.
screenshotsessionId (req)sessions:driveCapture the current page as a PNG.
session_endsessionId (req)sessions:writeEnd the session and release the lease. Idempotent.

credential_fill’s input schema has exactly {sessionId, credentialName, selector} with additionalProperties:false. There is no text/value/timeout field — the model is structurally unable to place a secret or choose its raw content.


8. Troubleshooting

Errors are deliberately value-free and avoid existence oracles (the same 404 covers cross-org and totally-unknown ids). Common ones, by what they mean:

Status & messageWhereWhat it means / fix
403 missing sessions:drive / sessions:write / identities:*controlYour token lacks the scope. A Claude Code OAuth token has only {sessions:write, sessions:drive, identities:read}; persona/grant authoring needs identities:write (use an admin key/console).
404 unknown sessioncontrolThe session id isn’t in your org (cross-tenant ids look identical to unknown ones — no oracle).
400 session has no bound identity; cannot fill a credentialcontrolYou called credential_fill on an anonymous session. Open it with session_start identity=… first.
404 no credential named <NAME> … (structured) / 400 no credential <placeholder> … (legacy)controlThe bound identity has no credential by that name/placeholder. (Name is user-chosen, never the secret.)
403 grant-origin denied (reason=grant_intersection)controlThe credential’s allowlist and the grant’s allowedOrigins are disjoint — the secret was never forwarded. Widen the grant or fix the credential allowlist so they overlap.
403 origin not on the credential allowlist (credential.origin_denied, host=…)nodeYou’re on a page whose host isn’t on the effective allowlist. Navigate to an allow-listed origin before filling. Subdomain match is dot-boundary strict (evilgithub.comgithub.com).
credential.field_denied (field_kind=button\|checkbox\|…)nodeThe selector resolved to a non-editable element. Point it at a real text/password/email/textarea/contenteditable field.
403 free-text fill denied (reason=freetext_disabled)controlYou used the deprecated {{credential:}}-in-act.text path while the org flag is FALSE (the default). Use credential_fill instead.
403 … identity grant not live (grant_expired \| grant_revoked)controlThe assume grant has expired or been revoked. Re-issue/renew the grant.
403 grant propagation='none' blocks a spawned-child bindcontrolA spawn_and_start_session child tried to bind a persona whose winning grant has propagation: none. Set same_conversation/descendants, or bind from the parent.
409 identity in use by another sessioncontrol + dbThe persona is already embodied by a live session (one-live-session-per-identity lease). End the other session, or wait for the liveness reaper.
409 identity in use on DELETE /v1/identities/:idcontrolYou tried to delete a persona while it holds a live lease. End its session first.
400 origin_allowlist must list at least one origin … on credential createcontrolA credential with no allowlist can never fill — give it at least one origin.
500 credential decryption unavailable: no KMS object store configuredcontrolThe deployment has no KMS object store; fills/writes fail closed. An operator must configure it.
401 on every request after logincontrolLikely an audience mismatch — your njo_ token was minted for a different MCP resource URI than this deployment expects. Re-run discovery and re-mint against this host.

For the underlying chokepoints, sentinels, and the value-free audit schema behind these errors, see the credential security model reference.