Authenticates the browser; signs no storage envelope.
Docs · Portal
Multi-keypair.
One login, several signing identities.
Linking lets a second provisioned keypair appear under the portal login you already have, so both identities' lockers show in one view. The cryptography happens in your terminal and the identity binding happens in the browser; nothing but a signature crosses the network. Linking never creates an account — each keypair must be provisioned on its own first.
Each authorizes gateway envelopes for its own lockers.
Private key material is never uploaded at any step.
Model
One portal identity, many signing keys
The passkey authenticates a browser session and cannot sign a storage envelope. Each signing keypair is provisioned separately and owns its own lockers.
portal identity # exactly one, created by the passkey credential: WebAuthn resident key, RP_ID nukez.xyz session: localStorage nukez_session_token, sent as Authorization: Bearer authorizes: GET /v1/portal/status, /files, /ops-log, /usage, /alerts, /auth/linked-keys signing keypair # one or many, each provisioned on its own ed25519: base58 pubkey, signature base58-encoded secp256k1: 0x address, EIP-191 personal_sign, 0x-prefixed hex signature authorizes: POST /v1/delegate with X-Nukez-Identity, signed storage envelopes link # POST /v1/portal/auth/link-pubkey joins: one portal identity to N already-provisioned keypairs merges: files, status, usage and alerts into one view (files tagged by key) does not: share lockers, or let one key sign for another
Code notes
The passkey is a WebAuthn credential that authenticates a browser session and nothing else — it cannot sign a storage envelope. Each signing keypair is provisioned on its own, owns its own lockers, and authorizes gateway work through the X-Nukez-Identity header plus a signature from its private key. Linking is the operation that tells the portal which already-provisioned keypairs belong to one human, so their files, status, usage and alerts render in a single view.
Linking is a portal-only concern. It groups your view; it does not change ownership, access control, or cryptographic identity. The gateway has no concept of portal identity linking.
Precondition
The key must already be provisioned
Linking joins two existing accounts; it does not create one. A keypair that has never completed provisioning is rejected outright.
import json, os, httpx, base58from nacl.signing import SigningKey SERVICE = "https://agent.nukez.xyz"NEW_KEYPAIR_PATH = "~/.keys/delegators/svm_key.json" # the key you want to link # Load the key to be linked. A Solana-style keypair file is a 64-byte# array; the first 32 bytes are the Ed25519 seed.key_bytes = json.load(open(os.path.expanduser(NEW_KEYPAIR_PATH)))sk = SigningKey(bytes(key_bytes[:32]))pubkey = base58.b58encode(bytes(sk.verify_key)).decode() client = httpx.Client(timeout=30)resp = client.post(f"{SERVICE}/v1/portal/auth/link-pubkey", json={"pubkey": pubkey}) if resp.status_code == 404: raise SystemExit( "Not provisioned. Run POST /v1/provision/challenge then " "POST /v1/provision/verify for this key, then link it." )if resp.status_code == 409: raise SystemExit("Already linked to this portal identity.")resp.raise_for_status() challenge = resp.json()print(challenge["challenge"]) # the exact string to signprint(challenge["expires_at"]) # unix seconds; the Keys view counts this down
Code notes
A keypair can only be linked after it has completed provisioning on its own — its own developer_id and active storage, which means running POST /v1/provision/challenge and POST /v1/provision/verify for that key first. The link-pubkey call is where the precondition is enforced, and it needs no session of its own: it is the one step in this flow you can run from a bare terminal. A 404 means the key has no NukezAgent subscription yet; a 409 means it is already linked to this portal identity. A success returns the exact challenge string to sign and the unix timestamp at which it stops being valid.
The portal's inline Keys form validates the key before calling this endpoint: base58 of 32 to 44 characters for Ed25519, or 0x followed by 40 hex characters for an EVM address. It also surfaces the 403 case as a message saying the key's account is not active.
Proof
Sign the challenge with the new key
Control of the new private key is the entire proof. Ed25519 base58-encodes the signature and secp256k1 signs the same string through EIP-191.
import webbrowser# continues from the previous step: client, sk, pubkey, challenge # Ed25519 - sign the challenge string, base58-encode the 64-byte signaturesigned = sk.sign(challenge["challenge"].encode())signature = base58.b58encode(signed.signature).decode() # secp256k1 variant - EIP-191 personal_sign over the same string:# from eth_account import Account# from eth_account.messages import encode_defunct# account = Account.from_key(evm_private_key) # pubkey = account.address# signed = account.sign_message(encode_defunct(challenge["challenge"].encode()))# signature = signed.signature.hex()# if not signature.startswith("0x"): signature = "0x" + signature resp = client.post(f"{SERVICE}/v1/portal/auth/link-pubkey/verify", json={ "pubkey": pubkey, "challenge": challenge["challenge"], "signature": signature,})resp.raise_for_status()result = resp.json() print(result["status"])print(result["link_token"])webbrowser.open(result["portal_url"])# opens https://portal.nukez.xyz/agent-portal?link_token=...
Code notes
Signing the challenge is the entire proof of control. Ed25519 signs the raw challenge bytes and base58-encodes the signature; secp256k1 signs the same string through EIP-191 personal_sign and submits 0x-prefixed hex. The request to POST /v1/portal/auth/link-pubkey/verify carries three fields — pubkey, the challenge exactly as it was issued, and the signature — and the response returns a status, a link_token, and a ready-made portal_url that already carries that token as a query parameter.
Only a signature is ever transmitted. The private key never leaves the machine running this script, and no Nukez endpoint has a field that would accept it.
Handoff
Finish in the logged-in browser
Completion needs the portal session, which the terminal does not have. Opening the returned URL lets the portal finish the link with its own credentials.
https://portal.nukez.xyz/agent-portal?link_token=... # already signed in -> the portal consumes the token immediately# signed out -> the token is stashed in sessionStorage and# consumed after one biometric tap POST /v1/portal/auth/link-pubkey/complete HTTP/1.1Host: agent.nukez.xyzAuthorization: Bearer <session token from localStorage nukez_session_token>Content-Type: application/json {"link_token": "..."} 200 status linked, portal_identity, pubkeys[] -> toast, then /agent-portal/keys200 status already_linked, pubkey -> same toast, same redirect400 invalid or expired link_token -> re-run the verify step # The link_token parameter is stripped from the URL in every case.
Code notes
The verify call does not complete the link, because completion requires your portal session and the terminal does not have one. Opening portal_url lands on /agent-portal with a link_token query parameter. If a session is already active the portal calls link-pubkey/complete immediately; if not, it stashes the token in sessionStorage, takes you through one passkey tap, then consumes it. Both paths end on the Keys page.
If the portal reports an expired link, run the challenge and verify calls again to mint a fresh link_token. The verify step consumes the challenge, so both calls have to be repeated.
Confirm
Read back the linked set
One endpoint is authoritative for which identities are linked, returning the portal identity plus an entry per key.
GET /v1/portal/auth/linked-keys HTTP/1.1Host: agent.nukez.xyzAuthorization: Bearer <session token from localStorage nukez_session_token> { "portal_identity": "<portal identity id>", "keys": [ { "developer_id": "<uuid of the original key>", "pubkey": "<base58 ed25519 pubkey>", "algorithm": "ed25519", "label": null, "linked_at": "2026-08-05T17:04:11+00:00" }, { "developer_id": "<uuid of the newly linked key>", "pubkey": "<0x evm address>", "algorithm": "secp256k1", "label": null, "linked_at": "2026-08-05T17:22:48+00:00" } ]} # Keys view columns: Public Key / Algorithm / Type / Linked.# Row one is shown as Primary, every later row as Linked.# The sidebar shows a key-count link only when more than one key is linked. # Unchanged by linking: each developer_id still owns its own lockers,# and X-Nukez-Identity still resolves to exactly the key in the header.
Code notes
GET /v1/portal/auth/linked-keys is the authoritative list. It returns portal_identity plus one entry per linked key carrying developer_id, pubkey, algorithm, label and linked_at. The portal Keys view renders exactly this, and falls back to showing only the pubkey from status when the endpoint is not yet live. Confirming here is worth the extra call because linking changes what you see and nothing else: every gateway request still resolves to the single key named in X-Nukez-Identity.
Identifiers are elided in this sample. The Keys view truncates each public key to six characters at either end and copies the full value to the clipboard on click.
Linking joins views, not authority.
A linked keypair contributes its lockers to the merged portal view. It does not gain authority over another key's lockers, and the portal session still cannot sign on any key's behalf. To enroll the passkey that gates this flow, see passkey setup.
